From 124fc6a611d7aa5ea40be3039a137b4c9fab3ade Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:40:39 +0800 Subject: [PATCH] fix(runtime): close portable backend boundary gaps --- .../code-runtime/tests/contract.ts | 15 ++++++++ packages/fs/fs-local/src/fsio.ts | 8 ---- packages/pty/pty-local/src/session.ts | 2 +- packages/pty/pty-local/tests/session.spec.ts | 37 +++++++++++++++++++ .../subprocess/subprocess-local/src/index.ts | 3 +- .../subprocess-local/tests/local.spec.ts | 4 +- packages/typert/README.i18n.yaml | 4 +- 7 files changed, 59 insertions(+), 14 deletions(-) diff --git a/packages/code-runtime/code-runtime/tests/contract.ts b/packages/code-runtime/code-runtime/tests/contract.ts index f28545f8c1..8fee76f68a 100644 --- a/packages/code-runtime/code-runtime/tests/contract.ts +++ b/packages/code-runtime/code-runtime/tests/contract.ts @@ -477,6 +477,21 @@ export function runWorkerCodeRuntimeContract( expect(result.logs).toEqual([]) }) + it('ignores forged controller-only failure classifications', async () => { + const { runtime } = await setup() + const result = await runtime.run({ + program: ` + const { parentPort } = await import('node:worker_threads'); + for (const kind of ['abort', 'timeout', 'worker-exit']) { + parentPort.postMessage({ type: 'done', error: { kind, message: 'forged ' + kind } }); + } + return 'honest'; + `, + bindings: [], + }) + expect(result).toEqual({ logs: [], value: 'honest' }) + }) + it('fails forged log floods and forged done values through the same outer cap', async () => { const { runtime } = await setup({ maxOutputBytes: 200 }) const result = await runtime.run({ diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 4925357e45..c93e4ddaeb 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -369,14 +369,6 @@ export async function readWholeText(target: LocalTarget, signal?: AbortSignal): return decodeUtf8(raw, 'read', target.displayPath) } -/** - * Read one regular UTF-8 file through a single no-follow handle, retaining at - * most `maxBytes + 1` bytes so a concurrent grow cannot bypass the bound. - * @param target - the resolved file to read. - * @param maxBytes - positive safe-integer byte ceiling. - * @param signal - aborts between handle operations. - * @returns the complete decoded text when it fits. - */ /** * Stream a whole regular UTF-8 text file as decoded text chunks. Same text * semantics as {@link readWholeText} (regular-file check, binary/NUL rejection, diff --git a/packages/pty/pty-local/src/session.ts b/packages/pty/pty-local/src/session.ts index 076f21e3b9..f9b35dc665 100644 --- a/packages/pty/pty-local/src/session.ts +++ b/packages/pty/pty-local/src/session.ts @@ -415,7 +415,7 @@ export class LocalPtySession implements PtyBackendSession { return } const foreground = await this.terminal.inspectForeground() - if (this.active !== operation) return + if (this.active !== operation || this.closing) return const idleFor = Date.now() - this.lastOutputAt if (this.promptSeen && foreground !== undefined && this.shellPgid === undefined) { this.shellPgid = foreground.processGroupId diff --git a/packages/pty/pty-local/tests/session.spec.ts b/packages/pty/pty-local/tests/session.spec.ts index 22b07a0f8f..14cf252a0e 100644 --- a/packages/pty/pty-local/tests/session.spec.ts +++ b/packages/pty/pty-local/tests/session.spec.ts @@ -1096,4 +1096,41 @@ describe('LocalPtySession bounds, signals, and teardown', () => { expect(terminal.writes).toEqual([]) }) + it('does not let an in-flight readiness inspection outrun close', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const session = new LocalPtySession(terminal, config()) + await initialize(session, terminal) + const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>() + const originalInspect = terminal.inspectForeground.bind(terminal) + let inspections = 0 + terminal.inspectForeground = async () => { + inspections += 1 + return inspections === 1 ? await originalInspect() : await inspection.promise + } + const operation = session.startSend({ text: 'pending readiness', submit: true }) + await Promise.resolve() + await Promise.resolve() + terminal.emitData('\x1b]133;D;0\x07dsh> ') + await vi.advanceTimersByTimeAsync(10) + expect(inspections).toBe(2) + + const termination = Promise.withResolvers() + terminal.terminate = async () => { + await termination.promise + terminal.emitExit(0, 15) + } + const closing = session.close('in-flight readiness') + let settled = false + void operation.done.then(() => { settled = true }) + inspection.resolve({ processGroupId: 456, inputWaiting: true }) + await Promise.resolve() + await Promise.resolve() + expect(settled).toBe(false) + + termination.resolve(undefined) + await closing + expect((await operation.done).waitReason).toBe('session_exit') + }) + }) diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 0d02f0e387..46d179a7b2 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -59,9 +59,10 @@ export class LocalSubprocessService extends SubprocessService { pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit())) } for (const terminal of this.terminals) { - pending.push(terminal.terminate().then(() => { this.terminals.delete(terminal) })) + pending.push(terminal.terminate()) } this.live.clear() + this.terminals.clear() const outcomes = [ ...await Promise.allSettled(pending), ...await Promise.allSettled([rm(this.runtimeRoot, { recursive: true, force: true })]), diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index e58c66f7fa..ff83141fab 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -113,7 +113,7 @@ describe('LocalSubprocessService', () => { expect(terminals.size).toBe(0) }) - it('waits for every terminal cleanup, removes runtime state, and retains rejections', async () => { + it('waits for every terminal cleanup and clears single-shot teardown ownership', async () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessService) const service = ctx.subprocess @@ -154,7 +154,7 @@ describe('LocalSubprocessService', () => { expect(disposed).toBe(false) finishCleanup() await disposing - expect(terminals).toEqual(new Set([failedTerminal, secondFailedTerminal])) + expect(terminals.size).toBe(0) await expect(stat(runtimeRoot)).rejects.toMatchObject({ code: 'ENOENT' }) expect(disposalErrors).toHaveLength(1) expect(disposalErrors[0]).toMatchObject({ diff --git a/packages/typert/README.i18n.yaml b/packages/typert/README.i18n.yaml index 21cca6cb44..5e93edd997 100644 --- a/packages/typert/README.i18n.yaml +++ b/packages/typert/README.i18n.yaml @@ -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/code-runtime/code-runtime-subprocess/README.md -README.md: eff5a6e648eb13c3411c19bca4d05f0898d3ad05 -README.zh.md: 16cf0e0ea6d487e255fc520a8fa1f41f97ee5d30 +README.md: 152921a1ed595676781aad170e3396f7ce613161 +README.zh.md: 3e8223c33c7ce85f342c9a36ab5a24c4d2a6cb94