| 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 |