diff --git a/cmd/toolsrv/integration_test.go b/cmd/toolsrv/integration_test.go index bd0ce4d..85083a7 100644 --- a/cmd/toolsrv/integration_test.go +++ b/cmd/toolsrv/integration_test.go @@ -368,11 +368,14 @@ func TestIntegration_ToolExecutionError(t *testing.T) { t.Fatalf("failed to parse toolsrv.ToolResult: %v (raw: %s)", err, string(result)) } - // Should have exit code in output + // Should be an error result with exit code info if len(toolResult.Content) == 0 { t.Fatal("toolsrv.ToolResult.Content is empty") } - if !strings.Contains(toolResult.Content[0].Text, "exit: 42") { + if !toolResult.IsError { + t.Error("expected IsError=true for non-zero exit") + } + if !strings.Contains(toolResult.Content[0].Text, "exit status 42") && !strings.Contains(toolResult.Content[0].Text, "exit: 42") { t.Errorf("output doesn't contain exit code: %q", toolResult.Content[0].Text) } diff --git a/cmd/toolsrv/internal/exec/exec.go b/cmd/toolsrv/internal/exec/exec.go index 0a6faa5..ae67bae 100644 --- a/cmd/toolsrv/internal/exec/exec.go +++ b/cmd/toolsrv/internal/exec/exec.go @@ -197,7 +197,7 @@ func executeSandboxed(ctx context.Context, toolPath, stdinData, cwd string, envE cmd.Env = append(cmd.Env, k+"="+v) } - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGKILL} cmd.Cancel = func() error { if cmd.Process != nil { syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) diff --git a/cmd/toolsrv/internal/fs/proc.go b/cmd/toolsrv/internal/fs/proc.go index bcf10f5..6a20e42 100644 --- a/cmd/toolsrv/internal/fs/proc.go +++ b/cmd/toolsrv/internal/fs/proc.go @@ -336,6 +336,28 @@ func (st *State) ListProcs() []int { return pids } +// KillAll sends SIGKILL to all running procs and cancels their contexts. +// Called on shutdown to ensure no orphaned processes. +func (st *State) KillAll() { + st.procMu.Lock() + procs := make([]*Proc, 0, len(st.procs)) + for _, p := range st.procs { + procs = append(procs, p) + } + st.procMu.Unlock() + + for _, p := range procs { + p.mu.Lock() + proc := p.proc + exited := p.Exited + p.mu.Unlock() + if !exited && proc != nil { + syscall.Kill(-proc.Pid, syscall.SIGKILL) + } + p.cancel() + } +} + // DismissProc removes a process from the list. func (st *State) DismissProc(pid int) bool { st.procMu.Lock() diff --git a/cmd/toolsrv/main.go b/cmd/toolsrv/main.go index 0a27d31..30f9ed4 100644 --- a/cmd/toolsrv/main.go +++ b/cmd/toolsrv/main.go @@ -105,6 +105,9 @@ func main() { cancel() }() + // Kill all procs on shutdown + defer srv.Fs.KillAll() + // Start idle timeout monitor if *idleTimeout > 0 { go idleMonitor(runCtx, cancel, *idleTimeout) diff --git a/data/tools/shell b/data/tools/shell index 33f05ee..cff2ae5 100755 --- a/data/tools/shell +++ b/data/tools/shell @@ -31,7 +31,11 @@ fi # Execute the command. The sandbox (landlock) is already applied by toolsrv. # We use eval to handle pipes, redirects, and compound commands. # Output streams directly to stdout/stderr for real-time capture. -eval "$cmd" 2>&1 +# Run in a subshell so 'exit N' doesn't kill this script. +# Disable errexit for the subshell so we can capture the exit code. +set +e +(eval "$cmd") 2>&1 rc=$? +set -e echo "exit: $rc" exit $rc \ No newline at end of file