feat(plugins/pool): WASM instance pool with checkout/return — closes #9#357
Merged
Conversation
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>
11 tasks
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>
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
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) -> Leaseblocks up to the caller's deadline;Lease.Return()recycles or restocks;Lease.MarkUnusable()retires a trapped instance.MaxIdleTimeand refills back toMinInstances.MaxUsesPerInstancerotates aging instances to prevent linear-memory bloat.checkout_total,checkout_errors_total,checkout_wait_seconds,recycle_total{reason=trap|max_uses|idle|close},pool_size,in_use— all undergonext_plugin_pool_*.Runtime/Moduleshapes without modifying them.Test plan
go vet ./plugins/runtime/pool/...cleango test -race -count=1 ./plugins/runtime/pool/...green (full suite ~6 s)MaxInstances; (Max+1)th blocks until Returnctx.Deadlinetimeout while blocked — wrappedErrCheckoutTimeout+context.DeadlineExceeded, no leakMaxUsesPerInstancerotates the underlying module pointer after N usesMaxIdleTimereaper evicts idle and refills toMinInstancesPool.Closedrains outstanding leases; honors ctx deadline; second Close is no-op-race— zero races, zero leaksCloses #9.
🤖 Generated with Claude Code