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 // ExecutablePath is the canonical rvbox.exe path used for private // per-command launcher mode. An empty value is resolved from the running // service executable on Windows. ExecutablePath string WorkRoot string JobProfiles map[string]JobProfile MaxWrapperBytes uint64 MaxOutputChunk uint64 WindowsTermGrace time.Duration // TestContextFailures is populated only by the separately tagged native // fixture binary. It is deliberately not protocol or TOML policy: it lets // the fixture fail token preparation before launch so the real daemon can // prove its fallback order without creating a second broker or weakening a // release binary. TestContextFailures map[ExecutionContext]bool 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 creation uint64 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 signalFn func(context.Context) (bool, 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 } func (process *execProcess) PID() uint32 { if process == nil { return 0 } return process.pid } // 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, secure func(string) error) (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 secure != nil { if err := secure(temporaryName); 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(), nil) 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 }