@@ -0,0 +1,143 @@
17+// TestProcessIsolation_SignalDoesNotReachParent verifies that a child
18+// sending SIGINT to its own process group does not deliver the signal to
19+// the parent (test) process. Without Setsid isolation, the child shares
20+// the parent's process group and the signal would kill the test.
21+func TestProcessIsolation_SignalDoesNotReachParent(t *testing.T) {
24+ sigCh := make(chan os.Signal, 1)
25+ signal.Notify(sigCh, syscall.SIGINT)
26+ defer signal.Stop(sigCh)
28+ var stderr bytes.Buffer
29+ err := Run(t.Context(), RunOptions{
30+ Command: `sh -c 'kill -INT -$$'`,
37+ t.Log("child exited 0 after self-signal (unexpected but not a failure)")
42+ t.Fatalf("SIGINT leaked to parent process: %v (stderr=%q)", sig, stderr.String())
43+ case <-time.After(200 * time.Millisecond):
47+// TestProcessIsolation_TTYAccessDoesNotBlock verifies that a child trying
48+// to read from /dev/tty does not block or suspend the parent. With Setsid,
49+// the child has no controlling terminal, so /dev/tty access fails immediately
51+func TestProcessIsolation_TTYAccessDoesNotBlock(t *testing.T) {
54+ ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
57+ var stderr bytes.Buffer
58+ done := make(chan error, 1)
60+ done <- Run(ctx, RunOptions{
61+ Command: `sh -c 'cat < /dev/tty'`,
71+ t.Fatal("expected error from /dev/tty access in detached session, got nil")
73+ case <-time.After(3 * time.Second):
74+ t.Fatalf("/dev/tty access blocked for >3s; session isolation may be broken (stderr=%q)", stderr.String())
78+// TestProcessIsolation_ZshJobControlDoesNotSuspend simulates the exact
79+// scenario that caused the original bug: running zsh with job control
80+// enabled. Without session isolation, zsh tries to become the foreground
81+// process group leader on the controlling terminal, gets SIGTTIN, and
82+// suspends. Skips if zsh is not installed.
83+func TestProcessIsolation_ZshJobControlDoesNotSuspend(t *testing.T) {
86+ if _, err := exec.LookPath("zsh"); err != nil {
87+ t.Skip("zsh not installed")
90+ ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
93+ var stdout, stderr bytes.Buffer
94+ done := make(chan error, 1)
96+ done <- Run(ctx, RunOptions{
97+ Command: `zsh -i -c 'echo ok'`,
108+ t.Logf("zsh exited with error (may be expected): %v", err)
110+ out := strings.TrimSpace(stdout.String())
111+ if !strings.Contains(out, "ok") {
112+ t.Errorf("expected 'ok' in stdout, got %q (stderr=%q)", out, stderr.String())
114+ case <-time.After(5 * time.Second):
115+ t.Fatalf("zsh -i -c did not complete within 5s; likely suspended on TTY (stderr=%q)", stderr.String())
119+// TestProcessIsolation_ChildProcessGroupKill verifies that when context
120+// is cancelled, the signal reaches the entire child process group (not
121+// just the direct child).
122+func TestProcessIsolation_ChildProcessGroupKill(t *testing.T) {
125+ ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond)
129+ err := Run(ctx, RunOptions{
130+ Command: `sh -c 'sleep 60 & wait'`,
134+ elapsed := time.Since(start)
137+ t.Fatal("expected error from cancelled context")
139+ // Allow up to 4s: 500ms context timeout + 2s kill timeout + margin.
140+ if elapsed > 4*time.Second {
141+ t.Fatalf("cancellation took %v; expected prompt process group kill", elapsed)