返回 DeepSeek-Reasonix
transport_stdio.go
根目录 / internal / plugin / transport_stdio.go
1 package plugin
2
3 import (
4 "bufio"
5 "bytes"
6 "context"
7 "encoding/json"
8 "fmt"
9 "io"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "runtime"
14 "strings"
15 "sync"
16 "time"
17
18 "reasonix/internal/proc"
19 "reasonix/internal/sandbox"
20 "reasonix/internal/secrets"
21 "reasonix/internal/tool"
22 )
23
24 const (
25 closeWaitBudget = 5 * time.Second
26 gracefulCloseWaitBudget = 750 * time.Millisecond
27 )
28
29 // stdioTransport speaks newline-delimited JSON-RPC 2.0 over a subprocess's
30 // stdin/stdout — the MCP stdio convention (one JSON message per line, no
31 // embedded newlines). A dedicated reader goroutine owns stdout and demuxes each
32 // response to the waiting call by id, so a call can abandon a blocking read the
33 // moment its context is cancelled (the subprocess is bound to the session, not
34 // the turn, so a hung server would otherwise hang a cancelled turn forever).
35 // callMu serialises a request/response round-trip over the shared pipe.
36 type stdioTransport struct {
37 name string
38 roots []mcpRoot
39 cmd *exec.Cmd
40 job uintptr // Windows Job Object handle (0 elsewhere); reaps detached grandchildren on close
41 stdin io.WriteCloser
42 stdout *bufio.Reader
43 stderr *tailBuffer
44
45 callMu sync.Mutex // one in-flight request/response at a time over the shared pipe
46 writeMu sync.Mutex // client calls and server-request replies share stdin
47
48 mu sync.Mutex
49 nextID int
50 pending map[int]chan rpcResponse
51 readErr error // set once the reader goroutine exits; further calls fail fast
52
53 waitOnce sync.Once
54 releaseSlot func() // returns a bounded instance slot (e.g. CodeGraph) on close; nil when unbounded
55 progress progressRouter
56 }
57
58 func newStdioTransport(ctx context.Context, s Spec) (*stdioTransport, error) {
59 if strings.TrimSpace(s.Command) == "" {
60 return nil, fmt.Errorf("stdio plugin %q: command is required", s.Name)
61 }
62 var releaseSlot func()
63 if isCodeGraphSpecName(s.Name) {
64 release, err := acquireCodeGraphSlot()
65 if err != nil {
66 return nil, err
67 }
68 releaseSlot = release
69 }
70 defer func() {
71 // Release the reserved slot if construction fails before the transport
72 // takes ownership of it (set to nil on the success path below).
73 if releaseSlot != nil {
74 releaseSlot()
75 }
76 }()
77 env := mergeEnv(secrets.ProcessEnv(), s.Env)
78 exe, env, err := resolveStdioExecutable(ctx, s, env)
79 if err != nil {
80 return nil, err
81 }
82 // Private state/cache/temp always apply so MCP processes do not pollute the
83 // user's home caches. Command-sandbox wrapping is separate and only used for
84 // confined mode; authorized user installs run as trusted host processes so
85 // Chrome, Keychain, and local app services keep working.
86 processSandbox := s.Sandbox
87 processSandbox, env, err = prepareMCPPrivateState(s, processSandbox, env)
88 if err != nil {
89 return nil, err
90 }
91 launchArgs := append([]string{exe}, effectiveLaunchArgs(s)...)
92 var argv []string
93 if s.ResolvedProcessMode() == MCPProcessConfined {
94 processSandbox.MinimalWrites = true
95 argv, _ = sandbox.CommandArgs(processSandbox, launchArgs)
96 } else {
97 argv = launchArgs
98 }
99 cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
100 proc.HideWindow(cmd)
101 if s.LowPriority {
102 proc.LowPriority(cmd)
103 }
104 cmd.Env = env
105 cmd.Dir = stdioWorkingDir(s)
106 stderr := &tailBuffer{limit: 16 * 1024}
107 cmd.Stderr = stderr
108 if s.Stderr != nil {
109 cmd.Stderr = io.MultiWriter(stderr, s.Stderr)
110 }
111
112 stdin, err := cmd.StdinPipe()
113 if err != nil {
114 return nil, err
115 }
116 stdout, err := cmd.StdoutPipe()
117 if err != nil {
118 return nil, err
119 }
120 job, err := proc.StartTracked(cmd)
121 if err != nil {
122 return nil, err
123 }
124 if s.LowPriority {
125 proc.LowPriorityStarted(cmd)
126 }
127 t := &stdioTransport{
128 name: s.Name,
129 roots: mcpRoots(s.WorkspaceRoot),
130 cmd: cmd,
131 job: job,
132 stdin: stdin,
133 stdout: bufio.NewReader(stdout),
134 stderr: stderr,
135 pending: map[int]chan rpcResponse{},
136 releaseSlot: releaseSlot,
137 }
138 releaseSlot = nil // ownership transferred to t; close() releases it
139 go t.readLoop()
140 return t, nil
141 }
142
143 func prepareMCPPrivateState(s Spec, processSandbox sandbox.Spec, env []string) (sandbox.Spec, []string, error) {
144 return prepareMCPPrivateStateForOS(s, processSandbox, env, runtime.GOOS)
145 }
146
147 func prepareMCPPrivateStateForOS(s Spec, processSandbox sandbox.Spec, env []string, goos string) (sandbox.Spec, []string, error) {
148 root := strings.TrimSpace(s.StateDir)
149 if root == "" {
150 return processSandbox, env, nil
151 }
152 if err := os.MkdirAll(root, 0o700); err != nil {
153 return processSandbox, env, err
154 }
155 privateRoot := root
156 cacheDir := filepath.Join(privateRoot, "cache")
157 stateDir := filepath.Join(privateRoot, "state")
158 dirs := []string{cacheDir, stateDir}
159 privateEnv := map[string]string{
160 "XDG_CACHE_HOME": cacheDir, "XDG_STATE_HOME": stateDir,
161 "npm_config_cache": filepath.Join(cacheDir, "npm"),
162 "UV_CACHE_DIR": filepath.Join(cacheDir, "uv"),
163 "BUN_INSTALL_CACHE_DIR": filepath.Join(cacheDir, "bun"),
164 }
165 if goos != "windows" {
166 tmpDir := filepath.Join(privateRoot, "tmp")
167 dirs = append(dirs, tmpDir)
168 privateEnv["TMP"] = tmpDir
169 privateEnv["TEMP"] = tmpDir
170 privateEnv["TMPDIR"] = tmpDir
171 }
172 for _, dir := range dirs {
173 if err := os.MkdirAll(dir, 0o700); err != nil {
174 return processSandbox, env, err
175 }
176 }
177 // Windows stdio processes are currently unsandboxed and must keep the host's
178 // short temporary directory. Nesting TEMP below Reasonix's workspace-scoped
179 // state path can exceed the 108-byte Unix-domain-socket limit used by MCP
180 // servers such as MATLAB before their initialize response is written.
181 for key, value := range privateEnv {
182 env = setEnvValue(env, key, value)
183 }
184 processSandbox.WriteRoots = append(processSandbox.WriteRoots, root, privateRoot)
185 processSandbox.AppContainerWriteRoots = append(processSandbox.AppContainerWriteRoots, root, privateRoot)
186 return processSandbox, env, nil
187 }
188
189 var stdioShellPATH = cachedShellPATH(defaultStdioShellPATH)
190
191 // cachedShellPATH memoizes the first completed shell-PATH probe: the user's
192 // interactive PATH is stable for the process, and resolveStdioExecutable now
193 // probes for every stdio plugin, so caching avoids a login shell per server.
194 // The probe runs up to three login shells with a 2s timeout each, so it must
195 // not run under the lock; concurrent spawns share the in-flight probe instead
196 // of each running (or queueing behind) their own. Empty results are cached too
197 // — a host without a usable login shell must not re-probe on every spawn —
198 // except when the probe's context was cancelled, since that empty reflects the
199 // aborted caller rather than the host, and caching it would pin "" for the
200 // rest of the process.
201 func cachedShellPATH(probe func(context.Context) string) func(context.Context) string {
202 var (
203 mu sync.Mutex
204 cached string
205 done bool
206 inflight chan struct{} // non-nil while a probe runs; closed when it settles
207 )
208 return func(ctx context.Context) string {
209 for {
210 mu.Lock()
211 if done {
212 p := cached
213 mu.Unlock()
214 return p
215 }
216 if inflight != nil {
217 wait := inflight
218 mu.Unlock()
219 select {
220 case <-wait:
221 continue // re-check: the probe may not have cached (cancelled)
222 case <-ctx.Done():
223 return ""
224 }
225 }
226 ch := make(chan struct{})
227 inflight = ch
228 mu.Unlock()
229
230 p := probe(ctx)
231
232 mu.Lock()
233 inflight = nil
234 if p != "" || ctx.Err() == nil {
235 cached, done = p, true
236 }
237 mu.Unlock()
238 close(ch)
239 return p
240 }
241 }
242 }
243
244 func resolveStdioExecutable(ctx context.Context, s Spec, env []string) (string, []string, error) {
245 // Unconditionally enrich PATH with the user's shell PATH so every
246 // subprocess—including wrapper scripts that invoke npx, uvx, etc.—
247 // inherits the expected tool locations even under a GUI launch.
248 env = enrichStdioShellPATH(ctx, env)
249
250 if hasPathSeparator(s.Command) {
251 exe := s.Command
252 if !filepath.IsAbs(exe) {
253 if dir := stdioWorkingDir(s); dir != "" {
254 exe = filepath.Join(dir, exe)
255 }
256 abs, err := filepath.Abs(exe)
257 if err != nil {
258 return "", env, fmt.Errorf("stdio plugin %q: resolve command %q: %w", s.Name, s.Command, err)
259 }
260 exe = abs
261 }
262 return exe, env, nil
263 }
264 if exe, ok := lookPathInEnv(s.Command, env); ok {
265 return exe, env, nil
266 }
267
268 currentPath, _ := envValue(env, "PATH")
269 if runtime.GOOS == "windows" {
270 fallbackPath := mergePathLists(windowsStdioFallbackPATH(env), currentPath)
271 if fallbackPath != currentPath {
272 fallbackEnv := setEnvValue(env, "PATH", fallbackPath)
273 if exe, ok := lookPathInEnv(s.Command, fallbackEnv); ok {
274 return exe, fallbackEnv, nil
275 }
276 env = fallbackEnv
277 currentPath = fallbackPath
278 }
279 }
280
281 return "", env, fmt.Errorf("stdio plugin %q: command %q not found on PATH; GUI launches and non-interactive sessions may not inherit your shell PATH. Use an absolute command path or set PATH in the MCP server env. PATH=%q",
282 s.Name, s.Command, currentPath)
283 }
284
285 // stdioWorkingDir keeps WorkspaceRoot's roots/list role separate from process
286 // execution for user-installed servers. Only repository-declared servers need
287 // relative arguments to resolve against the project that supplied the config.
288 func stdioWorkingDir(s Spec) string {
289 if s.Dir != "" {
290 return s.Dir
291 }
292 if s.RequireLaunchApproval {
293 return s.WorkspaceRoot
294 }
295 return ""
296 }
297
298 // enrichStdioShellPATH probes the user's interactive login shell for its PATH
299 // and prepends those directories to the current environment. The result is the
300 // subprocess environment with a PATH that matches what the user sees in their
301 // terminal, even when Reasonix was launched from the Finder / Dock / open(1).
302 func enrichStdioShellPATH(ctx context.Context, env []string) []string {
303 currentPath, _ := envValue(env, "PATH")
304 if shellPath := strings.TrimSpace(stdioShellPATH(ctx)); shellPath != "" {
305 if fallbackPath := mergePathLists(shellPath, currentPath); fallbackPath != currentPath {
306 env = setEnvValue(env, "PATH", fallbackPath)
307 }
308 }
309 return env
310 }
311
312 func hasPathSeparator(s string) bool {
313 return strings.ContainsAny(s, `/\`)
314 }
315
316 func lookPathInEnv(command string, env []string) (string, bool) {
317 path, _ := envValue(env, "PATH")
318 pathext, _ := envValue(env, "PATHEXT")
319 for _, dir := range filepath.SplitList(path) {
320 if dir == "" || !filepath.IsAbs(dir) {
321 continue
322 }
323 for _, name := range executableNames(command, pathext) {
324 candidate := filepath.Join(dir, name)
325 if isExecutableFile(candidate) {
326 return candidate, true
327 }
328 }
329 }
330 return "", false
331 }
332
333 func executableNames(command, pathext string) []string {
334 if runtime.GOOS != "windows" || filepath.Ext(command) != "" {
335 return []string{command}
336 }
337 if strings.TrimSpace(pathext) == "" {
338 pathext = ".COM;.EXE;.BAT;.CMD"
339 }
340 names := []string{command}
341 seen := map[string]bool{strings.ToLower(command): true}
342 for _, ext := range strings.Split(pathext, ";") {
343 ext = strings.TrimSpace(ext)
344 if ext == "" {
345 continue
346 }
347 if !strings.HasPrefix(ext, ".") {
348 ext = "." + ext
349 }
350 name := command + ext
351 key := strings.ToLower(name)
352 if !seen[key] {
353 seen[key] = true
354 names = append(names, name)
355 }
356 }
357 return names
358 }
359
360 func isExecutableFile(path string) bool {
361 info, err := os.Stat(path)
362 if err != nil || info.IsDir() {
363 return false
364 }
365 if runtime.GOOS == "windows" {
366 return true
367 }
368 return info.Mode().Perm()&0o111 != 0
369 }
370
371 func windowsStdioFallbackPATH(env []string) string {
372 if runtime.GOOS != "windows" {
373 return ""
374 }
375 programFiles, _ := envValue(env, "ProgramFiles")
376 programFilesX86, _ := envValue(env, "ProgramFiles(x86)")
377 localAppData, _ := envValue(env, "LOCALAPPDATA")
378 appData, _ := envValue(env, "APPDATA")
379 userProfile, _ := envValue(env, "USERPROFILE")
380 chocolatey, _ := envValue(env, "ChocolateyInstall")
381 if localAppData == "" && userProfile != "" {
382 localAppData = filepath.Join(userProfile, "AppData", "Local")
383 }
384 if appData == "" && userProfile != "" {
385 appData = filepath.Join(userProfile, "AppData", "Roaming")
386 }
387 candidates := []string{
388 filepath.Join(programFiles, "nodejs"),
389 filepath.Join(programFilesX86, "nodejs"),
390 filepath.Join(localAppData, "Programs", "nodejs"),
391 filepath.Join(appData, "npm"),
392 filepath.Join(localAppData, "Microsoft", "WindowsApps"),
393 filepath.Join(userProfile, "scoop", "shims"),
394 filepath.Join(userProfile, ".bun", "bin"),
395 filepath.Join(userProfile, ".cargo", "bin"),
396 filepath.Join(chocolatey, "bin"),
397 }
398 var existing []string
399 for _, dir := range candidates {
400 if isDir(dir) {
401 existing = append(existing, dir)
402 }
403 }
404 return strings.Join(existing, string(os.PathListSeparator))
405 }
406
407 func isDir(path string) bool {
408 if path == "" {
409 return false
410 }
411 if !filepath.IsAbs(path) {
412 return false
413 }
414 info, err := os.Stat(path)
415 return err == nil && info.IsDir()
416 }
417
418 func defaultStdioShellPATH(ctx context.Context) string {
419 if runtime.GOOS == "windows" {
420 return ""
421 }
422 shell := stdioShell()
423 if shell == "" {
424 return ""
425 }
426 const marker = "__REASONIX_PATH__="
427 script := "printf '\\n" + marker + "%s\\n' \"$PATH\""
428 for _, args := range [][]string{
429 {"-l", "-i", "-c", script},
430 {"-l", "-c", script},
431 {"-c", script},
432 } {
433 out := runShellPATHCommand(ctx, shell, args)
434 if path := parseShellPATH(out, marker); path != "" {
435 return path
436 }
437 }
438 return ""
439 }
440
441 func stdioShell() string {
442 if shell := strings.TrimSpace(os.Getenv("SHELL")); shell != "" {
443 if hasPathSeparator(shell) {
444 if isExecutableFile(shell) {
445 return shell
446 }
447 } else if exe, ok := lookPathInEnv(shell, secrets.ProcessEnv()); ok {
448 return exe
449 }
450 }
451 for _, shell := range []string{"/bin/zsh", "/bin/bash", "/bin/sh"} {
452 if isExecutableFile(shell) {
453 return shell
454 }
455 }
456 return ""
457 }
458
459 func runShellPATHCommand(parent context.Context, shell string, args []string) []byte {
460 ctx, cancel := context.WithTimeout(parent, 2*time.Second)
461 defer cancel()
462 cmd := exec.CommandContext(ctx, shell, args...)
463 // Explicit env so the login-shell probe honors [secrets]
464 // filter_subprocess_env instead of inheriting the full environment.
465 cmd.Env = secrets.ProcessEnv()
466 prepareStdioShellPATHProbe(cmd)
467 cmd.Stdin = strings.NewReader("")
468 out, _ := cmd.CombinedOutput()
469 return out
470 }
471
472 func prepareStdioShellPATHProbe(cmd *exec.Cmd) {
473 proc.PrepareShellPATHProbe(cmd)
474 }
475
476 func parseShellPATH(out []byte, marker string) string {
477 lines := strings.Split(strings.ReplaceAll(string(out), "\r\n", "\n"), "\n")
478 for i := len(lines) - 1; i >= 0; i-- {
479 if strings.HasPrefix(lines[i], marker) {
480 return strings.TrimSpace(strings.TrimPrefix(lines[i], marker))
481 }
482 }
483 return ""
484 }
485
486 func mergeEnv(base []string, overrides map[string]string) []string {
487 out := append([]string(nil), base...)
488 for k, v := range overrides {
489 out = setEnvValue(out, k, v)
490 }
491 return out
492 }
493
494 func setEnvValue(env []string, key, value string) []string {
495 out := make([]string, 0, len(env)+1)
496 replaced := false
497 for _, kv := range env {
498 k, _, ok := strings.Cut(kv, "=")
499 if ok && envKeyEqual(k, key) {
500 if !replaced {
501 out = append(out, key+"="+value)
502 replaced = true
503 }
504 continue
505 }
506 out = append(out, kv)
507 }
508 if !replaced {
509 out = append(out, key+"="+value)
510 }
511 return out
512 }
513
514 func envValue(env []string, key string) (string, bool) {
515 for i := len(env) - 1; i >= 0; i-- {
516 k, v, ok := strings.Cut(env[i], "=")
517 if ok && envKeyEqual(k, key) {
518 return v, true
519 }
520 }
521 return "", false
522 }
523
524 func envKeyEqual(a, b string) bool {
525 if runtime.GOOS == "windows" {
526 return strings.EqualFold(a, b)
527 }
528 return a == b
529 }
530
531 func mergePathLists(primary, secondary string) string {
532 var out []string
533 seen := map[string]bool{}
534 for _, path := range []string{primary, secondary} {
535 for _, dir := range filepath.SplitList(path) {
536 if dir == "" || seen[dir] {
537 continue
538 }
539 seen[dir] = true
540 out = append(out, dir)
541 }
542 }
543 return strings.Join(out, string(os.PathListSeparator))
544 }
545
546 // stdioReplyQueueBound caps buffered server-request replies. The queue only
547 // backs up while the reply writer is stuck behind a jammed stdin pipe, so a
548 // small bound is plenty; overflow drops the reply instead of blocking readLoop.
549 const stdioReplyQueueBound = 16
550
551 // readLoop owns stdout for the transport's lifetime: it reads one JSON-RPC
552 // message per line, routes progress notifications, answers server requests, and
553 // hands each response to the call waiting on its id. On any read error it fails
554 // every pending call and exits.
555 func (t *stdioTransport) readLoop() {
556 // Server-request replies go through replyLoop, never directly to stdin:
557 // readLoop is the only goroutine draining stdout, and blocking it on
558 // writeMu behind a client call whose own stdin write is jammed would
559 // deadlock both pipes once the server also blocks writing stdout.
560 replies := make(chan any, stdioReplyQueueBound)
561 defer close(replies)
562 go t.replyLoop(replies)
563 for {
564 line, readErr := t.stdout.ReadBytes('\n')
565 line = bytes.TrimSpace(line)
566 if len(line) > 0 {
567 t.handleInboundLine(line, replies)
568 }
569 if readErr != nil {
570 t.failAll(readErr)
571 return
572 }
573 }
574 }
575
576 // replyLoop serialises server-request replies onto the shared stdin pipe. A
577 // write failure is not terminal for the transport — the read side may still be
578 // healthy, and pipe errors surface through the next client call's own write —
579 // but it stops further replies and keeps draining so readLoop never blocks.
580 func (t *stdioTransport) replyLoop(replies <-chan any) {
581 var dead bool
582 for msg := range replies {
583 if dead {
584 continue
585 }
586 if t.write(msg) != nil {
587 dead = true
588 }
589 }
590 }
591
592 func (t *stdioTransport) handleInboundLine(line []byte, replies chan<- any) {
593 probe, ok := decodeInboundMessage(line)
594 if !ok {
595 return // unparseable line cannot be routed; keep the transport alive
596 }
597 if probe.Method != "" {
598 if isNotificationID(probe.ID) {
599 if probe.Method == "notifications/progress" {
600 t.progress.dispatchProgress(probe.Params)
601 }
602 return
603 }
604 response := serverRequestReply(probe.ID, probe.Method, t.roots)
605 select {
606 case replies <- response:
607 default:
608 // The reply writer is stalled behind a full stdin pipe. An
609 // unanswered request degrades to the server's own timeout; a
610 // blocked readLoop could deadlock both pipes.
611 }
612 return
613 }
614
615 var resp rpcResponse
616 if err := json.Unmarshal(line, &resp); err != nil {
617 return
618 }
619 t.mu.Lock()
620 ch := t.pending[resp.ID]
621 delete(t.pending, resp.ID)
622 t.mu.Unlock()
623 if ch != nil {
624 ch <- resp // buffered(1): never blocks, even if the caller already left
625 }
626 }
627
628 func (t *stdioTransport) registerProgress(token string, sink tool.ProgressFunc) func() {
629 return t.progress.registerProgress(token, sink)
630 }
631
632 // failAll records the terminal read error and unblocks every pending call by
633 // closing its channel; a caller distinguishes this from a real response by the
634 // closed-channel receive.
635 func (t *stdioTransport) failAll(err error) {
636 t.mu.Lock()
637 defer t.mu.Unlock()
638 if t.readErr == nil {
639 t.readErr = err
640 }
641 for id, ch := range t.pending {
642 close(ch)
643 delete(t.pending, id)
644 }
645 }
646
647 func (t *stdioTransport) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
648 t.callMu.Lock()
649 defer t.callMu.Unlock()
650
651 t.mu.Lock()
652 if t.readErr != nil {
653 t.mu.Unlock()
654 return nil, t.withStderr(fmt.Errorf("plugin %q: read: %w", t.name, t.readErr))
655 }
656 t.nextID++
657 id := t.nextID
658 ch := make(chan rpcResponse, 1)
659 t.pending[id] = ch
660 t.mu.Unlock()
661
662 defer func() {
663 t.mu.Lock()
664 delete(t.pending, id)
665 t.mu.Unlock()
666 }()
667
668 if err := t.write(rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params}); err != nil {
669 return nil, fmt.Errorf("plugin %q: write %s: %w", t.name, method, err)
670 }
671
672 select {
673 case <-ctx.Done():
674 return nil, ctx.Err()
675 case resp, ok := <-ch:
676 if !ok {
677 return nil, t.withStderr(fmt.Errorf("plugin %q: read: %w", t.name, t.readErr))
678 }
679 if resp.Error != nil {
680 return nil, fmt.Errorf("plugin %q: %w", t.name, resp.Error)
681 }
682 return resp.Result, nil
683 }
684 }
685
686 func (t *stdioTransport) notify(_ context.Context, method string, params any) error {
687 return t.write(rpcRequest{JSONRPC: "2.0", Method: method, Params: params})
688 }
689
690 func (t *stdioTransport) write(v any) error {
691 b, err := json.Marshal(v) // marshaled JSON never contains a literal newline
692 if err != nil {
693 return err
694 }
695 t.writeMu.Lock()
696 defer t.writeMu.Unlock()
697 if _, err = t.stdin.Write(append(b, '\n')); err != nil {
698 return t.withStderr(err)
699 }
700 return nil
701 }
702
703 func (t *stdioTransport) withStderr(err error) error {
704 if t.stderr == nil {
705 return err
706 }
707 // Reap the exited child so its stderr copy goroutine has flushed the tail.
708 // Budgeted: a surviving grandchild keeps cmd.Wait blocked forever (see
709 // close), and this path runs with callMu held — an unbounded wait here
710 // would wedge every future call on this transport.
711 waitWithBudget(t.wait, closeWaitBudget)
712 // This error is returned directly to callers outside startup as well as
713 // copied into diagnostics. Redact at the transport boundary so an early
714 // child exit cannot bypass the startup-specific redaction layer.
715 msg := secrets.RedactCredentials(t.stderr.String())
716 if msg == "" {
717 return err
718 }
719 return fmt.Errorf("%w: stderr: %s", err, msg)
720 }
721
722 func (t *stdioTransport) startupStderr() string {
723 if t == nil || t.stderr == nil {
724 return ""
725 }
726 return secrets.RedactCredentials(t.stderr.String())
727 }
728
729 // wait reaps the child exactly once; cmd.Wait blocks until the stderr-copy
730 // goroutine completes, so the tail buffer is settled before anyone reads it.
731 func (t *stdioTransport) wait() {
732 t.waitOnce.Do(func() {
733 if t.cmd != nil && t.cmd.Process != nil {
734 _ = t.cmd.Wait()
735 }
736 })
737 }
738
739 // waitWithBudget runs wait in a goroutine and returns once it finishes or the
740 // budget elapses, whichever comes first. On timeout the goroutine is left to
741 // complete the reap in the background, so wait must be safe to abandon
742 // (stdioTransport.wait is single-shot via waitOnce).
743 func waitWithBudget(wait func(), budget time.Duration) {
744 _ = waitFinishedWithinBudget(wait, budget)
745 }
746
747 func waitFinishedWithinBudget(wait func(), budget time.Duration) bool {
748 done := make(chan struct{})
749 go func() { wait(); close(done) }()
750 select {
751 case <-done:
752 return true
753 case <-time.After(budget):
754 return false
755 }
756 }
757
758 // close first offers a short stdin-EOF grace period, then kills the whole
759 // process tree if needed (a launcher's surviving grandchild can otherwise keep
760 // inherited pipes open). Both paths are budgeted so one wedged server can never
761 // stall a boot or turn teardown.
762 func (t *stdioTransport) close() {
763 if t.releaseSlot != nil {
764 t.releaseSlot() // idempotent; frees the bounded CodeGraph instance slot
765 }
766 if t.stdin != nil {
767 _ = t.stdin.Close()
768 }
769 if t.cmd == nil || t.cmd.Process == nil {
770 return
771 }
772 // Give protocol-aware servers a short chance to observe stdin EOF and clean
773 // up resources they launched outside the process group (Chrome isolated
774 // profiles are the important case). Hard-kill after the bounded grace period
775 // so an unresponsive MCP still cannot stall teardown.
776 if waitFinishedWithinBudget(t.wait, gracefulCloseWaitBudget) {
777 return
778 }
779 proc.KillTracked(t.cmd, t.job)
780 waitWithBudget(t.wait, closeWaitBudget)
781 }
782
783 type tailBuffer struct {
784 mu sync.Mutex
785 limit int
786 buf []byte
787 }
788
789 func (b *tailBuffer) Write(p []byte) (int, error) {
790 b.mu.Lock()
791 defer b.mu.Unlock()
792 b.buf = append(b.buf, p...)
793 if b.limit > 0 && len(b.buf) > b.limit {
794 b.buf = append([]byte(nil), b.buf[len(b.buf)-b.limit:]...)
795 }
796 return len(p), nil
797 }
798
799 func (b *tailBuffer) String() string {
800 b.mu.Lock()
801 defer b.mu.Unlock()
802 return strings.TrimSpace(string(b.buf))
803 }
804
804 lines GO