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: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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.
Expand Down
136 changes: 136 additions & 0 deletions supervisor/detached_linux_test.go
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
skevetter marked this conversation as resolved.
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)
}
4 changes: 2 additions & 2 deletions supervisor/exit_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 4 additions & 2 deletions supervisor/exit_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion supervisor/tree_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading