mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 17:57:54 -10:00
* fix(daemon): clean up child after startup timeout * fix(daemon): terminate startup process tree * test(background): accept reaped-later zombie children * fix(background): retain configured process group target * fix(background): address termination review feedback * fix(daemon): use launch-time group identity, fix zombie/reap classification Addresses jatmn's three #774 review findings: - TerminateCommand rediscovered the process group via execution.TerminateProcessTree's Getpgid(pid) lookup. On Darwin, that lookup can return ESRCH once an unreaped group leader has already exited, even though live descendants remain in the group it configured at launch — silently falling back to signalling only the (already dead) leader PID and leaving descendants running (the required macOS smoke job's failure). Add execution.TerminateProcessGroup, which signals the negative PID directly using the launch-time Setpgid invariant instead of rediscovering it, and route TerminateCommand through it. - signalTargetRunning's zombie check matched the individual-PID fallback target (a process that is not its own group leader) against the process table by PGID. That target's actual PGID differs from its own PID by definition, so no row ever matched, unknown resulted every time, and a zombie individual-PID target was always conservatively reported as still running. Branch on the sign of the target: group members by PGID, individual PIDs by PID. - TerminateCommand returned the termination error even when the subsequent reap succeeded — a successful cmd.Wait is authoritative proof the leader is gone and collected, regardless of how the kill attempt itself went (e.g. taskkill /T racing an already-exited PID on Windows). Classify a successful reap as success. All three regression tests are verified to fail against the pre-fix code (confirmed by temporarily reverting each fix and rerunning). Co-Authored-By: Claude Sonnet 5 <[email protected]> * fix(daemon): harden command termination inputs Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-abb8-73de-9489-51e9e6514c6b Co-authored-by: Pierre Bruno <[email protected]> * fix(background): preserve process tree failures Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8 Co-authored-by: Pierre Bruno <[email protected]> * docs(daemon): clarify boundary-probe and Setsid-guard comments per PR #774 review Co-authored-by: Cursor <[email protected]> --------- Co-authored-by: Claude Sonnet 5 <[email protected]> Co-authored-by: Amp <[email protected]> Co-authored-by: Cursor <[email protected]>
134 lines
4.7 KiB
Go
134 lines
4.7 KiB
Go
//go:build !windows
|
|
|
|
package execution
|
|
|
|
import (
|
|
"os/exec"
|
|
"strconv"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestTerminateProcessTreeTreatsZombieGroupAsExited is the regression test for
|
|
// counting unreaped processes as alive: kill(2) with signal 0 succeeds for a
|
|
// zombie, so a group whose only remaining member is waiting to be reaped used to
|
|
// sit through both grace periods, take a pointless SIGKILL, and then be reported
|
|
// as "did not exit after SIGKILL" — a spurious cleanup failure whose timing
|
|
// depended on when the reaper ran.
|
|
func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
|
|
cmd := exec.Command("sh", "-c", "exit 0")
|
|
ConfigureProcessGroup(cmd)
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
pid := cmd.Process.Pid
|
|
// Deliberately do NOT Wait: the child stays a zombie in its own group, which
|
|
// is exactly the state a terminated tree passes through.
|
|
t.Cleanup(func() { _ = cmd.Wait() })
|
|
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for {
|
|
if zombie, ok := processIsZombie(pid); ok && zombie {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if syscall.Kill(-pid, syscall.Signal(0)) != nil {
|
|
t.Skip("the zombie group is no longer signalable; cannot exercise the case")
|
|
}
|
|
|
|
if signalTargetRunning(-pid) {
|
|
t.Fatal("a group holding only a zombie must not count as running")
|
|
}
|
|
if err := TerminateProcessTree(pid, 50*time.Millisecond, 10*time.Millisecond); err != nil {
|
|
t.Fatalf("TerminateProcessTree on an already-exited tree: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestTerminateProcessTreeStopsRunningGroup is the positive control: a group with
|
|
// a live member must still be seen as running and then actually stopped, so the
|
|
// zombie tolerance above cannot degrade into ignoring real processes.
|
|
func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
|
|
cmd := exec.Command("sh", "-c", "sleep 30")
|
|
ConfigureProcessGroup(cmd)
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
pid := cmd.Process.Pid
|
|
defer func() { _ = cmd.Wait() }()
|
|
|
|
if !signalTargetRunning(-pid) {
|
|
t.Fatal("a group with a live member must count as running")
|
|
}
|
|
if err := TerminateProcessTree(pid, time.Second, 10*time.Millisecond); err != nil {
|
|
t.Fatalf("TerminateProcessTree: %v", err)
|
|
}
|
|
if running, ok := processGroupHasRunningMember(pid); ok && running {
|
|
t.Fatal("the group still has a running member after termination")
|
|
}
|
|
}
|
|
|
|
// TestSignalTargetRunningTreatsZombieIndividualPIDAsExited is the regression
|
|
// test for jatmn's second #774 finding: signalTargetRunning's zombie check
|
|
// matched the individual-PID fallback target (a process that is NOT its own
|
|
// group leader — processSignalTarget's positive-PID case) against process-table
|
|
// rows by PGID. That target's actual PGID differs from its own PID by
|
|
// definition (that's exactly why processSignalTarget fell back to the
|
|
// individual PID instead of a negative group target), so no row ever matched,
|
|
// "unknown" resulted every time, and signalTargetRunning conservatively (and
|
|
// incorrectly) treated a zombie individual-PID target as still running.
|
|
func TestSignalTargetRunningTreatsZombieIndividualPIDAsExited(t *testing.T) {
|
|
cmd := exec.Command("sh", "-c", "exit 0")
|
|
// Deliberately do NOT call ConfigureProcessGroup: the child inherits this
|
|
// test process's group, so it is not its own leader and processSignalTarget
|
|
// falls back to the individual (positive) PID.
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
pid := cmd.Process.Pid
|
|
t.Cleanup(func() { _ = cmd.Wait() })
|
|
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for {
|
|
if zombie, ok := processIsZombie(pid); ok && zombie {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
|
|
target, err := processSignalTarget(pid)
|
|
if err != nil {
|
|
t.Fatalf("processSignalTarget: %v", err)
|
|
}
|
|
if target != pid {
|
|
t.Skip("child unexpectedly became its own group leader; cannot exercise the individual-PID fallback")
|
|
}
|
|
if signalTargetRunning(target) {
|
|
t.Fatal("a zombie individual-PID target must not count as running")
|
|
}
|
|
}
|
|
|
|
// processIsZombie reports a process's zombie state via ps. ok is false when the
|
|
// state could not be read.
|
|
func processIsZombie(pid int) (zombie bool, ok bool) {
|
|
output, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
|
|
if err != nil {
|
|
return false, false
|
|
}
|
|
state := string(output)
|
|
for len(state) > 0 && (state[0] == ' ' || state[0] == '\n' || state[0] == '\t') {
|
|
state = state[1:]
|
|
}
|
|
if state == "" {
|
|
return false, false
|
|
}
|
|
return state[0] == 'Z', true
|
|
}
|