Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/bodek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
28 changes: 28 additions & 0 deletions cmd/bodek/watchdog.go
Original file line number Diff line number Diff line change
@@ -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 <parentPID> <serverPID>`:
// 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)
}
81 changes: 81 additions & 0 deletions cmd/bodek/watchdog_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
17 changes: 17 additions & 0 deletions cmd/bodek/watchdog_testmain_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
31 changes: 31 additions & 0 deletions internal/server/pgroup_unix.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
30 changes: 30 additions & 0 deletions internal/server/pgroup_windows.go
Original file line number Diff line number Diff line change
@@ -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)
}
67 changes: 62 additions & 5 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -36,8 +40,18 @@ type Conn struct {
Token string // per-instance CSRF token
Version string // engine version as printed by `<bin> 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.
Expand Down Expand Up @@ -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 `<bin> version` probe so a hung binary never
// delays startup.
const versionTimeout = 2 * time.Second
Expand Down Expand Up @@ -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)
}
}

Expand Down
7 changes: 7 additions & 0 deletions internal/server/server_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading