fix: enforce durable Windows launch release barrier

This commit is contained in:
2026-09-06 13:44:50 +00:00
parent 486894557d
commit 3f15377ed5
6 changed files with 82 additions and 24 deletions
@@ -95,13 +95,16 @@ type execProcess 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
mu sync.Mutex
finished bool
status supervisor.ExitStatus
waitErr error
closeIn sync.Once
release sync.Once
releaseErr error
}
type outputResult struct {
@@ -113,6 +116,21 @@ 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
@@ -453,15 +471,15 @@ func (manager *execSupervisor) startCommand(ctx context.Context, spec supervisor
}
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
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, cleanup func()) *execProcess {
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}
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()
@@ -8,6 +8,7 @@ import (
"errors"
"io"
"strings"
"sync/atomic"
"testing"
"time"
@@ -16,6 +17,23 @@ import (
"github.com/rvbox/rvbox/internal/domain"
)
func TestExecProcessReleaseIsIdempotent_BH_LAUNCH_05(t *testing.T) {
var calls atomic.Int32
process := &execProcess{releaseFn: func() error {
calls.Add(1)
return errors.New("release failed")
}}
if err := process.Release(context.Background()); err == nil || err.Error() != "release failed" {
t.Fatalf("first release error = %v", err)
}
if err := process.Release(context.Background()); err == nil || err.Error() != "release failed" {
t.Fatalf("repeated release error = %v", err)
}
if got := calls.Load(); got != 1 {
t.Fatalf("release calls = %d, want one", got)
}
}
func TestPortableSupervisorCapturesOutputAndSupportsStdin_HP_SUPERVISOR_03(t *testing.T) {
t.Parallel()
manager, err := NewSupervisor(NativeOptions{MaxOutputChunk: 8, WindowsTermGrace: 10 * time.Millisecond})
@@ -188,7 +188,9 @@ func (manager *execSupervisor) Start(ctx context.Context, spec supervisor.StartS
return fail(fmt.Errorf("create suspended command process: %w", err))
}
// The child owns these handles after CreateProcessAsUser returns. Keep only
// the three parent ends and the process/job handles in the daemon.
// the three parent ends and the process/job handles in the daemon. The
// primary thread remains suspended until the executor has durably recorded
// launch authorization and calls Process.Release.
_ = stdinRead.Close()
_ = stdoutWrite.Close()
_ = stderrWrite.Close()
@@ -198,14 +200,19 @@ func (manager *execSupervisor) Start(ctx context.Context, spec supervisor.StartS
_ = winapi.CloseHandle(processInfo.Thread)
return fail(fmt.Errorf("assign command to Job: %w", err))
}
if _, err := winapi.ResumeThread(processInfo.Thread); err != nil {
_ = winapi.TerminateJobObject(job, 1)
_ = winapi.CloseHandle(processInfo.Process)
_ = winapi.CloseHandle(processInfo.Thread)
return fail(fmt.Errorf("release suspended command: %w", err))
}
pipesTransferred = true
_ = winapi.CloseHandle(processInfo.Thread)
var threadClosed sync.Once
closeThread := func() {
threadClosed.Do(func() { _ = winapi.CloseHandle(processInfo.Thread) })
}
releaseFn := func() error {
if _, err := winapi.ResumeThread(processInfo.Thread); err != nil {
closeThread()
return fmt.Errorf("release suspended command: %w", err)
}
closeThread()
return nil
}
started := manager.options.Now()
handles := &nativeHandles{process: processInfo.Process, job: job, pid: processInfo.ProcessId}
cleanupJob = false
@@ -216,6 +223,7 @@ func (manager *execSupervisor) Start(ctx context.Context, spec supervisor.StartS
if err := winapi.GetExitCodeProcess(processInfo.Process, &code); err != nil && waitErr == nil {
waitErr = err
}
closeThread()
handles.close.Do(func() {
_ = winapi.CloseHandle(processInfo.Process)
_ = winapi.CloseHandle(job)
@@ -223,9 +231,11 @@ func (manager *execSupervisor) Start(ctx context.Context, spec supervisor.StartS
return int32(code), false, waitErr
}
killFn := func(code uint32) error {
return winapi.TerminateJobObject(job, code)
err := winapi.TerminateJobObject(job, code)
closeThread()
return err
}
process := manager.registerProcess(spec.IssueUUID, identity, command, stdinWrite, stdoutRead, stderrRead, started, waitFn, killFn, cleanup)
process := manager.registerProcess(spec.IssueUUID, identity, command, stdinWrite, stdoutRead, stderrRead, started, waitFn, killFn, releaseFn, cleanup)
process.snapshotFn = func() (supervisor.ResourceSnapshot, error) {
return queryJobSnapshot(job, manager.options.Now())
}