SIGN IN SIGN UP

core: Channels<H> — one keyed subscription store over Descriptor; retire event_mux.rs (#58)

A4d from the core stabilization audit (#49), the storage half of the generic
dispatch path. Stacked on #57.

event_mux.rs opened with the charter "events don't collapse". Its own users
falsified it: five instances collapse handler returns into a HookResult. So the
codebase carried TWO implementations of one idea — EventMux (keyed, no priority,
no auto-disable) and multiplexer::Descriptor (single-channel, WITH
priority/enabled/error_count/apply_errors) — and every capability except
OnGameFrame got the weaker one. Channels<H> is a keyed facade OVER Descriptor,
so there is now one. event_mux.rs deleted (175 lines).

THE DESIGN CALL WORTH REVIEWING: the audit assumed Descriptor could be reused
"unchanged". It cannot, and the reason is on the use-after-free-critical path.
EventMux::EventSub carries a `generation` — the plugin-reload liveness token
every dispatch checks via REGISTRY.is_live(owner, generation) — and
Descriptor::Subscription has no such field. Rather than add one to
multiplexer.rs (the OnGameFrame path, and the reference implementation for
priority + auto-disable), the token is folded into the handler type:
Descriptor<(u64, H)>. multiplexer.rs is therefore COMPLETELY UNTOUCHED here, and
the snapshot shape dispatch receives is byte-identical. A regression that
dropped or transposed the generation would make dispatch check the WRONG
generation, which fails OPEN into a reloaded plugin's stale context, so it has
its own dedicated test.

Second mismatch handled the same way: callers allocate subscription ids via
next_sub_id() and dispose by them (the Scope surface) while Descriptor mints its
own SubId; Channels keeps an ids -> (channel, SubId) side map.

BEHAVIOUR-IDENTICAL: every subscription lands at Priority::Normal and nothing
calls apply_errors. Priority and auto-disable are now AVAILABLE to all 17
channels — that is the point — but switching either on is a live behaviour
change and belongs in its own PR per capability.

The API is signature-compatible with the EventMux it replaces, so swapping the
17 statics was a TYPE CHANGE, not a rewrite: ~103 call sites compile unchanged.
That is what made this tractable and why the diff is small for the blast radius.

Semantic note flagged for later: EventMux::remove_by_owner reported "channel
became empty"; Channels reports "enabled count reached zero". Identical while
auto-disable is off, and the correct signal for the callers' engine-op
unsubscribe — but it needs revisiting when auto-disable is turned on.

NOT here: the generic __s2_hook_on/_off pair and the generic ffi dispatch entry.
Both need the 17 Channels statics consolidated into ONE keyed store with
namespaced names; without that a generic native still matches a string onto one
of 17 thread-locals, which is the same hand-maintained table in a new place.
That plus the C-ABI change is A4e, and the ffi half wants a live gate.

cargo test -p s2script-core: 468 passed (8 new Channels tests). CI=1 make ci: green.
G
Gabriel Hirakawa committed
2bea145ec732f5d4f2a7c33519ecf804fc293e50
Parent: fa1e5ec
Committed by GitHub <noreply@github.com> on 8/1/2026, 6:07:57 AM