From 0ea3b2607c746f3deb56851b4b15eb9a05b57945 Mon Sep 17 00:00:00 2001 From: clawedassistant26 <307253840+clawedassistant26@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:24:17 +0000 Subject: [PATCH] fix(runtime): keep a rejecting fire-and-forget verb from killing the script An async verb the script did not await had its raw promise pushed onto `pending`, and nothing subscribed to it until the `Promise.allSettled` drain after the script body finished. If the script crossed a tick boundary in between (the documented `notify("..."); await ask("...")` pattern does exactly that), Node saw an unhandled rejection first and tore the process down, so the drain never ran, the awaited verb was killed mid-flight, and the user got a raw stack trace instead of the clean one-line error bin/moshcode.mjs prints. Queue `Promise.allSettled([result])` instead. It subscribes immediately, so the rejection is observed from the moment it is queued. The script still receives the original promise, so an awaited call fails exactly as before, and the drain keeps swallowing fire-and-forget failures as it already did. Regression tests cover both halves: an un-awaited rejecting verb no longer stops a script that keeps running, and an awaited one still propagates. --- src/runtime.mjs | 9 ++++++++- test/runtime.test.mjs | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) 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/); +});