返回 DeepSeek-Reasonix
live_regression_test.go
根目录 / internal / persistentshell / live_regression_test.go
1 package persistentshell
2
3 import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "runtime"
9 "strings"
10 "testing"
11 "time"
12
13 "reasonix/internal/sandbox"
14 )
15
16 func skipNonPOSIX(t *testing.T) {
17 t.Helper()
18 if runtime.GOOS == "windows" {
19 t.Skip("POSIX persistent shell")
20 }
21 }
22
23 // A command whose output does not end in a newline used to leave the status
24 // marker mid-line, which no line-anchored match could find: the command ran to
25 // its full deadline, returned nothing, and took the session shell with it.
26 func TestPersistentShellCommandWithoutTrailingNewline(t *testing.T) {
27 skipNonPOSIX(t)
28 sh := posixShell(t)
29 dir := t.TempDir()
30 noEOL := filepath.Join(dir, "no-eol.txt")
31 if err := os.WriteFile(noEOL, []byte("tail without newline"), 0o644); err != nil {
32 t.Fatal(err)
33 }
34 cases := []struct{ command, want string }{
35 {"printf 'hi'", "hi"},
36 {"printf 'hi\\n'", "hi\n"},
37 {"cat " + noEOL, "tail without newline"},
38 }
39 m := testManager(t)
40 for _, tc := range cases {
41 res := runPersistent(t, m, sh, dir, tc.command, 5*time.Second)
42 if res.Err != nil {
43 t.Fatalf("%s: %v", tc.command, res.Err)
44 }
45 if res.Output != tc.want {
46 t.Fatalf("%s: output=%q want %q", tc.command, res.Output, tc.want)
47 }
48 }
49 }
50
51 // Output must be byte-identical to what the command wrote. The terminal line
52 // discipline can emit \r\r\n under pressure, which naive normalisation turned
53 // into blank lines scattered through model-visible output.
54 func TestPersistentShellOutputIsByteExact(t *testing.T) {
55 skipNonPOSIX(t)
56 sh := posixShell(t)
57 dir := t.TempDir()
58 m := testManager(t)
59 for _, n := range []int{100, 20000} {
60 var want strings.Builder
61 for i := 1; i <= n; i++ {
62 fmt.Fprintf(&want, "%d\n", i)
63 }
64 res := runPersistent(t, m, sh, dir, fmt.Sprintf("seq 1 %d", n), 60*time.Second)
65 if res.Err != nil {
66 t.Fatalf("seq %d: %v", n, res.Err)
67 }
68 if res.Output != want.String() {
69 t.Fatalf("seq %d: output is %d bytes, want %d", n, len(res.Output), want.Len())
70 }
71 }
72 }
73
74 // Terminal control sequences never reached the model through the one-shot path
75 // because it had no tty. A session PTY does, so they are stripped.
76 func TestPersistentShellStripsTerminalControls(t *testing.T) {
77 skipNonPOSIX(t)
78 sh := posixShell(t)
79 m := testManager(t)
80 res := runPersistent(t, m, sh, t.TempDir(), `printf '\033[31mred\033[0m\n'`, 5*time.Second)
81 if res.Err != nil {
82 t.Fatal(res.Err)
83 }
84 if res.Output != "red\n" {
85 t.Fatalf("output=%q", res.Output)
86 }
87 }
88
89 // A command that reads stdin must fail the way it did under one-shot execution
90 // instead of blocking the session shell until the deadline.
91 func TestPersistentShellDetachesStdin(t *testing.T) {
92 skipNonPOSIX(t)
93 sh := posixShell(t)
94 m := testManager(t)
95 start := time.Now()
96 res := runPersistent(t, m, sh, t.TempDir(), "read -r answer; printf 'got:%s\\n' \"$answer\"", 10*time.Second)
97 if elapsed := time.Since(start); elapsed > 5*time.Second {
98 t.Fatalf("stdin read blocked for %s", elapsed)
99 }
100 if res.TimedOut {
101 t.Fatal("a command reading stdin must not consume the deadline")
102 }
103 if !strings.Contains(res.Output, "got:") {
104 t.Fatalf("output=%q", res.Output)
105 }
106 }
107
108 // A timed-out command still owes the model whatever it printed, and must say
109 // that shell state is gone.
110 func TestPersistentShellTimeoutReportsPartialOutputAndReset(t *testing.T) {
111 skipNonPOSIX(t)
112 sh := posixShell(t)
113 m := testManager(t)
114 res := runPersistent(t, m, sh, t.TempDir(), "printf 'before\\n'; sleep 30", 500*time.Millisecond)
115 if !res.TimedOut {
116 t.Fatalf("want timeout, got %+v", res)
117 }
118 if !res.Reset {
119 t.Fatal("a timeout retires the shell and must report it")
120 }
121 if !strings.Contains(res.Output, "before") {
122 t.Fatalf("partial output lost: %q", res.Output)
123 }
124 }
125
126 // Cancellation reports what ran and retires the shell.
127 func TestPersistentShellCancelReportsPartialOutput(t *testing.T) {
128 skipNonPOSIX(t)
129 sh := posixShell(t)
130 m := testManager(t)
131 ctx, cancel := context.WithCancel(context.Background())
132 go func() {
133 time.Sleep(300 * time.Millisecond)
134 cancel()
135 }()
136 res := m.Run(ctx, Request{
137 Argv: InteractiveArgv(sh),
138 Dir: t.TempDir(),
139 Env: []string{"PATH=" + os.Getenv("PATH"), "TERM=dumb"},
140 Command: "printf 'started\\n'; sleep 30",
141 Timeout: 30 * time.Second,
142 Shell: sh,
143 })
144 if !res.Canceled {
145 t.Fatalf("want cancel, got %+v", res)
146 }
147 if !res.Reset || !strings.Contains(res.Output, "started") {
148 t.Fatalf("reset=%v output=%q", res.Reset, res.Output)
149 }
150 }
151
152 // A multi-line command reaches the shell as one physical line, so no PS2 prompt
153 // or wrapper source can appear in the result.
154 func TestPersistentShellMultilineCommand(t *testing.T) {
155 skipNonPOSIX(t)
156 sh := posixShell(t)
157 dir := t.TempDir()
158 m := testManager(t)
159 res := runPersistent(t, m, sh, dir, "cat <<'EOF' > multi.txt\nalpha\nbeta\nEOF\ncat multi.txt", 5*time.Second)
160 if res.Err != nil {
161 t.Fatalf("%v (%q)", res.Err, res.Output)
162 }
163 if res.Output != "alpha\nbeta\n" {
164 t.Fatalf("output=%q", res.Output)
165 }
166 }
167
168 // Scanning cost must be linear in output size. The rescan-per-read shape this
169 // replaced needed ~88s for 7 MiB, which a 120s foreground deadline cannot absorb.
170 func TestPersistentShellLargeOutputStaysLinear(t *testing.T) {
171 skipNonPOSIX(t)
172 if testing.Short() {
173 t.Skip("large-output timing")
174 }
175 sh := posixShell(t)
176 dir := t.TempDir()
177 m := testManager(t)
178 start := time.Now()
179 res := runPersistent(t, m, sh, dir, "seq 1 1000000", 120*time.Second)
180 elapsed := time.Since(start)
181 if res.Err != nil {
182 t.Fatalf("%v", res.Err)
183 }
184 if len(res.Output) < 6_800_000 {
185 t.Fatalf("output truncated at %d bytes", len(res.Output))
186 }
187 // Generous bound: the defect this pins was two orders of magnitude over it.
188 if elapsed > 20*time.Second {
189 t.Fatalf("6.9 MiB of output took %s", elapsed)
190 }
191 t.Logf("6.9 MiB in %s", elapsed.Round(time.Millisecond))
192 }
193
194 // Output beyond the shared cap is truncated, not turned into a dead shell.
195 func TestPersistentShellBoundsHugeOutput(t *testing.T) {
196 skipNonPOSIX(t)
197 if testing.Short() {
198 t.Skip("large-output bound")
199 }
200 sh := posixShell(t)
201 m := testManager(t)
202 dir := t.TempDir()
203 res := runPersistent(t, m, sh, dir, "head -c 12000000 /dev/zero | tr '\\0' 'x'; printf '\\n'", 120*time.Second)
204 if res.Err != nil {
205 t.Fatalf("%v", res.Err)
206 }
207 if res.ShellDied {
208 t.Fatal("crossing the output cap must not kill the session shell")
209 }
210 if !strings.Contains(res.Output, "truncated at 10 MiB") {
211 t.Fatalf("expected the shared truncation notice, got %d bytes", len(res.Output))
212 }
213 after := runPersistent(t, m, sh, dir, "printf 'alive\\n'", 5*time.Second)
214 if after.Output != "alive\n" {
215 t.Fatalf("shell unusable after truncation: %q", after.Output)
216 }
217 }
218
219 func TestPersistentShellSurvivesRepeatedCommands(t *testing.T) {
220 skipNonPOSIX(t)
221 sh := posixShell(t)
222 dir := t.TempDir()
223 m := testManager(t)
224 for i := range 20 {
225 res := runPersistent(t, m, sh, dir, fmt.Sprintf("printf '%%s\\n' %d", i), 5*time.Second)
226 if res.Err != nil || res.Output != fmt.Sprintf("%d\n", i) {
227 t.Fatalf("iteration %d: output=%q err=%v", i, res.Output, res.Err)
228 }
229 }
230 }
231
232 func TestInteractiveArgvKinds(t *testing.T) {
233 if got := InteractiveArgv(sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "pwsh"}); got[0] != "pwsh" {
234 t.Fatalf("argv=%v", got)
235 }
236 }
237
237 lines GO