fix(rebase): migrate the replayed stack onto current master APIs

The linear replay carried each commit's own lineage, so this checkpoint
restores the master-owned surfaces the conflicted regions clobbered and
migrates branch-owned code to master's post-rebase APIs:

- rebuild subprocess-local spawn.ts on master's tree-exit-observer
  machinery, keeping the branch's win32 childEnv key semantics and the
  Linux zombie-quiescence probe; the zombie test reaps its survivor
  directly since a confirmed-absent verdict is a permanent
  no-more-signals boundary
- migrate pty-local test stubs to the Inbox-model Agent interface,
  Session.create, runnerFailureRules, and the new turn/start payload
- implement the seam's resolveExecutable/spawnTerminal abstracts in the
  new pwsh-local and tool-fs-search test fakes
- restore code-runtime, atomic-write, pwsh-local, and app-boot to
  master's exact content (the net-zero code-runtime churn is pruned
  from this history) and drop rename-detection graft debris
- re-apply the PR's architecture rows and execution-world paragraph,
  re-record bilingual pairings, regenerate catalogs, and reconcile the
  lockfile
This commit is contained in:
Tianyi Cui
2026-08-07 20:34:31 +08:00
parent e385c11e8e
commit 18ab9f6db2
35 changed files with 582 additions and 673 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/README.md
README.md: 3f467641bbc9eae14a94aa2d3bff0402116a9d3f
README.zh.md: c9d11bbf6239b4239a4e037dac63b05d3a9a58f7
README.md: 8fbb6069a784a5bd45423a4e1ae11834a597750d
README.zh.md: 42a8d691344c716021188df6fd870a841d543f36

View File

@@ -119,6 +119,8 @@ describe('spawn construction (pure, every platform)', () => {
/** A subprocess service that records spawn specs and settles instantly. */
class CapturingSubprocessService extends SubprocessService {
specs: SubprocessSpawnSpec[] = []
override async resolveExecutable(command: string): Promise<string> { return command }
override spawnTerminal(): Promise<never> { throw new Error('pwsh spawns pipes, never terminals') }
private readonly reader: SubprocessOutputReader = {
readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }),
}

View File

@@ -18,19 +18,19 @@
"path": "../../../vendor/schemastery"
},
{
"path": "../code-runtime"
"path": "../../util/brand"
},
{
"path": "../code-runtime-worker"
"path": "../../util/timeout"
},
{
"path": "../../bash/bash"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../support/invariants"
},
{
"path": "../../util/timeout"
}
]
}

View File

@@ -34,7 +34,7 @@ Every field is validated and defaulted; `maxOutputBytes` is a safe integer of at
Source mode loads erasable-only `src/worker.ts` through Node's native type stripping. Its transitive runtime closure contains only Node built-ins and relative source modules, so a fresh checkout never requires a sibling workspace package's unbuilt `lib/` export. The worker-local and session-owned JSON boundaries both flatten and rebuild validated values around the message port so application nesting never reaches structured clone. Built mode passes the sibling `lib/worker.cjs` as a filesystem path because pkg's VFS Worker hook expects CommonJS; the same path works under ordinary Node. The repository-wide requirement to exercise this published entry path belongs to the [testing policy](../../../docs/testing.md).
The SDK surface is the default/named `WorkerCodeRuntime` class plus `Config`. The `./runtime-host` subpath shares type stripping, binding validation/dispatch, lossless JSON transport, and output accounting with sibling worker-based implementations; it is implementation support, not a plugin. The operational `./worker` subpath exists only as the packaged spawn entry; the wire protocol and bootstrap helpers remain source-private.
The SDK surface is the default/named `WorkerCodeRuntime` class plus `Config`. The operational `./worker` subpath exists only as the packaged spawn entry; the wire protocol and bootstrap helpers are source-private implementation details.
## Model Experience

View File

@@ -597,27 +597,6 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
expect(result.value).toEqual({ name: 'ToolCallError', toolName: 'bad', message: 'binding resolution must be lossless JSON' })
})
it('contains binding rejections whose thrown values cannot be rendered', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: 'try { await tools.bad({}) } catch (error) { return { name: error.name, toolName: error.toolName, message: error.message } }',
bindings: tools({
bad: async () => {
const hostile = new Error('hidden')
Object.defineProperty(hostile, 'message', {
get() { throw new Error('message getter failed') },
})
throw hostile
},
}),
})
expect(result.value).toEqual({
name: 'ToolCallError',
toolName: 'bad',
message: 'binding rejected with an unrenderable value',
})
})
it('rejects lossy binding arguments in the worker before invoking the host binding', async () => {
const { runtime } = await setup()
let calls = 0

View File

@@ -148,6 +148,8 @@ class FakeHandle implements SubprocessHandle {
*/
class FakeSubprocess extends SubprocessService {
spawns: SubprocessSpawnSpec[] = []
override async resolveExecutable(command: string): Promise<string> { return command }
override spawnTerminal(): Promise<never> { throw new Error('search tools spawn pipes, never terminals') }
handles: FakeHandle[] = []
/** Arms the per-spawn script; a `{ reject }` return scripts a spawn-level failure. */
handler: (spec: SubprocessSpawnSpec) => ScriptedRun | { reject: Error } = () => runResult('')

View File

@@ -24,7 +24,7 @@ The `servers` record key is the stable provider id reserved on `ctx.lsp`; each v
|---|---|---|
| `command` | (required) | Executable to spawn — absolute, or resolved on the child PATH at load. Launch uses no shell. |
| `args` | `[]` | Arguments passed to the executable. |
| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded); an explicit `DSH_*` entry merges after the seam's scrub of ambient ones. |
| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`PASSWORD`/`SECRET`/`TOKEN` are not forwarded); an explicit `DSH_*` entry merges after the seam's scrub of ambient ones. |
| `extensionToLanguage` | (required) | Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). |
| `initializationOptions` | `null` | Static `initialize` options forwarded to the server. |
| `configuration` | `null` | Static answer to every `workspace/configuration` item. |

View File

@@ -6,7 +6,7 @@ Persistent shell backend for `ctx.pty` over `ctx.subprocess.spawnTerminal`. It s
## Plugin (`pty-local`)
The plugin injects `pty`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly without requiring a sandbox provider; confined modes require a same-world `ctx.sandbox` and wrap the exact shell argv through it, failing before spawn when none is mounted. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
The plugin injects `pty`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly without requiring a sandbox provider; confined modes require a same-world `ctx.sandbox` and wrap the exact shell argv through it, failing before spawn when none is mounted. At spawn, one `ctx.sandboxPolicy.resolve({ session })` call supplies both the effective mode and the session workspace root; the same root is the default shell cwd when the caller omits one. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `PtyBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
@@ -14,19 +14,19 @@ Send cancellation marks queued input as canceled before asking the terminal hand
## Model Experience
### Indirect consumer
### Current file policy and indirect consumer
#### What the model sees
Nothing directly. Through `@deepseek-ai/dsh-tool-pty`, the model may receive bounded MOTD, send deltas, scrollback pages, readiness reasons, and cleanup errors.
The policy owner contributes capability-neutral `sandbox:policy` context. Through `@deepseek-ai/dsh-tool-pty` or another PTY consumer, the model may also receive bounded MOTD, send deltas, scrollback pages, readiness reasons, and cleanup errors.
#### Token effect
None until a consumer returns bounded backend output. Retained PTY scrollback is not placed in model history by this package.
The current-policy clause is present while this backend is mounted. Retained PTY scrollback is not placed in model history until a consumer returns bounded output.
#### KV Cache effect
No direct invalidation; the consumer owns prompts, schemas, and appended results.
A standing-policy change appends an owner-rendered superseding runtime-context snapshot after retained history; consumer results remain append-only.
## Known Limitations and Deferred Work

View File

@@ -6,7 +6,7 @@
## 插件(`pty-local`
该插件注入 `pty``sandboxPolicy``subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 无需沙箱提供方即可直接启动 shell受限模式要求同一执行世界中存在 `ctx.sandbox`,并通过它包装确切的 shell argv未挂载时会在 spawn 前失败。系统在 spawn 时解析会话的实际模式。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。
该插件注入 `pty``sandboxPolicy``subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 无需沙箱提供方即可直接启动 shell受限模式要求同一执行世界中存在 `ctx.sandbox`,并通过它包装确切的 shell argv未挂载时会在 spawn 前失败。Spawn 时,一次 `ctx.sandboxPolicy.resolve({ session })` 调用会同时给出实际模式与会话工作区根目录;调用方省略 cwd 时,同一根目录也是 shell 的默认 cwd。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。
就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell并以调用方提供的确切中止原因拒绝`PtyBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
@@ -14,19 +14,19 @@
## 模型体验
### 间接消费方
### 当前文件策略与间接消费方
#### 模型看到的内容
没有直接可见内容。模型通过 `@deepseek-ai/dsh-tool-pty` 可能收到有界的 MOTD、发送增量、scrollback 页、就绪原因和清理错误。
策略归属方会贡献与具体能力无关的 `sandbox:policy` 上下文。模型通过 `@deepseek-ai/dsh-tool-pty` 或其他 PTY 消费方还可能收到有界的 MOTD、发送增量、scrollback 页、就绪原因和清理错误。
#### Token 影响
消费方返回有界的后端输出前没有影响。此包package不会把保留的 PTY scrollback 入模型历史。
装载该后端期间,当前策略子句会一直存在。消费方返回有界输出前,保留的 PTY scrollback 不会进入模型历史。
#### KV Cache 影响
不会直接使 KV Cache 失效提示词、schema 与追加结果由消费方负责
常驻策略发生变化时,会在保留的历史之后追加一份由归属方渲染、取代先前状态的运行时上下文快照;消费方结果保持仅追加
## 已知限制与暂缓事项

View File

@@ -3,7 +3,7 @@ import { PassThrough } from 'node:stream'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { Inbox, type Agent } from '@deepseek-ai/dsh-agent'
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
@@ -22,7 +22,7 @@ import type {
class EmptySandbox extends SandboxProvider {
confine(_argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
}
}
@@ -31,7 +31,7 @@ class RecordingSandbox extends SandboxProvider {
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
this.calls.push({ argv, policy })
return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
}
}
@@ -46,9 +46,15 @@ function config(): ResolvedConfig {
function agent(ctx: Context): Agent {
const id = SessionId('agent')
const session = Session.create(id, undefined, { version: 0, id, createdAt: 0 })
return {
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
}
@@ -331,7 +337,7 @@ describe('pty-local plugin shape', () => {
const session = ctx.sessions.create(SessionId('unowned-mode'))
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
}).not.toThrow()
expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow()
})
@@ -348,8 +354,13 @@ describe('pty-local plugin shape', () => {
const session = ctx.sessions.create(SessionId('mode-owner'))
const ownerFiber = await ctx.plugin(() => {})
const owner: Agent = {
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: ownerFiber.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(owner)
const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
@@ -358,7 +369,7 @@ describe('pty-local plugin shape', () => {
const unrelated = ctx.sessions.create(SessionId('unrelated-mode'))
expect(() => { setSandboxMode(unrelated, 'read-only') }).not.toThrow()
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
}).not.toThrow()
expect(() => { setSandboxMode(session, 'danger-full-access') }).not.toThrow()
@@ -392,8 +403,13 @@ describe('pty-local plugin shape', () => {
const session = ctx.sessions.create(SessionId('pending-mode-owner'))
const ownerFiber = await ctx.plugin(() => {})
const owner: Agent = {
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: ownerFiber.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(owner)
const gate = Promise.withResolvers<undefined>()

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md
README.md: b901a69d6cfd45a084711ba0d32e555c481fd626
README.zh.md: 5723dc99ff955c5f4b07ea96e7d2134100d9fd6a
README.md: 087ca24a3207cb8cb1568769a462fbbb010aaa35
README.zh.md: 4d400906f71b653ce2be95a22751fd9893f447bd

View File

@@ -6,9 +6,9 @@ Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README
## Behavior (and where it came from)
- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID <pid> /T /F` (injectable for tests). `terminate()` — the handle's only termination verb — sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent) and is a no-op once the tree is gone; `waitForExit()` polls whole-tree liveness so consumer teardown confirms real quiescence. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools.
- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID <pid> /T /F`. `terminate()` — the handle's only termination verb — sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent) and is a no-op once the tree is gone; `waitForExit()` polls whole-tree liveness so consumer teardown confirms real quiescence. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools.
- **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory.
- **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement.
- **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd.
- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. The higher PTY backend owns prompt readiness, buffers, and model-facing operations.
@@ -24,10 +24,10 @@ No direct invalidation; the named consumers own any request-prefix changes.
## Known Limitations and Deferred Work
- **Windows tree support is best-effort and untested in CI** — termination routes through `taskkill /PID <pid> /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary; the suites cover the routing through an injected runner only, and `packages/subprocess/*` is excluded from the Windows test matrix.
- **Windows tree support is best-effort** — termination routes through `taskkill /PID <pid> /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary.
- **Terminal process inspection is Linux/macOS only** — the terminal primitive fails when its inspector has no supported platform implementation; Linux exact probes cover x64 and arm64, while macOS uses `ps` snapshots.
- **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor.
- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
- **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind.
The raw process handling lives in `src/spawn.ts`; `src/index.ts` is the service wiring.

View File

@@ -6,9 +6,9 @@
## 行为(以及设计来源)
- **以适合平台的方式发送信号的 detached 进程树**POSIX 子进程使用 `detached` spawn拥有独立进程组信号以负 pgid 发送并以直接子进程作为回退Windows 通过 `taskkill /PID <pid> /T /F` 终止进程树(可为测试注入)`terminate()`(句柄唯一的终止操作)先发送 SIGTERM经过 spec 的宽限期后再发送 SIGKILL沿用 OpenCode 的升级策略;流水线与子 shell 会随父进程一起结束),进程树消亡后为空操作;`waitForExit()` 轮询整棵进程树的存活状态,使消费方的拆卸能确认真正的完全停稳。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH重新指定父进程并脱离该组的 daemon 仍可能存活,这与所调研工具的局限相同。
- **以适合平台的方式发送信号的 detached 进程树**POSIX 子进程使用 `detached` spawn拥有独立进程组信号以负 pgid 发送并以直接子进程作为回退Windows 通过 `taskkill /PID <pid> /T /F` 终止进程树。`terminate()`(句柄唯一的终止操作)先发送 SIGTERM经过 spec 的宽限期后再发送 SIGKILL沿用 OpenCode 的升级策略;流水线与子 shell 会随父进程一起结束),进程树消亡后为空操作;`waitForExit()` 轮询整棵进程树的存活状态,使消费方的拆卸能确认真正的完全停稳。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH重新指定父进程并脱离该组的 daemon 仍可能存活,这与所调研工具的局限相同。
- **按流划分的处置方式**`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符收集模式collect在输出超过上限后于内存中保留尾部错误与结果通常聚集在末尾沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill仅返回带截断标记的尾部spill 文件描述符在结算时封存最终关闭失败时则不公布路径以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。
- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*``*PASSWORD*``*SECRET*``*TOKEN*`)和所有环境中已有的 `DSH_*` 名称spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*``*PASSWORD*``*SECRET*``*TOKEN*`)和所有环境中已有的 `DSH_*` 名称spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
- **基于偏移量的读取**收集模式的读取器按完整流的字节坐标返回增量服务自身从不持有游标因此消费方自有的游标bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。
- **可执行文件查找**`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在接缝处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。
- **终端进程所有权**`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护pid/start 身份则防止清理跟随 PID 复用。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。
@@ -24,7 +24,7 @@
## 已知限制与暂缓事项
- **Windows 进程树支持仅为尽力而为,且未经 CI 测试**:终止经由 `taskkill /PID <pid> /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界;测试套件只通过注入的运行器覆盖这条路由,且 `packages/subprocess/*` 被排除在 Windows 测试矩阵之外
- **Windows 进程树支持仅为尽力而为**:终止经由 `taskkill /PID <pid> /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界。
- **终端进程检查仅支持 LinuxmacOS**检查器没有受支持的平台实现时终端原语会失败Linux 精确探针覆盖 x64 与 arm64macOS 则使用 `ps` 快照。
- **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。
- **凭据清除依赖名称启发式规则**:只匹配 `*KEY*``*PASSWORD*``*SECRET*``*TOKEN*`;名称不同的 secret例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。

View File

@@ -15,6 +15,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { setTimeout as sleepMs } from 'node:timers/promises'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type {
CollectedOutput,
SubprocessCollect,
@@ -27,16 +28,16 @@ import { linuxProcessGroupHasLiveMembers } from './process-inspector.ts'
/**
* Build a child environment: explicit caller entries override the scrubbed
* parent base using the target platform's environment-key semantics, so a
* deliberately supplied credential or current `DSH_*` fact wins over the
* scrub that dropped its ambient namesake.
* @param extra - explicit caller entries merged after the scrubbed parent.
* parent base using the target platform's environment-key semantics. A string
* deliberately restores or overrides an entry; an explicit `undefined`
* tombstone removes an ordinary ambient entry.
* @param extra - explicit caller entries and tombstones, merged after the scrub.
* @returns the environment to hand to `spawn` for the child process.
*/
export function childEnv(extra?: Readonly<Record<string, string>>): NodeJS.ProcessEnv {
export function childEnv(extra?: Readonly<NodeJS.ProcessEnv>): NodeJS.ProcessEnv {
const env = scrubbedParentEnv()
if (process.platform !== 'win32') return { ...env, ...extra }
let entries = Object.entries(env)
let entries: [string, string | undefined][] = Object.entries(env)
for (const [key, value] of Object.entries(extra ?? {})) {
const normalized = key.toUpperCase()
entries = entries.filter(([inherited]) => inherited.toUpperCase() !== normalized)
@@ -310,8 +311,12 @@ function signalTree(
* @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment.
* @param internals - test-only spill-directory, platform, and taskkill overrides.
* @returns live subprocess handle.
* @throws when `graceMs` cannot be represented by one Node timer.
*/
export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle {
if (!Number.isFinite(spec.graceMs) || spec.graceMs <= 0 || spec.graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
}
const spillDir = internals.spillDir ?? privateSpillDir()
const platform = internals.platform ?? process.platform
const taskkill = internals.taskkill ?? taskkillProcessTree
@@ -354,7 +359,9 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
const stdoutCollector = collectStream(outMode, child.stdout, 'stdout')
const stderrCollector = collectStream(errMode, child.stderr, 'stderr')
let graceTimer: NodeJS.Timeout | undefined
let graceTimer: ReturnType<typeof setTimeout> | undefined
let treeExitObserved = false
let treeExitObservation: Promise<void> | undefined
let settled = false
// Failed spawns use pid -1 so signalling remains a no-op.
@@ -362,6 +369,9 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
/** Whether the detached tree's root (or POSIX group) is still alive. */
const treeAlive = (): boolean => {
/* v8 ignore next -- only a timer callback already queued when the observer settles can enter here;
the guard is the final defense against probing an id after its tree was confirmed absent. */
if (treeExitObserved) return false
if (pid <= 0) return false
if (platform === 'win32') {
// Windows has no group-liveness probe; the direct child's exit is the
@@ -389,19 +399,40 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
}
}
/**
* Start or reuse the handle's single whole-tree exit observer. The first
* confirmed absence is a permanent no-more-signals boundary: it cancels a
* pending escalation before this process-group id can be reused.
*/
const observeTreeExit = (): Promise<void> => {
treeExitObservation ??= (async () => {
while (treeAlive()) await sleepTick()
treeExitObserved = true
if (graceTimer !== undefined) clearTimeout(graceTimer)
graceTimer = undefined
})()
return treeExitObservation
}
// The escalation's tier primitive (not on the handle — terminate() is the
// only consumer-facing termination verb). Guards on TREE liveness, not
// outcome settlement: a TERM-trapping helper can outlive the settled direct
// child and must stay signalable, while a fully-dead tree (possible pid
// reuse) must not be re-signalled by a later tier.
const kill = (sig: NodeJS.Signals): void => {
/* v8 ignore next -- the shared exit observer cancels the ordinary dead-tree timer;
this remains the timer/death race guard and cannot be staged deterministically. */
if (!treeAlive()) return
signalTree(platform, pid, sig, child, taskkill)
}
const terminate = (): void => {
if (graceTimer !== undefined) return // escalation already in flight
if (!treeAlive()) return
if (treeExitObserved || graceTimer !== undefined) return
// Observe from the first termination tier onward, even when inherited
// pipes delay `done` and no consumer has begun its own teardown wait.
void observeTreeExit()
// oxlint-disable-next-line typescript/no-unnecessary-condition -- observer can record absence before its first await.
if (treeExitObserved) return
kill('SIGTERM')
// The escalation must survive direct-child settlement — the leader dying
// does not mean the tree died — so settle does not clear this timer, and
@@ -423,7 +454,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
}
const done = new Promise<SubprocessOutcome>((resolve, reject) => {
let pipeDrainTimer: NodeJS.Timeout | undefined
let pipeDrainTimer: ReturnType<typeof setTimeout> | undefined
const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
if (settled) return
settled = true
@@ -446,7 +477,9 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
// A surviving descendant that inherited a pipe must not hold the
// outcome open indefinitely: after exit, the same bounded grace that
// governs kills also bounds the close wait.
pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs)
pipeDrainTimer = setTimeout(() => {
settle(exitCode, signal)
}, spec.graceMs)
})
child.on('close', settle)
function cleanup(): void {
@@ -458,11 +491,23 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
})
const waitForExit = async (signal?: AbortSignal): Promise<boolean> => {
while (treeAlive()) {
if (signal?.aborted) return false
await sleepTick()
const observed = observeTreeExit()
if (treeExitObserved) return true
if (signal?.aborted) return false
if (signal === undefined) {
await observed
return true
}
const aborted = Promise.withResolvers<boolean>()
const onAbort = (): void => { aborted.resolve(false) }
signal.addEventListener('abort', onAbort, { once: true })
/* v8 ignore next -- closes the event-loop race between the preceding aborted check and listener registration. */
if (signal.aborted) onAbort()
try {
return await Promise.race([observed.then(() => true), aborted.promise])
} finally {
signal.removeEventListener('abort', onAbort)
}
return true
}
return {

View File

@@ -280,18 +280,18 @@ describe('spawnSubprocess', () => {
it('does not wait for a Linux group that has only zombie members', async () => {
const pidFile = join(spillDir, `zombie-group-${Date.now()}.pid`)
let hasLiveMembers = false
const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo leader-done`, { graceMs: 100 }), {
platform: 'linux',
linuxProcessGroupHasLiveMembers: () => hasLiveMembers,
linuxProcessGroupHasLiveMembers: () => false,
})
const descendant = await waitForPidFile(pidFile)
try {
await running.done
await expect(running.waitForExit()).resolves.toBe(true)
} finally {
hasLiveMembers = true
running.terminate()
// The confirmed-absent verdict is a permanent no-more-signals boundary,
// so terminate() must stay inert here; reap the live survivor directly.
process.kill(descendant, 'SIGKILL')
await waitGone(descendant)
}
})

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime-subprocess/README.md
README.md: 152921a1ed595676781aad170e3396f7ce613161
README.zh.md: 3e8223c33c7ce85f342c9a36ab5a24c4d2a6cb94
# pnpm run verify-translation-pairing --write packages/typert/README.md
README.md: ad9f843e48be0e3be85921ed8fd3ca4e2c327160
README.zh.md: 0d4be016b766178b54f7e269583fa4200e3eb24b

View File

@@ -1,6 +1,9 @@
import { defineConfig } from 'tsdown'
/** Bundle the host plugin and its dependency-free eval runner. */
/**
* Embed Include while keeping Loader external so the built include tree and
* app host bind to one Loader peer.
*/
export default defineConfig({
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
@@ -10,4 +13,7 @@ export default defineConfig({
fixedExtension: false,
dts: false,
clean: false,
deps: {
alwaysBundle: ['@cordisjs/plugin-include'],
},
})

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime-subprocess/README.md
README.md: 897112a32fa3e776df756d4caaba97b617c6af9e
README.zh.md: d9ab3713640fe955d2b36a8f0674f77686e4791b
# pnpm run verify-translation-pairing --write packages/util/atomic-write/README.md
README.md: 2ff4abb6ac10d8b592ccd2056b4f1f92cc8518b0
README.zh.md: 4284d06422564268bb9e31d1a1ed06ab5271e562

File diff suppressed because one or more lines are too long

View File

@@ -1,401 +0,0 @@
/** Typed source for the dependency-free execution-world runner bundle. */
import { Buffer } from 'node:buffer'
import { spawn } from 'node:child_process'
import type { ChildProcess } from 'node:child_process'
import { createInterface } from 'node:readline'
import type { Readable } from 'node:stream'
import { inspect } from 'node:util'
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'
import {
decodeWorkerJson,
encodeWorkerJson,
jsonStringBytesUpTo,
runWorkerMain,
waitForRuntimePipeDrain,
} from '@deepseek-ai/dsh-code-runtime-worker/runtime-host'
import type { WorkerJsonWire } from '@deepseek-ai/dsh-code-runtime-worker/runtime-host'
type WorkerBootData = Parameters<typeof runWorkerMain>[1]
type Controller = ChildProcess & { stdout: Readable; stderr: Readable }
type FailureKind = 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'
interface RuntimeBootData extends WorkerBootData {
type: 'boot'
maxFrameBytes: number
maxOldGenerationSizeMb: number
computeMs: number
}
interface RuntimeFailure {
kind: FailureKind
message: string
}
interface RuntimeCall {
type: 'call'
id: number
global: string
name: string
args: WorkerJsonWire | null
}
type RuntimeReply =
| { type: 'reply'; id: number; ok: true; value: unknown }
| { type: 'reply'; id: number; ok: false; message: string }
type RuntimeMessage = RuntimeCall
| { type: 'log'; text: string }
| { type: 'output-limit' }
| { type: 'done'; value?: WorkerJsonWire | null; error?: RuntimeFailure }
const failureKinds = new Set<FailureKind>([
'exception', 'timeout', 'abort', 'worker-exit', 'invalid-output', 'output-limit',
])
let maxFrameBytes = 0
function runnerSource(): string {
const evalIndex = process.execArgv.indexOf('--eval')
if (evalIndex < 0) throw new Error('code runtime runner requires its eval source')
const source = process.execArgv[evalIndex + 1]
if (source === undefined) throw new Error('code runtime runner requires its eval source')
return source
}
function recordOf(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null ? value as Record<string, unknown> : undefined
}
function encodeJsonBounded(value: unknown, maxBytes: number): string | undefined {
try {
const json: unknown = JSON.stringify(value)
return typeof json === 'string' && Buffer.byteLength(json) <= maxBytes ? json : undefined
} catch {
return undefined
}
}
function emitJson(json: string): void {
process.stdout.write(json)
process.stdout.write('\n')
}
function emitFrame(message: RuntimeMessage): boolean {
const json = encodeJsonBounded(message, maxFrameBytes)
if (json === undefined) return false
emitJson(json)
return true
}
function validFailure(value: unknown): value is RuntimeFailure {
const record = recordOf(value)
return record !== undefined
&& typeof record.kind === 'string'
&& failureKinds.has(record.kind as FailureKind)
&& typeof record.message === 'string'
}
function validWorkerFailure(value: unknown): value is RuntimeFailure {
return validFailure(value)
&& (value.kind === 'exception' || value.kind === 'invalid-output' || value.kind === 'output-limit')
}
function doneMessage(
message: Record<string, unknown>,
acceptsFailure: (value: unknown) => value is RuntimeFailure,
): RuntimeMessage | undefined {
if (message.error !== undefined) {
return acceptsFailure(message.error) ? { type: 'done', error: message.error } : undefined
}
return { type: 'done', ...message.value === undefined ? {} : { value: transportWireOrNull(message.value) } }
}
function runtimeBoot(value: unknown): RuntimeBootData | undefined {
const record = recordOf(value)
if (record === undefined
|| record.type !== 'boot'
|| typeof record.code !== 'string'
|| !Array.isArray(record.namespaces)
|| !Number.isSafeInteger(record.maxOutputBytes)
|| (record.maxOutputBytes as number) < 4
|| !Number.isSafeInteger(record.maxFrameBytes)
|| (record.maxFrameBytes as number) < (record.maxOutputBytes as number)
|| typeof record.computeMs !== 'number'
|| !Number.isFinite(record.computeMs)
|| (record.computeMs) <= 0
|| typeof record.maxOldGenerationSizeMb !== 'number'
|| !Number.isFinite(record.maxOldGenerationSizeMb)
|| (record.maxOldGenerationSizeMb) <= 0) return undefined
return record as unknown as RuntimeBootData
}
function runtimeReply(value: unknown): RuntimeReply | undefined {
const record = recordOf(value)
if (record === undefined || record.type !== 'reply' || typeof record.id !== 'number' || typeof record.ok !== 'boolean') return undefined
return record.ok
? { type: 'reply', id: record.id, ok: true, value: record.value }
: { type: 'reply', id: record.id, ok: false, message: String(record.message) }
}
function transportWireOrNull(input: unknown): WorkerJsonWire | null {
const value = decodeWorkerJson(input)
return value === undefined ? null : encodeWorkerJson(value)
}
function waitForChildExit(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
return new Promise((resolve) => { child.once('exit', () => { resolve() }) })
}
function frameLimitFailure(): RuntimeMessage {
return {
type: 'done',
error: { kind: 'worker-exit', message: 'code runtime bridge frame exceeded maxFrameBytes' },
}
}
function runLauncher(): void {
const input = createInterface({ input: process.stdin, crlfDelay: Infinity })
let controller: Controller | undefined
let maxOutputBytes = 0
let logBytes = 2
let logEntries = 0
let settling = false
const finish = (message: RuntimeMessage): void => {
if (settling) return
const encoded = encodeJsonBounded(message, maxFrameBytes)
?? encodeJsonBounded(frameLimitFailure(), maxFrameBytes)
settling = true
if (encoded !== undefined) emitJson(encoded)
const current = controller
controller = undefined
const drain = current === undefined
? Promise.resolve()
: new Promise<void>((resolve) => { setImmediate(resolve) }).then(async () => {
const stdoutDrained = waitForRuntimePipeDrain(current.stdout)
const stderrDrained = waitForRuntimePipeDrain(current.stderr)
const exited = waitForChildExit(current)
current.kill('SIGKILL')
await Promise.all([exited, stdoutDrained, stderrDrained])
})
void drain.catch((error: unknown) => {
process.stderr.write(`dsh-code-runtime-subprocess controller cleanup error: ${String(error)}\n`)
}).then(() => {
input.close()
process.stdin.destroy()
})
}
const forwardLog = (text: string): void => {
if (settling) return
const separator = logEntries > 0 ? 1 : 0
const cost = jsonStringBytesUpTo(text, maxOutputBytes - logBytes - separator)
if (cost === undefined) {
finish({ type: 'output-limit' })
return
}
logBytes += cost + separator
logEntries += 1
if (!emitFrame({ type: 'log', text })) finish(frameLimitFailure())
}
const startController = (boot: RuntimeBootData): void => {
maxOutputBytes = boot.maxOutputBytes
maxFrameBytes = boot.maxFrameBytes
controller = spawn(process.execPath, process.execArgv, {
env: { DSH_CODE_RUNTIME_CONTROLLER: '1' },
detached: false,
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
}) as Controller
const current = controller
current.stdout.on('data', (data: Buffer) => { forwardLog(data.toString('utf8')) })
current.stderr.on('data', (data: Buffer) => { forwardLog(data.toString('utf8')) })
current.on('message', (raw: unknown) => {
const message = recordOf(raw)
if (message === undefined) return
if (message.type === 'log' && typeof message.text === 'string') {
forwardLog(message.text)
return
}
if (settling) return
if (message.type === 'call'
&& typeof message.id === 'number'
&& typeof message.global === 'string'
&& typeof message.name === 'string') {
if (!emitFrame({
type: 'call', id: message.id, global: message.global, name: message.name, args: transportWireOrNull(message.args),
})) finish(frameLimitFailure())
} else if (message.type === 'output-limit') {
finish({ type: 'output-limit' })
} else if (message.type === 'done') {
const done = doneMessage(message, validFailure)
if (done !== undefined) finish(done)
}
})
current.on('error', (error: Error) => {
finish({ type: 'done', error: { kind: 'worker-exit', message: `remote controller error: ${error.message}` } })
})
current.on('exit', (code: number | null) => {
if (!settling) {
finish({ type: 'done', error: { kind: 'worker-exit', message: `remote controller exited with code ${code} before completing` } })
}
})
current.send(boot, (error: Error | null) => {
if (error !== null) {
finish({ type: 'done', error: { kind: 'worker-exit', message: `remote controller boot failed: ${error.message}` } })
}
})
}
input.on('line', (line: string) => {
let raw: unknown
try {
raw = JSON.parse(line) as unknown
} catch (error: unknown) {
process.stderr.write(`dsh-code-runtime-subprocess frame error: ${String(error)}\n`)
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote runner received a malformed frame' } })
return
}
if (controller === undefined) {
const boot = runtimeBoot(raw)
if (boot === undefined) {
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote runner received an invalid boot frame' } })
return
}
startController(boot)
return
}
const reply = runtimeReply(raw)
if (reply !== undefined) {
controller.send(reply, (error: Error | null) => {
if (error !== null) {
finish({ type: 'done', error: { kind: 'worker-exit', message: `remote controller reply failed: ${error.message}` } })
}
})
}
})
input.on('close', () => {
if (controller !== undefined && !settling) {
finish({ type: 'done', error: { kind: 'abort', message: 'remote runner input closed' } })
}
})
}
function runController(): void {
let worker: Worker | undefined
let finished = false
let computeTimer: NodeJS.Timeout | undefined
let controllerMaxFrameBytes = 0
const send = (message: RuntimeMessage): boolean => {
if (process.send === undefined) return false
if (controllerMaxFrameBytes > 0 && encodeJsonBounded(message, controllerMaxFrameBytes) === undefined) return false
process.send(message)
return true
}
const finish = (message: RuntimeMessage): void => {
if (finished) return
finished = true
clearInterval(computeTimer)
const bounded = controllerMaxFrameBytes > 0 && encodeJsonBounded(message, controllerMaxFrameBytes) === undefined
? frameLimitFailure()
: message
const current = worker
worker = undefined
const drain = current === undefined
? Promise.resolve()
: new Promise<void>((resolve) => { setImmediate(resolve) }).then(async () => {
const stdoutDrained = waitForRuntimePipeDrain(current.stdout)
const stderrDrained = waitForRuntimePipeDrain(current.stderr)
await Promise.all([current.terminate(), stdoutDrained, stderrDrained])
})
void drain.catch((error: unknown) => {
send({ type: 'log', text: `dsh-code-runtime-subprocess worker cleanup error: ${String(error)}\n` })
}).then(() => {
if (process.send === undefined) {
process.exitCode = 1
return
}
process.send(bounded, () => { if (process.connected) process.disconnect() })
})
}
process.on('message', (raw: unknown) => {
if (worker === undefined) {
const boot = runtimeBoot(raw)
if (boot === undefined) {
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote controller received an invalid boot frame' } })
return
}
controllerMaxFrameBytes = boot.maxFrameBytes
const sourceUrl = new URL(`data:text/javascript;base64,${Buffer.from(runnerSource()).toString('base64')}`)
worker = new Worker(sourceUrl, {
workerData: boot,
env: {},
execArgv: [],
stdout: true,
stderr: true,
resourceLimits: { maxOldGenerationSizeMb: boot.maxOldGenerationSizeMb },
})
const current = worker
current.stdout.on('data', (data: Buffer) => {
if (!send({ type: 'log', text: data.toString('utf8') })) finish(frameLimitFailure())
})
current.stderr.on('data', (data: Buffer) => {
if (!send({ type: 'log', text: data.toString('utf8') })) finish(frameLimitFailure())
})
current.on('message', (messageRaw: unknown) => {
const message = recordOf(messageRaw)
if (message === undefined) return
if (message.type === 'call'
&& typeof message.id === 'number'
&& typeof message.global === 'string'
&& typeof message.name === 'string') {
if (!send({
type: 'call', id: message.id, global: message.global, name: message.name, args: transportWireOrNull(message.args),
})) finish(frameLimitFailure())
} else if (message.type === 'log' && typeof message.text === 'string') {
if (!send({ type: 'log', text: message.text })) finish(frameLimitFailure())
} else if (message.type === 'output-limit') {
finish({ type: 'output-limit' })
} else if (message.type === 'done') {
const done = doneMessage(message, validWorkerFailure)
if (done !== undefined) finish(done)
}
})
current.on('error', (error: Error) => {
finish({
type: 'done',
error: { kind: 'worker-exit', message: `worker error: ${error.stack || error.message || inspect(error)}` },
})
})
current.on('exit', (code: number) => {
if (!finished) {
finish({ type: 'done', error: { kind: 'worker-exit', message: `worker exited with code ${code} before completing` } })
}
})
computeTimer = setInterval(() => {
if (worker !== undefined && worker.performance.eventLoopUtilization().active > boot.computeMs) {
finish({ type: 'done', error: { kind: 'timeout', message: `compute budget exhausted (${boot.computeMs}ms busy)` } })
}
}, 25)
return
}
const reply = runtimeReply(raw)
if (reply !== undefined) worker.postMessage(reply)
})
process.on('disconnect', () => { if (worker !== undefined && !finished) void worker.terminate() })
}
if (!isMainThread) {
if (parentPort === null) throw new Error('remote worker requires parentPort')
const boot = workerData as RuntimeBootData
void runWorkerMain(parentPort, boot, { stdout: process.stdout, stderr: process.stderr }, boot.maxFrameBytes)
} else if (process.env.DSH_CODE_RUNTIME_CONTROLLER === '1') {
runController()
} else {
runLauncher()
}