跳到主要内容

命令执行:流式 RPC、CommandHandle 与 PTY

30 秒导读: 想在沙箱里跑一句 npm install 或开一个交互式 shell,SDK 不是发一个「请求-响应」,而是发一次调用、开一条会一直往回吐数据的流。本章追一条端到端数据路径:Commands.run('...')Process.Start 服务器流式 RPC → 沙箱里的进程边跑边把 stdout/stderr 一块块推回来 → 客户端的 CommandHandle 把每一块分派给你的回调,最后把退出码汇成一个 CommandResult。PTY(伪终端)走的是同一条路,只是把三条输出流合成一条 pty 字节流。

本章聚焦「在沙箱里跑进程」这一条路。上游的连接怎么建(URL 推导、鉴权、流的生命周期)见 数据面连接;文件读写是另一条独立的路,见 文件系统。总览与阅读地图见 index


1. 这是什么(零基础也能懂)

一句话定义: 这是 E2B SDK 里「让沙箱替你跑命令」的模块——你在本机写 sandbox.commands.run('ls -la'),真正执行发生在远端沙箱里,输出实时流回你手上。

为什么不能用普通的「请求-响应」? 一条命令可能跑 1 毫秒,也可能跑 10 分钟;输出可能是一个字符,也可能是几百兆日志。如果等它跑完再一次性返回,你既看不到中间进度,也扛不住大输出。所以 E2B 用流式:进程一边跑,输出一边往回推。

它能做什么:

  • 跑一条命令并等它结束,拿到 { exitCode, stdout, stderr }(前台模式)。
  • 跑一条命令立刻返回句柄,之后再 wait()(后台模式)。
  • 实时订阅输出:传 onStdout / onStderr 回调,边跑边收。
  • 往正在跑的进程喂 stdin关 stdin(发 EOF)发信号杀掉它。
  • 重连到一个已经在跑的进程(比如换了个客户端)。
  • 开一个真正的伪终端(PTY),像本地终端一样跑交互式程序(vimtop、REPL)。

用起来什么样: 一个最小的真实感受——

// 示意,非源码:最常见的前台用法
const result = await sandbox.commands.run('echo hello && ls /')
console.log(result.exitCode) // 0
console.log(result.stdout) // "hello\nbin\netc\n..."

// 想边跑边看输出,就传回调(后台模式,立即拿到句柄)
const handle = await sandbox.commands.run('npm install', {
background: true,
onStdout: (line) => console.log('[out]', line),
onStderr: (line) => console.log('[err]', line),
})
const finalResult = await handle.wait() // 等它结束,汇总结果

一句话直觉: 把它想成一根从沙箱进程接到你手上的水管Start 打开水管,进程流出的每一滴(stdout / stderr 的一块块字节)顺着管子实时到你手上;管子末尾封着一个「结束塞子」(EndEvent),上面写着退出码。CommandHandle 就是管子这头的接水员:负责把水按颜色(stdout / stderr / pty)分流给你,最后把塞子上的退出码抄下来。


2. 顶层全景(一条命令怎么从本机流到沙箱又流回来)

2.1 三个层次:协议 → RPC 客户端 → 流消费

命令执行这条路,从下到上刚好三层,各自有一个源文件:

职责源文件
协议契约定义 Process 服务有哪些方法、事件长什么样spec/envd/process/process.proto
RPC 门面把「跑命令 / 杀进程 / 喂 stdin」翻译成 gRPC 调用packages/js-sdk/src/sandbox/commands/index.tsCommands 类)
流消费引擎把回流的事件流分派成回调 + 汇总成结果packages/js-sdk/src/sandbox/commands/commandHandle.tsCommandHandle 类)

PTY 复用同样三层,只是门面换成 Pty 类(packages/js-sdk/src/sandbox/commands/pty.ts),共用同一个 CommandHandle

2.2 一次前台 run() 的端到端流向

怎么读这张图:从上到下是时间顺序;左边是你的进程(客户端),右边是沙箱里的 envd。中间那条竖线是一条服务器流式 RPC——一次调用,多次回吐。

客户端 (你的机器) 沙箱内 envd (Process 服务)
───────────────── ──────────────────────────
commands.run('cmd')

├─ start(): rpc.start({ /bin/bash -l -c cmd }) ──开流──▶ fork 进程, 拿到 pid
│ ◀──①─── StartEvent{ pid }
│ handleProcessStartEvent(): 读第一个事件确认已启动
│ → new CommandHandle(pid, events, onStdout, ...)
│ │ 构造里就启动 handleEvents() 后台消费
│ ▼
│ for await (event of events): ◀──②─── DataEvent{ stdout: "hel" }
│ 分派 → onStdout("hel") ◀──②─── DataEvent{ stdout: "lo\n" }
│ 分派 → onStderr(...) ◀──②─── DataEvent{ stderr: "warn" }
│ ◀──KeepAlive── (心跳, 保活)
│ ◀──③─── EndEvent{ exit_code: 0 }
│ 记录 result, 流自然结束

handle.wait() → CommandResult{ exitCode:0, stdout, stderr }

三种回流事件( 的 DataEvent 可以有很多个,①③ 各一个)就是全部:

  • ① StartEvent —— 只带一个 pid,等于「进程起来了」的握手。
  • ② DataEvent —— 一块输出,oneofstdout / stderr / pty 三选一。
  • ③ EndEvent —— 带 exit_code / exited / status / error,等于「结束塞子」。

前台和后台的唯一区别:run()background: true 就把句柄直接给你,否则替你 await handle.wait()index.ts:417-424)。


3. 协议源头:Process 服务与 ProcessEvent

一切从 .proto 契约开始。Process 服务定义了在沙箱里操作进程的全部方法(spec/envd/process/process.proto:5-21):

RPC 方法形态干什么
Start一元入 / 服务器流启动新进程,回流 StartResponse 事件流
Connect一元入 / 服务器流重连到已有进程,回流 ConnectResponse 事件流
SendInput一元发一次 stdin / pty 输入
StreamInput客户端流入 / 一元出流式发输入(保证顺序),见方法注释
SendSignal一元发 SIGTERM / SIGKILL
CloseStdin一元关 stdin 发 EOF(仅非 PTY)
List一元列出在跑的进程 / PTY
Update一元改 PTY 尺寸

注意 StartConnect服务器流式returns (stream ...)),这是本章的核心——一次调用换回一条持续的事件流。

3.1 ProcessEvent:回流的四种事件

回流的每个 StartResponse / ConnectResponse 都包一个 ProcessEvent,它是一个 oneofprocess.proto:67-95):

ProcessEvent (oneof event)
├── StartEvent { pid } ← 握手,一次
├── DataEvent (oneof output: stdout|stderr|pty) ← 输出块,多次
├── EndEvent { exit_code, exited, status, error } ← 收尾,一次
└── KeepAlive {} ← 心跳,穿插

几个关键设计:

  • DataEvent 里 stdout / stderr / pty 三选一。 普通命令用前两个分别装标准输出和标准错误;PTY 会话用第三个 pty 装合并后的终端字节流(process.proto:79-85)。
  • EndEvent 带 exit_codesint32)和 error 非零 exit_code 或有 error,客户端就会抛 CommandExitError(见 §5.3)。
  • KeepAlive 是空事件,纯粹用来保活——配合请求头 Keepalive-Ping-IntervalKEEPALIVE_PING_INTERVAL_SEC = 50 秒,connectionConfig.ts:10),防止长时间无输出的命令被中间层当成死连接掐掉。

3.2 发输入的两条路:SendInput vs StreamInput

喂 stdin 有两种 RPC:SendInput(一次一发的一元调用)和 StreamInput(客户端流)。proto 在 StreamInput 上特意注释了动机——「Client input stream ensures ordering of messages」(process.proto:13-15):一元的 SendInput 多次并发时不保证到达顺序,客户端流则天然有序。JS SDK 当前的 sendStdin 走的是 SendInputindex.ts:208)。


4. RPC 门面:Commands 类

Commands 类是「跑进程」这组能力的对外门面。它在构造时用共享的 transport 造一个类型安全的 RPC 客户端(index.ts:147):

// index.ts:147,符号 createClient / ProcessService
this.rpc = createClient(ProcessService, transport)

这个 transport 就是 ch02 里建好的、指向沙箱内 envd 的连接。Commands 自己不管 URL 推导和鉴权,只管把方法调用翻译成 RPC。

4.1 方法一览

方法位置底层 RPC说明
run (前台/后台两重载)index.ts:382, 396startbackground 决定返回 CommandResult 还是 CommandHandle
start (private)index.ts:426Start (流)真正开流、造句柄
connectindex.ts:322Connect (流)重连到已有 pid
listindex.ts:167List列出在跑的进程
sendStdinindex.ts:199SendInput往 pid 喂 stdin
closeStdinindex.ts:243CloseStdin关 stdin 发 EOF
killindex.ts:281SendSignal发 SIGKILL

4.2 run 的两个重载:一个方法,两种返回

run 靠 TypeScript 重载在编译期就分出两种返回类型——传 background: true 返回 CommandHandle,否则返回 CommandResultindex.ts:382-424)。实现只有两行:

// index.ts:417-424(节选),符号 run
async run(cmd, opts) {
const proc = await this.start(cmd, opts)
return opts?.background ? proc : proc.wait() // 后台给句柄;前台替你 wait
}

一句话:前台 = 后台 + 立刻 wait()。它们共用同一个 start

4.3 start:命令是怎么被包进 bash 的

真正开流在私有的 startindex.ts:426-487)。有两个值得记住的细节:

① 命令永远被 /bin/bash -l -c 包一层index.ts:449-454):

// index.ts:449-454(节选),符号 start
process: {
cmd: '/bin/bash',
cwd: opts?.cwd,
envs: opts?.envs,
args: ['-l', '-c', cmd], // 你的整条 cmd 作为 -c 的参数
}

所以你写 run('a && b | c') 能用管道、&&、环境变量展开——因为它是交给登录 shell(-l)解释执行的,不是直接 execve。

② 开流后先做一次启动握手。 rpc.start(...) 返回的是一个 AsyncIterable 事件流;start 立刻调 handleProcessStartEvent(events)index.ts:468)读第一个事件,确认它是 StartEvent 并取出 pidapi.ts:143-166)。握手成功才 clearStartTimeout() 并构造 CommandHandle。这一步把「连接建立慢」和「命令跑得久」分开计时——详见 §6。


5. 流消费引擎:CommandHandle

CommandHandle 是本章的心脏。它拿到那条事件流后,要同时做三件事:实时分派输出到回调累积完整的 stdout/stderr在结束时汇总出 CommandResult

5.1 构造即开跑:handleEvents 在后台消费

句柄一被 new 出来,构造函数最后一行就启动了消费循环(commandHandle.ts:126):

// commandHandle.ts:126,符号 constructor / handleEvents
this._wait = this.handleEvents() // 不 await,后台跑;wait() 时再 await 这个 promise

this._wait 是一个 promise,handleEvents() 在后台一直消费事件流直到结束。wait() 只是 await this._waitcommandHandle.ts:166-182)。这样设计的好处:回调在事件到达的当下就触发,不用等你调 wait()wait() 只是「等那个后台循环跑完,然后把结果给你」。

5.2 两级生成器:iterateEvents 分类,handleEvents 分派

消费逻辑刻意拆成两层异步生成器,各管一件事:

events (原始 RPC 流)

▼ iterateEvents():270 ← 解析 oneof,把 DataEvent 分成三色元组
│ DataEvent.stdout → [out, null, null] (顺便 decode + 累积到 _stdout)
│ DataEvent.stderr → [null, out, null]
│ DataEvent.pty → [null, null, bytes]
│ EndEvent → 记录 this.result,再吐出 flush 的尾字节
▼ handleEvents():344 ← 把三色元组分派给对应回调
[out,_,_] → await onStdout(out)
[_,err,_] → await onStderr(err)
[_,_,pty] → await onPty(pty)

iterateEventscommandHandle.ts:270-342 负责「解释」:它 for await 原始事件,读 event.event.event 这个 oneof,按 case 分派。对 stdout/stderr 它用流式 TextDecoder{ stream: true })解码,既累积进 this._stdout / this._stderr,又把新解出的片段 yield 出去(commandHandle.ts:281-298)。用流式解码是因为一个多字节 UTF-8 字符可能被切在两个 DataEvent 之间,流式解码器会把半个字符暂存到下一块再补齐。

handleEventscommandHandle.ts:344-374 负责「派发」:它消费上面 yield 的三色元组,await 对应的 onStdout / onStderr / onPty 回调;无论正常结束还是异常,finally 里都调 handleDisconnect() 释放底层流(commandHandle.ts:371-373)。异常会被存进 this.iterationError,留给 wait() 抛出。

5.3 结束与结果:wait() 和 CommandExitError

iterateEvents 碰到 end 事件时,先 flush 解码器尾字节、再记录 this.resultcommandHandle.ts:304-322)。wait() 拿到这个结果后做三档判断(commandHandle.ts:166-182):

情况wait() 行为
消费过程出过错(iterationError抛那个错
流结束了但没记到 resultSandboxError('Process exited without a result')
result.exitCode !== 0CommandExitErrorcommandHandle.ts:43
exitCode === 0返回 CommandResult

CommandExitErrorcommandHandle.ts:43-77)本身 implements CommandResult,所以 catch 到它照样能读 .exitCode / .stdout / .stderr / .error——失败命令的输出不会丢。

5.4 一个精妙的时序坑:EndEvent 时先记 result 再吐尾字节

这里藏着一处必须留意的顺序(源码专门写了长注释解释,commandHandle.ts:304-322)。问题是这样的:handleEvents 在派发前会检查 this.disconnected,一旦被 disconnect 就 break。如果处理 end 事件时先 yield 尾字节、后记 result,那么一个已 disconnect 的消费者会在第一个 yield 处就 break,导致 this.result 永远没被赋值,wait() 就会误以为「进程没产出结果」而抛错。

源码的对策是把顺序反过来:

// commandHandle.ts:311-320(节选),符号 iterateEvents 的 end 分支
const flushed = [...this.flushDecoders()] // 先把尾字节收集好(还没 yield)
this.result = { // 先把 result 落定
exitCode: e.value.exitCode,
error: e.value.error,
stdout: this.stdout,
stderr: this.stderr,
}
for (const chunk of flushed) { // 再 yield,此时 break 也无所谓
yield chunk
}

同一段区域(commandHandle.ts:306-354)还处理了另外两个竞态:流在 end 之前就异常/断开时,catch 分支会 yield* this.flushDecoders() 把缓冲里的半个字符以替换字符(�)吐出而不是静默丢弃(commandHandle.ts:326-333);handleEvents 里对 this.disconnected 的检查点也写了注释说明「检查和派发之间没有同步挂起点,所以 disconnect 不会让一个迟到事件溜进回调」(commandHandle.ts:346-357)。

5.5 disconnect ≠ kill

两个容易混的操作,语义完全不同:

操作位置对沙箱里的进程对本地句柄
disconnect()commandHandle.ts:195不动,进程继续跑停止接收事件、不再触发回调;可用 Commands.connect(pid) 重连
kill()commandHandle.ts:206SIGKILL 杀掉——

disconnect()commandHandle.ts:195-198)只置 this.disconnected = true 再调 handleDisconnect()(即 ch02 建的 cleanup,撤销底层流)。文档注释保证:它返回后回调不会再为「之后产生的输出」触发,且不等事件处理器 drain,所以对空闲命令也能立刻返回。这就是「换个客户端接着看同一个进程」能成立的基础——配合 Commands.connect(pid)index.ts:322)走 Connect 流重新接上。

5.6 喂 stdin 与关 stdin

如果命令需要交互输入,用 start(..., { stdin: true }) 打开 stdin 通道,之后:

  • handle.sendStdin(data)Commands.sendStdin(pid, data)index.ts:199)→ SendInput RPC,字符串会先 TextEncoder 编码。
  • handle.closeStdin()Commands.closeStdin(pid)index.ts:243)→ CloseStdin RPC,等于给进程发 EOF(比如让 cat 结束读取)。

closeStdin 有版本门槛:老 envd 不支持时直接抛 SandboxError 提示重建模板(index.ts:244-248)。注意 proto 也提醒:CloseStdin 只对非 PTY 有效,PTY 要发 Ctrl+D(0x04)代替(process.proto:18-20)。


6. 背压与超时:靠 ch02 的连接层撑住

命令流可能跑很久、吐很多,如何不被拖垮、又能在真出问题时及时放手?关键机制都在 ch02 的连接层,命令这条路只是它们。

6.1 背压:回调是 await 的,慢消费者自动减速

handleEvents 派发时用的是 await this.onStdout?.(...)commandHandle.ts:359-363)。如果你的回调是异步的、处理得慢,for await 循环就会在这停住,不去拉下一个事件——这股「拉取变慢」的压力顺着流一路传导回底层的 ReadableStream

ch02 的 wrapStreamWithConnectionCleanupconnectionConfig.ts:259)正是配合这一点设计的:它用 pull() 拉模型包住网络流,只在等待网络读时才 arm 空闲计时器,一有数据块(或消费者暂停)就 clear(connectionConfig.ts:290-309)。注释写得很直白——「a slow or paused consumer never trips it」(connectionConfig.ts:292-295):慢消费者不会被空闲超时误杀,只有服务端真停发才会触发。这就是背压能安全存在的前提。

6.2 两种超时,两种含义

命令这条路上有两层时间限制,别混淆:

计时器管什么默认触发后
requestTimeoutMs建连握手:开流到收到第一个事件的时间60s(connectionConfig.ts:8setupRequestController 的 handshake 计时器 abort
timeoutMs整个命令能活多久(长命令的总时长)60s(index.ts:138作为 RPC 的 timeoutMs 传下去,到点由服务端/deadline 终止

握手一旦成功(拿到 pid),clearStartTimeout() 就拆掉 handshake 计时器(index.ts:469),这样「命令跑得久」不会被「建连超时」误伤。这两级计时都来自 ch02 的 setupRequestControllerconnectionConfig.ts:158)。0 可以禁用超时(connectionConfig.ts:128-131)。

6.3 错误码 → 超时语义:DeadlineExceeded vs Canceled

超时最后表现为一个 gRPC 错误码,handleRpcError 把它映射成带不同提示语TimeoutErrorrpc.ts:76-83)——这两条要分清:

gRPC Code映射到含义提示指向
Code.DeadlineExceededTimeoutError长命令总时长到顶「likely due to exceeding 'timeoutMs'」,可传 timeoutMs0 禁用(rpc.ts:80-83
Code.CanceledTimeoutError单次请求超时/被取消「likely due to exceeding 'requestTimeoutMs'」(rpc.ts:76-79

一句话记法:DeadlineExceeded 是「命令活太久」(对应 timeoutMs);Canceled 是「这一次请求等太久」(对应 requestTimeoutMs)。 所有命令方法的 catch 都过一遍 handleRpcErrorWithHealthCheckrpc.ts:127),它还会在连接中途被掐断时探一次沙箱健康,分辨「沙箱被杀」还是「网络抖动」(rpc.ts:127-143)。


7. PTY:把三条流合成一条终端流

普通 run 适合「跑完就走」的批处理命令;但要跑 vimtop、Python REPL 这类交互式、要方向键要清屏的程序,就得要一个真正的伪终端(PTY,pseudo-terminal,操作系统提供的假终端设备,能处理终端控制序列、行编辑、窗口尺寸)。这就是 Pty 类(pty.ts:82)。

7.1 PTY 和普通命令的异同

PTY 复用整条流式基础设施,只在四处不同:

方面普通命令 (Commands)PTY (Pty)
启动 args/bin/bash -l -c <cmd>index.ts:453/bin/bash -i -l交互式 -ipty.ts:123
Start 请求pty 字段pty.size {cols, rows}pty.ts:127-132
输出事件DataEvent.stdout / .stderr 分开全走 DataEvent.pty 一条字节流
回调onStdout / onStderr单个 onData(bytes: Uint8Array)pty.ts:46

构造 CommandHandle 时,PTY 把 onData 挂到第 7 个参数 onPty 上、stdout/stderr 回调传 undefinedpty.ts:148-159),于是 §5.2 里 [null, null, pty] 那一路就流进你的 onData。因为 PTY 输出是原始终端字节(含 ANSI 转义序列),所以类型是 Uint8Array 而非 decode 过的 string——由你自己喂给终端模拟器(如 xterm.js)。

7.2 create:环境变量默认值

Pty.createpty.ts:106-164)会补三个终端相关环境变量的默认值(pty.ts:109-112):

// pty.ts:109-112(节选),符号 create
envs.TERM = envs.TERM ?? 'xterm-256color' // 声明终端能力
envs.LANG = envs.LANG ?? 'C.UTF-8'
envs.LC_ALL = envs.LC_ALL ?? 'C.UTF-8' // 保证 UTF-8 语言环境

没有 TERM,很多程序会拒绝启用颜色/全屏;这里替你兜底。

7.3 输入与 resize

操作位置底层 RPC说明
sendInput(pid, bytes)pty.ts:230SendInputinput.case = 'pty'把键盘字节送进终端;发 Ctrl+D=0x04 表 EOF
resize(pid, {cols, rows})pty.ts:271Update终端窗口变大变小时同步给 PTY
kill(pid)pty.ts:313SendSignal(SIGKILL)杀掉 PTY 会话
connect(pid)pty.ts:174Connect(流)重连到已有 PTY

resize 走的是 Update RPC(pty.ts:280)——proto 里 UpdateRequest 带一个可选的 PTY.Size {cols, rows}process.proto:59-63, 23-30)。终端里拖动窗口改变行列数时必须调它,否则程序按旧尺寸渲染会错位。


8. 边界与局限(诚实地说它不做什么)

  • run 只能跑 shell 能解释的字符串,因为固定包了 /bin/bash -l -cindex.ts:453);不是直接 execve 一个 argv 数组。想避开 shell 解析就得自己拼。
  • closeStdin 只对非 PTY 有效;PTY 要发 Ctrl+D(process.proto:18-20),且 closeStdin 在老 envd 上直接抛错(index.ts:244-248)。
  • kill 固定用 SIGKILLindex.ts:291),不是可礼貌退出的 SIGTERM——虽然 proto 的 Signal 枚举同时有 SIGTERMprocess.proto:140-144),但 SDK 的 kill 只发 SIGKILL。
  • stdin 的顺序保证有限:SDK 的 sendStdin 走一元 SendInput,并发多发时的顺序不如 proto 里为此设计的 StreamInput 客户端流强(process.proto:13-15)。
  • 默认超时都是 60 秒(建连和命令时长),长命令必须显式传 timeoutMs,否则会以 DeadlineExceeded 收场(rpc.ts:80-83)。
  • PTY 输出不解码onData 拿到的是原始字节,SDK 不替你做终端仿真——渲染是你的事。
  • 本章不覆盖文件读写:往沙箱传文件、读结果文件走的是另一条「RPC 元数据 + HTTP 数据」的路,见 ch04 文件系统

9. 横向对比

同库(index)里,命令执行这条「数据面」路与其它两条对照着看最清楚:

  • 对比 ch02 数据面连接 ch02 建管子(URL、鉴权、流生命周期、wrapStreamWithConnectionCleanup / 空闲超时),本章用管子跑进程。本章的背压与超时(§6)全部落在 ch02 的机制上。
  • 对比 ch04 文件系统 命令用服务器流式 RPCStart 一次调用多次回吐);文件系统的大数据走 HTTP 通道、只用 RPC 传元数据。两者都建立在同一个 envd 连接上,但流模型不同。
  • 对比 ch01 控制面 控制面是「创建/销毁沙箱」的编排 REST API;本章是「沙箱已在跑之后,往里面塞活干」的数据面。

10. 代码地图(导航索引)

用符号名 grep 定位比行号更抗漂移。

主题文件路径符号名
协议:Process 服务方法spec/envd/process/process.protoservice Process
协议:回流事件 oneofspec/envd/process/process.protoProcessEventStartEvent/DataEvent/EndEvent/KeepAlive
协议:进程选择器spec/envd/process/process.protoProcessSelectorpid / tag
RPC 门面:类与客户端packages/js-sdk/src/sandbox/commands/index.tsCommands, createClient(ProcessService, transport)
前台/后台两重载packages/js-sdk/src/sandbox/commands/index.tsrun(:382 / :396 / :413)
开流 + 启动握手packages/js-sdk/src/sandbox/commands/index.tsstart(:426), handleProcessStartEvent
stdin / 关 stdin / 杀 / 列 / 重连packages/js-sdk/src/sandbox/commands/index.tssendStdin(:199), closeStdin(:243), kill(:281), list(:167), connect(:322)
流消费引擎packages/js-sdk/src/sandbox/commands/commandHandle.tsCommandHandle, handleEvents(:344), iterateEvents(:270)
汇总结果 / 等待packages/js-sdk/src/sandbox/commands/commandHandle.tswait(:166), CommandResult, CommandExitError(:43)
disconnect(不杀进程)packages/js-sdk/src/sandbox/commands/commandHandle.tsdisconnect(:195)
EndEvent 时序坑 / 断流竞态packages/js-sdk/src/sandbox/commands/commandHandle.tsiterateEventsend 分支(:306-354), flushDecoders(:255)
PTY:创建 / 连接 / 输入 / 尺寸 / 杀packages/js-sdk/src/sandbox/commands/pty.tsPty.create(:106), connect(:174), sendInput(:230), resize(:271), kill(:313)
超时错误码语义packages/js-sdk/src/envd/rpc.tsDEFAULT_ERROR_MAPDeadlineExceededtimeoutMs:80, CanceledrequestTimeoutMs:76)
健康探测式错误处理packages/js-sdk/src/envd/rpc.tshandleRpcErrorWithHealthCheck(:127)
建连/命令两级计时 + 背压清理packages/js-sdk/src/connectionConfig.tssetupRequestController(:158), wrapStreamWithConnectionCleanup(:259)