-
Notifications
You must be signed in to change notification settings - Fork 185
fix(daemon): kill launcher workers via launch-time process group #980
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
77e439a
058fabd
ce270fb
d9ddcd8
c89e571
19da35e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| //go:build !windows | ||
|
|
||
| package background | ||
|
|
||
| import ( | ||
| "bufio" | ||
| "io" | ||
| "os" | ||
| "os/exec" | ||
| "strconv" | ||
| "strings" | ||
| "syscall" | ||
| "testing" | ||
| "time" | ||
| ) | ||
|
|
||
| func TestTerminateOwnedProcessDoesNotReap(t *testing.T) { | ||
| cmd := exec.Command("sleep", "30") | ||
| ConfigureChildProcessGroup(cmd) | ||
| if err := cmd.Start(); err != nil { | ||
| t.Fatalf("start: %v", err) | ||
| } | ||
| t.Cleanup(func() { | ||
| if cmd.ProcessState == nil { | ||
| _ = cmd.Process.Kill() | ||
| _ = cmd.Wait() | ||
| } | ||
| }) | ||
|
|
||
| if err := TerminateOwnedProcess(cmd); err != nil { | ||
| t.Fatalf("TerminateOwnedProcess: %v", err) | ||
| } | ||
| if cmd.ProcessState != nil { | ||
| t.Fatal("TerminateOwnedProcess must not Wait; the caller still owns the reap") | ||
| } | ||
| if err := cmd.Wait(); err == nil { | ||
| t.Fatal("expected the terminated command's Wait to report a signal") | ||
| } | ||
| } | ||
|
|
||
| func TestTerminateOwnedProcessKillsChildAfterLeaderExits(t *testing.T) { | ||
| grace, poll := terminationGracePeriod, terminationPollInterval | ||
| terminationGracePeriod, terminationPollInterval = 2*time.Second, 20*time.Millisecond | ||
| t.Cleanup(func() { terminationGracePeriod, terminationPollInterval = grace, poll }) | ||
|
|
||
| // The leader exits immediately after launching the child and is left | ||
| // unreaped. TerminateOwnedProcess must signal the launch-time process | ||
| // group rather than rediscovering it via Getpgid (Darwin ESRCH, #861). | ||
| cmd := exec.Command("sh", "-c", "sleep 300 & echo $!; exit 0") | ||
| ConfigureChildProcessGroup(cmd) | ||
| stdout, err := cmd.StdoutPipe() | ||
| if err != nil { | ||
| t.Fatalf("stdout pipe: %v", err) | ||
| } | ||
| if err := cmd.Start(); err != nil { | ||
| t.Fatalf("start: %v", err) | ||
| } | ||
| finalized := false | ||
| var childPID int | ||
| t.Cleanup(func() { | ||
| if finalized { | ||
| return | ||
| } | ||
| if childPID > 0 { | ||
| _ = syscall.Kill(childPID, syscall.SIGKILL) | ||
| } | ||
| if cmd.Process != nil { | ||
| _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | ||
| _ = cmd.Process.Kill() | ||
| if cmd.ProcessState == nil { | ||
| _ = cmd.Wait() | ||
| } | ||
| } | ||
| }) | ||
|
|
||
| childPID = readPIDWithTimeout(t, stdout, 3*time.Second) | ||
|
|
||
| // echo $! races ahead of exit 0. Wait until the leader is an unreaped | ||
| // zombie without calling Wait, so this exercises Darwin Getpgid ESRCH. | ||
| waitUntilUnreapedZombie(t, cmd.Process.Pid) | ||
|
|
||
| if err := TerminateOwnedProcess(cmd); err != nil { | ||
| t.Fatalf("TerminateOwnedProcess: %v", err) | ||
| } | ||
| if cmd.ProcessState != nil { | ||
| t.Fatal("TerminateOwnedProcess must not reap the leader") | ||
| } | ||
| if err := cmd.Wait(); err != nil && terminatingSignal(err) == 0 { | ||
| // Leader may have exited 0 before the group signal, or have been | ||
| // signalled; either is a successful reap. | ||
| t.Fatalf("Wait after TerminateOwnedProcess: %v", err) | ||
| } | ||
|
|
||
| deadline := time.Now().Add(2 * time.Second) | ||
| for !processStopped(childPID) { | ||
| if time.Now().After(deadline) { | ||
| t.Fatalf("forked child %d survived TerminateOwnedProcess — group kill failed", childPID) | ||
| } | ||
| time.Sleep(20 * time.Millisecond) | ||
| } | ||
| finalized = true | ||
| } | ||
|
|
||
| func TestTerminateOwnedProcessNil(t *testing.T) { | ||
| defer func() { | ||
| if r := recover(); r != nil { | ||
| t.Fatalf("TerminateOwnedProcess(nil) panicked: %v", r) | ||
| } | ||
| }() | ||
| if err := TerminateOwnedProcess(nil); err == nil { | ||
| t.Fatal("TerminateOwnedProcess(nil) must return an error") | ||
| } | ||
| } | ||
|
|
||
| func TestTerminateOwnedProcessUnstarted(t *testing.T) { | ||
| defer func() { | ||
| if r := recover(); r != nil { | ||
| t.Fatalf("TerminateOwnedProcess(unstarted) panicked: %v", r) | ||
| } | ||
| }() | ||
| cmd := exec.Command("true") | ||
| if err := TerminateOwnedProcess(cmd); err == nil { | ||
| t.Fatal("TerminateOwnedProcess on an unstarted command must return an error") | ||
| } | ||
| } | ||
|
|
||
| // waitUntilUnreapedZombie polls until pid is a zombie without calling Wait. | ||
| func waitUntilUnreapedZombie(t *testing.T, pid int) { | ||
| t.Helper() | ||
| deadline := time.Now().Add(2 * time.Second) | ||
| for !isUnreapedZombie(pid) { | ||
| if time.Now().After(deadline) { | ||
| t.Fatalf("pid %d did not become an unreaped zombie before terminate", pid) | ||
| } | ||
| time.Sleep(20 * time.Millisecond) | ||
| } | ||
| } | ||
|
|
||
| func isUnreapedZombie(pid int) bool { | ||
| if data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat"); err == nil { | ||
| s := string(data) | ||
| i := strings.LastIndexByte(s, ')') | ||
| if i < 0 || i+2 >= len(s) { | ||
| return false | ||
| } | ||
| return s[i+2] == 'Z' | ||
| } | ||
| state, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output() | ||
| if err != nil { | ||
| return false | ||
| } | ||
| return strings.HasPrefix(strings.TrimSpace(string(state)), "Z") | ||
| } | ||
|
|
||
| func readPIDWithTimeout(t *testing.T, r io.Reader, timeout time.Duration) int { | ||
| t.Helper() | ||
| type res struct { | ||
| pid int | ||
| err error | ||
| } | ||
| ch := make(chan res, 1) | ||
| go func() { | ||
| line, err := bufio.NewReader(r).ReadString('\n') | ||
| if err != nil { | ||
| ch <- res{err: err} | ||
| return | ||
| } | ||
| pid, err := strconv.Atoi(strings.TrimSpace(line)) | ||
| ch <- res{pid: pid, err: err} | ||
| }() | ||
| select { | ||
| case out := <-ch: | ||
| if out.err != nil { | ||
| t.Fatalf("read child PID: %v", out.err) | ||
| } | ||
| return out.pid | ||
| case <-time.After(timeout): | ||
| t.Fatalf("timed out waiting for child PID after %v", timeout) | ||
| return 0 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ import ( | |
| "os" | ||
| "os/exec" | ||
| "strings" | ||
| "sync" | ||
|
|
||
| "github.com/Gitlawb/zero/internal/background" | ||
| "github.com/Gitlawb/zero/internal/sandbox" | ||
|
|
@@ -47,6 +48,8 @@ func scrubWorkerEnv(env []string) []string { | |
| // execWorker is a WorkerHandle backed by a `zero exec` child process speaking | ||
| // stream-json on stdout. | ||
| type execWorker struct { | ||
| mu sync.Mutex | ||
| reaped bool | ||
| cmd *exec.Cmd | ||
| stdout io.ReadCloser | ||
| lines Lines | ||
|
|
@@ -57,7 +60,19 @@ func (w *execWorker) Stdout() Lines { return w.lines } | |
| func (w *execWorker) Pid() int { return w.pid } | ||
|
|
||
| func (w *execWorker) Wait() (int, error) { | ||
| w.mu.Lock() | ||
| if w.reaped { | ||
| w.mu.Unlock() | ||
| return 0, nil | ||
| } | ||
| w.mu.Unlock() | ||
|
|
||
| err := w.cmd.Wait() | ||
|
|
||
| w.mu.Lock() | ||
| w.reaped = true | ||
| w.mu.Unlock() | ||
|
Comment on lines
+63
to
+74
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- repository conventions ---'
find /tmp/coderabbit-repo-knowledge/gitlawb-zero-238a126d -maxdepth 2 -type f -name '*.md' -print \
| sort
printf '%s\n' '--- changed files ---'
git diff --stat
printf '%s\n' '--- relevant diff ---'
git diff -- internal/daemon/launcher.go
printf '%s\n' '--- launcher.go ---'
cat -n internal/daemon/launcher.go
printf '%s\n' '--- direct definitions and callers ---'
rg -n -A20 -B8 'type execWorker|func \(.*\) Wait|func \(.*\) Kill|TerminateOwnedProcess|execWorker\{' internal
printf '%s\n' '--- Go version declarations ---'
rg -n '^(go|toolchain)[[:space:]]' go.mod '**/go.mod' 2>/dev/null || trueRepository: Gitlawb/zero Length of output: 50368 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- daemon learning ---'
cat /tmp/coderabbit-repo-knowledge/gitlawb-zero-238a126d/learnings/internal-daemon.md
printf '%s\n' '--- repository-wide conventions ---'
cat /tmp/coderabbit-repo-knowledge/gitlawb-zero-238a126d/conventions/repo-wide.md
printf '%s\n' '--- source/test conventions ---'
cat /tmp/coderabbit-repo-knowledge/gitlawb-zero-238a126d/conventions/src.md
cat /tmp/coderabbit-repo-knowledge/gitlawb-zero-238a126d/conventions/tests.md
printf '%s\n' '--- termination implementation ---'
cat -n internal/background/terminate.go | sed -n '1,115p'
printf '%s\n' '--- module/toolchain ---'
cat -n go.mod | sed -n '1,40p'
printf '%s\n' '--- daemon tests and Wait/Kill callers ---'
rg -n -A18 -B8 'execWorker|NewExecLauncher|\.Wait\(\)|\.Kill\(\)' internal/daemon --glob '*_test.go' --glob '*.go' | head -240
printf '%s\n' '--- local Go runtime metadata ---'
go version 2>/dev/null || true
go env GOROOT GOVERSION 2>/dev/null || trueRepository: Gitlawb/zero Length of output: 27421 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- Go 1.26.6 os/exec Wait implementation ---'
rg -n -A70 -B15 'func \(c \*Cmd\) Wait' /usr/local/go/src/os/exec/exec.go
printf '%s\n' '--- process-group termination bindings ---'
rg -n 'func (.*terminateOwnedProcess|func (.*ConfigureChildProcessGroup|launch-time|Pgid|Setpgid)' internal/background --glob '*.go'
printf '%s\n' '--- POSIX termination implementation ---'
for f in internal/background/*posix*.go internal/background/*unix*.go; do
if [ -f "$f" ]; then
printf '%s\n' "--- $f ---"
cat -n "$f" | sed -n '1,180p'
fi
done
printf '%s\n' '--- Windows termination implementation ---'
for f in internal/background/*windows*.go; do
if [ -f "$f" ]; then
printf '%s\n' "--- $f ---"
cat -n "$f" | sed -n '1,180p'
fi
doneRepository: Gitlawb/zero Length of output: 3208 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- background files ---'
find internal/background -maxdepth 1 -type f -print | sort
printf '%s\n' '--- relevant symbols ---'
rg -n -e 'terminateOwnedProcess' -e 'ConfigureChildProcessGroup' -e 'Pgid' -e 'Setpgid' internal/background
printf '%s\n' '--- relevant implementations ---'
for f in $(rg -l -e 'terminateOwnedProcess' -e 'ConfigureChildProcessGroup' internal/background); do
printf '%s\n' "--- $f ---"
cat -n "$f" | sed -n '1,220p'
doneRepository: Gitlawb/zero Length of output: 29653 Serialize worker reap state transitions.
🤖 Prompt for AI Agents |
||
|
|
||
| if err == nil { | ||
| return 0, nil | ||
| } | ||
|
|
@@ -69,12 +84,17 @@ func (w *execWorker) Wait() (int, error) { | |
| } | ||
|
|
||
| func (w *execWorker) Kill() error { | ||
| if w.cmd.Process == nil { | ||
| w.mu.Lock() | ||
| defer w.mu.Unlock() | ||
| if w.reaped || w.cmd.Process == nil { | ||
| return nil | ||
| } | ||
| // background.TerminateProcess is the cross-platform terminate (kills the | ||
| // process group on POSIX, taskkill /T on Windows). | ||
| return background.TerminateProcess(w.cmd.Process.Pid) | ||
| // TerminateOwnedProcess uses the launch-time group identity from | ||
| // ConfigureChildProcessGroup rather than TerminateProcess's Getpgid | ||
| // rediscovery. On Darwin, Getpgid of an unreaped group leader can return | ||
| // ESRCH and leave descendants running (#861, #774). TerminateCommand is | ||
| // the wrong helper here: it Wait()s, and the pool still owns the reap. | ||
| return background.TerminateOwnedProcess(w.cmd) | ||
| } | ||
|
|
||
| // readerLines adapts a bufio.Reader to the Lines interface. Unlike a capped | ||
|
|
@@ -154,13 +174,13 @@ func NewExecLauncher(cfg ExecLauncherConfig) (Launcher, error) { | |
| background.ConfigureChildProcessGroup(cmd) | ||
| // CommandContext's default cancel sends os.Process.Kill to the LEADER only, | ||
| // orphaning the process group we just configured (a stuck worker's children | ||
| // would survive ctx cancellation). Terminate the whole group instead — the | ||
| // same cross-platform group terminate Kill() uses (D11). | ||
| // would survive ctx cancellation). Terminate the whole group instead via | ||
| // the launch-time identity — the same primitive Kill() uses (D11, #861). | ||
| cmd.Cancel = func() error { | ||
| if cmd.Process == nil { | ||
| return nil | ||
| } | ||
| return background.TerminateProcess(cmd.Process.Pid) | ||
| return background.TerminateOwnedProcess(cmd) | ||
| } | ||
|
|
||
| stdout, err := cmd.StdoutPipe() | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.