mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -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]>
212 lines
6.0 KiB
Go
212 lines
6.0 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/background"
|
|
)
|
|
|
|
// isolateDaemonPaths points DefaultPaths at a temp dir so the test never touches
|
|
// a real daemon on the dev machine.
|
|
func isolateDaemonPaths(t *testing.T) {
|
|
t.Helper()
|
|
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
|
|
}
|
|
|
|
func runDaemonCLI(t *testing.T, args ...string) (int, string, string) {
|
|
t.Helper()
|
|
var out, errb bytes.Buffer
|
|
code := runDaemon(args, &out, &errb, appDeps{})
|
|
return code, out.String(), errb.String()
|
|
}
|
|
|
|
func TestDaemonUsage(t *testing.T) {
|
|
code, _, _ := runDaemonCLI(t)
|
|
if code != exitUsage {
|
|
t.Fatalf("no-args exit = %d, want exitUsage", code)
|
|
}
|
|
code, out, _ := runDaemonCLI(t, "--help")
|
|
if code != exitSuccess || !strings.Contains(out, "Usage: zero daemon") {
|
|
t.Fatalf("--help exit=%d out=%q", code, out)
|
|
}
|
|
}
|
|
|
|
func TestDaemonUnknownSubcommand(t *testing.T) {
|
|
code, _, errb := runDaemonCLI(t, "frobnicate")
|
|
if code != exitUsage {
|
|
t.Fatalf("unknown subcommand exit = %d, want exitUsage", code)
|
|
}
|
|
if !strings.Contains(errb, "unknown daemon subcommand") {
|
|
t.Fatalf("stderr = %q, want unknown-subcommand message", errb)
|
|
}
|
|
}
|
|
|
|
func TestDaemonRunRequiresSession(t *testing.T) {
|
|
isolateDaemonPaths(t)
|
|
code, _, errb := runDaemonCLI(t, "run", "--prompt", "hi")
|
|
if code != exitUsage {
|
|
t.Fatalf("run without --session exit = %d, want exitUsage", code)
|
|
}
|
|
if !strings.Contains(errb, "--session") {
|
|
t.Fatalf("stderr = %q, want a --session hint", errb)
|
|
}
|
|
}
|
|
|
|
func TestDaemonRunRequiresPromptOrArgs(t *testing.T) {
|
|
isolateDaemonPaths(t)
|
|
code, _, errb := runDaemonCLI(t, "run", "--session", "s1")
|
|
if code != exitUsage {
|
|
t.Fatalf("run without prompt/args exit = %d, want exitUsage", code)
|
|
}
|
|
if !strings.Contains(errb, "--prompt") {
|
|
t.Fatalf("stderr = %q, want a --prompt hint", errb)
|
|
}
|
|
}
|
|
|
|
func TestDaemonStopWhenNotRunning(t *testing.T) {
|
|
isolateDaemonPaths(t)
|
|
code, out, _ := runDaemonCLI(t, "stop")
|
|
if code != exitSuccess {
|
|
t.Fatalf("stop (not running) exit = %d, want exitSuccess", code)
|
|
}
|
|
if !strings.Contains(out, "not running") {
|
|
t.Fatalf("stop output = %q, want 'not running'", out)
|
|
}
|
|
}
|
|
|
|
func TestDaemonStatusWhenNotRunning(t *testing.T) {
|
|
isolateDaemonPaths(t)
|
|
code, out, _ := runDaemonCLI(t, "status")
|
|
if code != exitSuccess {
|
|
t.Fatalf("status (not running) exit = %d, want exitSuccess", code)
|
|
}
|
|
if !strings.Contains(out, "not running") {
|
|
t.Fatalf("status output = %q, want 'not running'", out)
|
|
}
|
|
}
|
|
|
|
func TestDaemonAttachRequiresSession(t *testing.T) {
|
|
isolateDaemonPaths(t)
|
|
code, _, errb := runDaemonCLI(t, "attach")
|
|
if code != exitUsage {
|
|
t.Fatalf("attach without session exit = %d, want exitUsage", code)
|
|
}
|
|
if !strings.Contains(errb, "session") {
|
|
t.Fatalf("stderr = %q, want a session hint", errb)
|
|
}
|
|
}
|
|
|
|
func TestDaemonRunWhenNotRunning(t *testing.T) {
|
|
isolateDaemonPaths(t)
|
|
code, _, errb := runDaemonCLI(t, "run", "--session", "s1", "--prompt", "hello")
|
|
if code != exitCrash {
|
|
t.Fatalf("run (no daemon) exit = %d, want exitCrash", code)
|
|
}
|
|
if !strings.Contains(errb, "not running") {
|
|
t.Fatalf("stderr = %q, want 'not running'", errb)
|
|
}
|
|
}
|
|
|
|
func TestDaemonSubcommandsRejectExtraArgs(t *testing.T) {
|
|
isolateDaemonPaths(t)
|
|
cases := [][]string{
|
|
{"stop", "oops"},
|
|
{"status", "oops"},
|
|
{"attach", "s1", "extra"},
|
|
}
|
|
for _, args := range cases {
|
|
code, _, errb := runDaemonCLI(t, args...)
|
|
if code != exitUsage {
|
|
t.Fatalf("%v exit = %d, want exitUsage (reject extra args); stderr=%q", args, code, errb)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWaitForDaemonReadinessChecksTimeoutBoundary(t *testing.T) {
|
|
checks := 0
|
|
ready := waitForDaemonReadiness(func() bool {
|
|
checks++
|
|
return checks == 2
|
|
}, 0, time.Millisecond)
|
|
if !ready {
|
|
t.Fatal("daemon that became reachable at the timeout boundary was not detected")
|
|
}
|
|
if checks != 2 {
|
|
t.Fatalf("reachability checks = %d, want initial and final checks", checks)
|
|
}
|
|
}
|
|
|
|
func TestTerminateAndReapDaemonProcess(t *testing.T) {
|
|
cmd := daemonDetachedChildCommand("hang")
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("start helper process: %v", err)
|
|
}
|
|
|
|
if err := terminateAndReapDaemonProcess(cmd); err != nil {
|
|
t.Fatalf("terminate and reap helper process: %v", err)
|
|
}
|
|
if cmd.ProcessState == nil {
|
|
t.Fatalf("helper process was not reaped: state=%v", cmd.ProcessState)
|
|
}
|
|
if cmd.ProcessState.Success() {
|
|
t.Fatalf("helper process unexpectedly exited successfully: %v", cmd.ProcessState)
|
|
}
|
|
}
|
|
|
|
func TestStartAndAwaitDaemonProcessTimeoutTerminatesAndReaps(t *testing.T) {
|
|
cmd := daemonDetachedChildCommand("hang")
|
|
if err := startAndAwaitDaemonProcess(cmd, func() bool { return false }, 0, time.Millisecond); !errors.Is(err, errDaemonStartTimeout) {
|
|
t.Fatalf("start and await error = %v, want timeout", err)
|
|
}
|
|
if cmd.ProcessState == nil {
|
|
t.Fatal("timed-out helper process was not reaped")
|
|
}
|
|
}
|
|
|
|
func TestStartAndAwaitDaemonProcessReleasesReadyChild(t *testing.T) {
|
|
marker := filepath.Join(t.TempDir(), "child-finished")
|
|
cmd := daemonDetachedChildCommand("mark", "ZERO_TEST_DAEMON_CHILD_MARKER="+marker)
|
|
if err := startAndAwaitDaemonProcess(cmd, func() bool { return true }, time.Second, time.Millisecond); err != nil {
|
|
t.Fatalf("start and await ready helper: %v", err)
|
|
}
|
|
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := os.Stat(marker); err == nil {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatal("released helper process did not remain alive to write its marker")
|
|
}
|
|
|
|
func daemonDetachedChildCommand(mode string, extraEnv ...string) *exec.Cmd {
|
|
cmd := exec.Command(os.Args[0], "-test.run=TestDaemonDetachedChildProcess")
|
|
cmd.Env = append(os.Environ(), "ZERO_TEST_DAEMON_DETACHED_CHILD="+mode)
|
|
cmd.Env = append(cmd.Env, extraEnv...)
|
|
background.ConfigureChildProcessGroup(cmd)
|
|
return cmd
|
|
}
|
|
|
|
func TestDaemonDetachedChildProcess(t *testing.T) {
|
|
switch os.Getenv("ZERO_TEST_DAEMON_DETACHED_CHILD") {
|
|
case "":
|
|
return
|
|
case "mark":
|
|
if err := os.WriteFile(os.Getenv("ZERO_TEST_DAEMON_CHILD_MARKER"), []byte("ready"), 0o600); err != nil {
|
|
os.Exit(2)
|
|
}
|
|
os.Exit(0)
|
|
}
|
|
for {
|
|
time.Sleep(time.Hour)
|
|
}
|
|
}
|