fix(runtime): close remote cleanup and readiness races
This commit is contained in:
@@ -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 .agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md
|
||||
2026-07-28-portable-execution-world-consumers.md: 000fa1b98964eb85550d0c607281937f8d7932c6
|
||||
2026-07-28-portable-execution-world-consumers.zh.md: 94e6bfcf94e450fafea2b33e152384da400e4b63
|
||||
2026-07-28-portable-execution-world-consumers.md: 5a15c2c0a4e552fadbdad9f7d09e00229afb033b
|
||||
2026-07-28-portable-execution-world-consumers.zh.md: 5ae7f3b96f4df4c3756d294ba12147a3b1b54d34
|
||||
|
||||
@@ -22,8 +22,8 @@ Generic consumers use that execution world:
|
||||
|
||||
- `dsh-bash-local` continues to map Bash semantics onto ordinary `ctx.subprocess.spawn()`.
|
||||
- `dsh-lsp-local` reads and contains source through `ctx.fs`, resolves and launches language servers through `ctx.subprocess`, and sends provider-owned file URIs. Its JSON-RPC, pooling, synchronization, cancellation, and normalization stay unchanged.
|
||||
- `dsh-pty-local` maps persistent-shell semantics onto `ctx.subprocess.spawnTerminal()`. The local `node-pty` and process-inspection implementation moves into `dsh-subprocess-local`; another subprocess provider supplies the same primitive.
|
||||
- `dsh-code-runtime-subprocess` materializes a dependency-free runner through `ctx.fs` and launches it through `ctx.subprocess`, preserving the Code Runtime binding and output contract across local or remote worlds. It shares host-side worker mechanics through the non-plugin `dsh-code-runtime-worker/runtime-host` subpath instead of copying them.
|
||||
- `dsh-pty-local` maps persistent-shell semantics onto `ctx.subprocess.spawnTerminal()`. The local `node-pty` and process-inspection implementation moves into `dsh-subprocess-local`; another subprocess provider supplies the same primitive. A timed-out asynchronous write retains the send reservation until the provider settles it, and completion of a stale inspection resumes polling for the current send.
|
||||
- `dsh-code-runtime-subprocess` materializes a dependency-free runner through `ctx.fs` and launches it through `ctx.subprocess`, preserving the Code Runtime binding and output contract across local or remote worlds. It shares host-side worker mechanics through the non-plugin `dsh-code-runtime-worker/runtime-host` subpath instead of copying them. The heap-bounded worker rejects oversized binding frames before transfer, each outer hop enforces the same bound before serialization, and the launcher reaps its controller before group termination becomes the fallback.
|
||||
|
||||
`dsh-code-runtime-worker` remains a separate implementation. It is the smaller in-process backend and works in single-file distributions that cannot assume an installed Node executable. Remote filesystem/process compositions select `dsh-code-runtime-subprocess`; they do not need a provider-specific Code Runtime package.
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ Status: implemented
|
||||
|
||||
- `dsh-bash-local` 继续把 Bash 语义映射到普通的 `ctx.subprocess.spawn()`。
|
||||
- `dsh-lsp-local` 通过 `ctx.fs` 读取源文件并验证包含关系,通过 `ctx.subprocess` 解析和启动语言服务器,并发送由提供方负责的文件 URI。其 JSON-RPC、池化、同步、取消和规范化保持不变。
|
||||
- `dsh-pty-local` 把持久 shell 语义映射到 `ctx.subprocess.spawnTerminal()`。本地 `node-pty` 与进程检查实现移入 `dsh-subprocess-local`;其他进程管理提供方则提供相同原语。
|
||||
- `dsh-code-runtime-subprocess` 通过 `ctx.fs` 物化无依赖 runner,并通过 `ctx.subprocess` 启动它,从而在本地或远程执行世界中保留代码运行时的绑定与输出契约。它通过非插件子路径 `dsh-code-runtime-worker/runtime-host` 共享宿主侧 worker 机制,而不是复制这些机制。
|
||||
- `dsh-pty-local` 把持久 shell 语义映射到 `ctx.subprocess.spawnTerminal()`。本地 `node-pty` 与进程检查实现移入 `dsh-subprocess-local`;其他进程管理提供方则提供相同原语。异步写入超时后仍保留发送预留,直至提供方将写入结算;陈旧检查完成后,会针对当前发送恢复轮询。
|
||||
- `dsh-code-runtime-subprocess` 通过 `ctx.fs` 物化无依赖 runner,并通过 `ctx.subprocess` 启动它,从而在本地或远程执行世界中保留代码运行时的绑定与输出契约。它通过非插件子路径 `dsh-code-runtime-worker/runtime-host` 共享宿主侧 worker 机制,而不是复制这些机制。受堆上限约束的 worker 会在传输前拒绝过大的绑定帧;每个外层转发环节都会在序列化前执行相同的上限检查;launcher 会先回收 controller,进程组终止才会成为后备手段。
|
||||
|
||||
`dsh-code-runtime-worker` 仍是独立实现。它是较小的进程内后端,可用于无法假定已安装 Node 可执行文件的单文件分发。远程文件系统/进程组合选择 `dsh-code-runtime-subprocess`;它们不需要提供方专用的代码运行时包。
|
||||
|
||||
|
||||
@@ -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/pty/pty-local/README.md
|
||||
README.md: 8a0a60139e27d98c4f506245a204723da997cd4a
|
||||
README.zh.md: 3df845931dac33abecbcd5030a1b204c68acc638
|
||||
README.md: 235599737d21289d291ccab8f06fbcca91c83223
|
||||
README.zh.md: ca01c9a10430644b0d6ea6c5cfa70472f0447b92
|
||||
|
||||
@@ -10,7 +10,7 @@ The plugin injects `pty`, `sandbox`, `sandboxPolicy`, and `subprocess`, then reg
|
||||
|
||||
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 printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; 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. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
|
||||
|
||||
Send cancellation asks the terminal handle to signal the current foreground process group with a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close starts provider-owned TERM-to-KILL whole-session cleanup and awaits quiescence after the terminal outcome. A cleanup failure does not cache a permanently rejected close; a later close retries the provider operation.
|
||||
Send cancellation asks the terminal handle to signal the current foreground process group with a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. A send that times out during an asynchronous provider write reports the timeout but retains the session's send slot until that write settles, so late bytes cannot interleave with a successor. Close starts provider-owned TERM-to-KILL whole-session cleanup and awaits quiescence after the terminal outcome. A cleanup failure does not cache a permanently rejected close; a later close retries the provider operation.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。可打印的提示符文本尚未到达时,即使 OSC 标记和 `PS1` 被拆到多个数据回调中,系统也不会把标记视为就绪。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法持续压制推断空闲就绪,最多只能延续到绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`PtyBackendCleanupError` 会单独保留清理失败。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
|
||||
|
||||
取消发送时,系统会请求终端句柄向当前前台进程组发送真正的 `SIGINT`;它绝不会通过写入 `\x03` 模拟中断,因此原始模式程序仍可取消。关闭操作启动由提供方负责的 TERM→KILL 全会话清理,并在终端结果之后等待完全停稳。清理失败不会缓存成永久拒绝的关闭操作;后续关闭会重试提供方操作。
|
||||
取消发送时,系统会请求终端句柄向当前前台进程组发送真正的 `SIGINT`;它绝不会通过写入 `\x03` 模拟中断,因此原始模式程序仍可取消。在提供方异步写入期间超时的发送会报告超时,但会继续占用该会话的发送槽位,直至写入结算,从而避免延迟到达的字节与后续发送交错。关闭操作启动由提供方负责的 TERM→KILL 全会话清理,并在终端结果之后等待完全停稳。清理失败不会缓存成永久拒绝的关闭操作;后续关闭会重试提供方操作。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -94,6 +94,10 @@ class LocalSendOperation implements PtySendOperation {
|
||||
return this.promise.promise
|
||||
}
|
||||
|
||||
get settled(): boolean {
|
||||
return this.finished
|
||||
}
|
||||
|
||||
append(text: string): void {
|
||||
if (!this.finished) this.output.append(text)
|
||||
}
|
||||
@@ -155,6 +159,8 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
private activeTimer: NodeJS.Timeout | undefined
|
||||
private activeDeadlineTimer: NodeJS.Timeout | undefined
|
||||
private activeAbort: (() => void) | undefined
|
||||
private writing: LocalSendOperation | undefined
|
||||
private pollingReady: LocalSendOperation | undefined
|
||||
private polling = false
|
||||
private promptSeen = false
|
||||
private promptTextSeen = false
|
||||
@@ -224,7 +230,7 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
this.activeAbort = () => request.signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
this.activeDeadlineTimer = setTimeout(() => {
|
||||
if (this.active === operation) this.settleActive('timeout')
|
||||
if (this.active === operation) this.settleActive('timeout', this.writing === operation)
|
||||
}, this.config.timeoutMs)
|
||||
void this.beginSend(operation, request)
|
||||
return operation
|
||||
@@ -236,12 +242,29 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
if (this.active !== operation || this.closing) return
|
||||
operation.setInitialForeground(foreground)
|
||||
const input = `${request.text}${request.submit ? '\r' : ''}`
|
||||
if (input.length > 0) await this.terminal.write(Buffer.from(input, 'utf8'))
|
||||
if (input.length > 0) {
|
||||
this.writing = operation
|
||||
try {
|
||||
await this.terminal.write(Buffer.from(input, 'utf8'))
|
||||
} finally {
|
||||
this.writing = undefined
|
||||
}
|
||||
}
|
||||
if (this.active === operation && operation.settled) {
|
||||
this.clearActive()
|
||||
return
|
||||
}
|
||||
// Closing can race the awaited provider write even though static analysis sees only local assignments.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (this.active === operation && !this.closing) this.schedulePoll(operation, 0)
|
||||
if (this.active === operation && !this.closing) {
|
||||
this.pollingReady = operation
|
||||
this.schedulePoll(operation, 0)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (this.active === operation) this.failActive(error)
|
||||
if (this.active === operation) {
|
||||
if (operation.settled) this.clearActive()
|
||||
else this.failActive(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,15 +424,24 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
if (this.active === operation) this.failActive(error)
|
||||
} finally {
|
||||
this.polling = false
|
||||
if (this.active === operation) this.schedulePoll(operation)
|
||||
const active = this.active
|
||||
// Awaited provider inspection can clear or replace the active send despite static analysis.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (active !== undefined && this.pollingReady === active) this.schedulePoll(active)
|
||||
}
|
||||
}
|
||||
|
||||
private settleActive(waitReason: PtyWaitReason): void {
|
||||
private settleActive(waitReason: PtyWaitReason, retainOwnership = false): void {
|
||||
const operation = this.active
|
||||
if (operation === undefined) return
|
||||
const scrollbackTruncated = this.scrollback.snapshot().truncated
|
||||
this.clearActive()
|
||||
if (retainOwnership) {
|
||||
this.stopPolling()
|
||||
this.activeAbort?.()
|
||||
this.activeAbort = undefined
|
||||
} else {
|
||||
this.clearActive()
|
||||
}
|
||||
operation.settle(waitReason, this.statusValue, scrollbackTruncated)
|
||||
}
|
||||
|
||||
@@ -424,6 +456,8 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
this.stopPolling()
|
||||
this.activeAbort?.()
|
||||
this.activeAbort = undefined
|
||||
this.writing = undefined
|
||||
this.pollingReady = undefined
|
||||
this.active = undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -268,6 +268,40 @@ describe('LocalPtySession readiness and output', () => {
|
||||
failedInternal.fail(new Error('ignored'))
|
||||
})
|
||||
|
||||
it('retains send ownership after timeout until an asynchronous provider write settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const writeGate = Promise.withResolvers<undefined>()
|
||||
terminal.write = async () => { await writeGate.promise }
|
||||
const operation = session.startSend({ text: 'slow write', submit: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect((await operation.done).waitReason).toBe('timeout')
|
||||
expect(() => session.startSend({ text: 'must wait', submit: true })).toThrow('active send')
|
||||
|
||||
writeGate.resolve(undefined)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const rejectedWrite = Promise.withResolvers<undefined>()
|
||||
terminal.write = async () => { await rejectedWrite.promise }
|
||||
const rejected = session.startSend({ text: 'late rejection', submit: true })
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect((await rejected.done).waitReason).toBe('timeout')
|
||||
rejectedWrite.reject(new Error('write failed after timeout'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
const next = session.startSend({ text: '', submit: false })
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect((await next.done).waitReason).toBe('inferred_idle')
|
||||
})
|
||||
|
||||
it('handles startup exit, unknown exit signals, cancel-write failure, and stale polls', async () => {
|
||||
vi.useFakeTimers()
|
||||
const startupTerminal = new FakeTerminal()
|
||||
@@ -509,6 +543,84 @@ describe('LocalPtySession readiness and output', () => {
|
||||
}).settle('timeout', { kind: 'running' }, false)
|
||||
})
|
||||
|
||||
it('reschedules readiness for a new send after a stale remote inspection releases the poll slot', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const old = session.startSend({ text: '', submit: false })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
|
||||
let block = true
|
||||
terminal.inspectForeground = async () => block
|
||||
? await inspection.promise
|
||||
: { processGroupId: 456, inputWaiting: false }
|
||||
const internals = session as unknown as {
|
||||
pollReadiness(operation: PtySendOperation): Promise<void>
|
||||
settleActive(reason: 'timeout'): void
|
||||
}
|
||||
const stalePoll = internals.pollReadiness(old)
|
||||
internals.settleActive('timeout')
|
||||
await old.done
|
||||
|
||||
block = false
|
||||
const current = session.startSend({ text: '', submit: false })
|
||||
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
inspection.resolve({ processGroupId: 456, inputWaiting: false })
|
||||
await stalePoll
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
expect((await current.done).waitReason).toBe('stdin_read')
|
||||
})
|
||||
|
||||
it('does not poll a successor before its own pre-write inspection and write complete', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const old = session.startSend({ text: '', submit: false })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const staleInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
|
||||
const successorInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
|
||||
let inspectCalls = 0
|
||||
terminal.inspectForeground = async () => {
|
||||
inspectCalls += 1
|
||||
return inspectCalls === 1 ? await staleInspection.promise : await successorInspection.promise
|
||||
}
|
||||
const internals = session as unknown as {
|
||||
pollReadiness(operation: PtySendOperation): Promise<void>
|
||||
settleActive(reason: 'timeout'): void
|
||||
}
|
||||
const stalePoll = internals.pollReadiness(old)
|
||||
internals.settleActive('timeout')
|
||||
await old.done
|
||||
|
||||
const current = session.startSend({ text: 'successor', submit: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
staleInspection.resolve({ processGroupId: 456, inputWaiting: false })
|
||||
await stalePoll
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(inspectCalls).toBe(2)
|
||||
expect(terminal.writes).toEqual([])
|
||||
|
||||
successorInspection.resolve({ processGroupId: 456, inputWaiting: false })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(terminal.writes).toEqual(['successor\r'])
|
||||
expect((await current.done).waitReason).toBe('stdin_read')
|
||||
})
|
||||
|
||||
it('contains stale timer, write, inspection, and interrupt continuations', async () => {
|
||||
vi.useFakeTimers()
|
||||
const settle = (operation: PtySendOperation): void => {
|
||||
|
||||
@@ -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/typert/README.md
|
||||
README.md: ad9f843e48be0e3be85921ed8fd3ca4e2c327160
|
||||
README.zh.md: 0d4be016b766178b54f7e269583fa4200e3eb24b
|
||||
# pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime-subprocess/README.md
|
||||
README.md: 9e0ea9065ea4e42696f6afcd243c385e514f7c2d
|
||||
README.zh.md: 6946248ec58054fcdd93a4c94affb55f90041301
|
||||
|
||||
Reference in New Issue
Block a user