diff --git a/src/runtime.mjs b/src/runtime.mjs index 415bf78..106fc99 100644 --- a/src/runtime.mjs +++ b/src/runtime.mjs @@ -59,12 +59,19 @@ function makeControl(max, out) { // otherwise the process could exit before a fire-and-forget notification lands. // Blocking verbs (the CLI verbs via spawnSync, sleep) return synchronously and // need no draining, which is what keeps the simple no-`await` style correct. +// +// What gets queued is `Promise.allSettled([result])`, not the bare `result`. The +// settled wrapper subscribes immediately, so a fire-and-forget verb that rejects +// is never seen as an unhandled rejection while the script runs on — queueing the +// bare promise let Node kill the process at the next tick, before the drain below +// could ever observe it. The script still gets the original promise back, so an +// `await`ed call fails exactly as before. function makeScope(registry, ctx, control, pending) { const bound = new Map(); for (const cmd of registry.all()) { bound.set(cmd.name, (...args) => { const result = cmd.run(ctx, ...args); - if (result && typeof result.then === "function") pending.push(result); + if (result && typeof result.then === "function") pending.push(Promise.allSettled([result])); return result; }); } diff --git a/test/runtime.test.mjs b/test/runtime.test.mjs index a8a7f9f..cea89f1 100644 --- a/test/runtime.test.mjs +++ b/test/runtime.test.mjs @@ -111,3 +111,27 @@ test("DEFAULT_MAX bounds an unbounded while when no max is passed", async () => await runScript(`while (alive) { push(1); }`, { commands: registry }); assert.equal(calls.length, DEFAULT_MAX); }); + +test("a rejecting fire-and-forget verb does not kill a script that keeps running", async () => { + const calls = []; + const registry = createRegistry([ + { name: "push", summary: "record", run: (_ctx, x) => calls.push(x) }, + { name: "fire", summary: "async, rejects", run: async () => { throw new Error("network boom"); } }, + { name: "waitv", summary: "async, resolves later", run: (_ctx, ms) => new Promise((r) => setTimeout(r, ms)) }, + ]); + // The rejection must stay observed from the moment it is queued: crossing a + // tick boundary (the `await waitv`) used to let Node report it as unhandled + // and tear the process down before the drain in runScript ran. + const result = await runScript(`fire(); await waitv(5); push("after wait");`, { + commands: registry, + }); + assert.deepEqual(calls, ["after wait"]); + assert.deepEqual(result, { iterations: 0, stopped: false }); +}); + +test("an awaited async verb still propagates its rejection to the script", async () => { + const registry = createRegistry([ + { name: "fire", summary: "async, rejects", run: async () => { throw new Error("network boom"); } }, + ]); + await assert.rejects(async () => runScript(`await fire();`, { commands: registry }), /network boom/); +});