feat(hooks): action + filter bus (WordPress-style) — closes #178#318
Merged
Conversation
Adds packages/go/hooks: the WordPress-style hook kernel that backs the
plugin system per docs/02-plugin-system.md §5. Two registration modes —
sync RegisterAction/RegisterFilter and non-blocking RegisterAsync — share
a single Bus whose readers (Do, ApplyFilters) are lock-free: per-name
handler chains live behind atomic.Pointer with copy-on-write mutation.
Semantics:
- Priority orders dispatch (lower runs first); ties preserve
registration order via a monotonic regSeq stable-sort key.
- Filters short-circuit via ErrShortCircuit sentinel; the chain
surface returns the handler's value with a nil error.
- Panics in handlers are recovered, logged at ERROR via slog, and
surfaced as *panicError. Action chains continue past a panic
(one bad listener should not silently drop the rest); filter
chains stop (no good value to continue threading).
- Reentrance: handlers may Register/Unregister mid-dispatch. New
handlers participate on the NEXT dispatch, not the in-flight one.
Metrics hooks are exposed via a small Sink interface (Counter,
Histogram) so packages/go/metrics can plug in later without inverting
the dependency. Default sink is no-op.
Tests: 100% statement coverage with -race -count=1; covers action +
filter happy path, priority ordering with ties, short-circuit (direct
and errors.Is-wrapped), aggregated errors, async non-blocking,
unsubscribe idempotence, self-unsubscribe mid-dispatch, reentrant
registration, concurrent register-while-firing, panic recovery for
both kinds, context propagation, and metrics sink wiring.
Signed-off-by: Claude (for tayebmokni) <tayeb.mokni@gmail.com>
13 tasks
tayebmokni
added a commit
that referenced
this pull request
May 17, 2026
## Summary Bridges the host-side action+filter bus (`packages/go/hooks`, PR #318) to plugin WebAssembly modules loaded by the wazero runtime (`packages/go/plugins/runtime`, PR #350). At activation time the lifecycle Manager walks the manifest's `hooks` declarations; for each declared hook the bridge installs a callback on the host bus that proxies through to the plugin's `gn_handle_hook` export. Closes #95. ## The ABI Every plugin exports: ``` (func (export "gn_handle_hook") (param $name_ptr i32) (param $name_len i32) (param $payload_ptr i32) (param $payload_len i32) (result i64)) (func (export "gn_alloc") (param i32) (result i32)) ;; bump or arena allocator (func (export "gn_free") (param i32 i32)) ;; counterpart ``` The packed `i64` return is `ptr<<32 | len`. Special cases: - `(0, 0)` — success, no body (action return) - `(0, negative)` — typed `ResultStatus`: `error` / `out_of_memory` / `bad_payload` / `unknown_hook` - `(ptr, len>0)` — success with body at `[ptr, ptr+len)` in the guest's exported memory; the host copies and then calls `gn_free` Payloads are JSON envelopes (`{"kind":"action","args":[…]}` or `{"kind":"filter","value":…,"args":[…]}`); filter results are `{"value":…}`. ## What's here - `abi.go` — contract documentation, `ResultStatus` catalog, `HookError` type with `errors.Is` matrix across sentinels - `marshal.go` — JSON envelopes for action and filter payloads - `dispatcher.go` — `Dispatcher` drives the ABI on a wazero `Module` (`InvokeAction` / `InvokeFilter`); caches export lookups; surfaces traps via `ResultStatusTrap` - `registry.go` — `Bridge` walks `manifest.Hooks`, installs proxy callbacks on the host bus; `Unregister` detaches them all - `wat/the_content.wat` — fixture plugin: `the_content` filter uppercases its input; `on_event` action returns OK-no-body; `trip_panic` action calls `gn_panic` for trap testing. Includes a bump allocator and a `force_oom()` test helper ## What's NOT here (separate issues) - Lifecycle wiring (Manager constructing the Bridge per activation) — the lifecycle Manager doesn't yet pull in this package; that wiring belongs to the activation flow refactor - Per-hook priority hints from the manifest — placeholder `PriorityFunc` hook in `NewBridge` until the manifest schema gains a `priority` field - ABI v2 (e.g. msgpack for hot-path hooks) — versioned via sibling package per the contract notes ## Test plan - [x] `go vet ./plugins/abi/hooks/...` clean - [x] `go test -race -count=1 ./plugins/abi/hooks/...` — 17 tests pass - [x] Filter happy path: `"hello"` → `"HELLO"` through `the_content` - [x] Action with no payload (empty action) — guest accepts empty args - [x] Action with args (string, int, map) marshals and dispatches - [x] Unknown hook → `ErrUnknownHook` (typed status) - [x] Plugin traps → `ErrTrapped`, underlying `*TrapError` unwrappable - [x] Guest OOM (bump pointer past memory end) → `ErrOutOfMemory` - [x] Host payload cap (>1 MiB) → `ErrPayloadTooLarge` before any guest call - [x] 100 concurrent invocations on the same Module: all correct, race detector clean - [x] `Bridge.Register` walks manifest, `bus.Do` and `bus.ApplyFilters` reach the plugin, `Bridge.Unregister` detaches cleanly - [x] Missing ABI exports surface as `ErrMissingExport` - [x] `HookError` `errors.Is` against `ErrOutOfMemory`, `ErrBadPayload`, `ErrUnknownHook`, `ErrGuestError`, `ErrTrapped` ## Notes for reviewers The fixture `.wasm` is committed as a generated Go slice (`fixturedata_test.go`) because the repo `.gitignore` excludes `*.wasm`. The `.wat` source plus a Python encoder (`wat/encode.py`) and Makefile let reviewers with `wat2wasm` regenerate in one command: `make -C wat`. The filename dodges two Go-tool quirks: - anything with a `testdata` prefix is ignored as test data - anything matching `*_wasm.go` or `*_wasm_test.go` is interpreted as `GOOS=wasm`-only `fixturedata_test.go` avoids both. There's a comment in `abi_test.go` explaining this for future archaeologists. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com> Co-authored-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the in-process hook bus kernel that backs the plugin system per
docs/02-plugin-system.md§5.BuswithRegisterAction/RegisterAsync/RegisterFilter, each returning an idempotent unsubscribe closure.Do(ctx, name, args...)dispatches an action; runs every handler in priority order (lower first; ties keep registration order); aggregates errors viaerrors.Join; recovers from panics so one bad listener doesn't drop the rest.ApplyFilters(ctx, name, value, args...)threads a value through the chain. A handler may stop early by returningErrShortCircuit; non-short-circuit errors return the last accepted value with the error.RegisterAsyncreturns immediately; the handler runs in its own goroutine and its error is logged via slog rather than propagated.atomic.Pointerwith copy-on-write mutation under a per-slot mutex. Registers are amortized at startup; dispatch is hot-path.Sinkinterface (Counter,Histogram) — default no-op, sopackages/go/metricscan plug in later without inverting the dependency.No new external dependencies — stdlib only (
sync,sync/atomic,log/slog,context,errors,sort,time).Test plan
go build ./hooks/...go vet ./hooks/...go test -race -count=1 -cover ./hooks/...— 100.0% statement coverageErrShortCircuit(direct andfmt.Errorf("...: %w", ErrShortCircuit))errors.JoinWait()synchronizesDo's caller*panicError, chain continues*panicError, chain STOPS, last accepted value returnedCloses
Closes #178
DCO
Signed-off-by: Claude (for tayebmokni) tayeb.mokni@gmail.com