diff --git a/agent/agent_test.go b/agent/agent_test.go index 66edcb4..f815865 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -44,6 +44,28 @@ func TestServerExecHelloWorld(t *testing.T) { } } +func TestServerExecBackgroundChildDoesNotPinSession(t *testing.T) { + t.Parallel() + ctx, conn := dialTestServer(t) + + var stdout bytes.Buffer + start := time.Now() + exit, err := client.Run(ctx, conn, []string{"sh", "-c", "sleep 6 & echo started"}, nil, nil, &stdout, io.Discard) + if err != nil { + t.Fatalf("client run: %v", err) + } + if exit != 0 { + t.Errorf("exit = %d, want 0", exit) + } + if got := strings.TrimSpace(stdout.String()); got != "started" { + t.Errorf("stdout = %q, want \"started\"", got) + } + // Without WaitDelay the session is pinned until the background sleep exits. + if elapsed := time.Since(start); elapsed > 4*time.Second { + t.Errorf("session took %v, want < 4s (pinned by background child)", elapsed) + } +} + func TestServerPropagatesNonZeroExit(t *testing.T) { t.Parallel() ctx, conn := dialTestServer(t) diff --git a/agent/exec.go b/agent/exec.go index d643b93..b4b3165 100644 --- a/agent/exec.go +++ b/agent/exec.go @@ -9,8 +9,14 @@ import ( "os" "os/exec" "strings" + "time" ) +// execWaitDelay bounds how long Wait blocks on the child's stdout/stderr +// pipes after exit: a daemonized grandchild inherits them and would pin +// the session (and its conn) until it dies. +const execWaitDelay = 2 * time.Second + // processController hooks platform-specific child-lifecycle steps into // runExec: AfterStart runs once cmd.Process exists (Windows assigns the // child to its Job Object); Close releases any held kernel resource. @@ -46,6 +52,7 @@ func runExec(parentCtx context.Context, argv []string, env map[string]string, st defer cancel() cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) //nolint:gosec // argv from trusted vsock peer + cmd.WaitDelay = execWaitDelay procCtl, err := setupProcess(cmd) if err != nil { return enc.SendErrorf("exec: setup process %s: %v", argv[0], err) @@ -115,6 +122,9 @@ func runExec(parentCtx context.Context, argv []string, env map[string]string, st case waitErr == nil: case errors.As(waitErr, &exitErr): exitCode = exitErr.ExitCode() + case errors.Is(waitErr, exec.ErrWaitDelay): + // Pipes were abandoned to a background child; the command itself exited. + exitCode = cmd.ProcessState.ExitCode() default: return enc.SendErrorf("exec: wait %s: %v", argv[0], waitErr) }