Skip to content

Sync/async-transparent hook dispatch — allow any mix of sync/async targets and handlers #253

Closed

Description

@Shinrai

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 before hook 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 (checkHookAccess at fire time)
  • lockCaller / force-pinned ownership
  • ALS context propagation across the added await points

Relationship to the other filed issues

Activity

  1. added
    type: feature requestA request to add new functionality that does not currently exist
    area: coreTouches core library / runtime source code
    on Jul 31, 2026
  2. added theissue type on Jul 31, 2026
  3. Shinrai commented on Jul 31, 2026

    @Shinrai
    ContributorAuthor

    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 model

    /** Per-call dispatch. The leaf is NEVER mutated. Strategy is derived from the
     *  CURRENT hook set, cached behind a generation counter that bumps on any
     *  mutation (hook add/remove, leaf replacement). Fully reversible. */
    import { types } from "node:util";
    const isAsyncFn = (f) => types.isAsyncFunction(f) || f.__async === true;
    const isThenable = (v) => v && typeof v.then === "function";
    
    // Loud guard: a promoted result consumed WITHOUT await throws and names the
    // path, instead of silently yielding NaN.
    const PASS = new Set(["then", "catch", "finally", Symbol.toStringTag]);
    const loud = (p, path) => new Proxy(p, { get(t, k) {
      if (PASS.has(k)) { const v = Reflect.get(t, k, t); return typeof v === "function" ? v.bind(t) : v; }
      if (k === Symbol.toPrimitive) return () => { throw new TypeError(`'${path}': an async hook is attached — await the result`); };
      throw new TypeError(`'${path}': an async hook is attached — await before reading '.${String(k)}'`); } });
    
    export function makePath(name, initialLeaf) {
      let leaf = initialLeaf;                                   // never mutated, only replaced
      const hooks = { before: [], after: [], always: [], error: [] };
      let gen = 0, cache = { gen: -1 }, recomputes = 0;
    
      const strategy = () => {
        if (cache.gen === gen) return cache;                     // cached — no per-call scan
        recomputes++;
        const leafAsync = isAsyncFn(leaf);
        // always/error are OBSERVERS — their return value is never consumed, so
        // they never force promotion. Only before/after transform.
        const pipelineAsync = leafAsync || [...hooks.before, ...hooks.after].some((h) => h.isAsync);
        cache = { gen, pipelineAsync, promoted: pipelineAsync && !leafAsync };
        return cache;
      };
    
      const on = (type, handler) => { hooks[type].push({ handler, isAsync: isAsyncFn(handler) }); gen++;
        return () => { hooks[type] = hooks[type].filter((h) => h.handler !== handler); gen++; }; };
      const replaceLeaf = (next) => { leaf = next; gen++; };
    
      const syncRun = (args) => {
        for (const h of hooks.before) { const r = h.handler({ args }); if (r !== undefined) args = r; }
        let result; try { result = leaf(...args); } catch (e) { for (const h of hooks.error) h.handler({ error: e }); throw e; }
        for (const h of hooks.after) { const r = h.handler({ result, args }); if (r !== undefined) result = r; }
        for (const h of hooks.always) h.handler({ result, args });
        return result; };
      const asyncRun = async (args) => {
        // Await ONLY real thenables — a sync handler inside an async pipeline
        // costs no microtask tick.
        for (const h of hooks.before) { const r = h.handler({ args }); const v = isThenable(r) ? await r : r; if (v !== undefined) args = v; }
        let result; try { const r = leaf(...args); result = isThenable(r) ? await r : r; }
        catch (e) { for (const h of hooks.error) { const r = h.handler({ error: e }); if (isThenable(r)) await r; } throw e; }
        for (const h of hooks.after) { const r = h.handler({ result, args }); const v = isThenable(r) ? await r : r; if (v !== undefined) result = v; }
        for (const h of hooks.always) { const r = h.handler({ result, args }); if (isThenable(r)) await r; }
        return result; };
    
      const call = (...args) => { const s = strategy();
        if (!s.pipelineAsync) return syncRun(args);
        const p = asyncRun(args);
        return s.promoted ? loud(p, name) : p; };
    
      return { call, on, replaceLeaf, stats: () => ({ recomputes, gen }) };
    }
    export const __isThenable = isThenable;

    proto-percall-matrix.mjs — matrix + ordering + chaining runner

    import { makePath, __isThenable as isThenable } from "./proto-percall.mjs";
    
    const leaves = {
      sync: (a, b) => a + b, async: async (a, b) => a + b,
      syncThrow: () => { throw new Error("boom-sync"); }, asyncThrow: async () => { throw new Error("boom-async"); }
    };
    const rows = [];
    for (const type of ["before", "after", "always", "error"])
     for (const tMode of ["sync", "async"])
      for (const hMode of ["sync", "async"]) {
        const throws = type === "error";
        const leaf = throws ? leaves[tMode === "sync" ? "syncThrow" : "asyncThrow"] : leaves[tMode];
        let ran = false;
        const body = (c) => { ran = true;
          if (type === "before") return [10, 10];
          if (type === "after") return c.result * 10;
          return undefined; };
        const p = makePath(`svc.${tMode}`, leaf);
        p.on(type, hMode === "sync" ? body : async (c) => body(c));
        let outcome, verdict;
        try {
          const r = p.call(2, 3);
          const v = isThenable(r) ? await r : r;
          const want = type === "before" ? 20 : type === "after" ? 50 : 5;
          outcome = `${JSON.stringify(v)} (${isThenable(r) ? "async" : "sync"} path)`;
          verdict = v === want ? "ok" : `WRONG (want ${want})`;
        } catch (e) { outcome = `threw ${e.message}`; verdict = throws && ran ? "ok (propagates, hook ran)" : "UNEXPECTED"; }
        rows.push({ type, tMode, hMode, outcome, verdict });
      }
    console.log("hook     target  handler  observed                     verdict");
    console.log("-".repeat(76));
    for (const r of rows) console.log(r.type.padEnd(9), r.tMode.padEnd(7), r.hMode.padEnd(8), r.outcome.padEnd(28), r.verdict);
    console.log("\nfailures:", rows.filter((r) => !r.verdict.startsWith("ok")).length, "/", rows.length);
    
    // Multi-hook ordering, mixed sync/async, on BOTH target kinds.
    for (const t of ["sync", "async"]) {
      const order = [], p = makePath(`ord.${t}`, leaves[t]);
      p.on("before", (c) => { order.push("b1s"); return c.args; });
      p.on("before", async (c) => { order.push("b2a"); return c.args; });
      p.on("before", (c) => { order.push("b3s"); return c.args; });
      p.on("after", async (c) => { order.push("a1a"); return c.result; });
      p.on("after", (c) => { order.push("a2s"); return c.result; });
      p.on("always", (c) => order.push("alw"));
      const r = p.call(2, 3); if (isThenable(r)) await r;
      const want = "b1s -> b2a -> b3s -> a1a -> a2s -> alw";
      console.log(`ordering (${t} target):`.padEnd(26), order.join(" -> "), order.join(" -> ") === want ? "ok" : `WRONG (want ${want})`);
    }
    
    // Chained before-hooks: a later hook must observe the earlier one's transform.
    const sc = makePath("sc", leaves.sync);
    sc.on("before", () => [100, 1]);
    sc.on("before", (c) => { console.log("\nsecond before sees args:", JSON.stringify(c.args), c.args[0] === 100 ? "ok" : "WRONG"); return c.args; });
    console.log("chained before result:", sc.call(2, 3), sc.call(2, 3) === 101 ? "ok" : "WRONG");

    Output as measured (Node 26.4.0)

    hook     target  handler  observed                     verdict
    ----------------------------------------------------------------------------
    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 4)          5                            ok
    error     (all 4)          propagates, hook ran         ok
    
    failures: 0 / 16
    ordering (sync target):    b1s -> b2a -> b3s -> a1a -> a2s -> alw ok
    ordering (async target):   b1s -> b2a -> b3s -> a1a -> a2s -> alw ok
    second before sees args: [100,1] ok
    chained before result: 101 ok
    

    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 makePath produces them via replaceLeaf and stats().

    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, lockCaller pinning, and ALS propagation across the added await points 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.

  4. cldmv-bot commented on Aug 11, 2026

    @cldmv-bot
    Contributor

    Closed in v3.13.0 — resolved by #264.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreTouches core library / runtime source codetype: feature requestA request to add new functionality that does not currently exist

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions