Skip to content

Commit 0ea3b26

Browse files
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.
1 parent d821ab8 commit 0ea3b26

2 files changed

Lines changed: 32 additions & 1 deletion

File tree

src/runtime.mjs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,19 @@ function makeControl(max, out) {
5959
// otherwise the process could exit before a fire-and-forget notification lands.
6060
// Blocking verbs (the CLI verbs via spawnSync, sleep) return synchronously and
6161
// need no draining, which is what keeps the simple no-`await` style correct.
62+
//
63+
// What gets queued is `Promise.allSettled([result])`, not the bare `result`. The
64+
// settled wrapper subscribes immediately, so a fire-and-forget verb that rejects
65+
// is never seen as an unhandled rejection while the script runs on — queueing the
66+
// bare promise let Node kill the process at the next tick, before the drain below
67+
// could ever observe it. The script still gets the original promise back, so an
68+
// `await`ed call fails exactly as before.
6269
function makeScope(registry, ctx, control, pending) {
6370
const bound = new Map();
6471
for (const cmd of registry.all()) {
6572
bound.set(cmd.name, (...args) => {
6673
const result = cmd.run(ctx, ...args);
67-
if (result && typeof result.then === "function") pending.push(result);
74+
if (result && typeof result.then === "function") pending.push(Promise.allSettled([result]));
6875
return result;
6976
});
7077
}

test/runtime.test.mjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,27 @@ test("DEFAULT_MAX bounds an unbounded while when no max is passed", async () =>
111111
await runScript(`while (alive) { push(1); }`, { commands: registry });
112112
assert.equal(calls.length, DEFAULT_MAX);
113113
});
114+
115+
test("a rejecting fire-and-forget verb does not kill a script that keeps running", async () => {
116+
const calls = [];
117+
const registry = createRegistry([
118+
{ name: "push", summary: "record", run: (_ctx, x) => calls.push(x) },
119+
{ name: "fire", summary: "async, rejects", run: async () => { throw new Error("network boom"); } },
120+
{ name: "waitv", summary: "async, resolves later", run: (_ctx, ms) => new Promise((r) => setTimeout(r, ms)) },
121+
]);
122+
// The rejection must stay observed from the moment it is queued: crossing a
123+
// tick boundary (the `await waitv`) used to let Node report it as unhandled
124+
// and tear the process down before the drain in runScript ran.
125+
const result = await runScript(`fire(); await waitv(5); push("after wait");`, {
126+
commands: registry,
127+
});
128+
assert.deepEqual(calls, ["after wait"]);
129+
assert.deepEqual(result, { iterations: 0, stopped: false });
130+
});
131+
132+
test("an awaited async verb still propagates its rejection to the script", async () => {
133+
const registry = createRegistry([
134+
{ name: "fire", summary: "async, rejects", run: async () => { throw new Error("network boom"); } },
135+
]);
136+
await assert.rejects(async () => runScript(`await fire();`, { commands: registry }), /network boom/);
137+
});

0 commit comments

Comments
 (0)