Skip to content

fix(runtime): keep a rejecting fire-and-forget verb from killing the script#34

Merged
ralyodio merged 1 commit into
moshcoder:mainfrom
clawedassistant26:fix/async-verb-unhandled-rejection
Jul 25, 2026
Merged

fix(runtime): keep a rejecting fire-and-forget verb from killing the script#34
ralyodio merged 1 commit into
moshcoder:mainfrom
clawedassistant26:fix/async-verb-unhandled-rejection

Conversation

@clawedassistant26

Copy link
Copy Markdown
Contributor

The bug

makeScope pushes the raw promise from an un-awaited async verb onto pending, and nothing subscribes to it until the Promise.allSettled(pending) drain runs after the script body finishes (src/runtime.mjs:67 and :139).

If the script crosses a tick boundary between the fire-and-forget call and the end of the body, Node flags the rejection as unhandled and tears the process down before the drain can ever observe it. The README's own notify(...) then await ask(...) pattern does exactly that.

The user cannot catch this: a try/catch around an un-awaited call does not help, and it bypasses the clean try/catch in bin/moshcode.mjs, so they get a raw stack trace instead of the one-line error.

Real trigger in tree: src/notify.mjs:44 does an unguarded await res.json(), so any 200 with an empty or non-JSON body (a proxy hiccup) makes notify() reject.

Reproduction

import { runScript } from "./src/runtime.mjs";
import { createRegistry } from "./src/registry.mjs";

const reg = createRegistry([
  { name: "say",   summary: "", run: (_c, x) => console.log("  say:", x) },
  { name: "fire",  summary: "", run: async () => { throw new Error("network boom"); } },
  { name: "waitv", summary: "", run: (_c, ms) => new Promise((r) => setTimeout(r, ms)) },
]);

console.log(await runScript(`fire(); await waitv(20); say("after wait");`, { commands: reg }));

Before:

Error: network boom
    at Proxy.<anonymous> (src/runtime.mjs:66:26)
    at eval (eval at runScript (src/runtime.mjs:136:14), <anonymous>:4:1)
Node.js v22.23.1
EXIT=1

after wait never prints. After the fix it prints and runScript returns { iterations: 0, stopped: false }, exit 0.

The fix

Queue Promise.allSettled([result]) rather than the bare result. The wrapper subscribes synchronously, so the rejection is observed the moment it is queued and can never be reported as unhandled.

Deliberately minimal, and semantics are unchanged:

  • the script still gets the original promise back, so an awaited call fails exactly as before;
  • the drain already discarded outcomes via allSettled, so fire-and-forget failures stay swallowed, matching the comment's stated intent.

Tests

Two regression tests in test/runtime.test.mjs, one per half of the contract:

  • an un-awaited rejecting verb no longer stops a script that keeps running (fails on main, passes with the fix);
  • an awaited async verb still propagates its rejection to the script.

Full suite: 136 pass before, 138 pass / 0 fail after (npm test).

Separate hardening worth doing, not included here to keep this to one change: wrapping res.json() in src/notify.mjs:44 so a bad body surfaces as { ok: false, error } like the other failure modes.

…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.
@ralyodio
ralyodio merged commit 66ae5e5 into moshcoder:main Jul 25, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants