458 lines
13 KiB
Go
458 lines
13 KiB
Go
package windows
|
|
|
|
// This file contains the platform-neutral process bookkeeping shared by the
|
|
// native Windows adapter and the deterministic non-Windows test adapter. The
|
|
// Windows build supplies the token/Job-backed start and signal operations in
|
|
// native_windows.go; keeping output and stdin semantics here prevents those
|
|
// paths from drifting apart.
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
|
"github.com/rvbox/rvbox/internal/client/supervisor"
|
|
"github.com/rvbox/rvbox/internal/domain"
|
|
)
|
|
|
|
var (
|
|
ErrProcessAlreadyRunning = errors.New("a process for this command is already running")
|
|
ErrProcessNotFound = errors.New("supervised process was not found")
|
|
ErrProcessNotReady = errors.New("supervised process is not ready")
|
|
)
|
|
|
|
// NativeOptions is the common policy input. Windows callers additionally get
|
|
// token/session selection and Job Object containment in the native build; the
|
|
// test adapter uses the same shell and output limits without OS handles.
|
|
type NativeOptions struct {
|
|
Shells ShellPaths
|
|
WorkRoot string
|
|
MaxWrapperBytes uint64
|
|
MaxOutputChunk uint64
|
|
WindowsTermGrace time.Duration
|
|
Now func() time.Time
|
|
}
|
|
|
|
func (options NativeOptions) withDefaults() NativeOptions {
|
|
if options.MaxWrapperBytes == 0 {
|
|
options.MaxWrapperBytes = 10 << 20
|
|
}
|
|
if options.MaxOutputChunk == 0 || options.MaxOutputChunk > 64<<10 {
|
|
options.MaxOutputChunk = 64 << 10
|
|
}
|
|
if options.WindowsTermGrace <= 0 {
|
|
options.WindowsTermGrace = 10 * time.Second
|
|
}
|
|
if options.Now == nil {
|
|
options.Now = func() time.Time { return time.Now().UTC() }
|
|
}
|
|
return options
|
|
}
|
|
|
|
type execSupervisor struct {
|
|
options NativeOptions
|
|
mu sync.Mutex
|
|
active map[domain.UUID]*execProcess
|
|
}
|
|
|
|
func newExecSupervisor(options NativeOptions) *execSupervisor {
|
|
options = options.withDefaults()
|
|
return &execSupervisor{options: options, active: make(map[domain.UUID]*execProcess)}
|
|
}
|
|
|
|
type execProcess struct {
|
|
issue domain.UUID
|
|
identity supervisor.EffectiveIdentity
|
|
cmd *exec.Cmd
|
|
stdin io.WriteCloser
|
|
stdout io.ReadCloser
|
|
stderr io.ReadCloser
|
|
outputs chan outputResult
|
|
done chan struct{}
|
|
started time.Time
|
|
waitFn func() (int32, bool, error)
|
|
killFn func(uint32) error
|
|
|
|
mu sync.Mutex
|
|
finished bool
|
|
status supervisor.ExitStatus
|
|
waitErr error
|
|
closeIn sync.Once
|
|
}
|
|
|
|
type outputResult struct {
|
|
chunk supervisor.OutputChunk
|
|
err error
|
|
}
|
|
|
|
func (process *execProcess) IssueUUID() domain.UUID { return process.issue }
|
|
|
|
func (process *execProcess) Identity() supervisor.EffectiveIdentity { return process.identity }
|
|
|
|
func (process *execProcess) ReadOutput(ctx context.Context) (supervisor.OutputChunk, error) {
|
|
if process == nil || process.outputs == nil {
|
|
return supervisor.OutputChunk{}, ErrProcessNotReady
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return supervisor.OutputChunk{}, ctx.Err()
|
|
case result, ok := <-process.outputs:
|
|
if !ok {
|
|
return supervisor.OutputChunk{}, io.EOF
|
|
}
|
|
return result.chunk, result.err
|
|
}
|
|
}
|
|
|
|
func (process *execProcess) Wait(ctx context.Context) (supervisor.ExitStatus, error) {
|
|
if process == nil || process.done == nil {
|
|
return supervisor.ExitStatus{}, ErrProcessNotReady
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return supervisor.ExitStatus{}, ctx.Err()
|
|
case <-process.done:
|
|
process.mu.Lock()
|
|
defer process.mu.Unlock()
|
|
return process.status, process.waitErr
|
|
}
|
|
}
|
|
|
|
func (process *execProcess) WriteStdin(ctx context.Context, data []byte, appendNewline bool) error {
|
|
if process == nil || process.stdin == nil {
|
|
return ErrProcessNotReady
|
|
}
|
|
if appendNewline {
|
|
data = append(append([]byte(nil), data...), stdinLineEnding()...)
|
|
}
|
|
return writeWithContext(ctx, process.stdin, data)
|
|
}
|
|
|
|
func (process *execProcess) CloseStdin(_ context.Context) error {
|
|
if process == nil || process.stdin == nil {
|
|
return ErrProcessNotReady
|
|
}
|
|
var err error
|
|
process.closeIn.Do(func() { err = process.stdin.Close() })
|
|
return err
|
|
}
|
|
|
|
func writeWithContext(ctx context.Context, writer io.Writer, data []byte) error {
|
|
if len(data) == 0 {
|
|
return nil
|
|
}
|
|
result := make(chan error, 1)
|
|
go func() {
|
|
_, err := writer.Write(data)
|
|
result <- err
|
|
}()
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case err := <-result:
|
|
return err
|
|
}
|
|
}
|
|
|
|
func (process *execProcess) setResult(status supervisor.ExitStatus, err error) {
|
|
process.mu.Lock()
|
|
process.status, process.waitErr, process.finished = status, err, true
|
|
process.mu.Unlock()
|
|
close(process.done)
|
|
}
|
|
|
|
func (process *execProcess) startReaders(maxChunk uint64, remove func()) {
|
|
var readers sync.WaitGroup
|
|
read := func(stream rvboxv1.StreamKind, source io.ReadCloser) {
|
|
defer readers.Done()
|
|
defer source.Close()
|
|
limit := int(maxChunk)
|
|
if limit <= 0 {
|
|
limit = 64 << 10
|
|
}
|
|
buffer := make([]byte, limit)
|
|
for {
|
|
count, err := source.Read(buffer)
|
|
if count > 0 {
|
|
data := append([]byte(nil), buffer[:count]...)
|
|
select {
|
|
case process.outputs <- outputResult{chunk: supervisor.OutputChunk{Stream: stream, Data: data}}:
|
|
default:
|
|
// The output channel is bounded. A reader must never hold a
|
|
// child pipe open while waiting for network I/O; dropping here
|
|
// is surfaced as an explicit read error to the caller.
|
|
select {
|
|
case process.outputs <- outputResult{err: fmt.Errorf("%w: output channel full", supervisor.ErrUnsupported)}:
|
|
case <-time.After(time.Second):
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
if !errors.Is(err, io.EOF) {
|
|
select {
|
|
case process.outputs <- outputResult{err: err}:
|
|
default:
|
|
}
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
readers.Add(2)
|
|
go read(rvboxv1.StreamKind_STREAM_STDOUT, process.stdout)
|
|
go read(rvboxv1.StreamKind_STREAM_STDERR, process.stderr)
|
|
go func() {
|
|
readers.Wait()
|
|
close(process.outputs)
|
|
if remove != nil {
|
|
remove()
|
|
}
|
|
}()
|
|
}
|
|
|
|
func materializeWrapper(directory string, wrapper Wrapper, now time.Time) (string, func(), error) {
|
|
if directory == "" || !now.IsZero() && now.Location() == nil {
|
|
return "", nil, ErrInvalidWorkingDirectory
|
|
}
|
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
|
return "", nil, err
|
|
}
|
|
temporary, err := os.CreateTemp(directory, ".rvbox-wrapper-*")
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
temporaryName := temporary.Name()
|
|
cleanup := func() {
|
|
_ = temporary.Close()
|
|
_ = os.Remove(temporaryName)
|
|
}
|
|
if err := temporary.Chmod(0o600); err != nil {
|
|
cleanup()
|
|
return "", nil, err
|
|
}
|
|
if _, err := temporary.Write(wrapper.Bytes); err != nil {
|
|
cleanup()
|
|
return "", nil, err
|
|
}
|
|
if err := temporary.Sync(); err != nil {
|
|
cleanup()
|
|
return "", nil, err
|
|
}
|
|
if err := temporary.Close(); err != nil {
|
|
_ = os.Remove(temporaryName)
|
|
return "", nil, err
|
|
}
|
|
finalName := temporaryName + wrapper.Extension
|
|
if err := os.Rename(temporaryName, finalName); err != nil {
|
|
_ = os.Remove(temporaryName)
|
|
return "", nil, err
|
|
}
|
|
return finalName, func() { _ = os.Remove(finalName) }, nil
|
|
}
|
|
|
|
func parseEnvironment(values []string) map[string]string {
|
|
result := make(map[string]string, len(values))
|
|
for _, value := range values {
|
|
index := strings.IndexByte(value, '=')
|
|
if index <= 0 {
|
|
continue
|
|
}
|
|
result[value[:index]] = value[index+1:]
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (process *execProcess) terminate(code uint32) error {
|
|
if process == nil {
|
|
return ErrProcessNotReady
|
|
}
|
|
if process.killFn != nil {
|
|
return process.killFn(code)
|
|
}
|
|
if process.cmd == nil || process.cmd.Process == nil {
|
|
return ErrProcessNotReady
|
|
}
|
|
return process.cmd.Process.Kill()
|
|
}
|
|
|
|
func (manager *execSupervisor) remove(issue domain.UUID, process *execProcess) {
|
|
manager.mu.Lock()
|
|
if manager.active[issue] == process {
|
|
delete(manager.active, issue)
|
|
}
|
|
manager.mu.Unlock()
|
|
}
|
|
|
|
func validateExecutionSource(spec *supervisor.StartSpec) error {
|
|
if err := spec.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if spec.Execution.GetScript() != nil && len(spec.ScriptBody) == 0 && spec.Execution.GetScript().GetSizeBytes() != 0 {
|
|
return ErrProcessNotReady
|
|
}
|
|
if len(spec.ExecutionProfiles) != 0 {
|
|
return fmt.Errorf("%w: execution profiles are not supported by this adapter yet", supervisor.ErrUnsupported)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sourceCommand(spec supervisor.StartSpec, wrapperPath string) (string, []string, error) {
|
|
source := spec.Execution.GetCommandText()
|
|
if wrapperPath != "" {
|
|
source = wrapperPath
|
|
}
|
|
switch spec.Execution.GetShellType() {
|
|
case rvboxv1.ShellType_SHELL_SH:
|
|
if wrapperPath != "" {
|
|
return "/bin/sh", []string{wrapperPath}, nil
|
|
}
|
|
return "/bin/sh", []string{"-c", source}, nil
|
|
case rvboxv1.ShellType_SHELL_BASH:
|
|
if wrapperPath != "" {
|
|
return "/bin/bash", []string{wrapperPath}, nil
|
|
}
|
|
return "/bin/bash", []string{"-c", source}, nil
|
|
default:
|
|
return "", nil, ErrUnsupportedShell
|
|
}
|
|
}
|
|
|
|
// startPortable launches the same bounded pipe contract on Unix. It is kept
|
|
// private because v1 does not advertise a Unix client; tests use it to prove
|
|
// runner/supervisor ordering without a Windows host.
|
|
func (manager *execSupervisor) startPortable(ctx context.Context, spec supervisor.StartSpec) (supervisor.Process, error) {
|
|
if err := validateExecutionSource(&spec); err != nil {
|
|
return nil, err
|
|
}
|
|
manager.mu.Lock()
|
|
if _, exists := manager.active[spec.IssueUUID]; exists {
|
|
manager.mu.Unlock()
|
|
return nil, ErrProcessAlreadyRunning
|
|
}
|
|
manager.mu.Unlock()
|
|
var wrapperPath string
|
|
var cleanup func()
|
|
if spec.Execution.GetScript() != nil {
|
|
wrapper, err := BuildWrapper(spec.Execution, spec.ScriptBody, manager.options.MaxWrapperBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
wrapperPath, cleanup, err = materializeWrapper(spec.WorkingDirectory, wrapper, manager.options.Now())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
program, arguments, err := sourceCommand(spec, wrapperPath)
|
|
if err != nil {
|
|
if cleanup != nil {
|
|
cleanup()
|
|
}
|
|
return nil, err
|
|
}
|
|
return manager.startCommand(ctx, spec, program, arguments, supervisor.EffectiveIdentity{Context: "CURRENT_PROCESS", Elevated: false}, cleanup, nil)
|
|
}
|
|
|
|
func (manager *execSupervisor) startCommand(ctx context.Context, spec supervisor.StartSpec, program string, arguments []string, identity supervisor.EffectiveIdentity, cleanup func(), configure func(*exec.Cmd) error) (supervisor.Process, error) {
|
|
command := exec.CommandContext(ctx, program, arguments...)
|
|
if configure != nil {
|
|
if err := configure(command); err != nil {
|
|
if cleanup != nil {
|
|
cleanup()
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
command.Dir = filepath.Clean(spec.WorkingDirectory)
|
|
base := parseEnvironment(os.Environ())
|
|
entries, err := MergeEnvironment(base, spec.Environment)
|
|
if err != nil {
|
|
if cleanup != nil {
|
|
cleanup()
|
|
}
|
|
return nil, err
|
|
}
|
|
command.Env = make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
command.Env = append(command.Env, entry.Key+"="+entry.Value)
|
|
}
|
|
stdinRead, stdin, err := os.Pipe()
|
|
if err != nil {
|
|
if cleanup != nil {
|
|
cleanup()
|
|
}
|
|
return nil, err
|
|
}
|
|
stdout, stdoutWrite, err := os.Pipe()
|
|
if err != nil {
|
|
_ = stdinRead.Close()
|
|
_ = stdin.Close()
|
|
if cleanup != nil {
|
|
cleanup()
|
|
}
|
|
return nil, err
|
|
}
|
|
stderr, stderrWrite, err := os.Pipe()
|
|
if err != nil {
|
|
_ = stdinRead.Close()
|
|
_ = stdin.Close()
|
|
_ = stdout.Close()
|
|
_ = stdoutWrite.Close()
|
|
if cleanup != nil {
|
|
cleanup()
|
|
}
|
|
return nil, err
|
|
}
|
|
command.Stdin = stdinRead
|
|
command.Stdout = stdoutWrite
|
|
command.Stderr = stderrWrite
|
|
if err := command.Start(); err != nil {
|
|
_ = stdinRead.Close()
|
|
_ = stdin.Close()
|
|
_ = stdout.Close()
|
|
_ = stdoutWrite.Close()
|
|
_ = stderr.Close()
|
|
_ = stderrWrite.Close()
|
|
if cleanup != nil {
|
|
cleanup()
|
|
}
|
|
return nil, err
|
|
}
|
|
_ = stdinRead.Close()
|
|
_ = stdoutWrite.Close()
|
|
_ = stderrWrite.Close()
|
|
started := manager.options.Now()
|
|
waitFn := func() (int32, bool, error) {
|
|
err := command.Wait()
|
|
var code int32
|
|
if command.ProcessState != nil {
|
|
code = int32(command.ProcessState.ExitCode())
|
|
}
|
|
return code, false, err
|
|
}
|
|
return manager.registerProcess(spec.IssueUUID, identity, command, stdin, stdout, stderr, started, waitFn, func(uint32) error { return command.Process.Kill() }, cleanup), nil
|
|
}
|
|
|
|
func (manager *execSupervisor) registerProcess(issue domain.UUID, identity supervisor.EffectiveIdentity, command *exec.Cmd, stdin io.WriteCloser, stdout, stderr io.ReadCloser, started time.Time, waitFn func() (int32, bool, error), killFn func(uint32) error, cleanup func()) *execProcess {
|
|
process := &execProcess{issue: issue, identity: identity, cmd: command, stdin: stdin, stdout: stdout, stderr: stderr, outputs: make(chan outputResult, 32), done: make(chan struct{}), started: started, waitFn: waitFn, killFn: killFn}
|
|
manager.mu.Lock()
|
|
manager.active[issue] = process
|
|
manager.mu.Unlock()
|
|
process.startReaders(manager.options.MaxOutputChunk, cleanup)
|
|
go func() {
|
|
code, signaled, err := process.waitFn()
|
|
finished := manager.options.Now()
|
|
status := supervisor.ExitStatus{StartedAt: started, FinishedAt: finished, OutputDrained: false, Code: code, Signaled: signaled}
|
|
process.setResult(status, err)
|
|
manager.remove(issue, process)
|
|
}()
|
|
return process
|
|
}
|