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()