diff --git a/cmd/bodek/main.go b/cmd/bodek/main.go index 507f394..120c170 100644 --- a/cmd/bodek/main.go +++ b/cmd/bodek/main.go @@ -148,6 +148,10 @@ func applyNoColor() { func run() error { // Bare subcommands (`bodek version`, `bodek upgrade`) bypass the TUI // entirely, so they run before flag parsing. + if len(os.Args) > 1 && os.Args[1] == watchdogSubcommandName { + runWatchdog(os.Args[2:]) + os.Exit(0) + } if handled, err := handleSubcommand(os.Args[1:], os.Stdout); handled { return err } diff --git a/cmd/bodek/watchdog.go b/cmd/bodek/watchdog.go new file mode 100644 index 0000000..831727e --- /dev/null +++ b/cmd/bodek/watchdog.go @@ -0,0 +1,28 @@ +package main + +import ( + "context" + "strconv" + + "github.com/BackendStack21/bodek/internal/watchdog" +) + +// watchdogSubcommandName is the hidden re-exec entry the orphan guard uses. +const watchdogSubcommandName = "__bodek-watchdog" + +// runWatchdog implements `bodek __bodek-watchdog `: +// a self-terminating guard that kills the spawned odek serve if the bodek +// process that launched it dies for any reason (SIGKILL, crash, lost +// terminal). Never user-facing; returns when the guard's job is done. +func runWatchdog(args []string) { + if len(args) != 2 { + return + } + parentPID, err1 := strconv.Atoi(args[0]) + serverPID, err2 := strconv.Atoi(args[1]) + if err1 != nil || err2 != nil { + return + } + watchdog.Run(context.Background(), parentPID, serverPID, + watchdog.DefaultPoll, watchdog.DefaultGrace) +} diff --git a/cmd/bodek/watchdog_test.go b/cmd/bodek/watchdog_test.go new file mode 100644 index 0000000..d237ec4 --- /dev/null +++ b/cmd/bodek/watchdog_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "os" + "os/exec" + "strconv" + "testing" + "time" + + "github.com/BackendStack21/bodek/internal/watchdog" +) + +// TestWatchdogSubcommandDispatch re-execs the test binary through the hidden +// `__bodek-watchdog` subcommand and verifies the guard kills the target when +// its parent dies — the same chain production uses via os.Executable(). +func TestWatchdogSubcommandDispatch(t *testing.T) { + if !watchdog.Supported() { + t.Skip("no orphan guard on this platform") + } + sleep, err := exec.LookPath("sleep") + if err != nil { + t.Skip("no 'sleep' binary") + } + self, err := os.Executable() + if err != nil { + t.Fatalf("executable: %v", err) + } + + parent := exec.Command(sleep, "30") + if err := parent.Start(); err != nil { + t.Fatalf("start parent: %v", err) + } + target := exec.Command(sleep, "30") + if err := target.Start(); err != nil { + t.Fatalf("start target: %v", err) + } + defer func() { _ = target.Process.Kill() }() + + guard := exec.Command(self, watchdogSubcommandName, + strconv.Itoa(parent.Process.Pid), strconv.Itoa(target.Process.Pid)) + if err := guard.Start(); err != nil { + t.Fatalf("start guard: %v", err) + } + + time.Sleep(300 * time.Millisecond) + if err := parent.Process.Kill(); err != nil { + t.Fatalf("kill parent: %v", err) + } + + deadline := time.Now().Add(10 * time.Second) + for watchdog.Alive(target.Process.Pid) && time.Now().Before(deadline) { + time.Sleep(100 * time.Millisecond) + } + if watchdog.Alive(target.Process.Pid) { + t.Fatal("target survived parent death — orphan guard failed") + } + // The guard itself must be gone too once its job is done. + for watchdog.Alive(guard.Process.Pid) && time.Now().Before(deadline) { + _, _ = guard.Process.Wait() // reap if exited + time.Sleep(100 * time.Millisecond) + } + _, _ = guard.Process.Wait() + if watchdog.Alive(guard.Process.Pid) { + t.Error("guard still running after target termination") + } +} + +// TestWatchdogSubcommandBadArgs: malformed input exits cleanly instead of +// hanging or crashing. +func TestWatchdogSubcommandBadArgs(t *testing.T) { + done := make(chan struct{}) + go func() { + runWatchdog([]string{"not-a-pid"}) + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("runWatchdog hung on bad args") + } +} diff --git a/cmd/bodek/watchdog_testmain_test.go b/cmd/bodek/watchdog_testmain_test.go new file mode 100644 index 0000000..cf3baad --- /dev/null +++ b/cmd/bodek/watchdog_testmain_test.go @@ -0,0 +1,17 @@ +package main + +import ( + "os" + "testing" +) + +// TestMain routes a hidden-subcommand re-exec of the test binary into the +// orphan-guard path (mirroring run()), instead of letting the testing +// package reject the non-flag argv. +func TestMain(m *testing.M) { + if len(os.Args) > 1 && os.Args[1] == watchdogSubcommandName { + runWatchdog(os.Args[2:]) + os.Exit(0) + } + os.Exit(m.Run()) +} diff --git a/internal/server/pgroup_unix.go b/internal/server/pgroup_unix.go new file mode 100644 index 0000000..62bb758 --- /dev/null +++ b/internal/server/pgroup_unix.go @@ -0,0 +1,31 @@ +//go:build darwin || linux + +package server + +import ( + "os/exec" + "syscall" +) + +// watchdogArg is the hidden subcommand bodek re-execs as to guard the +// spawned server against orphaning (see internal/watchdog). +const watchdogArg = "__bodek-watchdog" + +// setPgroup puts the child in its own process group so it (and its own +// subprocesses) can be signalled as a unit. +func setPgroup(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +// signalServer sends sig to the spawned server's whole process group (the +// server's own subprocesses follow it down), falling back to the leader +// when the group is gone. Safe because Stop holds a live Process handle +// for this pid — no recycled-PID window. +func (c *Conn) signalServer(sig syscall.Signal) { + if c.proc == nil || c.proc.Process == nil { + return + } + if err := syscall.Kill(-c.proc.Process.Pid, sig); err != nil { + _ = syscall.Kill(c.proc.Process.Pid, sig) + } +} diff --git a/internal/server/pgroup_windows.go b/internal/server/pgroup_windows.go new file mode 100644 index 0000000..95dfbfb --- /dev/null +++ b/internal/server/pgroup_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package server + +import ( + "os" + "os/exec" + "syscall" +) + +// watchdogArg is unused on windows: there is no process-group signalling, +// so no orphan guard is spawned. +const watchdogArg = "__bodek-watchdog" + +// setPgroup is a no-op on windows. +func setPgroup(*exec.Cmd) {} + +// signalServer falls back to bare-process signalling on windows (no +// process groups); Kill maps to Process.Kill, anything else to Interrupt +// as before. +func (c *Conn) signalServer(sig syscall.Signal) { + if c.proc == nil || c.proc.Process == nil { + return + } + if sig == syscall.SIGKILL { + _ = c.proc.Process.Kill() + return + } + _ = c.proc.Process.Signal(os.Interrupt) +} diff --git a/internal/server/server.go b/internal/server/server.go index 7477b9a..44cd645 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -13,9 +13,13 @@ import ( "net/url" "os" "os/exec" + "strconv" "strings" "sync" + "syscall" "time" + + "github.com/BackendStack21/bodek/internal/watchdog" ) const wsTokenCookie = "odek_ws_token" @@ -36,8 +40,18 @@ type Conn struct { Token string // per-instance CSRF token Version string // engine version as printed by ` version` (e.g. "v0.2.0"); spawn mode only - proc *exec.Cmd // non-nil when bodek spawned the server - scan *tokenScanWriter // non-nil when bodek spawned the server + proc *exec.Cmd // non-nil when bodek spawned the server + scan *tokenScanWriter // non-nil when bodek spawned the server + watch func() // cancels the orphan watchdog (nil when none) + watchMu sync.Mutex +} + +// watchdogBin is the executable the orphan watchdog re-execs as. It is a +// variable so tests can substitute a harmless stand-in. +var watchdogBin func() (string, error) + +func init() { + watchdogBin = func() (string, error) { return os.Executable() } } // Options configures how the odek serve instance is obtained. @@ -150,13 +164,47 @@ func (c *Conn) spawn(opts Options, addr string) error { cmd := exec.Command(bin, args...) cmd.Stderr = c.scan cmd.Env = os.Environ() + // Own process group so the watchdog (and Stop) can signal the server + // and any of its subprocesses as a unit, without touching bodek itself. + setPgroup(cmd) if err := cmd.Start(); err != nil { return fmt.Errorf("start odek serve: %w", err) } c.proc = cmd + c.startWatchdog() return nil } +// startWatchdog launches the orphan guard as a separate process: a re-exec +// of the bodek binary that kills the spawned server's process group if this +// process dies without stopping it (SIGKILL, crash, lost terminal). The +// guard is self-terminating — it exits once the server does — and is a +// no-op on platforms without process-group signalling. +func (c *Conn) startWatchdog() { + if !watchdog.Supported() { + return + } + self, err := watchdogBin() + if err != nil { + return // best effort: graceful Stop remains the primary path + } + wd := exec.Command(self, watchdogArg, + strconv.Itoa(os.Getpid()), strconv.Itoa(c.proc.Process.Pid)) + wd.Stdout = io.Discard + wd.Stderr = io.Discard + setPgroup(wd) // detached: not in bodek's group, immune to group signals + if err := wd.Start(); err != nil { + return // best effort: graceful Stop remains the primary path + } + c.watchMu.Lock() + c.watch = func() { + // Kill and reap: an unreaped guard reads as alive to kill -0 probes. + _ = wd.Process.Kill() + go func() { _ = wd.Wait() }() + } + c.watchMu.Unlock() +} + // versionTimeout bounds the ` version` probe so a hung binary never // delays startup. const versionTimeout = 2 * time.Second @@ -186,15 +234,24 @@ func (c *Conn) Stop() { if c == nil || c.proc == nil || c.proc.Process == nil { return } + // Graceful shutdown owns the exit — retire the orphan watchdog first. + c.watchMu.Lock() + if c.watch != nil { + c.watch() + c.watch = nil + } + c.watchMu.Unlock() // SIGINT triggers odek serve's graceful shutdown (closes sockets, removes - // sandbox containers). Fall back to Kill if it lingers. - _ = c.proc.Process.Signal(os.Interrupt) + // sandbox containers), delivered to the server's whole process group so + // its own subprocesses follow. SIGKILL escalation likewise targets the + // group. Fall back to Kill if it lingers. + c.signalServer(syscall.SIGINT) done := make(chan struct{}) go func() { _ = c.proc.Wait(); close(done) }() select { case <-done: case <-time.After(stopTimeout): - _ = c.proc.Process.Kill() + c.signalServer(syscall.SIGKILL) } } diff --git a/internal/server/server_internal_test.go b/internal/server/server_internal_test.go index f332aee..0983ef3 100644 --- a/internal/server/server_internal_test.go +++ b/internal/server/server_internal_test.go @@ -14,6 +14,13 @@ import ( "time" ) +// TestMain disables the orphan watchdog for the whole package: without +// this, spawn() would re-exec the test binary itself as the guard. +func TestMain(m *testing.M) { + watchdogBin = func() (string, error) { return "", os.ErrNotExist } + os.Exit(m.Run()) +} + // TestSpawnAndStop exercises the spawn + Stop lifecycle using a harmless // short-lived binary in place of odek. func TestSpawnAndStop(t *testing.T) { diff --git a/internal/server/watchdog_internal_test.go b/internal/server/watchdog_internal_test.go new file mode 100644 index 0000000..2b2069f --- /dev/null +++ b/internal/server/watchdog_internal_test.go @@ -0,0 +1,128 @@ +//go:build unix + +package server + +import ( + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/BackendStack21/bodek/internal/watchdog" +) + +// fakeBodekScript builds a stand-in for the bodek binary: on the hidden +// `__bodek-watchdog` subcommand it records its pid and sleeps; any other +// invocation just sleeps (server stand-in duties are handled by other +// fixtures — this one only impersonates the guard host). +func fakeBodekScript(t *testing.T) (bin string, pidfile string) { + t.Helper() + dir := t.TempDir() + pidfile = filepath.Join(dir, "guard.pid") + bin = filepath.Join(dir, "fake-bodek") + script := "#!/bin/sh\n" + + "if [ \"$1\" = \"__bodek-watchdog\" ]; then\n" + + " echo $$ > \"" + pidfile + "\"\n" + + " exec sleep 60\n" + + "fi\n" + + "exec sleep 60\n" + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatalf("write fake bodek: %v", err) + } + return bin, pidfile +} + +func pidFromFile(t *testing.T, path string) (int, bool) { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + return 0, false + } + pid, err := strconv.Atoi(strings.TrimSpace(string(b))) + if err != nil { + return 0, false + } + return pid, true +} + +func processExists(pid int) bool { + return watchdog.Alive(pid) +} + +// TestSpawnStartsWatchdogAndStopRetiresIt: spawning a server also launches +// the orphan guard, and a graceful Stop retires the guard (it must not +// linger or fire after the orderly path ran). +func TestSpawnStartsWatchdogAndStopRetiresIt(t *testing.T) { + if !watchdog.Supported() { + t.Skip("no process-group signalling on this platform") + } + // Server stand-in: ignore spawn args, just live. + serverBin := filepath.Join(t.TempDir(), "fake-server") + if err := os.WriteFile(serverBin, []byte("#!/bin/sh\nexec sleep 60\n"), 0o755); err != nil { + t.Fatalf("write fake server: %v", err) + } + + fake, pidfile := fakeBodekScript(t) + old := watchdogBin + watchdogBin = func() (string, error) { return fake, nil } + defer func() { watchdogBin = old }() + + c := &Conn{} + if err := c.spawn(Options{Bin: serverBin}, "127.0.0.1:0"); err != nil { + t.Fatalf("spawn: %v", err) + } + defer c.Stop() + + var guardPID int + deadline := time.Now().Add(5 * time.Second) + for { + if pid, ok := pidFromFile(t, pidfile); ok { + guardPID = pid + break + } + if time.Now().After(deadline) { + t.Fatal("watchdog guard was never launched") + } + time.Sleep(50 * time.Millisecond) + } + if !processExists(guardPID) { + t.Fatal("watchdog guard died immediately") + } + // The spawned server must lead its own process group. + pgid, _, errno := syscall.Syscall(syscall.SYS_GETPGID, uintptr(c.proc.Process.Pid), 0, 0) + if errno != 0 || int(pgid) != c.proc.Process.Pid { + t.Errorf("server not in its own process group: pgid=%d pid=%d errno=%v", + pgid, c.proc.Process.Pid, errno) + } + + c.Stop() + deadline = time.Now().Add(5 * time.Second) + for processExists(guardPID) && time.Now().Before(deadline) { + time.Sleep(50 * time.Millisecond) + } + if processExists(guardPID) { + t.Error("watchdog guard still alive after Stop") + } +} + +// TestSpawnWatchdogBestEffort: a guard host that cannot be resolved must +// not break spawning — the graceful Stop path stays authoritative. +func TestSpawnWatchdogBestEffort(t *testing.T) { + sleep, err := exec.LookPath("sleep") + if err != nil { + t.Skip("no 'sleep' binary") + } + old := watchdogBin + watchdogBin = func() (string, error) { return "", os.ErrNotExist } + defer func() { watchdogBin = old }() + + c := &Conn{} + if err := c.spawn(Options{Bin: sleep}, "127.0.0.1:0"); err != nil { + t.Fatalf("spawn without guard: %v", err) + } + c.Stop() +} diff --git a/internal/watchdog/watchdog.go b/internal/watchdog/watchdog.go new file mode 100644 index 0000000..819577f --- /dev/null +++ b/internal/watchdog/watchdog.go @@ -0,0 +1,89 @@ +// Package watchdog guards against orphaned `odek serve` child processes. +// +// bodek spawns the server as a child; its own Stop path shuts it down +// gracefully, but if bodek itself is SIGKILLed (or crashes, or the terminal +// vanishes) nothing remains to reap the child. Unix offers no portable +// "die with parent" for Go children on darwin, so bodek re-execs itself as +// a tiny watchdog process that polls its parent and terminates the server +// when the parent disappears for any reason. +package watchdog + +import ( + "context" + "time" +) + +// DefaultPoll is how often the watchdog checks that the parent is alive. +const DefaultPoll = 500 * time.Millisecond + +// DefaultGrace bounds the graceful (SIGINT) shutdown window after parent +// death before the watchdog escalates to SIGKILL on the process group. +const DefaultGrace = 8 * time.Second + +// Supported reports whether the orphan guard can run on this platform. +func Supported() bool { return supported } + +// Alive reports whether pid is a live process (zombies count as dead). +// Exported for callers that need honest liveness probes. +func Alive(pid int) bool { return processAlive(pid) } + +// Run blocks until parentPID dies, then terminates targetPID's process +// group: SIGINT first (graceful), SIGKILL after grace. It also returns as +// soon as the target itself dies — there is nothing left to guard — and +// when ctx is cancelled (the parent is shutting down through its own Stop +// path). +// +// The target is identified by its start time, captured on entry: if the +// original server exits and the OS recycles its PID, the replacement is +// never signalled. On platforms without process signals (windows) Run is a +// no-op. +func Run(ctx context.Context, parentPID, targetPID int, poll, grace time.Duration) { + if !supported || parentPID <= 0 || targetPID <= 0 { + return + } + if poll <= 0 { + poll = DefaultPoll + } + token := startToken(targetPID) + for processAlive(parentPID) { + if !sameTarget(targetPID, token) { + return // server already gone (or its PID recycled): nothing to guard + } + select { + case <-ctx.Done(): + return + case <-time.After(poll): + } + } + // Parent is gone: if the target already exited, nothing to do. + if !sameTarget(targetPID, token) { + return + } + signalGroup(targetPID, sigInterrupt) + deadline := time.Now().Add(grace) + for sameTarget(targetPID, token) && time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return + case <-time.After(poll): + } + } + if sameTarget(targetPID, token) { + signalGroup(targetPID, sigKill) + } +} + +// sameTarget reports whether targetPID still refers to the live process +// the watchdog was started for. A start-time token distinguishes a +// recycled PID from the original; liveness (zombie-aware) is required +// either way. +func sameTarget(pid int, token int64) bool { + if !processAlive(pid) { + return false + } + cur := startToken(pid) + if token != 0 && cur != 0 { + return cur == token + } + return true +} diff --git a/internal/watchdog/watchdog_darwin.go b/internal/watchdog/watchdog_darwin.go new file mode 100644 index 0000000..d821843 --- /dev/null +++ b/internal/watchdog/watchdog_darwin.go @@ -0,0 +1,62 @@ +//go:build darwin + +package watchdog + +import ( + "syscall" + + "golang.org/x/sys/unix" +) + +// darwin's SZOMB (sys/proc.h) is not exported by x/sys. +const szomb = 5 + +// startToken returns a start-time identity for pid (0 when unavailable). +// kinfo's P_start distinguishes a recycled PID from the original process. +func startToken(pid int) int64 { + kp, err := unix.SysctlKinfoProc("kern.proc.pid", pid) + if err != nil { + return 0 + } + return int64(kp.Proc.P_starttime.Sec)*1_000_000 + int64(kp.Proc.P_starttime.Usec) +} + +// processAlive reports whether pid is still a live process. kill -0 alone +// also succeeds on unreaped zombies, so a darwin kinfo lookup filters +// those: the watchdog must treat a dead-but-unreaped parent as gone. +// Degraded observation fails SAFE (alive): EPERM from kill means the +// process exists but is not ours to probe, and an unexplained kinfo +// failure is treated as alive — a false-alive at worst delays the guard, +// a false-dead kills a healthy server. +func processAlive(pid int) bool { + err := syscall.Kill(pid, 0) + if err == syscall.EPERM { + return true // exists, but not ours to probe + } + if err != nil { + return false // ESRCH: no such process + } + kp, kerr := unix.SysctlKinfoProc("kern.proc.pid", pid) + if kerr != nil { + // kill -0 said it exists; kinfo failure is not evidence of death. + return true + } + return kp.Proc.P_stat != szomb +} + +// signalGroup signals the target's process group when the target leads one +// (bodek spawns the server with Setpgid), falling back to the bare pid +// when it does not (ESRCH on the group). Safe because every caller has +// just verified the pid's identity — a recycled PID never reaches here. +func signalGroup(pid int, sig syscall.Signal) { + if err := syscall.Kill(-pid, sig); err != nil { + _ = syscall.Kill(pid, sig) + } +} + +const ( + sigInterrupt = syscall.SIGINT + sigKill = syscall.SIGKILL +) + +const supported = true diff --git a/internal/watchdog/watchdog_linux.go b/internal/watchdog/watchdog_linux.go new file mode 100644 index 0000000..ea01dea --- /dev/null +++ b/internal/watchdog/watchdog_linux.go @@ -0,0 +1,76 @@ +//go:build linux + +package watchdog + +import ( + "bytes" + "os" + "strconv" + "syscall" +) + +// startToken returns a start-time identity for pid (0 when unavailable): +// field 22 of /proc//stat (clock ticks since boot). A recycled PID +// reports a different value. +func startToken(pid int) int64 { + stat, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") + if err != nil { + return 0 + } + i := bytes.LastIndexByte(stat, ')') + if i < 0 || i+2 > len(stat) { + return 0 + } + fields := bytes.Fields(stat[i+2:]) + // Remainder starts at field 3 (state); starttime is field 22. + if len(fields) < 20 { + return 0 + } + n, err := strconv.ParseInt(string(fields[19]), 10, 64) + if err != nil { + return 0 + } + return n +} + +// processAlive reports whether pid is still a live process. It fails +// SAFE (alive) when observation is degraded: EPERM from kill means the +// process exists but is not ours to probe; an unreadable /proc entry +// (hidepid) is likewise treated as alive — a false-alive at worst delays +// the guard, a false-dead kills a healthy server. Only a definitive +// no-such-process or zombie state counts as dead. +func processAlive(pid int) bool { + err := syscall.Kill(pid, 0) + if err == syscall.EPERM { + return true // exists, but not ours to probe + } + if err != nil { + return false // ESRCH: no such process + } + stat, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") + if err != nil { + return true // unreadable ≠ dead (hidepid): fail safe + } + // Field 3 is the state, after "(comm)" — comm may contain spaces. + if i := bytes.LastIndexByte(stat, ')'); i >= 0 && i+2 < len(stat) { + return stat[i+2] != 'Z' + } + return true +} + +// signalGroup signals the target's process group when the target leads one +// (bodek spawns the server with Setpgid), falling back to the bare pid +// when it does not (ESRCH on the group). Safe because every caller has +// just verified the pid's identity — a recycled PID never reaches here. +func signalGroup(pid int, sig syscall.Signal) { + if err := syscall.Kill(-pid, sig); err != nil { + _ = syscall.Kill(pid, sig) + } +} + +const ( + sigInterrupt = syscall.SIGINT + sigKill = syscall.SIGKILL +) + +const supported = true diff --git a/internal/watchdog/watchdog_test.go b/internal/watchdog/watchdog_test.go new file mode 100644 index 0000000..b3f1be2 --- /dev/null +++ b/internal/watchdog/watchdog_test.go @@ -0,0 +1,164 @@ +package watchdog + +import ( + "context" + "os/exec" + "runtime" + "testing" + "time" +) + +func skipWindows(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("watchdog process-group kill is unix-only") + } +} + +func alive(t *testing.T, pid int) bool { + t.Helper() + return processAlive(pid) +} + +// TestRunKillsTargetWhenParentDies is the core orphan-prevention contract: +// the spawned server must not outlive bodek when bodek is killed outright. +func TestRunKillsTargetWhenParentDies(t *testing.T) { + skipWindows(t) + target, err := exec.LookPath("sleep") + if err != nil { + t.Skip("no 'sleep' binary") + } + srv := exec.Command(target, "30") + if err := srv.Start(); err != nil { + t.Fatalf("start server stand-in: %v", err) + } + defer func() { _ = srv.Process.Kill() }() + + // A short-lived stand-in for the bodek parent process. + parent := exec.Command(target, "1") + if err := parent.Start(); err != nil { + t.Fatalf("start parent stand-in: %v", err) + } + + done := make(chan struct{}) + go func() { + Run(context.Background(), parent.Process.Pid, srv.Process.Pid, 100*time.Millisecond, time.Second) + close(done) + }() + + // Kill the parent abruptly (what a SIGKILLed bodek looks like). + time.Sleep(200 * time.Millisecond) + if err := parent.Process.Kill(); err != nil { + t.Fatalf("kill parent: %v", err) + } + + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("Run did not return after parent death") + } + deadline := time.Now().Add(5 * time.Second) + for alive(t, srv.Process.Pid) && time.Now().Before(deadline) { + time.Sleep(100 * time.Millisecond) + } + if alive(t, srv.Process.Pid) { + t.Fatal("target still alive after parent died — orphaned server") + } +} + +// TestRunExitsWhenTargetDiesWhileParentLives pins the self-termination +// contract: once the server is gone there is nothing to guard, so the +// watchdog must not linger for the rest of the parent's session. +func TestRunExitsWhenTargetDiesWhileParentLives(t *testing.T) { + skipWindows(t) + bin, err := exec.LookPath("sleep") + if err != nil { + t.Skip("no 'sleep' binary") + } + parent := exec.Command(bin, "30") + if err := parent.Start(); err != nil { + t.Fatalf("start parent: %v", err) + } + defer func() { _ = parent.Process.Kill() }() + target := exec.Command(bin, "1") + if err := target.Start(); err != nil { + t.Fatalf("start target: %v", err) + } + + done := make(chan struct{}) + go func() { + Run(context.Background(), parent.Process.Pid, target.Process.Pid, 100*time.Millisecond, time.Second) + close(done) + }() + // Target dies within ~1s while the parent lives: Run must return soon + // after, not hang until the parent exits. + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Run lingered after target death while parent alive") + } + if !alive(t, parent.Process.Pid) { + t.Fatal("parent must be untouched") + } +} + +// TestRunLeavesTargetWhileParentLives: no kill while bodek is healthy. +func TestRunLeavesTargetWhileParentLives(t *testing.T) { + skipWindows(t) + bin, err := exec.LookPath("sleep") + if err != nil { + t.Skip("no 'sleep' binary") + } + parent := exec.Command(bin, "30") + if err := parent.Start(); err != nil { + t.Fatalf("start parent: %v", err) + } + defer func() { _ = parent.Process.Kill() }() + target := exec.Command(bin, "30") + if err := target.Start(); err != nil { + t.Fatalf("start target: %v", err) + } + defer func() { _ = target.Process.Kill() }() + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + Run(ctx, parent.Process.Pid, target.Process.Pid, 100*time.Millisecond, time.Second) + close(done) + }() + + time.Sleep(400 * time.Millisecond) + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Run ignored cancellation") + } + if !alive(t, target.Process.Pid) { + t.Fatal("target killed while parent alive") + } +} + +// TestRunTargetAlreadyDead exits promptly without hanging or erroring. +func TestRunTargetAlreadyDead(t *testing.T) { + skipWindows(t) + bin, err := exec.LookPath("true") + if err != nil { + t.Skip("no 'true' binary") + } + parent := exec.Command(bin) + if err := parent.Start(); err != nil { + t.Fatalf("start parent: %v", err) + } + _ = parent.Wait() // both dead already + + done := make(chan struct{}) + go func() { + Run(context.Background(), parent.Process.Pid, parent.Process.Pid, 50*time.Millisecond, time.Second) + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Run hung on dead processes") + } +} diff --git a/internal/watchdog/watchdog_windows.go b/internal/watchdog/watchdog_windows.go new file mode 100644 index 0000000..d3d34dd --- /dev/null +++ b/internal/watchdog/watchdog_windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package watchdog + +import "time" + +const supported = false + +const ( + sigInterrupt = 0 + sigKill = 0 +) + +func processAlive(int) bool { return false } +func startToken(int) int64 { return 0 } +func signalGroup(int, int) {} + +var _ = time.Second