Files
rvbox/internal/client/supervisor/windows/native_exec.go
T

496 lines
14 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
JobProfiles map[string]JobProfile
MaxWrapperBytes uint64
MaxOutputChunk uint64
WindowsTermGrace time.Duration
Now func() time.Time
}
// JobProfile is the validated administrator policy for one protocol profile.
// The native Windows adapter applies only controls it can set and read back
// atomically on a Job Object; a requested unsupported required control rejects
// the command before any child is created.
type JobProfile struct {
RequiredControls []string
CPUPercent uint64
MemoryMaxBytes uint64
PIDsMax uint64
IOReadBPS uint64
IOWriteBPS uint64
}
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
pid uint32
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
releaseFn func() error
snapshotFn func() (supervisor.ResourceSnapshot, error)
mu sync.Mutex
finished bool
status supervisor.ExitStatus
waitErr error
closeIn sync.Once
release sync.Once
releaseErr error
}
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 }
// Release is the second half of the durable launch barrier. The portable
// adapter has no suspended native handle, so its release is intentionally a
// no-op; the Windows adapter supplies a ResumeThread closure.
func (process *execProcess) Release(_ context.Context) error {
if process == nil {
return ErrProcessNotReady
}
process.release.Do(func() {
if process.releaseFn != nil {
process.releaseErr = process.releaseFn()
}
})
return process.releaseErr
}
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 == "" {
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() }, nil, 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, releaseFn func() error, cleanup func()) *execProcess {
var pid uint32
if command != nil && command.Process != nil && command.Process.Pid > 0 {
pid = uint32(command.Process.Pid)
}
process := &execProcess{issue: issue, identity: identity, cmd: command, pid: pid, stdin: stdin, stdout: stdout, stderr: stderr, outputs: make(chan outputResult, 32), done: make(chan struct{}), started: started, waitFn: waitFn, killFn: killFn, releaseFn: releaseFn}
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
}