From c5eae98119d12b0d3d81093ba6676d110588017a Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 8 Oct 2026 18:40:56 -0600 Subject: [PATCH 1/3] fix(supervisor): reap only leased process descendants --- README.md | 4 +- supervisor/detached_linux_test.go | 129 ++++++++++++++++++++++++++++++ supervisor/exit_darwin.go | 4 +- supervisor/exit_linux.go | 6 +- supervisor/tree_unix.go | 2 +- 5 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 supervisor/detached_linux_test.go diff --git a/README.md b/README.md index 9fee08a..6a1441f 100644 --- a/README.md +++ b/README.md @@ -366,7 +366,7 @@ after process reaping, until the reader drains them. | Platform | Ownership mechanism | | --- | --- | -| Linux | Dedicated plugin process group; supervisor adopts and reaps orphaned descendants as a subreaper | +| Linux | Dedicated plugin process group; supervisor adopts orphaned descendants as a subreaper and reaps those in the leased group | | macOS | Dedicated plugin process group; supervisor reaps the plugin and the OS adopts orphaned descendants | | Windows | Supervisor joins a non-breakaway Job Object before spawning the plugin; descendants inherit membership, and the last handle closes when the supervisor exits | @@ -383,6 +383,8 @@ session), or privilege elevation requires an additional explicit owner. On Unix, simultaneously killing the host and its supervisor prevents that supervisor from performing cleanup. Long-lived runtime services and container resources must have their own resource lifecycle rather than depend on these command processes. +The Linux supervisor does not wait for separately owned backend sessions to +exit; the OS adopts them when the supervisor exits. The default environment remains inherited, with optional `Env` overrides; client-assigned handshake, certificate, and socket metadata retain precedence. diff --git a/supervisor/detached_linux_test.go b/supervisor/detached_linux_test.go new file mode 100644 index 0000000..e4c1c79 --- /dev/null +++ b/supervisor/detached_linux_test.go @@ -0,0 +1,129 @@ +package supervisor + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" + + "golang.org/x/sys/unix" +) + +const ( + detachedFixtureRole = "DEVSY_DETACHED_FIXTURE_ROLE" + detachedFixtureSocket = "DEVSY_DETACHED_FIXTURE_SOCKET" +) + +func TestRunnerDoesNotWaitForDetachedBackend(t *testing.T) { + options := fixtureOptions(t) + options.Args = []string{"-test.run=^TestDetachedBackendFixture$"} + socket := filepath.Join(shortDirectory(t), "backend.sock") + options.Env = append( + os.Environ(), + detachedFixtureRole+"=runtime", + detachedFixtureSocket+"="+socket, + ) + runtime, err := Runner(options)(nil, &exec.Cmd{}, shortDirectory(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = runtime.Stdout().Close(); _ = runtime.Stderr().Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := runtime.Start(ctx); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = runtime.Kill(context.Background()) }) + var pid int + if err := json.NewDecoder(runtime.Stdout()).Decode(&pid); err != nil { + t.Fatal(err) + } + // The backend has its own session and lifetime. Release it before waiting + // for supervisor cleanup even when the regression makes Wait time out. + t.Cleanup(func() { _ = unix.Kill(pid, unix.SIGKILL) }) + if err := runtime.Wait(ctx); err != nil { + t.Fatalf("supervisor waited for detached backend: %v", err) + } + connection, err := (&net.Dialer{}).DialContext(ctx, "unix", socket) + if err != nil { + t.Fatalf("detached backend unavailable after session cleanup: %v", err) + } + defer func() { _ = connection.Close() }() + if err := connection.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(connection) + if err != nil || string(data) != "alive" { + t.Fatalf("detached backend did not respond: %q (%v)", data, err) + } +} + +func TestDetachedBackendFixture(t *testing.T) { + switch os.Getenv(detachedFixtureRole) { + case "backend": + serveDetachedBackend(t) + case "runtime": + runDetachedBackend(t) + } +} + +func serveDetachedBackend(t *testing.T) { + t.Helper() + listener, err := net.Listen("unix", os.Getenv(detachedFixtureSocket)) + if err != nil { + t.Fatal(err) + } + if err := json.NewEncoder(os.Stdout).Encode(os.Getpid()); err != nil { + t.Fatal(err) + } + connection, err := listener.Accept() + if err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(connection, "alive"); err != nil { + t.Fatal(err) + } + _ = connection.Close() + _ = listener.Close() + time.Sleep(time.Minute) + os.Exit(0) +} + +func runDetachedBackend(t *testing.T) { + t.Helper() + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + // #nosec G204 -- Relaunches this test executable in a separate backend session. + child := exec.Command(binary, "-test.run=^TestDetachedBackendFixture$") + child.Env = append(os.Environ(), detachedFixtureRole+"=backend") + child.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + stdout, err := child.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := child.Start(); err != nil { + t.Fatal(err) + } + var pid int + if err := json.NewDecoder(stdout).Decode(&pid); err != nil { + _ = child.Process.Kill() + _ = child.Wait() + t.Fatal(err) + } + if err := stdout.Close(); err != nil { + t.Fatal(err) + } + if _, err := fmt.Fprintln(os.Stdout, pid); err != nil { + t.Fatal(err) + } + os.Exit(0) +} diff --git a/supervisor/exit_darwin.go b/supervisor/exit_darwin.go index 67f7581..41f3e70 100644 --- a/supervisor/exit_darwin.go +++ b/supervisor/exit_darwin.go @@ -6,8 +6,8 @@ import ( "golang.org/x/sys/unix" ) -func adoptDescendants() error { return nil } -func reapDescendants() error { return nil } +func adoptDescendants() error { return nil } +func reapDescendants(_ int) error { return nil } func watchExit(pid int) (func() error, error) { queue, err := unix.Kqueue() diff --git a/supervisor/exit_linux.go b/supervisor/exit_linux.go index 6872b92..152cd54 100644 --- a/supervisor/exit_linux.go +++ b/supervisor/exit_linux.go @@ -20,10 +20,12 @@ func watchExit(pid int) (func() error, error) { }, nil } -func reapDescendants() error { +func reapDescendants(group int) error { for { var status unix.WaitStatus - _, err := unix.Wait4(-1, &status, 0, nil) + // Subreaping also adopts detached backend services. Only the leased + // process group belongs to this operation; other sessions outlive it. + _, err := unix.Wait4(-group, &status, 0, nil) if errors.Is(err, unix.ECHILD) { return nil } diff --git a/supervisor/tree_unix.go b/supervisor/tree_unix.go index 352c9c0..b784bbd 100644 --- a/supervisor/tree_unix.go +++ b/supervisor/tree_unix.go @@ -54,7 +54,7 @@ func (t *processTree) Wait() error { killed := t.killGroup() waited := t.cmd.Wait() t.reaped = true - return errors.Join(observed, killed, waited, reapDescendants()) + return errors.Join(observed, killed, waited, reapDescendants(t.cmd.Process.Pid)) } func (t *processTree) Kill() error { From efd4d3531964b390129cb9db634d61882388d41b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 8 Oct 2026 19:17:15 -0600 Subject: [PATCH 2/3] test(supervisor): simplify detached backend assertions --- supervisor/detached_linux_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/supervisor/detached_linux_test.go b/supervisor/detached_linux_test.go index e4c1c79..a2d17da 100644 --- a/supervisor/detached_linux_test.go +++ b/supervisor/detached_linux_test.go @@ -51,6 +51,11 @@ func TestRunnerDoesNotWaitForDetachedBackend(t *testing.T) { if err := runtime.Wait(ctx); err != nil { t.Fatalf("supervisor waited for detached backend: %v", err) } + assertDetachedBackendResponds(ctx, t, socket) +} + +func assertDetachedBackendResponds(ctx context.Context, t *testing.T, socket string) { + t.Helper() connection, err := (&net.Dialer{}).DialContext(ctx, "unix", socket) if err != nil { t.Fatalf("detached backend unavailable after session cleanup: %v", err) From ad28ff0dbedd1b872afdc042487525ac124e4cc4 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 8 Oct 2026 19:31:54 -0600 Subject: [PATCH 3/3] test(supervisor): bound detached backend readiness --- supervisor/detached_linux_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/supervisor/detached_linux_test.go b/supervisor/detached_linux_test.go index a2d17da..815735c 100644 --- a/supervisor/detached_linux_test.go +++ b/supervisor/detached_linux_test.go @@ -41,6 +41,8 @@ func TestRunnerDoesNotWaitForDetachedBackend(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = runtime.Kill(context.Background()) }) + stopRead := context.AfterFunc(ctx, func() { _ = runtime.Stdout().Close() }) + defer stopRead() var pid int if err := json.NewDecoder(runtime.Stdout()).Decode(&pid); err != nil { t.Fatal(err)