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.
This commit is contained in:
_Kerman committed 2026-10-21 10:30:31 +08:00
1 parent 36820a5462
commit 94dae61fb5
2 files changed
+26 -2

No files matched your search

@@ -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<void>()
// 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<never>(() => {})
const connection = createProcessRpc<HostMethods, ProgramMethods>(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)
@@ -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<ReturnType<typeof encodePtcJsonWire>>()
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()