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..815735c --- /dev/null +++ b/supervisor/detached_linux_test.go @@ -0,0 +1,136 @@ +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()) }) + 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) + } + // 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) + } + 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) + } + 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 {