From 94dae61fb5fcf6839c8fe0eba6bbf47161bbee61 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 21 Oct 2026 10:30:31 +0800 Subject: [PATCH] fix(ptc-runtime-node): keep per-call binding waits pending when the host closes Closing the control pipe after the result rejected outstanding birpc calls; an unawaited binding promise then crashed the child and its trace reached the result logs. Once the result is returned or the pipe closes, binding calls now never settle, including those the closure rejects. --- .../ptc-runtime-node/src/process.ts | 9 +++++++-- .../tests/process-main.spec.ts | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/ptc-runtime/ptc-runtime-node/src/process.ts b/packages/ptc-runtime/ptc-runtime-node/src/process.ts index 31ba28f962..4315ab4ba8 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/process.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/process.ts @@ -25,12 +25,17 @@ export async function runNodeMain(stream: Duplex, maxMessageBytes: number, proce if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0) throw new Error('invalid control message limit') clearProgramEnvironment(processState) const hostClosed = Promise.withResolvers() - // Set once the result is returned or the endpoint closes; later calls stay pending and output is dropped. + // Set once the result is returned or the endpoint closes; binding calls then never settle and output is dropped. let ended = false + const unsettled = new Promise(() => {}) const connection = createProcessRpc(stream, maxMessageBytes, { async run(_cwd, data) { const result = await runProgram({ - invoke: (global, name, args) => ended ? new Promise(() => {}) : connection.invoke(global, name, args), + invoke: (global, name, args) => ended ? unsettled : connection.invoke(global, name, args).catch((error: unknown) => { + // Closure rejects outstanding calls; an unawaited one would crash the child into the result logs. + if (ended) return unsettled + throw error + }), log: (text) => { if (!ended) void connection.log(text) }, outputLimit: () => { if (!ended) void connection.outputLimit() }, }, data, processState) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/process-main.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/process-main.spec.ts index 113d549bdb..e30834c651 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/process-main.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/process-main.spec.ts @@ -69,6 +69,25 @@ it('keeps unfinished calls pending and suppresses later logs while awaiting host } finally { release.resolve(encodePtcJsonWire(null)) } }) +it('rejects a binding call that the host fails during the run', async () => { + const h = await setup() + h.methods.invoke = async () => { throw new Error('denied by host') } + const result = await h.run('try { await tools.echo({}) } catch (error) { return error.message }') + expect(decodePtcJsonWire(result.value)).toBe('denied by host') +}) + +it('keeps an unawaited call pending when the host closes after the result', async () => { + const h = await setup() + const pending = Promise.withResolvers>() + onTestFinished(() => { pending.resolve(encodePtcJsonWire(null)) }) + h.methods.invoke = () => pending.promise + expect(decodePtcJsonWire((await h.run('void tools.echo({}); return 42')).value)).toBe(42) + h.peer.$close() + await h.main + await nextTurn() + expect(h.state.exitCode).toBeUndefined() +}) + it('reports exhausted output accounting during the run', async () => { const h = await setup() const limited = vi.fn()