{
"project": "RivetKit Wasm Support Review Fixes",
"branchName": "05-02-fix_rivetkit-wasm_fix_mem_leaks",
"description": "Address correctness, durability, parity, and build robustness issues identified in code review of PR #4860 (chore(rivetkit): wasm support). Each story targets one concrete defect or divergence between the wasm and NAPI runtimes so the wasm path matches the NAPI contract that the rest of rivetkit-core relies on.",
"userStories": [
{
"id": "US-001",
"title": "Make WasmActorContext.requestSaveAndWait actually wait",
"description": "As an actor author running on wasm, I want `ctx.requestSaveAndWait({ immediate: true })` to resolve only after the save has completed so durability promises hold on Cloudflare Workers and Supabase Functions.",
"acceptanceCriteria": [
"`WasmActorContext::request_save_and_wait` in `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs` calls `self.inner.request_save_and_wait(opts).await` instead of `self.inner.request_save(opts)`",
"Errors from core `request_save_and_wait` propagate to JS via `anyhow_to_js_error` so the bridged RivetError is preserved",
"NAPI and wasm both expose the same `requestSaveAndWait` semantics (resolves after save completes, rejects on save failure)",
"Add or extend a wasm host smoke test in `rivetkit-typescript/packages/rivetkit/tests/wasm-host-smoke.test.ts` that verifies `requestSaveAndWait` does not resolve before the save completes",
"Typecheck passes",
"Tests pass"
],
"priority": 1,
"passes": true,
"notes": ""
},
{
"id": "US-002",
"title": "Cache bridged RivetErrorSchema to stop per-error memory leak",
"description": "As an operator running a long-lived wasm worker, I want bridged JS error decoding to stop leaking unbounded `String`s and `RivetErrorSchema` boxes so memory does not grow with bridged-error volume.",
"acceptanceCriteria": [
"`bridge_rivet_error_schema` in `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs` deduplicates schema entries by `(group, code, default_message)` so each unique tuple is leaked at most once for the process lifetime",
"Use a process-global cache (for example `OnceCell<scc::HashMap<...>>` or equivalent allowed concurrent map) instead of `Box::leak` on every call",
"`parse_bridge_rivet_error` continues to return an `anyhow::Error` carrying a `RivetTransportError` plus `BridgeRivetErrorContext`",
"Add a wasm crate test that decodes the same bridge error payload many times and asserts only one schema instance is interned",
"Typecheck passes",
"Tests pass"
],
"priority": 2,
"passes": true,
"notes": ""
},
{
"id": "US-003",
"title": "Reject engine_binary_path on wasm runtimes",
"description": "As a wasm user, I want a clear error if I pass `engine_binary_path` so I do not get an obscure failure deep in core when wasm cannot spawn an engine binary anyway.",
"acceptanceCriteria": [
"`From<WasmServeConfig> for ServeConfig` in `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs` returns or surfaces a typed error when `engine_binary_path` is set",
"Or the wasm `serve` and `serverless_runtime` entrypoints validate `engine_binary_path.is_none()` up front and return a `RivetError` with group `wasm` and an actionable message",
"Error metadata includes the rejected field name so users can find it",
"Add a wasm crate test that calls `serve` with `engine_binary_path: Some(...)` and expects the typed configuration error",
"Indentation inside the `From<WasmServeConfig> for ServeConfig` struct literal is corrected to single tabs",
"Typecheck passes",
"Tests pass"
],
"priority": 3,
"passes": true,
"notes": ""
},
{
"id": "US-004",
"title": "Wire wasm registerTask to core register_task",
"description": "As an actor on wasm, I want `ctx.registerTask(promise)` to behave the same as on NAPI so sleep counters and shutdown semantics stay consistent between runtimes.",
"acceptanceCriteria": [
"`WasmActorContext::register_task` in `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs` calls the dedicated `register_task` path on `rivetkit_core::ActorContext` instead of forwarding to `wait_until`",
"If core does not yet expose a wasm-friendly `register_task`, expose one in `rivetkit-rust/packages/rivetkit-core/src/actor/context.rs` behind the existing `wasm-runtime` feature flag",
"Sleep counter and shutdown drain semantics for `registerTask` match between NAPI and wasm",
"Add a wasm host smoke test that exercises `registerTask` and asserts the registered task is awaited during shutdown drain",
"Typecheck passes",
"Tests pass"
],
"priority": 4,
"passes": true,
"notes": ""
},
{
"id": "US-005",
"title": "Move bridge error status promotion into rivetkit-core",
"description": "As a maintainer, I want HTTP status promotion for known RivetError codes to live in core so NAPI and wasm return identical statusCodes for the same underlying error, per the single-source-of-truth rule in CLAUDE.md.",
"acceptanceCriteria": [
"Identify the canonical promotion list currently in `promoteKnownBridgeError` in `rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts`",
"Move the `(group, code) -> statusCode` mapping into `rivetkit-rust/packages/rivetkit-core/src/error.rs` or the existing RivetError build path so core sets `statusCode` correctly on extract",
"Remove `promoteKnownBridgeError` from `wasm-runtime.ts` (and any `callWasm` wrapping that exists only to call it)",
"Add a unit test (Rust or TS) that decodes a bridged `auth.forbidden` and asserts statusCode 403 from both NAPI and wasm paths",
"Typecheck passes",
"Tests pass"
],
"priority": 5,
"passes": true,
"notes": ""
},
{
"id": "US-006",
"title": "Pin wasm-pack instead of fetching via npx -y",
"description": "As a release engineer, I want the wasm publish job to not depend on a live npm registry fetch on every build so a flaky network does not block publishes.",
"acceptanceCriteria": [
"`rivetkit-typescript/packages/rivetkit-wasm/scripts/build.mjs` no longer relies on `npx -y wasm-pack`",
"Add `wasm-pack` as a pinned `devDependency` in `rivetkit-typescript/packages/rivetkit-wasm/package.json` or invoke a vendored binary",
"Build script resolves the pinned `wasm-pack` binary path locally and falls back with an explicit error message if missing, rather than silently re-fetching",
"`pnpm --filter @rivetkit/rivetkit-wasm build` succeeds offline once dependencies are installed",
"`pnpm --filter @rivetkit/rivetkit-wasm run check:package` still passes",
"Typecheck passes"
],
"priority": 6,
"passes": true,
"notes": ""
},
{
"id": "US-007",
"title": "Add wasm-runtime parity tests for save, registerTask, status promotion",
"description": "As a maintainer, I want a parity test that runs the same actor scenarios under NAPI and wasm so future regressions in either adapter are caught.",
"acceptanceCriteria": [
"Add a parity test file under `rivetkit-typescript/packages/rivetkit/tests/` that runs the same scenario through `NapiCoreRuntime` and `WasmCoreRuntime` (using the existing wasm host smoke harness)",
"Cover: durable save via `requestSaveAndWait`, `registerTask` shutdown drain, and HTTP statusCode promotion for at least `auth.forbidden`, `actor.action_not_found`, `actor.action_timed_out`",
"Each scenario asserts the same observable result on both runtimes",
"Tests skip gracefully if the wasm package artifact is absent rather than failing CI on environments without `wasm-pack`",
"Typecheck passes",
"Tests pass"
],
"priority": 7,
"passes": true,
"notes": ""
},
{
"id": "US-008",
"title": "Fix per-call schema leaks in napi_actor_events",
"description": "As an operator running a long-lived NAPI process, I want unknown bridged error decoding to stop allocating and leaking a fresh `RivetErrorSchema` per call so memory is bounded under high error volume.",
"acceptanceCriteria": [
"`action_not_found` in `rivetkit-typescript/packages/rivetkit-napi/src/napi_actor_events.rs` uses a `static`/`const` `RivetErrorSchema` instead of `Box::leak(Box::new(...))` since all fields are compile-time constants",
"`structured_timeout_schema` fallback path in the same file routes through the existing `BRIDGE_RIVET_ERROR_SCHEMAS` intern map (or an equivalent shared dedup map keyed by `(group, code)`) instead of leaking on every unknown timeout",
"Add a NAPI-side test that triggers `action_not_found` many times and asserts the schema pointer is reused (e.g., compare addresses)",
"Typecheck passes",
"Tests pass"
],
"priority": 8,
"passes": true,
"notes": ""
},
{
"id": "US-009",
"title": "Stop wasm websocket_callback_regions Vec from growing unboundedly",
"description": "As an actor author running on wasm with frequent websocket callbacks, I want region tracking to release slots when callbacks end so memory does not grow with callback churn for the actor lifetime.",
"acceptanceCriteria": [
"Replace `Rc<RefCell<Vec<Option<WebSocketCallbackRegion>>>>` in `WasmActorContext` (`rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs`) with a `HashMap<u32, WebSocketCallbackRegion>` (or equivalent map) keyed by region_id, mirroring the NAPI `BTreeMap` pattern in `actor_context.rs`",
"`end_websocket_callback` removes the entry rather than leaving a `None` slot",
"Region IDs are still monotonically increasing and never reused while in flight",
"Add a wasm crate test that calls begin/end websocket callbacks many times and asserts the underlying map is empty afterward",
"Typecheck passes",
"Tests pass"
],
"priority": 9,
"passes": true,
"notes": ""
},
{
"id": "US-010",
"title": "Investigate napi runtime_state mem::forget bounded leak",
"description": "As a maintainer, I want a path to drop the napi `runtime_state` JsObject ref via an Env-bearing thread so the documented per-actor-wake leak goes away.",
"acceptanceCriteria": [
"Investigate `reset_runtime_state` and `Drop for ActorContextShared` in `rivetkit-typescript/packages/rivetkit-napi/src/actor_context.rs:720-749`",
"Document or implement an Env-bearing drop path (for example post a TSF call on the main thread that drops the napi `Ref` with an Env in scope)",
"If implementation is deferred, write up the constraints in `docs-internal/engine/napi-bridge.md` so the rationale survives the comment",
"If implemented: add a NAPI test that creates and destroys many actors and asserts the JsObject ref count returns to zero after process tear-down",
"Typecheck passes",
"Tests pass"
],
"priority": 10,
"passes": true,
"notes": "Investigated and documented the Env-bearing cleanup path plus constraints in docs-internal/engine/napi-bridge.md. Implementation deferred until a NAPI integration test can verify TSF drain and reference counts."
},
{
"id": "US-011",
"title": "Drop message from wasm BRIDGE_RIVET_ERROR_SCHEMAS cache key",
"description": "As an operator running a long-lived wasm worker, I want bridged JS error decoding to intern at most one schema per `(group, code)` so callers whose error messages vary per call (timestamps, request IDs, dynamic context) cannot grow the schema map and `Box::leak` arena unboundedly. The current US-002 fix keys on `(group, code, message)`, which still leaks one schema per unique message and re-introduces the same class of leak the PR is trying to close. The NAPI-side `STRUCTURED_TIMEOUT_SCHEMAS` already keys only on `(&'static str, &'static str)` (`rivetkit-typescript/packages/rivetkit-napi/src/napi_actor_events.rs:96-98`); wasm must match.",
"acceptanceCriteria": [
"`BridgeRivetErrorSchemaKey` in `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs:2596` becomes `(String, String)` (or equivalent two-tuple) keyed only on `(group, code)`",
"`bridge_rivet_error_schema` (`lib.rs:2599-2618`) inserts the first-seen `default_message` for a given `(group, code)` and reuses that schema for subsequent decodes regardless of payload message",
"The actual per-error `message` field continues to flow through `RivetTransportError.message: Some(payload.message)` so callers still see the live message",
"Update `parse_bridge_rivet_error_reuses_interned_schema` to assert that varying only the message for the same `(group, code)` reuses the schema (and therefore does NOT increment `BRIDGE_RIVET_ERROR_SCHEMAS.len()`)",
"Add a second test (or extend the existing one) that varying `(group, code)` does still allocate a new schema",
"`pnpm --filter @rivetkit/rivetkit-wasm run check:package` passes; `cargo test -p rivetkit-wasm` passes",
"Typecheck passes",
"Tests pass"
],
"priority": 11,
"passes": true,
"notes": "Wasm bridged RivetError schemas now intern by `(group, code)` only while preserving each payload's live message on the transport error. Native `cargo test -p rivetkit-wasm` still hits the pre-existing host-target `wasm_bindgen_futures` compile issue; wasm-target cargo test compilation and package checks pass."
},
{
"id": "US-012",
"title": "Bound register_task shutdown drain against shutdown deadline",
"description": "As an operator, I want `ctx.registerTask(promise)` not to block actor shutdown indefinitely when the user-supplied JS promise never resolves. `WasmActorContext::register_task` (`rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs:1345-1356`) and the analogous NAPI path route through `ActorContext::register_task` -> `track_shutdown_task`, so a never-resolving promise hangs shutdown drain. This contradicts the new CLAUDE.md rule added in this PR (\"Spawned futures that capture JS callbacks or other heavy resources must have a guaranteed completion path (e.g. a `CancellationToken` whose clones are guaranteed to drop)\").",
"acceptanceCriteria": [
"`ActorContext::register_task` in `rivetkit-rust/packages/rivetkit-core/src/actor/context.rs:552-587` races the user future against `ctx.shutdown_deadline_token().cancelled()` (or equivalent) so registered tasks unblock shutdown when the grace deadline elapses",
"Cancellation cause is logged at `tracing::warn!` with `actor_id` and a stable `reason` field so operators can find hanging registered tasks",
"Behavior is identical for the `wasm-runtime` and native `cfg(not(feature = \"wasm-runtime\"))` variants of `register_task`",
"Wasm `WasmActorContext::register_task` does not need a separate cancel path because core handles it",
"Add a Rust integration test in `rivetkit-rust/packages/rivetkit-core/tests/` that registers a never-completing future, triggers shutdown, and asserts shutdown completes within a bounded deadline rather than hanging",
"Typecheck passes",
"Tests pass"
],
"priority": 12,
"passes": true,
"notes": "Core register_task now races registered futures against the shutdown deadline token for both native and wasm cfgs. A focused core test verifies a never-completing task drains after deadline cancellation and logs the stable reason."
},
{
"id": "US-013",
"title": "Avoid panic on websocket_callback_regions ID overflow",
"description": "As an operator running a long-lived wasm actor with frequent websocket callbacks, I do not want my actor to crash after ~4B handshakes because `next_websocket_callback_region_id` overflows `u32`. The current code (`rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs:1351-1355`) uses `checked_add(1).expect(\"websocket callback region id overflow\")`, which crashes the actor process instead of degrading.",
"acceptanceCriteria": [
"Either bump `next_websocket_callback_region_id` to `Cell<u64>` (and update the `begin_websocket_callback` return type plus any wasm-bindgen surface that exposes it) so overflow becomes operationally unreachable",
"Or implement wraparound that skips IDs already present in `websocket_callback_regions` so reuse is safe while regions are in flight",
"Either approach must keep IDs strictly monotonic for IDs currently in the map so no two live regions ever share an ID",
"Add a wasm crate test that exercises the wraparound (or large-counter) path to confirm no panic and no ID collision with live regions",
"If u32 is preserved, the test seeds `next_websocket_callback_region_id` near `u32::MAX` and verifies wraparound",
"Typecheck passes",
"Tests pass"
],
"priority": 13,
"passes": true,
"notes": "Wasm websocket callback region IDs now wrap across the u32 boundary, skip ID 0, and skip live region IDs instead of panicking. Added wasm-target regression coverage for wraparound with live regions."
},
{
"id": "US-014",
"title": "Document global cache delta in parse_bridge_rivet_error test",
"description": "As a future contributor adding tests under `rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs`, I want a comment on `parse_bridge_rivet_error_reuses_interned_schema` (`lib.rs:2715-2737`) explaining that its `BRIDGE_RIVET_ERROR_SCHEMAS.len()` delta assertions only hold because the test owns a unique `(group, code)` namespace (`wasm_schema_cache_test`/`same_payload`). Without the comment, a future test that shares the global cache namespace will silently flip this assertion under parallel `cargo test`.",
"acceptanceCriteria": [
"Add a one or two line comment immediately above `parse_bridge_rivet_error_reuses_interned_schema` (or above the `initial_count` capture) noting that the test relies on a dedicated `(group, code)` namespace so concurrent tests do not perturb the global `BRIDGE_RIVET_ERROR_SCHEMAS.len()` delta",
"Comment names the namespace strings (`wasm_schema_cache_test`, `same_payload`) so future contributors know what to avoid",
"If US-011 lands first and changes the cache key shape, update the comment to reflect the new key",
"Typecheck passes",
"Tests pass"
],
"priority": 14,
"passes": true,
"notes": "Added a comment documenting the dedicated `(wasm_schema_cache_test, same_payload)` cache namespace used by the global schema-count delta assertion."
}
]
}