Terminate owned subprocess trees
This commit is contained in:
@@ -56,6 +56,12 @@ configured filesystem secrets before adapter initialization.
|
|||||||
- Constructor errors fail stage execution setup early.
|
- Constructor errors fail stage execution setup early.
|
||||||
- Runtime adapter errors propagate to stage code and then manifest failure handling.
|
- Runtime adapter errors propagate to stage code and then manifest failure handling.
|
||||||
- Subprocess adapters persist stage logs/generated configs through stage-managed paths.
|
- Subprocess adapters persist stage logs/generated configs through stage-managed paths.
|
||||||
|
- Shared subprocess execution starts an owned process group on Linux/macOS or a
|
||||||
|
kill-on-close job object on Windows. Cancellation and deadlines request
|
||||||
|
termination, use a bounded forceful fallback, and wait for the leader before
|
||||||
|
returning. Unlogged stdout/stderr use direct null-device descriptors so a
|
||||||
|
descendant cannot retain an adapter pipe after its leader exits. Unsupported
|
||||||
|
platforms reject owned command execution.
|
||||||
|
|
||||||
## Implementation And Tests
|
## Implementation And Tests
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,16 @@ temporary promotion tree because Narratio has no verified atomic no-replace
|
|||||||
directory primitive there. This is an extraction limitation, not a broader
|
directory primitive there. This is an extraction limitation, not a broader
|
||||||
platform-support guarantee for every Narratio workflow.
|
platform-support guarantee for every Narratio workflow.
|
||||||
|
|
||||||
|
## External Command Lifecycle
|
||||||
|
|
||||||
|
When an external command is cancelled or times out, Narratio terminates its
|
||||||
|
owned descendants as well as the command itself. Cancellation first requests
|
||||||
|
termination where the platform supports it, then force terminates after a
|
||||||
|
bounded wait. A command is not considered finished until its leader has been
|
||||||
|
reaped, and descendants that keep standard output or error open cannot keep
|
||||||
|
the invocation blocked. Other operating systems fail closed rather than launch
|
||||||
|
a command without tree ownership.
|
||||||
|
|
||||||
Run-local diagnostics are:
|
Run-local diagnostics are:
|
||||||
|
|
||||||
- `runs/{run_id}/extract/notarius.receipt.json`
|
- `runs/{run_id}/extract/notarius.receipt.json`
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ All stages are pending when this plan is created.
|
|||||||
| 5 | Confine recursive cleanup and replace sentinel locks | RSK-003 | Completed |
|
| 5 | Confine recursive cleanup and replace sentinel locks | RSK-003 | Completed |
|
||||||
| 6 | Harden API-key file acquisition | RSK-010 | Completed |
|
| 6 | Harden API-key file acquisition | RSK-010 | Completed |
|
||||||
| 7 | Bound and verify external result acquisition | RSK-013, TST-007 | Completed |
|
| 7 | Bound and verify external result acquisition | RSK-013, TST-007 | Completed |
|
||||||
| 8 | Terminate owned subprocess trees | RSK-011 | Pending |
|
| 8 | Terminate owned subprocess trees | RSK-011 | Completed |
|
||||||
| 9 | Redact and cap subprocess diagnostics | RSK-012 | Pending |
|
| 9 | Redact and cap subprocess diagnostics | RSK-012 | Pending |
|
||||||
| 10 | Confine publish archive reads | COR-005 | Pending |
|
| 10 | Confine publish archive reads | COR-005 | Pending |
|
||||||
| 11 | Make manifest and run identity singular | COR-001, TST-006 | Pending |
|
| 11 | Make manifest and run identity singular | COR-001, TST-006 | Pending |
|
||||||
|
|||||||
65
internal/adapters/subprocess/process_tree.go
Normal file
65
internal/adapters/subprocess/process_tree.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package subprocess
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
gracefulTerminationWait = 2 * time.Second
|
||||||
|
forcefulTerminationWait = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// ownedProcessTree owns every process started by a command invocation.
|
||||||
|
// Implementations must tolerate a leader that has already exited.
|
||||||
|
type ownedProcessTree interface {
|
||||||
|
Start(*exec.Cmd) error
|
||||||
|
TerminateGracefully() error
|
||||||
|
TerminateForcefully() error
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForOwnedCommand(ctx context.Context, tree ownedProcessTree, waitCh <-chan error) (waitErr, ctxErr, cleanupErr error) {
|
||||||
|
select {
|
||||||
|
case waitErr = <-waitCh:
|
||||||
|
return waitErr, nil, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
ctxErr = ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupErr = tree.TerminateGracefully()
|
||||||
|
gracefulTimer := time.NewTimer(gracefulTerminationWait)
|
||||||
|
defer gracefulTimer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case waitErr = <-waitCh:
|
||||||
|
// The leader may exit before descendants finish graceful shutdown.
|
||||||
|
cleanupErr = joinErrors(cleanupErr, tree.TerminateForcefully())
|
||||||
|
return waitErr, ctxErr, cleanupErr
|
||||||
|
case <-gracefulTimer.C:
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupErr = joinErrors(cleanupErr, tree.TerminateForcefully())
|
||||||
|
forcefulTimer := time.NewTimer(forcefulTerminationWait)
|
||||||
|
defer forcefulTimer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case waitErr = <-waitCh:
|
||||||
|
return waitErr, ctxErr, cleanupErr
|
||||||
|
case <-forcefulTimer.C:
|
||||||
|
return nil, ctxErr, joinErrors(cleanupErr, fmt.Errorf("owned subprocess did not reap within %s after forceful termination", forcefulTerminationWait))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinErrors(errs ...error) error {
|
||||||
|
filtered := make([]error, 0, len(errs))
|
||||||
|
for _, err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
filtered = append(filtered, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(filtered...)
|
||||||
|
}
|
||||||
119
internal/adapters/subprocess/process_tree_supported_test.go
Normal file
119
internal/adapters/subprocess/process_tree_supported_test.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
//go:build linux || darwin || windows
|
||||||
|
|
||||||
|
package subprocess
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunCancellationTerminatesProcessTree(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resultCh := make(chan runOutcome, 1)
|
||||||
|
req, sentinelPath := processTreeRequest(t)
|
||||||
|
go func() {
|
||||||
|
result, err := Run(ctx, req)
|
||||||
|
resultCh <- runOutcome{result: result, err: err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
awaitHelperReady(t, req.EnvOverrides["SUBPROCESS_HELPER_READY_PATH"])
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
outcome := awaitRunOutcome(t, resultCh)
|
||||||
|
if !outcome.result.Canceled {
|
||||||
|
t.Fatalf("Canceled = %v, want true", outcome.result.Canceled)
|
||||||
|
}
|
||||||
|
if !errors.Is(outcome.err, context.Canceled) {
|
||||||
|
t.Fatalf("error = %v, want context cancellation", outcome.err)
|
||||||
|
}
|
||||||
|
assertDescendantDidNotSurvive(t, sentinelPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTimeoutTerminatesProcessTree(t *testing.T) {
|
||||||
|
req, sentinelPath := processTreeRequest(t)
|
||||||
|
req.Timeout = 100 * time.Millisecond
|
||||||
|
|
||||||
|
result, err := Run(context.Background(), req)
|
||||||
|
if !result.TimedOut {
|
||||||
|
t.Fatalf("TimedOut = %v, want true", result.TimedOut)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("error = %v, want context deadline exceeded", err)
|
||||||
|
}
|
||||||
|
assertDescendantDidNotSurvive(t, sentinelPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
type runOutcome struct {
|
||||||
|
result RunResult
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func processTreeRequest(t *testing.T) (RunRequest, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
executable, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("os.Executable() error = %v", err)
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
readyPath := filepath.Join(dir, "ready")
|
||||||
|
sentinelPath := filepath.Join(dir, "descendant-survived")
|
||||||
|
return RunRequest{
|
||||||
|
Executable: executable,
|
||||||
|
Args: []string{"-test.run=^TestSubprocessHelper$", "--", "tree"},
|
||||||
|
EnvOverrides: map[string]string{
|
||||||
|
"GO_WANT_SUBPROCESS_HELPER": "1",
|
||||||
|
"SUBPROCESS_HELPER_READY_PATH": readyPath,
|
||||||
|
"SUBPROCESS_HELPER_SENTINEL_PATH": sentinelPath,
|
||||||
|
},
|
||||||
|
StdoutLogPath: filepath.Join(dir, "stdout.log"),
|
||||||
|
StderrLogPath: filepath.Join(dir, "stderr.log"),
|
||||||
|
}, sentinelPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func awaitHelperReady(t *testing.T, readyPath string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if _, err := os.Stat(readyPath); err == nil {
|
||||||
|
return
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("stat helper readiness: %v", err)
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("helper did not start its descendant")
|
||||||
|
}
|
||||||
|
|
||||||
|
func awaitRunOutcome(t *testing.T, outcomes <-chan runOutcome) runOutcome {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case outcome := <-outcomes:
|
||||||
|
if outcome.err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want cancellation error")
|
||||||
|
}
|
||||||
|
return outcome
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("Run() did not return after cancellation")
|
||||||
|
return runOutcome{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertDescendantDidNotSurvive(t *testing.T, sentinelPath string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
time.Sleep(700 * time.Millisecond)
|
||||||
|
if _, err := os.Stat(sentinelPath); err == nil {
|
||||||
|
t.Fatal("descendant survived cancellation and wrote its sentinel")
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("stat descendant sentinel: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
50
internal/adapters/subprocess/process_tree_unix.go
Normal file
50
internal/adapters/subprocess/process_tree_unix.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
//go:build linux || darwin
|
||||||
|
|
||||||
|
package subprocess
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
type unixProcessTree struct {
|
||||||
|
processGroupID int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOwnedProcessTree() (ownedProcessTree, error) {
|
||||||
|
return &unixProcessTree{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tree *unixProcessTree) Start(cmd *exec.Cmd) error {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tree.processGroupID = cmd.Process.Pid
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tree *unixProcessTree) TerminateGracefully() error {
|
||||||
|
return tree.signal(syscall.SIGTERM)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tree *unixProcessTree) TerminateForcefully() error {
|
||||||
|
return tree.signal(syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tree *unixProcessTree) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tree *unixProcessTree) signal(signal syscall.Signal) error {
|
||||||
|
if tree.processGroupID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := syscall.Kill(-tree.processGroupID, signal)
|
||||||
|
if errors.Is(err, syscall.ESRCH) || errors.Is(err, os.ErrProcessDone) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
12
internal/adapters/subprocess/process_tree_unsupported.go
Normal file
12
internal/adapters/subprocess/process_tree_unsupported.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build !linux && !darwin && !windows
|
||||||
|
|
||||||
|
package subprocess
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newOwnedProcessTree() (ownedProcessTree, error) {
|
||||||
|
return nil, fmt.Errorf("owned subprocess trees are unsupported on %s", runtime.GOOS)
|
||||||
|
}
|
||||||
122
internal/adapters/subprocess/process_tree_windows.go
Normal file
122
internal/adapters/subprocess/process_tree_windows.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package subprocess
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
type windowsProcessTree struct {
|
||||||
|
job windows.Handle
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOwnedProcessTree() (ownedProcessTree, error) {
|
||||||
|
return &windowsProcessTree{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tree *windowsProcessTree) Start(cmd *exec.Cmd) error {
|
||||||
|
job, err := windows.CreateJobObject(nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create job object: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
limits := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
|
||||||
|
limits.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||||
|
if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits))); err != nil {
|
||||||
|
_ = windows.CloseHandle(job)
|
||||||
|
return fmt.Errorf("configure job object: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_SUSPENDED}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
_ = windows.CloseHandle(job)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
process, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
|
||||||
|
if err == nil {
|
||||||
|
err = windows.AssignProcessToJobObject(job, process)
|
||||||
|
_ = windows.CloseHandle(process)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = resumeInitialThread(uint32(cmd.Process.Pid))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
killErr := cmd.Process.Kill()
|
||||||
|
waitErr := cmd.Wait()
|
||||||
|
_ = windows.CloseHandle(job)
|
||||||
|
return joinErrors(fmt.Errorf("assign process to job object: %w", err), killErr, waitErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
tree.job = job
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumeInitialThread(processID uint32) error {
|
||||||
|
snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("snapshot initial thread: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = windows.CloseHandle(snapshot) }()
|
||||||
|
|
||||||
|
entry := windows.ThreadEntry32{Size: uint32(unsafe.Sizeof(windows.ThreadEntry32{}))}
|
||||||
|
if err := windows.Thread32First(snapshot, &entry); err != nil {
|
||||||
|
return fmt.Errorf("find initial thread: %w", err)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if entry.OwnerProcessID != processID {
|
||||||
|
// Keep enumerating until the suspended process's only initial thread
|
||||||
|
// is found.
|
||||||
|
} else {
|
||||||
|
thread, openErr := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, entry.ThreadID)
|
||||||
|
if openErr != nil {
|
||||||
|
return fmt.Errorf("open initial thread: %w", openErr)
|
||||||
|
}
|
||||||
|
defer func() { _ = windows.CloseHandle(thread) }()
|
||||||
|
if _, resumeErr := windows.ResumeThread(thread); resumeErr != nil {
|
||||||
|
return fmt.Errorf("resume initial thread: %w", resumeErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := windows.Thread32Next(snapshot, &entry); err != nil {
|
||||||
|
if errors.Is(err, windows.ERROR_NO_MORE_FILES) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return fmt.Errorf("find initial thread: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("find initial thread: no thread found for process %d", processID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tree *windowsProcessTree) TerminateGracefully() error {
|
||||||
|
// Windows jobs have no portable graceful signal. Terminating the owned job
|
||||||
|
// is the safe fallback and prevents a descendant from escaping cleanup.
|
||||||
|
return tree.terminate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tree *windowsProcessTree) TerminateForcefully() error {
|
||||||
|
return tree.terminate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tree *windowsProcessTree) Close() error {
|
||||||
|
if tree.job == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := windows.CloseHandle(tree.job)
|
||||||
|
tree.job = 0
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tree *windowsProcessTree) terminate() error {
|
||||||
|
if tree.job == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return windows.TerminateJobObject(tree.job, 1)
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package subprocess
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -61,7 +60,12 @@ func Run(ctx context.Context, req RunRequest) (RunResult, error) {
|
|||||||
}
|
}
|
||||||
defer logs.Close()
|
defer logs.Close()
|
||||||
|
|
||||||
cmd := exec.CommandContext(runCtx, req.Executable, req.Args...)
|
tree, err := newOwnedProcessTree()
|
||||||
|
if err != nil {
|
||||||
|
return RunResult{}, fmt.Errorf("prepare owned subprocess tree: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(req.Executable, req.Args...)
|
||||||
cmd.Dir = req.WorkingDir
|
cmd.Dir = req.WorkingDir
|
||||||
cmd.Env = mergeEnv(os.Environ(), req.EnvOverrides)
|
cmd.Env = mergeEnv(os.Environ(), req.EnvOverrides)
|
||||||
cmd.Stdout = logs.Stdout
|
cmd.Stdout = logs.Stdout
|
||||||
@@ -75,28 +79,37 @@ func Run(ctx context.Context, req RunRequest) (RunResult, error) {
|
|||||||
StderrLogPath: req.StderrLogPath,
|
StderrLogPath: req.StderrLogPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
if err := runCtx.Err(); err != nil {
|
||||||
|
result.CompletedAt = time.Now().UTC()
|
||||||
|
result.Duration = result.CompletedAt.Sub(result.StartedAt)
|
||||||
|
return result, fmt.Errorf("command was not started: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tree.Start(cmd); err != nil {
|
||||||
result.CompletedAt = time.Now().UTC()
|
result.CompletedAt = time.Now().UTC()
|
||||||
result.Duration = result.CompletedAt.Sub(result.StartedAt)
|
result.Duration = result.CompletedAt.Sub(result.StartedAt)
|
||||||
return result, fmt.Errorf("start command %q with args %v: %w", req.Executable, req.Args, err)
|
return result, fmt.Errorf("start command %q with args %v: %w", req.Executable, req.Args, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
waitErr := cmd.Wait()
|
waitCh := make(chan error, 1)
|
||||||
|
go func() { waitCh <- cmd.Wait() }()
|
||||||
|
|
||||||
|
waitErr, ctxErr, cleanupErr := waitForOwnedCommand(runCtx, tree, waitCh)
|
||||||
|
cleanupErr = joinErrors(cleanupErr, tree.Close())
|
||||||
result.CompletedAt = time.Now().UTC()
|
result.CompletedAt = time.Now().UTC()
|
||||||
result.Duration = result.CompletedAt.Sub(result.StartedAt)
|
result.Duration = result.CompletedAt.Sub(result.StartedAt)
|
||||||
if cmd.ProcessState != nil {
|
if cmd.ProcessState != nil {
|
||||||
result.ExitCode = cmd.ProcessState.ExitCode()
|
result.ExitCode = cmd.ProcessState.ExitCode()
|
||||||
}
|
}
|
||||||
|
|
||||||
ctxErr := runCtx.Err()
|
if ctxErr == context.DeadlineExceeded {
|
||||||
if errors.Is(ctxErr, context.DeadlineExceeded) {
|
|
||||||
result.TimedOut = true
|
result.TimedOut = true
|
||||||
}
|
}
|
||||||
if errors.Is(ctxErr, context.Canceled) && !result.TimedOut {
|
if ctxErr == context.Canceled && !result.TimedOut {
|
||||||
result.Canceled = true
|
result.Canceled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if waitErr == nil {
|
if waitErr == nil && ctxErr == nil && cleanupErr == nil {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,14 +117,17 @@ func Run(ctx context.Context, req RunRequest) (RunResult, error) {
|
|||||||
diagnostics := buildDiagnostics(req, result, stderrTail)
|
diagnostics := buildDiagnostics(req, result, stderrTail)
|
||||||
|
|
||||||
if result.TimedOut {
|
if result.TimedOut {
|
||||||
return result, fmt.Errorf("command timed out after %s (%s)", req.Timeout, diagnostics)
|
return result, fmt.Errorf("command timed out after %s (%s): %w", req.Timeout, diagnostics, joinErrors(ctxErr, waitErr, cleanupErr))
|
||||||
}
|
}
|
||||||
if result.Canceled {
|
if result.Canceled {
|
||||||
return result, fmt.Errorf("command canceled (%s)", diagnostics)
|
return result, fmt.Errorf("command canceled (%s): %w", diagnostics, joinErrors(ctxErr, waitErr, cleanupErr))
|
||||||
}
|
}
|
||||||
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
||||||
return result, fmt.Errorf("command failed with exit code %d (%s): %w", exitErr.ExitCode(), diagnostics, waitErr)
|
return result, fmt.Errorf("command failed with exit code %d (%s): %w", exitErr.ExitCode(), diagnostics, waitErr)
|
||||||
}
|
}
|
||||||
|
if cleanupErr != nil {
|
||||||
|
return result, fmt.Errorf("command cleanup failed (%s): %w", diagnostics, joinErrors(waitErr, cleanupErr))
|
||||||
|
}
|
||||||
|
|
||||||
return result, fmt.Errorf("command failed to run (%s): %w", diagnostics, waitErr)
|
return result, fmt.Errorf("command failed to run (%s): %w", diagnostics, waitErr)
|
||||||
}
|
}
|
||||||
@@ -203,7 +219,13 @@ func cleanLogPath(path string) string {
|
|||||||
|
|
||||||
func logWriter(path string) (*os.File, io.Writer, error) {
|
func logWriter(path string) (*os.File, io.Writer, error) {
|
||||||
if strings.TrimSpace(path) == "" {
|
if strings.TrimSpace(path) == "" {
|
||||||
return nil, io.Discard, nil
|
// Use a descriptor instead of io.Discard so os/exec does not create a
|
||||||
|
// pipe and wait for a descendant that inherited it after its leader exits.
|
||||||
|
f, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("open null output: %w", err)
|
||||||
|
}
|
||||||
|
return f, f, nil
|
||||||
}
|
}
|
||||||
f, err := openLogFile(path)
|
f, err := openLogFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package subprocess
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -368,6 +369,24 @@ func TestSubprocessHelper(t *testing.T) {
|
|||||||
key := os.Getenv("SUBPROCESS_HELPER_ENV_KEY")
|
key := os.Getenv("SUBPROCESS_HELPER_ENV_KEY")
|
||||||
_, _ = os.Stdout.WriteString(os.Getenv(key) + "\n")
|
_, _ = os.Stdout.WriteString(os.Getenv(key) + "\n")
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
|
case "tree":
|
||||||
|
descendant := exec.Command(os.Args[0], "-test.run=^TestSubprocessHelper$", "--", "descendant")
|
||||||
|
descendant.Env = append(os.Environ(), "GO_WANT_SUBPROCESS_HELPER=1")
|
||||||
|
descendant.Stdout = os.Stdout
|
||||||
|
descendant.Stderr = os.Stderr
|
||||||
|
if err := descendant.Start(); err != nil {
|
||||||
|
os.Exit(3)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(os.Getenv("SUBPROCESS_HELPER_READY_PATH"), []byte("ready"), 0o600); err != nil {
|
||||||
|
os.Exit(4)
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
os.Exit(0)
|
||||||
|
case "descendant":
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
_ = os.WriteFile(os.Getenv("SUBPROCESS_HELPER_SENTINEL_PATH"), []byte("survived"), 0o600)
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
os.Exit(0)
|
||||||
default:
|
default:
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user