图解 Pi AgentPi Agent, Illustratedv1.1.0
包Packages· env
演示Slides
Infra已完成Donepackages/env · v1.1.0

env@earendil-works/pi-env

Pi Durable 的远程执行环境(经 SSH 部署的小型守护进程)。Remote execution environments for Pi Durable: a small daemon deployed over SSH and its ExecutionEnv client.

1.1.0
版本version
6
源文件src files
2,073
源码行src LOC
5
测试文件test files
16
测试用例test cases
1
导出路径export paths
依赖Depends onchorddurable被依赖Used by—

这个包是什么What this package ispi-env 让 agent 的工具在另一台机器上运行,而 Durable worker、它的存储和凭据留在本地。它由两半组成:远端的 pi-env 守护进程(Rust)在 stdin/stdout 上讲一个分帧协议、执行文件操作和命令;本地的 RemoteExecutionEnv(TypeScript)实现 Durable 的 ExecutionEnv,结果与在远端机器上运行 NodeExecutionEnv 一致——只有错误消息可以不同。连接通常走 SSH:信任主机密钥、探测平台、按 SHA-256 部署守护进程、启动。pi-env runs an agent's tools on another machine while the Durable worker, its storage and credentials stay local. It has two halves: the remote pi-env daemon (Rust) speaks a framed protocol on stdin/stdout and performs file operations and commands; the local RemoteExecutionEnv (TypeScript) implements Durable's ExecutionEnv with results that match NodeExecutionEnv running on the remote machine — only error messages may differ. Connections usually go over SSH: trust the host key, detect the platform, deploy the daemon by SHA-256, start it.

2,073 行 TypeScript 客户端加 4,027 行 Rust 守护进程(daemon/src)。依赖 chord 与 pi-durable;只有一个导入路径;包里附带每个支持平台的守护进程二进制(bin/)。pi 仓库里目前没有其他包导入它。A 2,073-line TypeScript client plus a 4,027-line Rust daemon (daemon/src). Depends on chord and pi-durable; one import path; the package ships a daemon binary for every supported platform (bin/). No other package in the pi repo imports it yet.

01Infra

两半:TypeScript 客户端与 Rust 守护进程Two halves: a TypeScript client and a Rust daemon#

一句话结论Takeaway本地:remote-env.ts(RemoteExecutionEnv)、ssh.ts(主机密钥、探测、部署、启动)、connection.ts(帧、请求 id、心跳、重启)、watch.ts(断线后重新打开监视)、errors.ts(错误码映射)。远端:main.rs(同步行、调度、工作线程池)、exec.rs 与 window.rs(命令与输出)、fs.rs 与 scan.rs(文件与行扫描)、watch.rs(Durable NodeFileWatcher 的移植)、sys/(Unix 与 Windows)。Local: remote-env.ts (RemoteExecutionEnv), ssh.ts (host keys, detection, deployment, launch), connection.ts (frames, request ids, pings, restarts), watch.ts (reopening watches after a loss) and errors.ts (error-code mapping). Remote: main.rs (sync line, scheduling, the worker pool), exec.rs and window.rs (commands and output), fs.rs and scan.rs (files and line scans), watch.rs (a port of Durable's NodeFileWatcher) and sys/ (Unix and Windows).

TypeScript 客户端与 Rust 守护进程的模块The modules of the TypeScript client and the Rust daemon
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
pi-env: a 2,073-line TypeScript client + a 4,027-line Rust daemon本地:Durable worker 所在的机器Local: where the Durable worker runssrc/*.ts远端:工具真正运行的机器Remote: where the tools actually rundaemon/src/*.rs包与平台Package and platforms939remote-env.tsRemoteExecutionEnv:实现 Durable 的 ExecutionEnvRemoteExecutionEnv: a Durable ExecutionEnv530ssh.ts主机密钥 · 探测平台 · 部署 · 启动host keys · detect · deploy · launch384connection.tsConnection:帧、请求 id、心跳、重连Connection: frames, request ids, ping, restart158watch.tsRemoteWatcher:断线后重新打开RemoteWatcher: reopens after a lost link34errors.tstoFileError:错误码映射toFileError: error-code mapping28index.ts公开 APIpublic API516main.rsserve:同步行、调度、工作线程serve: sync line, scheduling, workers778watch.rsNodeFileWatcher 的移植a port of NodeFileWatcher483exec.rs命令、进程组、超时、溢出文件commands, process groups, timeouts, spill269window.rs输出窗口与节流output window and pacing473fs.rs · scan.rs文件操作 · 行扫描file ops · line scans988sys/unix.rs · sys/windows.rs平台差异platform specifics@earendil-works/pi-envchord ^1.1.0pi-durable ^1.1.0LinuxmacOSAndroid (Termux)Windowsx86-64arm64sshstdin/stdout随包附带 bin/pi-env-{platform}-{arch}/pi-envships bin/pi-env-{platform}-{arch}/pi-envREADME:Durable worker、存储与凭据留在本地README: the Durable worker, storage and credentials stay local标签 = 行数tag = lines本地 TypeScriptlocal TypeScript远端 Rustremote Rust请求requests结果与事件results & events

要点Key points

  • 支持的远端:Linux、macOS、Android(Termux)与 Windows,x86-64 与 arm64。Windows 上字符串命令像 NodeExecutionEnv 那样通过 Git Bash 运行,argv 命令直接运行。Supported remotes: Linux, macOS, Android (Termux) and Windows, on x86-64 and arm64. On Windows, string commands run through Git Bash as NodeExecutionEnv runs them; argv commands run directly.
  • 测试对 RemoteExecutionEnv 跑 Durable 的 env 一致性套件,并把随机操作序列和 Durable 的工具与本机 NodeExecutionEnv 比对(differential 测试)。Tests run Durable's env conformance suite against RemoteExecutionEnv and compare random operation sequences and Durable's tools with NodeExecutionEnv on the same machine (differential tests).
  • RemoteExecutionEnv 需要一个 id:相同 id 表示看到同样的文件,Durable 的 edit / write 按 id 加路径串行化修改。RemoteExecutionEnv takes an id: equal ids see the same files, and Durable's edit / write serialize changes by id plus path.

工作原理How it works

  1. const { connection } = await connectSsh(target)。const { connection } = await connectSsh(target).
  2. new RemoteExecutionEnv({ connection, id: "pi-env:gpu", cwd: "/home/me/project" })。new RemoteExecutionEnv({ connection, id: "pi-env:gpu", cwd: "/home/me/project" }).
  3. 交给 Durable:Harness.open(storage, { …, env: () => remoteEnv }, ctx)。Hand it to Durable: Harness.open(storage, { …, env: () => remoteEnv }, ctx).

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/env/src/index.tsL10export …包入口package entry
packages/env/src/remote-env.tsL351RemoteExecutionEnv客户端环境the client environment
packages/env/src/connection.tsL168Connection一条连接one connection
packages/env/daemon/src/main.rsL438serve()守护进程主循环the daemon main loop
packages/env/daemon/src/exec.rsL242exec::run()命令commands
02Infra

公共 APIPublic API#

一句话结论Takeaway只有 1 个导入路径。index.ts 从 4 个模块导出 17 个值和 9 个类型:SSH 部署与主机密钥、Connection、RemoteExecutionEnv 与 RemoteWatcher。Just 1 import path. index.ts exports 17 values and 9 types from 4 modules: SSH deployment and host keys, Connection, RemoteExecutionEnv and RemoteWatcher.

导入路径Import path目标Target说明Notes
@earendil-works/pi-env./dist/index.jsSSH 部署、连接、远程 ExecutionEnv 与监视SSH deployment, the connection, the remote ExecutionEnv and watching

package.json · exportsL8

常用导出Key exports

模块Module常用导出Key exports用途Use
packages/env/src/ssh.tsconnectSsh探测、部署并连接detect, deploy and connect
packages/env/src/ssh.tsscanHostKey acceptHostKey主机密钥信任host-key trust
packages/env/src/ssh.tsdetectPlatform deployDaemon packagedDaemon平台探测与守护进程部署platform detection and daemon deployment
packages/env/src/connection.tsConnection分帧请求 / 回复连接the framed request / reply connection
packages/env/src/remote-env.tsRemoteExecutionEnv远端的 ExecutionEnvan ExecutionEnv on the remote host
packages/env/src/watch.tsRemoteWatcher远端文件监视remote file watching

根入口 index.ts 的全部导出(按来源模块)Every root index.ts export, by source module

展开全部 4 个来源模块Show all 4 source modules17 values9 types
来源模块Source module值导出Value exports类型导出Type exports
packages/env/src/connection.tsConnection isConnectionLost RemoteErrorConnectionOptions RemoteInfo Reply RequestOptions
packages/env/src/remote-env.tsRemoteExecutionEnvRemoteExecutionEnvOptions
packages/env/src/ssh.tsacceptHostKey connectSsh deployDaemon detectPlatform forgetHostKey HostKeyChangedError HostKeyUnknownError packagedDaemon SshError scanHostKey sshArguments sshConnectionRemotePlatform SshConnectOptions SshTarget
packages/env/src/watch.tsRemoteWatcherRemoteWatchOptions

由构建脚本解析 export { … } from 语句生成。Generated by parsing the export { … } from statements at build time.

03Infra

分工与语义Split of work and semantics#

一句话结论Takeaway参照物是远端机器上的 Durable NodeExecutionEnv。凡是 Node 在 JavaScript 里做的事——路径解析、取消检查点、错误码映射、readFile 的分块与解码、writeFile 的 512 KiB 分块、行读取器、超时与结果优先级——都留在客户端;系统调用、进程、溢出文件、行扫描、输出解码和文件监视在守护进程里做。The reference is Durable's NodeExecutionEnv on the remote machine. Whatever Node does in JavaScript — path resolution, abort checkpoints, error-code mapping, readFile chunking and decoding, writeFile's 512 KiB writes, the line reader, timeouts and result precedence — stays in the client; system calls, processes, spill files, line scans, output decoding and watching happen in the daemon.

分工:结果要与远端的 NodeExecutionEnv 一致The split of work: results must match NodeExecutionEnv on the remote machine
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
Split of work: results must match NodeExecutionEnv on the remote machine客户端(TypeScript,本地)Client (TypeScript, local)守护进程(Rust,远端)Daemon (Rust, remote)路径解析Path resolution~(远端 home)、file://、相对 cwd;Windows 驱动器相对路径~ (remote home), file://, relative to cwd; Windows drive-relative取消检查点Abort checkpoints每个方法的检查点与 NodeExecutionEnv 相同each method's checkpoints, as NodeExecutionEnv错误码映射Error-code mappingENOENT → not_found …(toFileError)ENOENT → not_found … (toFileError)readFile 的分块与解码readFile chunking & decodingStringDecoder,保留 BOMStringDecoder, BOM keptwriteFile512 KiB 一块,每块前检查取消512 KiB writes, abort check before each行读取器Line reader定位读取 + Durable 的 StreamDecoderpositional reads + Durable's StreamDecoder超时与结果优先级Timeouts & result precedence回调错误 > 超时 > 取消 > 溢出失败 > 退出码callback error > timeout > abort > spill failure > exit code系统调用System callslstat、open、pread、rename、rm …lstat, open, pread, rename, rm …进程Processes每条命令一个新会话(进程组)a new session (process group) per command溢出文件Spill filespi-output-<uuid>.log,在新的 tmp- 目录里pi-output-<uuid>.log in a fresh tmp- directory行扫描Line scansscanLines:在文件旁边数行scanLines: counted next to the file输出解码Output decoding每个流独立,WHATWG UTF-8(encoding_rs)per stream, WHATWG UTF-8 (encoding_rs)文件监视Watching快照、原生事件、轮询:NodeFileWatcher 的移植snapshots, native events, polling: a NodeFileWatcher port两边由 Durable 的 env 一致性套件和 test/differential.test.ts 一起检查:随机操作序列在 RemoteExecutionEnv 与本机 NodeExecutionEnv 上比对。Both sides are checked by Durable's env conformance suite and test/differential.test.ts: random operation sequences compared with NodeExecutionEnv.只有错误消息可以不同;错误码才是契约。Only error messages may differ; codes are the contract.客户端client守护进程daemon

要点Key points

  • docs/semantics.md 记录了标准库不遵守、但这里必须遵守的规则:递归 mkdir 遇到已有文件报 EEXIST;rm 目录不带 recursive 报 ERR_FS_EISDIR;listDir 按字节序;mkdtemp 的前缀用 POSIX path.join……docs/semantics.md records rules the standard libraries do not follow but this must: a recursive mkdir over an existing file fails with EEXIST; rm of a directory without recursive gives ERR_FS_EISDIR; listDir is in byte order; mkdtemp joins its prefix with POSIX path.join…
  • tmpdir 按 TMPDIR、TMP、TEMP、/tmp 的顺序(Termux 用 $PREFIX/tmp),与远端 Node 的 os.tmpdir() 一致。tmpdir follows TMPDIR, TMP, TEMP, then /tmp (Termux: $PREFIX/tmp), matching the remote Node's os.tmpdir().
  • 命令输出按流解码为 WHATWG UTF-8(encoding_rs),只丢掉开头的 BOM;文件读取保留 BOM——都和 Node 的行为对齐。Command output is decoded per stream as WHATWG UTF-8 (encoding_rs) with only a leading BOM dropped; file reads keep the BOM — both matching Node.

工作原理How it works

  1. 改动任何一边的语义,都要跑 test/differential.test.ts:它在两种环境上执行同一串随机操作并比较结果。Changing semantics on either side means running test/differential.test.ts: it runs the same random operation sequence on both environments and compares results.
  2. 自定义环境可以用 Durable 的 registerEnvConformance() 自检。A custom environment can check itself with Durable's registerEnvConformance().
  3. 真实 SSH:设置 PI_ENV_SSH_HOST 后 test/ssh-external.test.ts 在真正的服务器上跑套件。Real SSH: with PI_ENV_SSH_HOST set, test/ssh-external.test.ts runs the suite against a real server.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/env/docs/semantics.mdL6semantics.md分工表the split table
packages/env/docs/semantics.mdL21semantics.md额外规则extra rules
packages/env/src/errors.tsL9toFileError()错误码映射error-code mapping
packages/env/daemon/src/decode.rsL5decode.rs输出解码output decoding
packages/env/test/differential.test.tsL9differential.test.ts对比测试differential test
04Infra

通过 SSH 连接Connecting over SSH#

一句话结论Takeaway一次性信任:scanHostKey() 用临时 known_hosts 抓到主机密钥,给用户看指纹,acceptHostKey() 存进应用自己的 known_hosts(固定的 HostKeyAlias)。之后每次连接:detectPlatform() 探测系统与架构,deployDaemon() 确保远端有内容哈希一致的守护进程,再经 ssh 启动 pi-env serve --token <hex>。Trust once: scanHostKey() captures the host key with a temporary known_hosts, the owner checks the fingerprints, and acceptHostKey() stores it in the application's own known_hosts (under a fixed HostKeyAlias). Then on each connection: detectPlatform() finds the system and architecture, deployDaemon() makes sure the remote has a daemon with the matching content hash, and ssh starts pi-env serve --token <hex>.

经 SSH 连上一台机器:信任、探测、部署与启动Reaching a machine over SSH: trust, detection, deployment and start
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
Reaching a machine over SSH: trust → detect → deploy → startdeployDaemon 的细节(POSIX)deployDaemon in detail (POSIX)src/ssh.tssshArguments():每次调用都锁死这些选项sshArguments(): these options on every callscanHostKey用临时 known_hosts 抓取主机密钥,给用户比对指纹capture the host key with a temp known_hosts; owner checks fingerprintsacceptHostKey写进应用自己的 known_hosts,固定别名store it in the app's own known_hosts, under a fixed aliasdetectPlatformPOSIX:sh -c uname …;Windows:PowerShellPOSIX: sh -c uname …; Windows: PowerShelldeployDaemon按 SHA-256 命名、校验后才放到位named by SHA-256, verified before it is put in placepi-env serve --token <hex>经 ssh 启动;等到 "PI-ENV <token>" 这一行started over ssh; wait for the "PI-ENV <token>" lineBatchMode=yesClearAllForwardings=yesForwardAgent=noForwardX11=noControlMaster=noControlPath=noneRemoteCommand=nonePermitLocalCommand=noSendEnv=-*ServerAliveInterval=15GlobalKnownHostsFile=noneHashKnownHosts=noIdentitiesOnly=yesStrictHostKeyChecking=yesUserKnownHostsFile=<app file>HostKeyAlias=<alias>connectSsh():立即探测与部署(3–4),守护进程在第一个请求时启动;sshConnection():全部懒执行,connectSsh(): detects and deploys now (3–4), the daemon starts on the first request; sshConnection(): all lazily;失败就是该操作的错误,下一次操作再试。forgetHostKey() 删除已存的密钥。a failure is that operation's error and the next one retries. forgetHostKey() removes a key.f=~/.pi/mobile/tools/pi-env-{sha256 前 32 位}f=~/.pi/mobile/tools/pi-env-{first 32 of sha256}# 检查:present → 直接用 · missing → 上传 · nohash → 报错# check: present → use it · missing → upload · nohash → errort=$(mktemp "$d/.pi-env.XXXXXX"); cat > "$t"[ "$(hash "$t")" = {sha256} ] || 删除并报 corrupt[ "$(hash "$t")" = {sha256} ] || rm, "upload is corrupt"chmod 700 "$t"; mv -f "$t" "$f"# 删除其他 pi-env-*(运行中的保持打开直到退出)# remove other pi-env-* (running ones stay open until exit)hash 依次尝试 sha256sum、shasum、openssl;Windows 用 Get-FileHash 与 base64 上传。hash tries sha256sum, shasum, openssl; Windows uses Get-FileHash and a base64 upload.不转发任何东西、不复用连接、不执行配置的命令、不发送本地 locale。No forwarding of any kind, no shared connections, no configured commands, no local locale.accept-new 只在 scanHostKey 的临时文件里使用。accept-new is used only against scanHostKey's temporary file.12345信任trust部署与启动deploy & start顺序order

要点Key points

  • 守护进程放在 ~/.pi/mobile/tools/pi-env-<sha256 前 32 位>:按内容命名,版本之间不会冲突;每次启动前都先校验哈希。The daemon lives at ~/.pi/mobile/tools/pi-env-<first 32 of sha256>: named by content so versions never collide, and its hash is checked before every start.
  • 上传写到同目录的 mktemp 临时文件,哈希一致才 chmod 700 并 mv 到位,然后删除其他版本(运行中的保持打开直到退出)。远端没有 sha256sum / shasum / openssl 时直接报错。The upload goes to a mktemp file in the same directory and is chmod 700-ed and mv-ed into place only if its hash matches; other versions are then removed (running ones stay open until they exit). A remote without sha256sum / shasum / openssl is an error.
  • ssh 选项锁死:BatchMode=yes、不转发任何东西、ControlMaster=no、RemoteCommand=none、SendEnv=-*、主机密钥按应用自己的 known-hosts 文件严格检查。ssh 配置仍决定路由(别名、跳板机),但加不进提示、转发或命令。ssh options are pinned: BatchMode=yes, no forwarding of any kind, ControlMaster=no, RemoteCommand=none, SendEnv=-*, host keys checked strictly against the application's own known-hosts file. The ssh config still picks the route (aliases, jump hosts) but cannot add prompts, forwarding or commands.
  • Windows 远端通过 PowerShell 探测与部署(base64 上传、Get-FileHash),守护进程通过服务器默认 shell 启动。Windows remotes are detected and deployed through PowerShell (base64 upload, Get-FileHash), and the daemon starts through the server's default shell.

工作原理How it works

  1. const { lines, fingerprints } = await scanHostKey(target) → 用户确认 → await acceptHostKey(target, lines)。const { lines, fingerprints } = await scanHostKey(target) → the owner confirms → await acceptHostKey(target, lines).
  2. 立即连接:await connectSsh(target);懒连接:sshConnection(target),第一次操作时才探测、部署、连接,失败只是那次操作的错误。Eager: await connectSsh(target); lazy: sshConnection(target) detects, deploys and connects on the first operation, and a failure is just that operation's error.
  3. POSIX 上 loginShell: true 让守护进程经 $SHELL -l 启动,命令能看到 ~/.profile 里的环境。On POSIX, loginShell: true starts the daemon through $SHELL -l so commands see the environment from ~/.profile.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/env/src/ssh.tsL77sshArguments()锁死的选项pinned options
packages/env/src/ssh.tsL182scanHostKey()抓取主机密钥capturing the host key
packages/env/src/ssh.tsL329detectPlatform()探测平台platform detection
packages/env/src/ssh.tsL396deployDaemon()部署deployment
packages/env/src/ssh.tsL510sshConnection()懒连接lazy connection
05Infra

线上协议The wire protocol#

一句话结论Takeaway协议版本 1。shell 启动文件可能先往 stdout 打印东西,所以守护进程的第一行输出是 PI-ENV <token>,客户端丢弃它之前的一切;之后 stdout 上只有帧:u32 length、u8 type、u32 id、u32 jsonLength、JSON、原始字节,全部大端序,单帧最多 16 MiB。帧类型有 6 种:request、result、error、event、cancel、ping。Protocol version 1. Shell startup files may print to stdout first, so the daemon's first output is the line PI-ENV <token> and the client discards everything before it; after that stdout carries only frames: u32 length, u8 type, u32 id, u32 jsonLength, JSON, raw bytes, all big-endian, at most 16 MiB a frame. There are 6 frame types: request, result, error, event, cancel, ping.

线上格式:同步行之后只有帧The wire format: after the sync line, only frames
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
The wire format: after the sync line, only frames同步Sync一帧(两个方向都一样,大端序,最多 16 MiB)One frame (both directions, big-endian, at most 16 MiB)6 种帧类型The 6 frame typesconnection.ts · frame.rsshell 启动文件可能打印的杂项…whatever shell startup files print…PI-ENV 3f9a…c1\nframeframeframe …length之后的字节数bytes after this fieldtype1–6id请求 id;0 = 不属于请求request id; 0 = nonejsonLengthjsonJSON 对象a JSON objectbytes文件内容 / 输出file contents / output123456客户端丢弃同步行之前的The client discards一切输出everything before itu32u8u32u32UTF-8rawbytes 长度 = length − 9 − jsonLength;JSON 解析失败 → EINVAL;帧格式错误 → 断开连接。bytes length = length − 9 − jsonLength; unparsable JSON → EINVAL; a framing error ends the connection.request客户端 → 守护进程client → daemonjson.op 指明操作json.op names the operationresult守护进程 → 客户端daemon → client请求成功并结束success; the request is finishederror守护进程 → 客户端daemon → client{ code, message, syscall?, path? }event守护进程 → 客户端daemon → client进度:exec 输出、watch 变化;不结束请求progress: exec output, watch changes; not finishingcancel客户端 → 守护进程client → daemon取消请求 idcancel request idping双向both心跳;接收方忽略liveness; ignored by the receiver同步行sync line帧字段frame fields帧类型frame types

要点Key points

  • 文件内容和命令输出走原始字节负载,不做 base64;更大的传输由客户端拆分。File contents and output travel as raw payload bytes, not base64; larger transfers are split by the client.
  • 错误是 { code, message, syscall?, path? },code 是 Node 风格的(ENOENT、ERR_FS_EISDIR…)或执行错误码;只有 code 属于契约,消息只用于诊断。Errors are { code, message, syscall?, path? } with a Node-style code (ENOENT, ERR_FS_EISDIR…) or an execution code; only codes are part of the contract, messages are diagnostic.
  • 路径都是绝对路径:客户端先解析相对路径、~ 和 file://。字符串以合法 UTF-8 发送(孤立代理项变成 U+FFFD),和 Node 编码字符串参数的方式一致。Paths are absolute: the client resolves relative paths, ~ and file:// first. Strings are sent as well-formed UTF-8 (lone surrogates become U+FFFD), as Node encodes string arguments.
  • 第一个请求是 hello { protocol },返回 os、arch、home、tmpdir、cwd、Windows 的 driveCwds 和 pid。The first request is hello { protocol }, which returns os, arch, home, tmpdir, cwd, Windows' driveCwds and the pid.

工作原理How it works

  1. 客户端:Connection.request(op, json, { payload, signal }) 分配 id、写帧、等 result 或 error。Client: Connection.request(op, json, { payload, signal }) assigns an id, writes the frame, and waits for result or error.
  2. 守护进程:serve_frames() 读帧,按 type 分派;request 先登记再执行,所以紧随其后的 cancel 不会丢。Daemon: serve_frames() reads frames and dispatches by type; a request is registered before it runs, so a cancel right after it is never lost.
  3. JSON 解析失败的请求得到 EINVAL;帧格式错误直接断开连接。A request whose JSON does not parse gets EINVAL; a framing error ends the connection.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/env/docs/protocol.mdL12protocol.md帧格式frame format
packages/env/src/connection.tsL12MAX_FRAME16 MiB16 MiB
packages/env/src/connection.tsL190request()发出请求sending a request
packages/env/daemon/src/frame.rsL14frame.rs守护进程的帧the daemon's frames
packages/env/daemon/src/main.rsL389serve_frames()读帧与分派reading and dispatching
06Infra

连接的生命周期The connection lifecycle#

一句话结论TakeawayConnection 在第一个请求时懒启动守护进程,60 秒内必须完成同步行和 hello。之后双方每 5 秒发一次 ping;守护进程 30 秒收不到任何字节就杀掉它启动的所有进程组并退出(stdin 结束时也一样),所以断线会停止远端命令。客户端把丢失的连接上的请求以 { code: "unknown", lost: true } 失败,并在下一个请求时启动新的守护进程。Connection starts the daemon lazily on the first request, and the sync line plus hello must finish within 60 seconds. After that both sides ping every 5 seconds; after 30 seconds without receiving any bytes the daemon kills every process group it started and exits (as it does when stdin ends), so a dropped link stops remote commands. The client fails requests of a lost connection with { code: "unknown", lost: true } and starts a new daemon on the next request.

一条连接的一生:懒启动、心跳与丢失后重启The life of a connection: lazy start, heartbeats and restart after loss
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
The life of a connection: lazy start, heartbeats, restart after loss连接丢失时(客户端)When the connection is lost (client)connection.ts上层如何感知What the layers above see第一个请求first request懒启动lazy startssh … serve--token <hex>PI-ENV <token>同步行sync linehello { protocol: 1 }60 s 内必须完成within 60 s{ os, arch, home, … }请求 / 结果 / 事件requests / results / events工作线程 · exec · watchworkers · exec · watch双向 ping,每 5 sping both ways, every 5 s30 s 没收到任何字节30 s without any bytes杀掉所有进程组并退出kill every process group, exit客户端 Connectionclient Connection守护进程daemonsshstdin 结束时守护进程也退出:The daemon also exits when stdin断线 = 远端命令停止。ends: a dropped link stops commands.进行中的请求失败Requests in flight fail{ code: "unknown", lost: true }变更类请求的结果因此未知A mutation's outcome is then unknown写入可能已发生,也可能没有the write may or may not have happened下一个请求启动新的守护进程The next request starts a new daemon再次同步 + hellosync line + hello again旧会话的句柄不会落到新进程上Old handles never reach the new daemon它们的请求直接失败their requests fail instead原因:进程退出、帧损坏("Corrupt frame from pi-env")、30 s 静默、hello 协议不符。Causes: process exit, a corrupt frame, 30 s of silence, a hello protocol mismatch.isConnectionLost(err)区分"断线"和"守护进程拒绝了请求"tells "lost" apart from "the daemon refused"RemoteExecutionEnv把丢失映射成 FileError / ExecutionError 的 unknownmaps a loss to FileError / ExecutionError unknownRemoteWatcher新守护进程启动后重新打开监视并报告 overflowreopens the watch on the new daemon, reports overflowsshConnection()懒连接:网络或密钥错误只是那一次操作的错误lazy: a network or key failure fails that operation only请求request回应reply丢失loss

要点Key points

  • 丢失时正在进行的变更(写入、改名…)结果未知:可能执行了,也可能没有。For mutations in flight when the link is lost (writes, renames…), the outcome is unknown: they may or may not have happened.
  • 句柄属于打开它的那个守护进程:旧句柄上的请求直接失败,绝不会落到新进程上。Handles belong to the daemon that opened them: requests on old handles fail instead of reaching the new one.
  • isConnectionLost(err) 区分"断线"与"守护进程拒绝了请求";丢失的原因可能是进程退出、帧损坏、30 秒静默或协议版本不符。isConnectionLost(err) tells "lost" apart from "the daemon refused the request"; causes are a process exit, a corrupt frame, 30 seconds of silence or a protocol mismatch.

工作原理How it works

  1. #start():启动进程 → 丢弃输出直到 PI-ENV <token> → 发 hello { protocol: 1 } → 检查版本。#start(): spawn → discard output until PI-ENV <token> → send hello { protocol: 1 } → check the version.
  2. 定时器每 5 秒发 ping,并检查 lastSeen:超过 30 秒就 #teardown()。A timer pings every 5 seconds and checks lastSeen: after 30 seconds it calls #teardown().
  3. 监视器在新守护进程上重新打开并报告一次 overflow(见文件监视一节)。Watchers reopen on the new daemon and report one overflow (see the watching section).

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/env/src/connection.tsL255#start()启动与同步start and sync
packages/env/src/connection.tsL13PING_INTERVAL_MS心跳heartbeat
packages/env/src/connection.tsL15SILENCE_LIMIT_MS30 秒静默30 s of silence
packages/env/src/connection.tsL113isConnectionLost()判断丢失detecting a loss
packages/env/daemon/src/main.rsL307kill_all()杀掉所有进程组killing every group
07Infra

命令与输出Commands and output#

一句话结论Takeawayexec 运行一个 shell 字符串或一个 argv 数组。每条命令有自己的会话(进程组)、默认信号处理和空信号掩码;输出以 event 帧按流发送。可选的输出窗口把输出合并并按调用方的提交节奏发送,超出窗口的部分在下一帧报告为 skipped;可选的 spill 在越线后把完整原始输出写进一个日志文件。exec runs a shell string or an argv array. Each command gets its own session (process group), default signal dispositions and an empty signal mask; output goes out as event frames per stream. An optional output window coalesces output and paces it like the caller's commits, reporting what overflowed as skipped in the next frame; an optional spill writes the complete raw output to a log file once a threshold is crossed.

exec:命令、输出窗口、溢出文件与取消exec: commands, output windows, spill files and cancellation
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
exec: commands, output windows, spill files and cancellation输出:event { kind: "output", stream }Output: event { kind: "output", stream }调度:回复先于大块数据Scheduling: replies before bulk data命令怎样结束How a command settlesexec { command | argv, cwd, env, … }timeoutMs? · spill? · window?字符串 → shellstring → shellshellPath,否则 /bin/bash、which bash、sh;-cshellPath, else /bin/bash, which bash, sh; -cargv 数组 → 直接运行argv array → run directlyWindows 按 libuv 规则搜索,拒绝批处理Windows: libuv search, batch files refused新会话(进程组)new session (process group)默认信号处理 · 空信号掩码default signal dispositions · empty mask控制队列control queue结果 · 错误 · ping · watch 事件results · errors · pings · watch大块队列bulk queue命令输出 · > 64 KiB 的结果command output · results > 64 KiBstdout→ ssh → 客户端→ ssh → client{ exitCode }timeoutaborted137spawn_errorshell_unavailable每个流独立解码为 UTF-8,作为负载字节发送。Each stream is decoded to UTF-8 and sent as payload bytes.window { maxBytes, maxLines, minIntervalMs, bytesPerSecond }合并并按调用方的提交节奏发送;每条命令最多一帧未发出;coalesced and paced like the caller's commits; at most one unsent frame;超出窗口的部分在下一帧里变成 skipped { bytes, newlines, … }output beyond the window becomes skipped { bytes, newlines, … }spill { afterBytes, afterLines }越线后完整原始输出写入 tmp-…/pi-output-<uuid>.log;once crossed, the full raw output goes to tmp-…/pi-output-<uuid>.log;结果带 spillPath(超时 / 取消时也带)。the result carries spillPath (also on timeout / abort).进程退出后继续收集输出,直到两个管道都关闭或 100 ms 无输出。After exit, output is collected until both pipes end or 100 ms pass silently.没有 window 的命令:未发输出超过 4 MiB 就等待——像管道写满一样拖慢命令。Without a window: output waits while more than 4 MiB is unsent, slowing the command like a full pipe.正常退出;被信号杀死报告 128 + 信号normal exit; killed by a signal → 128 + signal超时:SIGKILL 整个进程组timeoutMs passed: SIGKILL the groupcancel 帧:杀进程组,以 aborted 结束a cancel frame: kill the group, settle abortedcleanup():cancel { mode: "kill" },不 abortcleanup(): cancel { mode: "kill" }, no abortcwd 不存在等missing cwd, etc.配置的 shellPath 不存在the configured shellPath is missing请求request输出output控制优先control first结束方式settlements

要点Key points

  • 调度:结果、错误、ping 和 watch 事件排在命令输出之前,超过 64 KiB 的结果也排到输出后面——慢链路只延迟大块数据,不延迟回复。Scheduling: results, errors, pings and watch events go before queued command output, and results above 64 KiB queue behind output too — a slow link delays bulk data, not replies.
  • 没有窗口的命令在未发输出超过 4 MiB 时等待,像写满的管道一样拖慢命令。A command without a window waits while more than 4 MiB of output is unsent, slowing it the way a full pipe would.
  • 超时、cancel 与 cleanup() 都用 SIGKILL 杀整个进程组:取消以 aborted 结束;cleanup() 用 { mode: "kill" },命令以退出码 137 结束。Timeout, cancel and cleanup() all SIGKILL the process group: a cancel settles as aborted; cleanup() uses { mode: "kill" }, so the command settles with exit code 137.
  • 进程退出后继续收集输出,直到两个管道都关闭或 100 ms 没有输出;命令结束时管道一定关闭,即使后代进程还开着它们。After the process exits, output is still collected until both pipes end or 100 ms pass silently; the pipes are closed when the command settles, even if a descendant keeps them open.

工作原理How it works

  1. env.exec("npm test", { onOutput, timeout, spill, window }, ctx),或 argv:env.exec(["git", "status"], …)。env.exec("npm test", { onOutput, timeout, spill, window }, ctx), or argv: env.exec(["git", "status"], …).
  2. 字符串命令:配置的 shellPath(不存在则 shell_unavailable),否则 /bin/bash、which bash、sh,加 -c。String commands: the configured shellPath (missing → shell_unavailable), else /bin/bash, which bash, sh, with -c.
  3. 环境变量:默认 inheritEnv 时依次叠加守护进程环境、shellEnv、env;否则只用 env。Environment: with inheritEnv (the default) the daemon's environment, then shellEnv, then env; without it, only env.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/env/src/remote-env.tsL834exec()客户端client side
packages/env/daemon/src/exec.rsL110kill_group()杀进程组killing the group
packages/env/daemon/src/exec.rsL21EXIT_STDIO_GRACE退出后 100 ms100 ms after exit
packages/env/daemon/src/window.rsL1window.rs输出窗口the output window
packages/env/daemon/src/output.rsL14BULK_LIMIT4 MiB4 MiB
08Infra

文件操作File operations#

一句话结论TakeawayRemoteExecutionEnv 的文件方法都落在守护进程的 17 个文件 op 上:lstat、realpath、write / writeChunk、truncate、fsync、rename、mkdir、rm、mkdtemp、open、pread、fstat、scanLines、opendir、readdir、close。文件 op 在 16 个工作线程的固定池里运行;句柄是数字,最多 4096 个。客户端负责分块与流水线:二进制读 256 KiB × 8 个并发,写 512 KiB × 8 个并发。RemoteExecutionEnv's file methods all map onto the daemon's 17 file ops: lstat, realpath, write / writeChunk, truncate, fsync, rename, mkdir, rm, mkdtemp, open, pread, fstat, scanLines, opendir, readdir, close. File ops run on a fixed pool of 16 worker threads; handles are numbers, at most 4096. The client does chunking and pipelining: binary reads 256 KiB × 8 in flight, writes 512 KiB × 8.

ExecutionEnv 的文件方法与守护进程的 17 个文件 opExecutionEnv file methods and the daemon's 17 file ops
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
File operations: ExecutionEnv methods → the daemon's 17 file opsRemoteExecutionEnv守护进程的文件 opThe daemon's file opsprotocol.md客户端的分块与流水线Client chunking and pipeliningremote-env.ts错误码映射(toFileError)Error mapping (toFileError)errors.ts标准库不遵守、但这里遵守的规则(节选)Rules the standard libraries do not follow (excerpt)semantics.mdlstatrealpathwritewriteChunktruncatefsyncrenamemkdirrmmkdtempopenpreadfstatscanLinesopendirreaddirclosereadTextFile / readBinaryFile→ open(mode:"read") → pread…openBinaryReader→ open(noFollow) · pread · fstatopenTextLineReader→ pread + StreamDecoderwriteFile / appendFile→ write(keep) · writeChunk…listDir / openDirReader→ opendir · readdir(max)fileInfo / exists→ lstatcanonicalPath→ realpathrenameFile / remove→ rename · rmcreateDir / createTempDir→ mkdir · mkdtemptruncateFile / flushFile→ truncate · fsyncwatch→ watch全部在 16 个工作线程的固定池里运行;All run on a fixed pool of 16 worker threads;同一句柄的分块写按到达顺序执行。chunk writes to one handle run in order.句柄是数字,close 或连接结束前有效;Handles are numbers, valid until close or打开超过 4096 个 → EMFILE。the connection ends; over 4096 → EMFILE.路径在客户端解析成绝对路径后再发送。Paths are made absolute by the client.READ_CHUNK256 KiB × 8二进制读,8 个并发binary reads, 8 in flightWRITE_CHUNK512 KiB × 8writeFile,8 个并发writeFile, 8 in flightREAD_FILE_CHUNK512 KiBreadFile 每次读each readFile readLINE_CHUNK64 KiB行读取器每次读each line-reader readENOENT→ not_foundEACCES · EPERM→ permission_deniedENOTDIR→ not_directoryEISDIR→ is_directoryEINVAL …→ invalid· mkdir -p:已有文件 → EEXISTrecursive mkdir: an existing file → EEXIST· rm 目录不带 recursive → ERR_FS_EISDIRrm of a dir without recursive → ERR_FS_EISDIR· listDir:按字节序排序名字listDir: names in byte order· openBinaryReader:noFollow → O_NOFOLLOWopenBinaryReader: noFollow → O_NOFOLLOW客户端方法client methods守护进程 opdaemon ops错误映射error mapping

要点Key points

  • write 默认像递归 mkdir 那样创建缺失的父目录;带 keep 时返回句柄供后续 writeChunk,一块失败后后续块都报 EBADF。write creates missing parents like a recursive mkdir by default; with keep it returns a handle for later writeChunks, and after a failed chunk later chunks fail with EBADF.
  • open 有两种:给 openBinaryReader 的非阻塞、只限普通文件(noFollow 用 O_NOFOLLOW);以及 mode: "read" 的阻塞打开,和 Node 的 open(path, "r") 一样能读 /dev/null、FIFO。open comes in two kinds: nonblocking and regular files only for openBinaryReader (noFollow uses O_NOFOLLOW), and mode: "read", a blocking open like Node's open(path, "r") that reads /dev/null or a FIFO.
  • readdir 的名字按 UTF-8 解码(非法字节换成替换字符),原始字节放在 raw 里用于排序;listDir 每次读最多 1000 项。readdir decodes names as UTF-8 (invalid bytes become replacement characters) and keeps the original bytes in raw for sorting; listDir reads at most 1000 entries at a time.
  • 错误映射与 NodeExecutionEnv 相同:ENOENT → not_found,EACCES / EPERM → permission_denied,ENOTDIR、EISDIR、EINVAL 等。Error mapping matches NodeExecutionEnv: ENOENT → not_found, EACCES / EPERM → permission_denied, ENOTDIR, EISDIR, EINVAL and so on.

工作原理How it works

  1. env.readTextFile(path, ctx) → open(mode:"read") → 按 512 KiB pread → StringDecoder 解码。env.readTextFile(path, ctx) → open(mode:"read") → pread in 512 KiB → decode with StringDecoder.
  2. env.writeFile(path, content, ctx) → write(keep) + 多个 writeChunk,每块之前检查取消。env.writeFile(path, content, ctx) → write(keep) + writeChunks, checking for abort before each.
  3. openBinaryReader(path).scanLines({ startLine, endLine }) 在守护进程里数行,只把结果传回来。openBinaryReader(path).scanLines({ startLine, endLine }) counts lines in the daemon and sends back only the result.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/env/src/remote-env.tsL36READ_CHUNK分块大小chunk sizes
packages/env/src/remote-env.tsL523readTextFile()读文件reading a file
packages/env/src/remote-env.tsL628writeFile()写文件writing a file
packages/env/daemon/src/main.rsL37WORKERS16 个工作线程16 workers
packages/env/daemon/src/main.rsL39MAX_HANDLES4096 个句柄4096 handles
packages/env/daemon/src/fs.rsL14fs.rs文件信息与 opfile info and ops
09Infra

文件监视Watching#

一句话结论Takeawaywatch 在守护进程里运行 Durable NodeFileWatcher 的移植,就在文件旁边:原生事件(inotify、FSEvents)只触发一次去抖的重新扫描,变化 = 快照之间的差异加上事件路径;读取不算事件。Windows 与网络、FUSE 文件系统改用轮询。请求一直持续到被取消,先发 ready,再发 change,或一个之后不再有任何事件的 error。watch runs a port of Durable's NodeFileWatcher in the daemon, next to the files: native events (inotify, FSEvents) only trigger a debounced rescan, and changes are the difference between snapshots plus the event paths; reads are not events. Windows and network or FUSE file systems poll instead. The request lasts until cancelled: first ready, then changes, or an error after which nothing follows.

文件监视:在文件旁边运行的 NodeFileWatcherWatching: NodeFileWatcher running next to the files
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
Watching: NodeFileWatcher running next to the files变化怎样被发现How changes are foundevent 帧(请求一直持续到被取消)Event frames (the request lasts until cancelled)断线之后(客户端 watch.ts)After a lost link (client watch.ts)env.watch(targets, …)RemoteWatcher(客户端)RemoteWatcher (client)watch { targets, mode?, pollIntervalMs?, … }targets:{ path, recursive, exclude }targets: { path, recursive, exclude }守护进程 watch.rsdaemon watch.rsDurable NodeFileWatcher 的移植a port of Durable's NodeFileWatcher原生事件native eventsinotify / FSEvents去抖 50 msdebounce 50 msFSEvents 再等 500 msFSEvents settles 500 ms重新扫描rescan最多 10,000 个目录up to 10,000 directories比较快照diff snapshots≤ 256 KiB 的文件比哈希hash files ≤ 256 KiBchange { paths }快照差异 + 事件路径snapshot diff + event paths原生事件只触发重新扫描;读取不算事件。Windows、网络与 FUSE 文件系统改用轮询(默认 2000 ms)。Native events only trigger a rescan; reads are not events. Windows, network and FUSE file systems poll instead (2000 ms by default).{ kind: "ready", mode }覆盖范围建立;之后才有变化coverage established; changes follow{ kind: "change", paths }一批变化的路径a batch of changed paths{ kind: "change", overflow: true, mode: "polling" }原生监视用尽 → 转轮询native watches ran out → polling{ kind: "error", code, message }之后不再有任何事件nothing follows新守护进程启动后,RemoteWatcher 重新打开同样的监视,Once a new daemon starts, RemoteWatcher reopens the same watch并报告一次 overflow——期间可能漏掉了变化。and reports overflow: changes may have been missed meanwhile.重连退避:1 s 起,最多 30 s。Reconnect backoff: from 1 s up to 30 s.cancel 帧让 watch 以 {} 结束。A cancel frame finishes the watch with {}.收到 overflow 的使用方应当整体重新读取,A consumer that sees overflow should re-read而不是只看路径列表。everything rather than trust the path list.客户端client守护进程daemon事件events

要点Key points

  • 去抖 50 ms(FSEvents 再等 500 ms 稳定);轮询默认每 2000 ms;最多 10,000 个目录;≤ 256 KiB 的文件比较哈希。Debounce 50 ms (FSEvents settles another 500 ms); polling every 2000 ms by default; at most 10,000 directories; files ≤ 256 KiB are compared by hash.
  • 原生监视用尽时切换到轮询,并报告 { kind: "change", overflow: true, mode: "polling" }。Running out of native watches switches to polling and reports { kind: "change", overflow: true, mode: "polling" }.
  • 连接丢失后,客户端的 RemoteWatcher 在新守护进程启动后重新打开监视并报告 overflow;重连退避从 1 秒到 30 秒。After a lost connection, the client's RemoteWatcher reopens the watch once a new daemon starts and reports overflow; reconnect back-off runs from 1 s to 30 s.

工作原理How it works

  1. env.watch([{ path, recursive: true, exclude: { hidden, names } }], onChange, ctx)。env.watch([{ path, recursive: true, exclude: { hidden, names } }], onChange, ctx).
  2. 收到 overflow 时整体重新读取,不要只依赖路径列表。On overflow, re-read everything rather than trusting the path list.
  3. 关闭:watcher.close(ctx) 发 cancel,watch 以 {} 结束。Close: watcher.close(ctx) sends a cancel and the watch finishes with {}.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/env/src/watch.tsL23RemoteWatcher客户端监视client watcher
packages/env/src/watch.tsL17RECONNECT_MAX_MS重连退避reconnect back-off
packages/env/daemon/src/watch.rsL21DEBOUNCE去抖debounce
packages/env/daemon/src/watch.rsL26DEFAULT_MAX_DIRECTORIES目录上限directory limit
packages/env/daemon/src/watch.rsL678parse_targets()监视目标watch targets
10Infra

扩展点Extension points#

RemoteExecutionEnv({ connection, id, cwd })任何 Connection 都行:ssh、管道或测试里的本地进程。Any Connection works: ssh, a pipe or a local process in tests.
sshConnection / connectSsh(options)懒或立即;binary 可替换,loginShell 可选。Lazy or eager; the binary can be replaced, loginShell is optional.
scanHostKey · acceptHostKey · forgetHostKey把主机密钥信任交给你的 UI。Put host-key trust in your own UI.
deployDaemon(target, remote, binary?)单独部署,或部署自己构建的守护进程。Deploy on its own, or deploy a daemon you built.
PI_ENV_DAEMON测试时指定守护进程二进制(CI 用发布构建)。Point tests at a daemon binary (CI uses release builds).
11Infra

测试Tests#

一句话结论TakeawayTypeScript 测试跑 durable 的执行环境一致性套件,并与本机 NodeExecutionEnv 做差分对比;SSH 测试覆盖主机密钥与部署;守护进程的 Rust 代码另有自己的单元测试。The TypeScript tests run durable's execution-environment conformance suite and diff the results against the local NodeExecutionEnv; the SSH tests cover host keys and deployment; the daemon's Rust code has its own unit tests.

用例数 = 源码中 it( / test( 的出现次数(静态统计,不运行测试)。Cases = occurrences of it( / test( in the source (static count; tests were not run).

12Infra

交叉链接Cross-links#