feat(plugin): WASM hook registration and execution (#444)#570
Conversation
Add hooks() export discovery in LoadModule, CallHook method for JSON envelope dispatch via the existing alloc/ptr/len ABI, and RegisteredHookDetails for HookRegistry bridging with default priority 50. Rebuild compiled.wasm with hooks()/hook() exports, add bad-hooks-export.wasm and wasm-malformed-hook-response.wasm error fixtures, and add "hooks" to reserved WASM exports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds WASM plugin hook support to the Tier 2 WASMRuntime so WASM modules can (1) declare which lifecycle hooks they implement and (2) be invoked via the existing WASM string ABI, enabling integration with the HookRegistry alongside QuickJS and Node runtimes.
Changes:
- Discover hook names from a
hooks()WASM export duringLoadModule()and expose them viaRegisteredHooks()/RegisteredHookDetails()(default priority 50). - Implement
CallHook(name, payload)for WASM by JSON-marshaling an{event, payload}envelope, dispatching through the existing(ptr,len)->(ptr,len)ABI, and JSON-parsing the response. - Update/add WASM fixtures (
compiled.*,bad-hooks-export.*,wasm-malformed-hook-response.*) to cover hook discovery and malformed response cases.
Reviewed changes
Copilot reviewed 4 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/plugin/wasm.go | Implements WASM hook discovery/registration details and CallHook, and reserves "hooks" in the export set. |
| internal/plugin/testdata/single-files/compiled.wat | WAT source for the rebuilt compiled WASM fixture including hooks()/hook() exports. |
| internal/plugin/testdata/single-files/compiled.wasm | Rebuilt WASM fixture binary with hooks()/hook() exports for hook tests. |
| internal/plugin/testdata/single-files/bad-hooks-export.wat | WAT source for a fixture returning invalid JSON from hooks(). |
| internal/plugin/testdata/single-files/bad-hooks-export.wasm | Fixture binary returning invalid JSON from hooks(). |
| internal/plugin/testdata/single-files/wasm-malformed-hook-response.wat | WAT source for a fixture returning non-JSON bytes from hook(). |
| internal/plugin/testdata/single-files/wasm-malformed-hook-response.wasm | Fixture binary returning non-JSON bytes from hook(). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
zeroedin
left a comment
There was a problem hiding this comment.
Code Review — PR #570 (Review #1)
9/9 reviewers returned | Verdict: Ready to merge
Findings
#1 P2 safe_auto — RegisteredHooks(), RegisteredHookDetails(), CallHook(), discoverHooks() lack doc comments. Every other exported method on WASMRuntime has one. The old RegisteredHooks comment was removed but not replaced. RegisteredHookDetails hardcodes Priority 50 with no comment explaining the ABI limitation.
#2 P2 safe_auto — hookNames not cleared in Close(). If LoadModule is called after a prior successful load, Close() runs but hookNames survives. If the second LoadModule fails before discoverHooks (e.g., at CompileModule), RegisteredHooks() returns stale names from the defunct module.
#3 P2 safe_auto — discoverHooks has two silent failure paths: (a) len(results) < 2 returns nil silently — callStringFilter errors on wrong result count, but discoverHooks doesn't. (b) ptr == 0 && length == 0 treated as "no hooks" but (0,0) is the ABI error convention. A hooks() that fails internally returns (0,0) per convention, but discoverHooks doesn't check last_error(). The module's hooks silently disappear.
#4 P2 safe_auto — No validation of hook names from WASM module. discoverHooks accepts any strings from the JSON array. Empty strings would be registered and routed through the hook registry. A filter for empty strings would be defensive.
#5 P2 advisory — Cross-runtime CallHook contract: QuickJS returns (payload, nil) for unregistered hooks, WASM errors via (0,0). The spec test at line 731 codifies the error behavior. The registry gates dispatch, so this only matters for direct callers. Intentional but undocumented.
#6 P2 advisory, pre-existing — Bump allocator exhaustion: monotonic alloc never resets, accumulates across per-page hook/filter calls. ~100 pages with moderate HTML can exhaust 64KB linear memory.
#7 P2 advisory, pre-existing — RunWithTimeout abandoned goroutines can race with subsequent WASM calls on the same bump allocator. Pre-existing — applies to all WASM operations.
#8 P3 safe_auto — CallHook returns "wasm module not loaded" while CallExport returns "wasm module not loaded — call LoadModule first". Minor inconsistency.
#9 P3 advisory — WASM hooks lack scope/priority metadata in ABI. hooks() returns only names. Hardcoded priority 50, no scope filtering. By design for ABI simplicity.
#10 P3 advisory — JSON envelope ABI: CallHook wraps input in {"event", "payload"} but doesn't unwrap output. compiled.wat echoes the full envelope. Real plugins must return only the processed payload. Contract should be documented.
#11 P3 advisory, pre-existing — ExportedFunction("alloc"), Memory(), ExportedFunction("hook") looked up per call. wazero returns O(1) from internal map, so minimal overhead. Caching would be consistent with existing exports caching.
Testing Gaps (advisory)
- CallHook happy-path test asserts only
NotNil— doesn't verify result content r.mod == nilpath in CallHook has no test- Module without
hook()export path has no test discoverHookshooksFn==nilbranch not tested via module loaddiscoverHooksdefensive branches (arity,(0,0), mem==nil, Read fail) all untested- No concurrent access test, no nil payload test, no round-trip payload structure test
Residual Risks
- WASM bump allocator never resets — alloc calls accumulate. Pre-existing.
- No timeout on WASM calls —
context.Background()with no deadline throughout. Pre-existing. - No mutex on
WASMRuntime— concurrent access would racehookNames,mod,exports.
Recalibration Notes
- 2 adversarial P1s recalibrated: ADV-001 (cross-runtime contract → P2, spec-intentional), ADV-002 (timeout race → P2 pre-existing)
- 1 adversarial finding dismissed: ADV-003 (mem.Read alias → conf 30, self-refuted)
- 2 maintainability P1s recalibrated: MAINT-001 (merged with ADV-001 → P2), MAINT-002 (doc comments → P2)
- 5 testing findings demoted to testing gaps (implementation PR, spec tests immutable)
- 4 cross-reviewer merges (hookNames/Close, CallHook contract, discoverHooks silent failures, JSON envelope)
Run artifact: 20260511-c5d73f28
- Add doc comments to RegisteredHooks, RegisteredHookDetails, CallHook, discoverHooks - Clear hookNames in Close() and at LoadModule start to prevent stale state - Check last_error() on (0,0) return from hooks() per ABI error convention - Error on hooks() returning fewer than 2 values instead of silent ignore - Filter empty hook names and deduplicate - Validate hook() export exists when hooks() declares hook names - Consistent "call LoadModule first" error message in CallHook Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Response to Review #1All Copilot threads resolved. Fixes pushed in f0003ae. Fixed (safe_auto)#1 Missing doc comments — Added doc comments to #2 Stale hookNames on Close/reload — #3 Silent failure paths in discoverHooks — Two changes: (a) #4 Empty hook name validation — #8 Inconsistent error message — Additionally fixed (from Copilot threads)Validate hook() export — Acknowledged (advisory, no action)#5 Cross-runtime CallHook contract — Intentional. WASM delegates unknown events to the module (which returns #6-7 Bump allocator exhaustion / RunWithTimeout races — Pre-existing, applies to all WASM operations. #9 No scope/priority in WASM ABI — By design for ABI simplicity. Documented in #10 JSON envelope contract — The envelope wrapping is the host-side contract. WASM modules receive #11 Per-call function lookups — wazero's Testing gapsAcknowledged. These are advisory — spec tests are immutable and the implementation tests cover the contract. |
Summary
hooks()export discovery duringLoadModule— parses JSON array of hook names, validates format, fails loud on invalid dataCallHook(name, payload)— marshals JSON envelope{"event":"<name>","payload":<data>}, dispatches via existingcallStringFilterABI, parses JSON responseRegisteredHookDetails()(HookDetailerinterface) — returnsHookRegistrationwith default priority 50 for each discovered hook"hooks"towasmRuntimeExportsreserved set so hook discovery export isn't treated as a filtercompiled.wasmwithhooks()/hook()exports (WAT source included)bad-hooks-export.wasmfixture (invalid JSON fromhooks()) andwasm-malformed-hook-response.wasmfixture (non-JSON fromhook())Closes #444
Test plan
compiled.wasm🤖 Generated with Claude Code