Skip to content

feat(plugins/pool): WASM instance pool with checkout/return — closes #9#357

Merged
tayebmokni merged 1 commit into
mainfrom
feat/9-plugin-instance-pool
May 17, 2026
Merged

feat(plugins/pool): WASM instance pool with checkout/return — closes #9#357
tayebmokni merged 1 commit into
mainfrom
feat/9-plugin-instance-pool

Conversation

@tayebmokni
Copy link
Copy Markdown
Contributor

Summary

  • Adds packages/go/plugins/runtime/pool/ — a fixed-bounded pool of pre-warmed wazero modules so per-request plugin calls (filter hooks, REST handlers) skip the 1–5 ms instantiation tax.
  • Pool.Checkout(ctx) -> Lease blocks up to the caller's deadline; Lease.Return() recycles or restocks; Lease.MarkUnusable() retires a trapped instance.
  • Reaper goroutine evicts idle instances past MaxIdleTime and refills back to MinInstances. MaxUsesPerInstance rotates aging instances to prevent linear-memory bloat.
  • Prometheus surface: checkout_total, checkout_errors_total, checkout_wait_seconds, recycle_total{reason=trap|max_uses|idle|close}, pool_size, in_use — all under gonext_plugin_pool_*.
  • Builds on PR feat(plugins/runtime): wazero-backed WASM runtime foundation (#6) #350's wazero Runtime / Module shapes without modifying them.

Test plan

  • go vet ./plugins/runtime/pool/... clean
  • go test -race -count=1 ./plugins/runtime/pool/... green (full suite ~6 s)
  • Checkout/Return cycle
  • Concurrent checkouts saturate at MaxInstances; (Max+1)th blocks until Return
  • ctx.Deadline timeout while blocked — wrapped ErrCheckoutTimeout + context.DeadlineExceeded, no leak
  • MaxUsesPerInstance rotates the underlying module pointer after N uses
  • MaxIdleTime reaper evicts idle and refills to MinInstances
  • Trap-flagged lease forces recycle on Return, never re-enters rotation
  • Pool.Close drains outstanding leases; honors ctx deadline; second Close is no-op
  • Race test: 100 goroutines × 1000 checkouts under -race — zero races, zero leaks

Closes #9.

🤖 Generated with Claude Code

Per-request plugin invocations (filter hooks, REST handlers) can't pay
the 1–5 ms cost of a fresh wazero instantiation. The pool keeps N
pre-warmed runtime.Module instances, hands them out via Checkout, and
recycles on Return.

Knobs:
  - MinInstances / MaxInstances bound the live count
  - MaxIdleTime drives reaper eviction; refill restores the floor
  - MaxUsesPerInstance rotates aging instances
  - Lease.MarkUnusable forces close on trap

Metrics: checkout_total, checkout_errors_total, checkout_wait_seconds,
recycle_total{reason}, pool_size, in_use — all under
gonext_plugin_pool_*.

Tested: 100 goroutines × 1000 checkouts under -race; ctx-deadline
no-leak, trap recycle, MaxUses rotation, idle eviction + refill,
Close drain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
@tayebmokni tayebmokni merged commit 0b28a31 into main May 17, 2026
8 checks passed
@tayebmokni tayebmokni deleted the feat/9-plugin-instance-pool branch May 17, 2026 21:10
tayebmokni added a commit that referenced this pull request May 18, 2026
…loses #245 (#371)

## Summary

Closes #245. Adds the `gonext plugin dev <project-dir>` subcommand for
plugin authors: auto-detect the toolchain, build to WASM, upload to a
running dev host, watch for changes, and hot-reload. Lives in
`cli/gonext/cmd/plugin/`.

Foundation merged so the plugin platform now has: wazero runtime (#350),
manifest validator (#345), capability registry (#344), instance pool
(#357), resource limits (#356), hook ABI (#364), lifecycle (#339). This
PR is the productive author-side dev loop on top of all that.

### Flags

```
gonext plugin dev [flags] <project-dir>

  --host         URL of the running gonext dev host (default http://localhost:8080)
  --watch        Watch the project tree and hot-reload on change (default true)
  --build-only   Build only; skip upload and watch
  --lang         auto | go | tinygo | rust (default auto)
```

### Architecture

The orchestrator (`runDevLoop`) runs four phases — detect → build →
upload → watch — and every I/O dependency funnels through an injectable
seam:

| Seam | Production | Test |
|---|---|---|
| `CommandRunner` | `execRunner` (exec.CommandContext) | `fakeRunner`
recording argv + optional Hook to plant artifacts |
| `Uploader` | `httpUploader` (multipart POST) | `stubUploader` counter
/ `httptest.Server` |
| `Watcher` (factory) | `fsnotifyWatcher` (recursive Add) |
`fakeWatcher` (buffered chans) |
| `Now func()` | `time.Now` | frozen clock |

That lets the watch+rebuild loop be unit-tested end-to-end without
forking, real filesystem races, or network round-trips. 64 test cases
across six files.

### Per-language build commands

| Language | Detection | Command | Output |
|---|---|---|---|
| TinyGo | `go.mod` present | `tinygo build -o build/plugin.wasm
-target=wasi .` | `build/plugin.wasm` |
| Rust | `Cargo.toml` present | `cargo build --target wasm32-wasi
--release` | Copied from `target/wasm32-wasi/release/*.wasm` to
`build/plugin.wasm` |

Both markers present produces an ambiguity error rather than a silent
pick. The Rust path errors if cargo produces more than one `.wasm` (asks
the author to set `[lib].name`).

### Watch loop

- `fsnotify` recursively registers the project tree at startup, skipping
`build/`, `target/`, `node_modules/`, `.git/`, `.idea/`, `.vscode/`,
`vendor/`, `dist/`, `__pycache__/`.
- Generic `debounce[T any]` with a 200ms trailing-edge window collapses
bursty inotify events (single save → single rebuild). Tested with
20–30ms windows and synthetic event sources.
- Editor tempfiles (`*~`, `.#*`) and our own `build/` writes are
filtered so the loop doesn't feed itself.
- First-pass build/upload errors propagate out (non-zero exit). Errors
during watch are printed but the loop keeps running — the operator's
next save is probably the fix.
- SIGINT / SIGTERM → graceful exit 0.

### Capability diff

After each successful build the manifest's `capabilities` list is
compared against the previous snapshot:

```
[19:11:29] detected language: tinygo
[19:11:29] build: tinygo
[19:11:29] upload: http://localhost:8080
capabilities:
  = http.fetch
  = db.read
[19:11:30] uploaded successfully
[19:11:30] watching /path/to/plugin (Ctrl-C to stop)
[19:11:42] change detected — rebuilding
capabilities changed:
  + media.write
  - db.read
```

This is what gives the operator a clear before/after view between hot
reloads — the capability set is what the host is being asked to grant.

## TODO — host-side endpoint follow-up

This CLI uploads to `POST ${host}/_/plugins/dev/install` but the
host-side endpoint is **not in this PR**. The contract this CLI assumes:

- Path: `/_/plugins/dev/install`
- Method: `POST`
- Content-Type: `multipart/form-data`
- Parts:
- `manifest` — file part, `application/json`, the `manifest.json` bytes
  - `wasm` — file part, `application/wasm`, the compiled module
- Success: any 2xx (body ignored)
- Failure: any non-2xx; body's first 1KiB is included verbatim in the
CLI's error message

The natural host-side implementation calls the existing lifecycle
Manager's `Install(ctx, bundle io.Reader)` — that function already
validates the manifest and handles slug/version/abi extraction. The dev
install endpoint would also need to bypass signature verification
(dev-only) and idempotently re-activate on every upload. Followup issue
suggested: \"feat(server): host-side dev plugin install endpoint\".

## Test plan

- [x] `cd cli/gonext && go vet ./...` — clean
- [x] `cd cli/gonext && go test -race -count=1 ./cmd/plugin/...` — 64
cases pass under `-race`
- [x] `cd cli/gonext && go test -race -count=1 ./...` — entire CLI
module green
- [x] `cd cli/gonext && go build ./...` — builds
- [x] Language detector: tinygo via `go.mod`, rust via `Cargo.toml`,
ambiguous error when both present, missing-marker error, dir-not-file
rejected
- [x] Build orchestrator: TinyGo argv = `[build -o
<abs>/build/plugin.wasm -target=wasi .]`, Rust artifact normalisation,
multiple `.wasm` error path, runner failure propagates
- [x] Multipart upload: httptest server roundtrip verifies path,
Content-Type, both parts, byte fidelity; host trailing-slash + subpath
join correctly; 4xx body preview surfaces; missing wasm errors; relative
host rejected
- [x] Debounce: 5-event burst collapses to 1 emission; two bursts 60ms
apart fire twice; ctx-cancel closes out-channel; input-close closes
out-channel
- [x] Capability diff: first build prints `=` list, no-change emits
nothing, add/remove deltas use `+`/`-`
- [x] Dispatcher: `--build-only` skips upload + watch, watch+rebuild on
event, watch keeps running through build error and recovers on next
event, usage/help/missing-arg/extra-arg/not-a-directory exit codes
- [ ] Manual smoke against a running dev host — gated on the host-side
endpoint follow-up

🤖 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.

Plugin host: per-plugin WASM instance pool with TTL + memory-pressure recycling

2 participants