Repository navigation
Sync/async-transparent hook dispatch — allow any mix of sync/async targets and handlers #253
Description
Summary
Hook handlers are currently required to be synchronous. Four of the sixteen (hook type × target sync-ness × handler sync-ness) combinations do not work: two are refused, one silently corrupts the return value, and one of the refusals is unnecessary.
This proposes deriving the dispatch strategy per call from the current hook set, so any mixture of synchronous and asynchronous targets and handlers composes correctly. A working reference implementation is described below.
The intent is to expand what the hook system accepts, not to add refusals.
Current behaviour
Measured across all four mode × runtime combinations (eager/lazy × async/live). Behaviour is level-invariant — materialization strategy and context runtime do not affect hook dispatch, so this is one fix, not four.
| hook | target | handler | today |
|---|---|---|---|
before |
sync | sync | ok |
before |
sync | async | throws HOOK_BEFORE_RETURNED_PROMISE |
before |
async | sync | ok |
before |
async | async | throws HOOK_BEFORE_RETURNED_PROMISE ← unnecessary |
after |
sync | sync | ok |
after |
sync | async | Promise leaks as the return value (#251) |
after |
async | sync | ok |
after |
async | async | ok |
always |
any | any | ok |
error |
any | any | ok |
The before + async-target + async-handler cell is refused for no reason: the caller already receives a Promise and already awaits, so awaiting the before-chain changes nothing observable. executeBeforeHooks runs before fn() is invoked and therefore refuses uniformly, without distinguishing the target.
Proposed design
Nothing is declared by module authors. Everything is derived.
1. Tag the leaf and each handler. util.types.isAsyncFunction, with an internal override for the four detection blind spots (a plain function returning a Promise, a .bind()-ed async function, an async function wrapped in a plain one, an arrow returning a Promise). The predicate has zero false positives — the error direction is conservative, so an undetected async function is treated as sync and simply keeps today's behaviour.
2. Derive one boolean per path.
pipelineAsync = leafIsAsync || any TRANSFORMING hook is async
always and error are observers — their return value is never consumed, so they never force promotion. This is why an async always hook on a sync target already works today and continues to.
3. Never mutate the leaf. The strategy is a property of the call, derived from the current hook set. Removing the async hook returns the path to synchronous dispatch. Replacing the leaf (reload, api.add) re-derives it.
4. Cache the strategy behind a generation counter, so it is not recomputed per call. Measured: 0 recomputes across 10,000 calls; per-call cost is one integer comparison.
5. Two dispatch paths, strict ordering in both. In the async path, await only actual thenables:
const r = h.handler(ctx);
const v = isThenable(r) ? await r : r;so a synchronous handler inside an asynchronous pipeline costs no microtask tick.
6. Guard the promoted return. When a sync leaf is promoted by an attached async hook, wrap the returned Promise so that consuming it without awaiting throws and names the path, instead of silently yielding NaN:
'svc.computeTotal': an async hook is attached — await the result
This matters because promotion is transient: the same path can return a value, then a Promise, then a value again as hooks come and go. The caller's code was correct when written and broke because another module attached a hook — the error should say so.
Reference implementation results
Full matrix against the per-call model — 16/16, zero failures, including both cells that are refused today:
before sync sync 20 (sync path) ok
before sync async 20 (async path) ok
before async sync 20 (async path) ok
before async async 20 (async path) ok
after sync sync 50 (sync path) ok
after sync async 50 (async path) ok
after async sync 50 (async path) ok
after async async 50 (async path) ok
always (all four) 5 ok
error (all four) propagates ok
Ordering preserved across mixed handlers on both target kinds: b1-sync → b2-async → b3-sync → a1-async → a2-sync → always. Chained before hooks correctly feed transformed args forward.
Reversibility and leaf replacement:
1. sync leaf, no hooks value 5
2. + SYNC after hook value 50
3. + ASYNC after hook Promise (awaited: 51)
4. async hook REMOVED value 50 <- reverts to sync
5. all hooks removed value 5
6. leaf REPLACED with async Promise
7. leaf replaced back to sync value 5
Step 4 is the property that rules out any design based on mutating the leaf or freezing wrapper shapes at init: removing the hook must restore the original contract, and a mutate-based approach strands the leaf async permanently.
Implementation note — the highest-risk part
Hooks are pattern-matched, not registered per path. A registration like svc.**:before affects paths that never individually saw it, so a per-path generation counter alone would serve a stale strategy.
The cache key needs to be a pair:
- a global hook-registry epoch, bumped by
on,off,remove,clear,enable,disable,enablePattern,disablePattern,resetPatternFilter - a per-path leaf generation, bumped on leaf replacement / reload
Missing any one mutator produces an intermittent stale-strategy bug that would be unpleasant to track down.
Not covered by the prototype
The reference implementation validates the dispatch design, not an integration. It does not model, and the real patch would need to prove composition with:
- short-circuit (a
beforehook returning a terminal value so the target is never invoked) — interacts directly with the sync/async split, since a sync short-circuit on a promoted path still resolves through the async wrapper - hook priorities and subsets
- permission gating (
checkHookAccessat fire time) lockCaller/ force-pinned ownership- ALS context propagation across the added
awaitpoints
Relationship to the other filed issues
- Async
afterhook on a sync target leaks a Promise as the return value #251 — theafter+ sync target + async handler cell, filed separately as a bug because silent value corruption should be fixed regardless of whether this larger design is accepted. This proposal supersedes that fix by making the cell work rather than merely fail loudly. CONTEXT_EXECUTION_FAILEDdestroys a synchronous leaf's thrown error shape (async leaves unaffected) #252 —CONTEXT_EXECUTION_FAILEDdestroying a sync leaf's error shape. Independent of hooks; stands on its own.
Activity
Shinrai commentedon Jul 31, 2026
Attaching the reference implementation the results in the body came from, so this proposal ships with runnable code rather than prose. Two files: the dispatch model, and the matrix/reversibility runner that produced the measurements.
proto-percall.mjs— the dispatch modelproto-percall-matrix.mjs— matrix + ordering + chaining runnerOutput as measured (Node 26.4.0)
Reversibility/leaf-replacement transitions and the cache measurement (0 recomputes across 10,000 unmutated calls; 7 recomputes across 6 mutations) are in the issue body; the same
makePathproduces them viareplaceLeafandstats().Caveats repeated from the body so they travel with the code: this validates the dispatch design, not an integration — short-circuit, priorities/subsets, permission gating,
lockCallerpinning, and ALS propagation across the addedawaitpoints are not modeled, and the pattern-matched registry means the real cache key must pair a global hook-registry epoch with a per-path leaf generation.cldmv-bot commentedon Aug 11, 2026
Closed in v3.13.0 — resolved by #264.