Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
}
Expand Down
24 changes: 24 additions & 0 deletions test/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
Loading