返回 DeepSeek-Reasonix
pipe_test.go
根目录 / internal / persistentshell / pipe_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 // Run the Windows byte-stream transport on every host with Bash installed.
17 // On Windows use the production dispatcher, so accidentally restoring ConPTY
18 // cannot pass this regression merely because startPipe still works in isolation.
19 func newPipeTestManager(t *testing.T) (*Manager, Request) {
20 t.Helper()
21 sh, ok := sandbox.ResolveExplicitBash("")
22 if !ok {
23 t.Skip("native Bash is required for the pipe transport regression")
24 }
25 dir := t.TempDir()
26 env := make([]string, 0, len(os.Environ())+2)
27 for _, item := range os.Environ() {
28 key, _, _ := strings.Cut(item, "=")
29 if !strings.EqualFold(key, "LC_ALL") && !strings.EqualFold(key, "LANG") && !strings.EqualFold(key, "BASH_ENV") {
30 env = append(env, item)
31 }
32 }
33 env = append(env, "LC_ALL=C", "TERM=dumb")
34 req := Request{Shell: sh, Argv: interactiveArgvForOS(sh, "windows"), Dir: dir, Env: env, Timeout: 5 * time.Second}
35 var s *session
36 var err error
37 if runtime.GOOS == "windows" {
38 req.Argv = InteractiveArgv(sh)
39 s, err = startSession(req, fingerprint(req))
40 } else {
41 s, err = startPOSIXSession(req, fingerprint(req), startPipe)
42 }
43 if err != nil {
44 t.Fatal(err)
45 }
46 m := testManager(t)
47 m.live = s
48 if _, ok := s.conn.(*pipeProcess); !ok {
49 t.Fatalf("Windows Bash must use pipes, got %T", s.conn)
50 }
51 return m, req
52 }
53
54 func TestBashPipeUnicodeCompletionAndState(t *testing.T) {
55 m, req := newPipeTestManager(t)
56 filename := "中文😀 '文件.txt"
57 if err := os.WriteFile(filepath.Join(req.Dir, filename), []byte("内容😀"), 0o600); err != nil {
58 t.Fatal(err)
59 }
60 if err := os.Mkdir(filepath.Join(req.Dir, "sub"), 0o700); err != nil {
61 t.Fatal(err)
62 }
63 run := func(command, want string) {
64 t.Helper()
65 req.Command = command
66 res := m.Run(t.Context(), req)
67 if res.Err != nil || !res.ExitCodeKnown || res.ExitCode != 0 || res.Reset || res.Output != want {
68 t.Fatalf("command=%q result=%+v, want output=%q", command, res, want)
69 }
70 }
71 // Both the interpreter and native utilities must see pipes, never a console.
72 run("test ! -t 0 && test ! -t 1 && test ! -t 2", "")
73 run("ls -a", ".\n..\nsub\n"+filename+"\n")
74 run("cat "+posixQuote(filename), "内容😀")
75 // Exercise output well beyond the former 80-column screen and read boundary.
76 long := strings.Repeat("中文😀", 2000)
77 run("printf '%s' "+posixQuote(long), long)
78 run("printf '前'; printf '错误' >&2; printf '后'", "前错误后")
79 run("cd sub; export REASONIX_PIPE_STATE='变量😀'; rx_pipe_fn() { printf '%s' \"$REASONIX_PIPE_STATE\"; }", "")
80 run("printf '%s:' \"${PWD##*/}\"; rx_pipe_fn", "sub:变量😀")
81 req.Command = "printf '失败中文' >&2; false"
82 failed := m.Run(t.Context(), req)
83 if failed.Err == nil || !failed.ExitCodeKnown || failed.ExitCode != 1 || failed.Reset || failed.Output != "失败中文" {
84 t.Fatalf("non-zero completion was lost: %+v", failed)
85 }
86 run("rx_pipe_fn", "变量😀")
87 // Commands sharing this manager are serialized; output must stay with its call.
88 results := make(chan Result, 4)
89 for i := range 4 {
90 go func() {
91 r := req
92 r.Command = fmt.Sprintf("printf '调用%d中文'", i)
93 results <- m.Run(context.Background(), r)
94 }()
95 }
96 seen := map[string]bool{}
97 for range 4 {
98 res := <-results
99 if res.Err != nil || !res.ExitCodeKnown || res.ExitCode != 0 || seen[res.Output] {
100 t.Fatalf("mixed concurrent output: %+v", res)
101 }
102 seen[res.Output] = true
103 }
104 for i := range 4 {
105 if !seen[fmt.Sprintf("调用%d中文", i)] {
106 t.Fatalf("missing call %d: %v", i, seen)
107 }
108 }
109 }
110
111 func TestBashPipeCancellationReapsShell(t *testing.T) {
112 m, req := newPipeTestManager(t)
113 p := m.live.conn.(*pipeProcess)
114 ctx, cancel := context.WithCancel(t.Context())
115 defer cancel()
116 req.Command = "printf 'started\\n%0128d\\n' 0; sleep 30"
117 req.Progress = &cancelOnStarted{cancel: cancel}
118 res := m.Run(ctx, req)
119 if !res.Canceled || !res.Reset || m.live != nil {
120 t.Fatalf("cancel did not reset pipe session: %+v", res)
121 }
122 if p.cmd.ProcessState == nil || p.cmd.ProcessState.Success() {
123 t.Fatal("cancel did not kill and reap shell")
124 }
125 if _, err := p.Write([]byte("echo stale\n")); err == nil {
126 t.Fatal("retired pipe still accepts commands")
127 }
128 }
129
130 func TestBashPipeTimeoutResetsSession(t *testing.T) {
131 m, req := newPipeTestManager(t)
132 req.Command = "sleep 30"
133 req.Timeout = 100 * time.Millisecond
134 res := m.Run(t.Context(), req)
135 if !res.TimedOut || !res.Reset || res.ExitCodeKnown || m.live != nil {
136 t.Fatalf("timeout must retire the session without claiming completion: %+v", res)
137 }
138 }
139
139 lines GO