@@ -0,0 +1,143 @@
1+//go:build !windows
2+
3+package shell
4+
5+import (
6+ "bytes"
7+ "context"
8+ "os"
9+ "os/exec"
10+ "os/signal"
11+ "strings"
12+ "syscall"
13+ "testing"
14+ "time"
15+)
16+
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) {
22+ t.Parallel()
23+
24+ sigCh := make(chan os.Signal, 1)
25+ signal.Notify(sigCh, syscall.SIGINT)
26+ defer signal.Stop(sigCh)
27+
28+ var stderr bytes.Buffer
29+ err := Run(t.Context(), RunOptions{
30+ Command: `sh -c 'kill -INT -$$'`,
31+ Cwd: t.TempDir(),
32+ Env: os.Environ(),
33+ Stderr: &stderr,
34+ })
35+
36+ if err == nil {
37+ t.Log("child exited 0 after self-signal (unexpected but not a failure)")
38+ }
39+
40+ select {
41+ case sig := <-sigCh:
42+ t.Fatalf("SIGINT leaked to parent process: %v (stderr=%q)", sig, stderr.String())
43+ case <-time.After(200 * time.Millisecond):
44+ }
45+}
46+
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
50+// with ENXIO.
51+func TestProcessIsolation_TTYAccessDoesNotBlock(t *testing.T) {
52+ t.Parallel()
53+
54+ ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
55+ defer cancel()
56+
57+ var stderr bytes.Buffer
58+ done := make(chan error, 1)
59+ go func() {
60+ done <- Run(ctx, RunOptions{
61+ Command: `sh -c 'cat < /dev/tty'`,
62+ Cwd: t.TempDir(),
63+ Env: os.Environ(),
64+ Stderr: &stderr,
65+ })
66+ }()
67+
68+ select {
69+ case err := <-done:
70+ if err == nil {
71+ t.Fatal("expected error from /dev/tty access in detached session, got nil")
72+ }
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())
75+ }
76+}
77+
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) {
84+ t.Parallel()
85+
86+ if _, err := exec.LookPath("zsh"); err != nil {
87+ t.Skip("zsh not installed")
88+ }
89+
90+ ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
91+ defer cancel()
92+
93+ var stdout, stderr bytes.Buffer
94+ done := make(chan error, 1)
95+ go func() {
96+ done <- Run(ctx, RunOptions{
97+ Command: `zsh -i -c 'echo ok'`,
98+ Cwd: t.TempDir(),
99+ Env: os.Environ(),
100+ Stdout: &stdout,
101+ Stderr: &stderr,
102+ })
103+ }()
104+
105+ select {
106+ case err := <-done:
107+ if err != nil {
108+ t.Logf("zsh exited with error (may be expected): %v", err)
109+ }
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())
113+ }
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())
116+ }
117+}
118+
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) {
123+ t.Parallel()
124+
125+ ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond)
126+ defer cancel()
127+
128+ start := time.Now()
129+ err := Run(ctx, RunOptions{
130+ Command: `sh -c 'sleep 60 & wait'`,
131+ Cwd: t.TempDir(),
132+ Env: os.Environ(),
133+ })
134+ elapsed := time.Since(start)
135+
136+ if err == nil {
137+ t.Fatal("expected error from cancelled context")
138+ }
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)
142+ }
143+}