Skip to content

feat(hooks): action + filter bus (WordPress-style) — closes #178#318

Merged
tayebmokni merged 1 commit into
mainfrom
feat/178-hook-bus
May 17, 2026
Merged

feat(hooks): action + filter bus (WordPress-style) — closes #178#318
tayebmokni merged 1 commit into
mainfrom
feat/178-hook-bus

Conversation

@tayebmokni
Copy link
Copy Markdown
Contributor

Summary

Implements the in-process hook bus kernel that backs the plugin system per docs/02-plugin-system.md §5.

  • Bus with RegisterAction / 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 via errors.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 returning ErrShortCircuit; non-short-circuit errors return the last accepted value with the error.
  • RegisterAsync returns immediately; the handler runs in its own goroutine and its error is logged via slog rather than propagated.
  • Lock-free reads: per-name handler chains live behind atomic.Pointer with copy-on-write mutation under a per-slot mutex. Registers are amortized at startup; dispatch is hot-path.
  • Metrics hooks via a small Sink interface (Counter, Histogram) — default no-op, so packages/go/metrics can plug in later without inverting the dependency.
  • Reentrance-safe: handlers may register/unregister hooks mid-dispatch. New handlers join the next dispatch, not the in-flight one.

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 coverage
  • Action: register + fire + handler runs
  • Multiple handlers in priority order (with tie-break by registration order)
  • Filter chain transforms value through every handler
  • Filter short-circuit via ErrShortCircuit (direct and fmt.Errorf("...: %w", ErrShortCircuit))
  • Action error aggregation: all handlers run, errors join via errors.Join
  • Async action returns immediately; handler runs in goroutine; Wait() synchronizes
  • Async error is logged via slog, not returned to Do's caller
  • Panic in sync action: logged, surfaced as *panicError, chain continues
  • Panic in filter: logged, surfaced as *panicError, chain STOPS, last accepted value returned
  • Panic in async action: logged, never crashes the bus
  • Unsubscribe is idempotent (double-call is a no-op)
  • Self-unsubscribe mid-dispatch is safe; later handlers still run; next dispatch sees the change
  • Reentrant registration from inside a handler joins on the NEXT dispatch
  • Concurrent register + fire + unregister: race detector clean
  • Context propagation through both action and filter
  • Metrics sink receives dispatch, error, panic, short-circuit, and duration callbacks

Closes

Closes #178

DCO

Signed-off-by: Claude (for tayebmokni) tayeb.mokni@gmail.com

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>
@tayebmokni tayebmokni merged commit 3cdfb8d into main May 17, 2026
6 of 8 checks passed
@tayebmokni tayebmokni deleted the feat/178-hook-bus branch May 17, 2026 12:17
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>
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.

Hook bus kernel: actions + filters with priority ordering

2 participants