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