SIGN IN SIGN UP

core: fan_out — one dispatch preamble; convert the 6 notify-only dispatchers (#55)

A4a from the core stabilization audit (#49), the first slice of the headline
item.

Every engine->JS notification hand-rolled the same six-part preamble:
snapshot -> try_borrow_mut re-entrancy guard -> per-subscriber liveness ->
context clone -> HandleScope/ContextScope -> per-handler TryCatch. The copies
had DRIFTED, and the drift was the real problem, not the line count.

fan_out owns that preamble once. Six genuinely notify-only dispatchers convert
to it: precache 31->4, map_start 38->7, cvar_change 37->13, client_event
58->23, game_event 67->23, entity_event 50->28. Net -64 lines while adding a
test and substantial doc comment. WARN strings kept byte-identical so operator
log greps keep working.

CLASSIFICATION FIRST, and it corrected the audit twice. The 22 dispatch_*
functions are four groups, not one: 6 pure notify (converted here); 7
return-value folds -- the 5 run_chain collapses PLUS chat_message and
command_listeners, which fold a >= 2 suppress by hand without ever naming
HookResult (chat_message additionally breaks on first claim, the documented
fix for one keystroke hitting three menus) -- deferred to A4b; 4 pending-drain
shells; and 4 that are NOT dispatchers at all (frame_async_drain is the async
resolver spine; dispatch_chat and dispatch_client_command are routers).
Folding those last four in would be a category error; the audit's '18
dispatchers' over-counted by including them.

INSTRUMENTATION MADE EXPLICIT: enter_dispatch reached 4 of ~20 paths and
report_js_error reached 2, and sharing a preamble would have hidden that
unevenness. fan_out takes Instrument { breadcrumb, report_as }, both opt-in, so
each converted path keeps its exact behaviour while 'which paths are
instrumented' becomes readable off the call sites. Deliberately NOT switching
instrumentation on everywhere here -- that is a behaviour change across 20
paths and deserves its own decision.

build_args returns Option so a failed v8::String::new still SKIPS that
subscriber (map_start/cvar_change did this via continue); an empty Vec would
have called the handler with its arguments silently missing.

cargo test -p s2script-core: 465 passed. CI=1 make ci: green. New test
fan_out_isolates_a_throwing_handler_from_the_rest guards the per-handler
TryCatch -- the piece a dedup most easily loses, whose loss is invisible except
as 'my plugin stopped getting events'; mutation-verified by making a throw
break the fan-out.
G
Gabriel Hirakawa committed
9c6e18f1ac495ecdc256c9f82a3add71810ccdeb
Parent: ec97eb0
Committed by GitHub <noreply@github.com> on 8/1/2026, 6:07:09 AM