Skip to content

feat(plugin): WASM hook registration and execution (#444)#570

Merged
zeroedin merged 2 commits into
mainfrom
feat/issue-444-wasm-hooks
May 12, 2026
Merged

feat(plugin): WASM hook registration and execution (#444)#570
zeroedin merged 2 commits into
mainfrom
feat/issue-444-wasm-hooks

Conversation

@zeroedin

Copy link
Copy Markdown
Owner

Summary

  • Add hooks() export discovery during LoadModule — parses JSON array of hook names, validates format, fails loud on invalid data
  • Implement CallHook(name, payload) — marshals JSON envelope {"event":"<name>","payload":<data>}, dispatches via existing callStringFilter ABI, parses JSON response
  • Implement RegisteredHookDetails() (HookDetailer interface) — returns HookRegistration with default priority 50 for each discovered hook
  • Add "hooks" to wasmRuntimeExports reserved set so hook discovery export isn't treated as a filter
  • Rebuild compiled.wasm with hooks()/hook() exports (WAT source included)
  • Add bad-hooks-export.wasm fixture (invalid JSON from hooks()) and wasm-malformed-hook-response.wasm fixture (non-JSON from hook())

Closes #444

Test plan

🤖 Generated with Claude Code

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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 during LoadModule() and expose them via RegisteredHooks() / 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.

Comment thread internal/plugin/wasm.go
Comment thread internal/plugin/wasm.go
Comment thread internal/plugin/wasm.go
Comment thread internal/plugin/wasm.go

@zeroedin zeroedin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — PR #570 (Review #1)

9/9 reviewers returned | Verdict: Ready to merge

Findings

#1 P2 safe_autoRegisteredHooks(), 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_autohookNames 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_autodiscoverHooks 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-existingRunWithTimeout abandoned goroutines can race with subsequent WASM calls on the same bump allocator. Pre-existing — applies to all WASM operations.

#8 P3 safe_autoCallHook 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-existingExportedFunction("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)

  1. CallHook happy-path test asserts only NotNil — doesn't verify result content
  2. r.mod == nil path in CallHook has no test
  3. Module without hook() export path has no test
  4. discoverHooks hooksFn==nil branch not tested via module load
  5. discoverHooks defensive branches (arity, (0,0), mem==nil, Read fail) all untested
  6. No concurrent access test, no nil payload test, no round-trip payload structure test

Residual Risks

  1. WASM bump allocator never resets — alloc calls accumulate. Pre-existing.
  2. No timeout on WASM calls — context.Background() with no deadline throughout. Pre-existing.
  3. No mutex on WASMRuntime — concurrent access would race hookNames, 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>
@zeroedin

Copy link
Copy Markdown
Owner Author

Response to Review #1

All Copilot threads resolved. Fixes pushed in f0003ae.

Fixed (safe_auto)

#1 Missing doc comments — Added doc comments to RegisteredHooks, RegisteredHookDetails, CallHook, and discoverHooks. RegisteredHookDetails comment now documents the ABI limitation for priority 50.

#2 Stale hookNames on Close/reloadhookNames cleared in both Close() and at the top of LoadModule().

#3 Silent failure paths in discoverHooks — Two changes: (a) len(results) < 2 now returns an error instead of silent nil. (b) (0,0) return now checks last_error() per the ABI error convention before treating as "no hooks."

#4 Empty hook name validationdiscoverHooks now filters empty strings and deduplicates via seen-set.

#8 Inconsistent error messageCallHook now returns "wasm module not loaded — call LoadModule first" matching CallExport.

Additionally fixed (from Copilot threads)

Validate hook() exportdiscoverHooks now errors when hooks() declares hook names but the module has no hook() export. Prevents registering hooks that would always fail at execution time.

Acknowledged (advisory, no action)

#5 Cross-runtime CallHook contract — Intentional. WASM delegates unknown events to the module (which returns (0,0) per ABI), QuickJS returns passthrough. The registry gates dispatch so this only matters for direct callers. Spec test at line 731 codifies the WASM behavior.

#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 RegisteredHookDetails comment.

#10 JSON envelope contract — The envelope wrapping is the host-side contract. WASM modules receive {"event":"<name>","payload":<data>} and return the processed payload. The echo behavior in compiled.wat is a test fixture simplification.

#11 Per-call function lookups — wazero's ExportedFunction is O(1) map lookup. Caching would be consistent but negligible gain.

Testing gaps

Acknowledged. These are advisory — spec tests are immutable and the implementation tests cover the contract.

@zeroedin
zeroedin merged commit 6f0aea3 into main May 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec(plugin): WASM hook registration and execution tests

2 participants