diff --git a/.agent/notes/04-14-break-this-up-pr-plan.md b/.agent/notes/04-14-break-this-up-pr-plan.md new file mode 100644 index 0000000000..7187302a44 --- /dev/null +++ b/.agent/notes/04-14-break-this-up-pr-plan.md @@ -0,0 +1,161 @@ +# 04-14-break_this_up_wip_patches PR Split Plan + +Base branch: `04-14-chore_engine_publish_engine_bases_in_ci` + +## Proposed Stack + +1. `fix(api): subscribe before namespace workflow dispatch` + Files: + - `engine/packages/api-peer/src/namespaces.rs` + +2. `fix(guard): serialize gateway actor keys correctly` + Files: + - `engine/packages/guard/src/routing/pegboard_gateway/resolve_actor_query.rs` + +3. `fix(pegboard): persist and replay hibernating requests` + Files: + - `engine/packages/pegboard/src/ops/actor/hibernating_request/delete.rs` + - `engine/packages/pegboard/src/ops/actor/hibernating_request/list.rs` + - `engine/packages/pegboard/src/ops/actor/hibernating_request/upsert.rs` + - `engine/packages/pegboard/src/workflows/actor/runtime.rs` + - `engine/packages/pegboard/src/workflows/actor2/runtime.rs` + - `engine/packages/pegboard-gateway/src/keepalive_task.rs` + - `engine/packages/pegboard-gateway/src/lib.rs` + - `engine/packages/pegboard-gateway/src/shared_state.rs` + - `engine/packages/pegboard-gateway2/src/keepalive_task.rs` + - `engine/packages/pegboard-gateway2/src/lib.rs` + - `engine/packages/pegboard-gateway2/src/shared_state.rs` + - `engine/sdks/rust/envoy-client/src/connection.rs` + +4. `fix(rivetkit-native): expose full hibernation metadata to JS` + Files: + - `rivetkit-typescript/packages/rivetkit-native/index.d.ts` + - `rivetkit-typescript/packages/rivetkit-native/wrapper.js` + - `rivetkit-typescript/packages/rivetkit-native/src/bridge_actor.rs` + - `rivetkit-typescript/packages/rivetkit-native/src/envoy_handle.rs` + - `rivetkit-typescript/packages/rivetkit-native/src/lib.rs` + - `rivetkit-typescript/packages/rivetkit-native/src/types.rs` + +5. `fix(rivetkit): restore hibernatable sockets and hydrate serverless starts` + Scope: + - Keep only the hibernation and socket restore work from the engine driver. + - Exclude the separate native-db envoy / separate SQLite pool work. + Files: + - `rivetkit-typescript/packages/rivetkit/src/drivers/engine/actor-driver.ts` + Keep only: + - hibernating request hydration for serverless start payloads + - hibernatable socket binding and rebind logic + - dynamic runtime socket restore logic + - actor shutdown cleanup that is directly required by the socket restore path + - `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-conn-hibernation.ts` + - `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-sleep.ts` + - `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/gateway-routing.ts` + +6. `test(rivetkit): re-enable gateway URL and direct-registry coverage` + Files: + - `rivetkit-typescript/packages/rivetkit/src/test/mod.ts` + - `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/mod.ts` + - `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/utils.ts` + - `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-http-direct-registry.ts` + - `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-websocket-direct-registry.ts` + - `rivetkit-typescript/packages/rivetkit/tests/driver-engine.test.ts` + - `rivetkit-typescript/packages/rivetkit/tests/driver-engine-ping.test.ts` + - `rivetkit-typescript/packages/rivetkit/tests/driver-registry-variants.ts` + - `rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/warmup.ts` + - `rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actors/warmupActor.ts` + - `rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry-static.ts` + +7. `fix(test): stabilize lifecycle, sleep, queue, and run edge cases` + Files: + - `rivetkit-typescript/packages/rivetkit/src/actor/instance/queue.ts` + - `rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/access-control.ts` + - `rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/db-lifecycle.ts` + - `rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/destroy.ts` + - `rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/inline-client.ts` + - `rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/queue.ts` + - `rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/run.ts` + - `rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/sleep-db.ts` + - `rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/start-stop-race.ts` + - `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-handle.ts` + - `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-lifecycle.ts` + - `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-queue.ts` + - `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-run.ts` + - `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-sleep-db.ts` + - `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/lifecycle-hooks.ts` + +8. `fix(rivetkit): keep internal error exposure behavior consistent` + Files: + - `rivetkit-typescript/packages/rivetkit/src/actor/router-endpoints.ts` + - `rivetkit-typescript/packages/rivetkit/src/dynamic/isolate-runtime.ts` + +## Rewrite Instead Of Copying + +### Dynamic DB over the main transport + +There is a real follow-up PR hiding in the DB/runtime changes, but it should be rewritten instead of copied from this WIP branch. + +Keepable areas to revisit: +- `rivetkit-typescript/packages/rivetkit/src/db/config.ts` +- `rivetkit-typescript/packages/rivetkit/src/db/mod.ts` +- `rivetkit-typescript/packages/rivetkit/src/db/drizzle/mod.ts` +- `rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts` +- `rivetkit-typescript/packages/rivetkit/src/actor/driver.ts` +- `rivetkit-typescript/packages/rivetkit/src/dynamic/runtime-bridge.ts` +- `rivetkit-typescript/packages/rivetkit/src/dynamic/isolate-runtime.ts` +- `rivetkit-typescript/packages/rivetkit/dynamic-isolate-runtime/src/index.cts` + +Constraint: +- Do not create a separate SQLite pool. +- Do not create a separate native-db envoy connection. +- All SQLite KB traffic should go over the main endpoint and default SQLite pool. + +## Discard + +Discard these changes from the stack as currently implemented: + +### Separate native-db envoy / separate SQLite pool work + +Main files to exclude: +- `rivetkit-typescript/packages/rivetkit/src/drivers/engine/actor-driver.ts` + Exclude: + - `#nativeDatabaseEnvoyHandlePromise` + - `#getOrCreateNativeDatabaseEnvoyHandle` + - `forceDisconnectNativeDatabaseTransportForTests` + - any `poolName: \`${this.#config.envoy.poolName}-native-db\`` +- `rivetkit-typescript/packages/rivetkit/src/actor/driver.ts` + Exclude: + - `forceDisconnectNativeDatabaseTransportForTests` +- `rivetkit-typescript/packages/rivetkit/src/dynamic/runtime-bridge.ts` + Exclude: + - endpoint/namespace/token plumbing that exists only to create a second DB transport +- `rivetkit-typescript/packages/rivetkit/src/dynamic/isolate-runtime.ts` + Exclude: + - `rawDatabaseExecute` host bridge support as currently wired if it depends on a separate transport + - endpoint/namespace/token bootstrap plumbing for a second transport +- `rivetkit-typescript/packages/rivetkit/dynamic-isolate-runtime/src/index.cts` + Exclude: + - `startEnvoySync` native-db bootstrap path + - `getNativeSqliteConfig` + - `rawDatabaseExecute` path if it still opens a second transport +- `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db.ts` +- `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-stress.ts` +- `rivetkit-typescript/packages/rivetkit/tests/driver-engine.test.ts` + Exclude the test-only native-db force-disconnect endpoint. + +Keep in the stack instead of discarding: +- `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/mod.ts` +- `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/utils.ts` + Reason: + - their current WIP hunks are test-harness and gateway plumbing, not separate-pool DB transport work + +### Stray cleanup + +- `scripts/ralph/CODEX.md` + Reason: + - merge-marker garbage, unrelated to the stack + +### Probably keep out unless needed by the rewrite + +- `Cargo.toml` + Reason: + - the `sqlite-native` workspace-member add looks tied to the DB transport work and should not be dragged into the stack unless the rewritten main-transport DB PR actually needs it diff --git a/.agent/notes/driver-test-suite-priority-checklist.md b/.agent/notes/driver-test-suite-priority-checklist.md new file mode 100644 index 0000000000..f8d10f8951 --- /dev/null +++ b/.agent/notes/driver-test-suite-priority-checklist.md @@ -0,0 +1,163 @@ +# Driver Test Suite Priority Checklist + +## Scope + +- Only run the `registry (static)` + `encoding (bare)` coverage in `tests/driver-engine.test.ts`. +- Run from `rivetkit-typescript/packages/rivetkit`. +- Always pipe logs to `/tmp` and inspect the log instead of reading Vitest output inline. +- Work **one file at a time**. Do not run an entire priority bucket in one command. +- For each file: run **one targeted test first** to scope the issue down. After the file-specific fix looks good, run the **entire file suite**. +- `actor-db-stress.ts` is a special case. It runs once at the top level of `Driver Tests`, outside the `client type (...) > encoding (...)` nesting, so its filter should match the actual suite path. + +## Workflow + +### 1. Run One Test First + +Use this shape first, replacing `` with the file's top-level suite name and `` with one concrete test name from that file. + +```bash +cd /home/nathan/r4/rivetkit-typescript/packages/rivetkit +pnpm test driver-engine -t 'registry \(static\).*encoding \(bare\).*.*' >/tmp/driver-engine-one-test.log 2>&1 +grep -nE " FAIL |Error:|Caused by:|Test Files|Tests |Duration" /tmp/driver-engine-one-test.log | tail -n 200 +``` + +### 2. Then Run The Entire File + +Use this after the scoped test passes and the file-specific fix is in place. + +```bash +cd /home/nathan/r4/rivetkit-typescript/packages/rivetkit +pnpm test driver-engine -t 'registry \(static\).*encoding \(bare\).*' >/tmp/driver-engine-full-file.log 2>&1 +grep -nE " FAIL |Error:|Caused by:|Test Files|Tests |Duration" /tmp/driver-engine-full-file.log | tail -n 200 +``` + +### Examples + +Run one scoped SQLite test from `actor-db.ts`: + +```bash +cd /home/nathan/r4/rivetkit-typescript/packages/rivetkit +pnpm test driver-engine -t 'registry \(static\).*encoding \(bare\).*Actor Database \(raw\) Tests.*bootstraps schema on startup' >/tmp/driver-engine-one-test.log 2>&1 +grep -nE " FAIL |Error:|Caused by:|Test Files|Tests |Duration" /tmp/driver-engine-one-test.log | tail -n 200 +``` + +Run the full `actor-db.ts` suite after the fix: + +```bash +cd /home/nathan/r4/rivetkit-typescript/packages/rivetkit +pnpm test driver-engine -t 'registry \(static\).*encoding \(bare\).*Actor Database \(raw\) Tests|registry \(static\).*encoding \(bare\).*Actor Database \(drizzle\) Tests|registry \(static\).*encoding \(bare\).*Actor Database Lifecycle Cleanup Tests' >/tmp/driver-engine-full-file.log 2>&1 +grep -nE " FAIL |Error:|Caused by:|Test Files|Tests |Duration" /tmp/driver-engine-full-file.log | tail -n 200 +``` + +Run one scoped conn test from `actor-conn.ts`: + +```bash +cd /home/nathan/r4/rivetkit-typescript/packages/rivetkit +pnpm test driver-engine -t 'registry \(static\).*encoding \(bare\).*Actor Connection Tests.*should connect using \\.get\\(\\)\\.connect\\(\\)' >/tmp/driver-engine-one-test.log 2>&1 +grep -nE " FAIL |Error:|Caused by:|Test Files|Tests |Duration" /tmp/driver-engine-one-test.log | tail -n 200 +``` + +Run the full `actor-conn.ts` suite after the fix: + +```bash +cd /home/nathan/r4/rivetkit-typescript/packages/rivetkit +pnpm test driver-engine -t 'registry \(static\).*encoding \(bare\).*Actor Connection Tests' >/tmp/driver-engine-full-file.log 2>&1 +grep -nE " FAIL |Error:|Caused by:|Test Files|Tests |Duration" /tmp/driver-engine-full-file.log | tail -n 200 +``` + +## High Priority + +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db.ts` + Issues: Initial scoped run exposed a stale `@rivetkit/rivetkit-native` `.node` artifact. The envoy websocket request was missing the required `version` query param, so I force-rebuilt the native addon. This file also carries test-stabilization changes: the DB suites run sequentially, the mixed-workload integrity path retries the transient `Actor stopping: database accessed after actor stopped` race around sleep/stop boundaries, and the lifecycle-counter assertions are skipped in dynamic mode because those globals are not shared across isolates there. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-raw.ts` + Issues: No new file-specific failures after the native addon rebuild. This file passed cleanly. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-stress.ts` + Issues: The original checklist command shape was wrong for this file because it runs once at top-level `Driver Tests`, not under `client type (...) > encoding (...)`. I fixed the note and reran with the correct filter. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-pragma-migration.ts` + Issues: No new file-specific failures. Sleep/wake idempotent migration coverage passed cleanly. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-sleep-db.ts` + Issues: No red tests, but this file is long-running. The full file took about 48 seconds because it covers queued actions, close handlers, grace-period shutdown, and interrupted write paths. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-conn.ts` + Issues: Full file passed, but shutdown-time cleanup still emits noisy `envoy channel closed` logs from persist and hibernatable websocket cleanup. This has not failed the suite yet. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-conn-state.ts` + Issues: No new file-specific failures. Connection state, lifecycle tracking, and targeted messaging passed cleanly. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-conn-status.ts` + Issues: Same shutdown-time `envoy channel closed` cleanup noise seen in other connection files. Status-change coverage itself passed. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/gateway-routing.ts` + Issues: Full file passed cleanly. Like other gateway and conn-adjacent files, cleanup still emits shutdown-time `envoy channel closed` noise after the assertions are already green. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/gateway-query-url.ts` + Issues: Full file passed cleanly. This path also emits the usual post-test envoy shutdown chatter, but no gateway query URL assertions failed. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-websocket.ts` + Issues: Full file passed, but I had to tighten the filter because a naive `raw websocket` regex also pulled in sleep tests and the separate gateway-query-url websocket suite. Even on the clean run, shutdown still sprays `envoy channel closed`, `SinglePromiseQueue`, and hibernatable websocket persistence errors after the assertions are already green. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-websocket-direct-registry.ts` + Issues: Full file passed. Cleanup still emits the same post-success `envoy channel closed` and persist-save noise, especially after the protocol/custom-subpath cases. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/lifecycle-hooks.ts` + Issues: Full file passed, but I had to narrow the filter to this file's exact test names because `Lifecycle Hooks` also matches suites in `actor-conn.ts`, `actor-handle.ts`, and `request-access.ts`. Expected rejected-connection cases still emit noisy `user.connection_rejected` and reconnect warnings, plus the usual shutdown-time persist-save noise. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-schedule.ts` + Issues: Full file passed cleanly. The only real note is runtime length because the ordered-alarm case intentionally waits through multiple schedule windows. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-destroy.ts` + Issues: Full file passed cleanly. It is just long because every case waits for destroy observers, `not_found` cleanup, and stale-handle re-resolution paths to settle. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/conn-error-serialization.ts` + Issues: Full file passed. The intentional error path still emits `connection.custom_error` close/reconnect warnings before disposal, which is noisy but expected for this test. + +## Mid Priority + +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-sleep.ts` + Issues: Full file passed, but it is a long bastard at about 79 seconds because it covers many real sleep/wake windows, `preventSleep`, long RPCs, raw websocket wake behavior, and delayed onSleep message delivery. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-lifecycle.ts` + Issues: Full file passed. The rapid create/destroy race case dominates runtime, and expected destroy-race/internal-error logs still show up while the assertions stay green. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-conn-hibernation.ts` + Issues: Full file passed. It is one of the slower mid-priority files because the last case intentionally waits across multiple sleep windows, but I did not hit a red assertion. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/hibernatable-websocket-protocol.ts` + Issues: Full file passed. Restore/replay coverage still emits the usual shutdown-time `envoy channel closed` persist-save noise after success, plus duplicate-message logging during replay, but no protocol assertion failed. +- [ ] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/dynamic-reload.ts` (`N/A` for static + bare only) + +## Low Priority + +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/access-control.ts` + Issues: Full file passed cleanly. This one is chatty because every auth gate path logs the expected denials and websocket teardown noise, but I did not hit a red assertion. +- [ ] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/action-features.ts` +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/action-features.ts` + Issues: Full file passed. Expected timeout and oversized-message tests still emit internal-error logging and some shutdown-time `envoy channel closed` persist noise, but those logs are the tested behavior here, not a regression. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-agent-os.ts` + Issues: Full file passed. This one is slower than the average low-priority file because it boots the agentOS VM sandbox and exercises real filesystem, process, fetch, and cron paths; `vmFetch` is the longest case. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-error-handling.ts` + Issues: Full file passed. The file is intentionally noisy because it exercises user errors, sanitized internal errors, and timeout paths, so both actor-runtime and actor-client log expected error records while the assertions stay green. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-handle.ts` + Issues: Full file passed cleanly. The `Lifecycle Hooks` sub-suite name collides with other files if the filter is too loose, but the top-level `Actor Handle Tests` filter kept this one isolated. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-inline-client.ts` + Issues: Full file passed. The stateful and mixed cases still emit the usual shutdown-time `envoy channel closed` persist-save noise during teardown, but inline stateless/stateful dispatch behavior itself stayed green. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-inspector.ts` + Issues: Full file passed. This is one of the fatter low-priority files because it exercises the full inspector HTTP surface, including workflow replay/auth checks and SQLite schema/rows/query endpoints, but it stayed green without special-case fixes. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-kv.ts` + Issues: Full file passed cleanly. No file-specific failures showed up; the range-scan/delete path was the only mildly interesting part and it behaved. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-metadata.ts` + Issues: Full file passed cleanly. The `onWake` metadata preservation case and region/tag reads behaved without any file-specific fixups. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-onstatechange.ts` + Issues: Full file passed cleanly. It is slightly slower than the tiny utility files because the negative cases verify that no extra change notifications fire across reads, computed values, and connect/dispose flows. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-queue.ts` + Issues: Full file passed. This is one of the slower low-priority files because the wait-send timeout/manual-completion paths and the many-child-actor drain cases take real time, but queue semantics stayed green without changes. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-run.ts` + Issues: Full file passed. This one is slow by design because it waits across run-handler startup, continuous ticking, queue consumption, sleep/resume, and error-exit paths, but it did not need file-specific fixes. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-sandbox.ts` + Issues: Full file passed. It is absurdly expensive for a one-test file because real sandbox startup and teardown dominates the runtime, but the runtime path itself stayed green. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-state.ts` + Issues: Full file passed cleanly. Basic persistence, reconnect restore, and per-actor isolation all behaved without file-specific fixes. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-state-zod-coercion.ts` + Issues: Full file passed cleanly. Sleep/wake restoration plus Zod defaults/coercion all behaved without needing any targeted patch. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-stateless.ts` + Issues: Full file passed. The expected `StateNotEnabled` and `DatabaseNotEnabled` error cases are noisy in the logs, but the stateless runtime behavior itself stayed green. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-vars.ts` + Issues: Full file passed cleanly. Static vars, deep cloning, dynamic `createVars`, and driver-context access all behaved without file-specific fixes. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-workflow.ts` + Issues: Full file passed. It is one of the fatter low-priority files because it exercises replay, workflow queue iteration, child workflows, sleep/resume, onError hooks, and teardown behavior; expected shutdown-time workflow/envoy noise still appears after success. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/manager-driver.ts` + Issues: Full file passed. This one is mostly just broad manager-key and retrieval permutations, so it is a little longer than the tiny files but did not need targeted fixes. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-http.ts` + Issues: Full file passed. The first broad `raw http` filter also pulled in the sibling request-properties and gateway-query-url suites, so I reran those files separately afterward to keep the checklist honest. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-http-direct-registry.ts` + Issues: Full file passed. Gateway-query-url coverage still emits the usual shutdown-time `envoy channel closed` persist-save noise after success, but the direct-registry request path stayed green. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/raw-http-request-properties.ts` + Issues: Full file passed. This one is a bit fatter than the basic HTTP file because it covers multipart, large bodies, weird URLs, custom methods, and concurrent requests, but it did not need targeted fixes. +- [x] `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/request-access.ts` + Issues: Full file passed cleanly. Request visibility in lifecycle hooks and the `trackRequest: false` negative case both behaved as expected. diff --git a/rivetkit-typescript/packages/rivetkit/src/actor-gateway/gateway.ts b/rivetkit-typescript/packages/rivetkit/src/actor-gateway/gateway.ts index d313198607..91a42788f1 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor-gateway/gateway.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor-gateway/gateway.ts @@ -102,9 +102,10 @@ async function handleHttpGatewayPathBased( // Preserve all headers const proxyHeaders = new Headers(c.req.raw.headers); - // Build the proxy request with the actor URL format - const proxyUrl = new URL( - `http://actor${resolvedActorPathInfo.remainingPath}`, + // Raw HTTP handlers are mounted under /request/* on the actor router, so + // gateway-routed HTTP requests must preserve that prefix when proxying. + const proxyUrl = buildActorRawHttpProxyUrl( + resolvedActorPathInfo.remainingPath, ); const proxyRequest = new Request(proxyUrl, { @@ -301,9 +302,10 @@ async function handleHttpGateway( proxyHeaders.delete(HEADER_RIVET_TARGET); proxyHeaders.delete(HEADER_RIVET_ACTOR); - // Build the proxy request with the actor URL format + // Raw HTTP handlers are mounted under /request/* on the actor router, so + // gateway-routed HTTP requests must preserve that prefix when proxying. const url = new URL(c.req.url); - const proxyUrl = new URL(`http://actor${strippedPath}${url.search}`); + const proxyUrl = buildActorRawHttpProxyUrl(`${strippedPath}${url.search}`); const proxyRequest = new Request(proxyUrl, { method: c.req.raw.method, @@ -316,6 +318,12 @@ async function handleHttpGateway( return await engineClient.proxyRequest(c, proxyRequest, actorId); } +function buildActorRawHttpProxyUrl(pathWithQuery: string): URL { + const normalizedPath = + pathWithQuery.startsWith("/") ? pathWithQuery.slice(1) : pathWithQuery; + return new URL(`http://actor/request/${normalizedPath}`); +} + /** * Creates a WebSocket proxy for test endpoints that forwards messages between server and client WebSockets * diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/errors.ts b/rivetkit-typescript/packages/rivetkit/src/actor/errors.ts index 5d4724c0bd..9bd5a54127 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/errors.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/errors.ts @@ -12,6 +12,8 @@ interface ActorErrorOptions extends ErrorOptions { public?: boolean; /** Metadata associated with this error. This will be sent to clients. */ metadata?: unknown; + /** HTTP status code override for serialized responses. */ + statusCode?: number; } export class ActorError extends Error { @@ -46,7 +48,9 @@ export class ActorError extends Error { // Set status code based on error type if (opts?.public) { - this.statusCode = 400; // Bad request for public errors + this.statusCode = opts.statusCode ?? 400; // Bad request for public errors + } else if (opts?.statusCode) { + this.statusCode = opts.statusCode; } } @@ -392,6 +396,32 @@ export class ActorStopping extends ActorError { } } +export interface ActorRestartingOptions { + phase?: "stopping" | "sleeping" | "waking" | "runner_shutdown"; + retryAfterMs?: number; +} + +export class ActorRestarting extends ActorError { + constructor(opts?: ActorRestartingOptions) { + super( + "actor", + "restarting", + "Actor is restarting. Retry the request.", + { + public: true, + statusCode: 503, + metadata: { + retryable: true, + ...(opts?.phase ? { phase: opts.phase } : {}), + ...(opts?.retryAfterMs !== undefined + ? { retryAfterMs: opts.retryAfterMs } + : {}), + }, + }, + ); + } +} + export class ProxyError extends ActorError { constructor(operation: string, error?: unknown) { super( diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/instance/connection-manager.ts b/rivetkit-typescript/packages/rivetkit/src/actor/instance/connection-manager.ts index cfbd830942..4415b28576 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/instance/connection-manager.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/instance/connection-manager.ts @@ -106,7 +106,9 @@ export class ConnectionManager< ): Promise> { this.#actor.assertReady(); if (this.#actor.isStopping) - throw new errors.ActorStopping("Cannot accept new connections while actor is stopping"); + throw new errors.ActorStopping( + "Cannot accept new connections while actor is stopping", + ); // TODO: Add back // const url = request?.url; diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts b/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts index c77607136c..4bbdc25138 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts @@ -923,7 +923,7 @@ export class ActorInstance< async restartRunHandler(): Promise { this.assertReady(); if (this.#stopCalled) - throw new errors.InternalError("Actor is stopping"); + throw new errors.ActorRestarting({ phase: "stopping" }); if (this.#runHandlerActive && this.#runPromise) { await this.#runPromise; } diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/router-endpoints.ts b/rivetkit-typescript/packages/rivetkit/src/actor/router-endpoints.ts index c09668200f..34929fd467 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/router-endpoints.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/router-endpoints.ts @@ -155,10 +155,11 @@ export async function handleAction( break; } catch (error) { const shouldRetry = - error instanceof errors.InternalError && - error.message === "Actor is stopping" && + error instanceof errors.ActorRestarting && attempt < maxAttempts - 1; if (shouldRetry) { + // TODO(RVT-6193): Remove this router-local retry after the + // packaged client owns lifecycle-boundary retries. await new Promise((resolve) => setTimeout(resolve, 25)); continue; } diff --git a/rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts b/rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts index 1545003150..16b5388d1f 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts @@ -1,6 +1,7 @@ import * as cbor from "cbor-x"; import invariant from "invariant"; import pRetry from "p-retry"; +import { AbortError } from "p-retry"; import type { CloseEvent } from "ws"; import type { AnyActorDefinition } from "@/actor/definition"; import { inputDataToBuffer } from "@/actor/protocol/old"; @@ -51,6 +52,7 @@ import { messageLength, parseWebSocketCloseReason, } from "./utils"; +import { isRetryableLifecycleReconnectSignal } from "./lifecycle-errors"; /** * Connection status for an actor connection. @@ -426,7 +428,11 @@ export class ActorConnRaw { // Cancel retry if aborted signal: this.#abortController.signal, }).catch((err) => { - if ((err as Error).name === "AbortError") { + if ( + err instanceof AbortError || + (err as Error).name === "AbortError" || + !this.#shouldRetryConnectionOpenError(err) + ) { logger().info({ msg: "connection retry aborted" }); } else { logger().error({ @@ -453,11 +459,36 @@ export class ActorConnRaw { // Wait for result await this.#onOpenPromise.promise; + } catch (error) { + if (this.#shouldRetryConnectionOpenError(error)) { + throw error; + } + + throw new AbortError( + error instanceof Error + ? error + : new Error(stringifyError(error)), + ); } finally { this.#onOpenPromise = undefined; } } + #shouldRetryConnectionOpenError(error: unknown): boolean { + if (error instanceof errors.ActorConnDisposed) { + return false; + } + + if ( + error instanceof errors.ActorError && + this.#shouldReconnectForStaleActor(error.group, error.code) + ) { + return true; + } + + return isRetryableLifecycleReconnectSignal(error); + } + #clearQueuedMessages() { if (this.#messageQueue.length === 0) return; diff --git a/rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts b/rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts index 2406c9dfc9..e8c746c686 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts @@ -153,67 +153,112 @@ export class ActorHandleRaw { `Invalid action call: expected an options object { name, args }, got ${typeof opts}. Use handle.actionName(...args) for the shorthand API.`, ); } - const target = getGatewayTarget(this.#actorResolutionState); - const actorId = "directId" in target ? target.directId : undefined; - try { - logger().debug( - actorId - ? { msg: "using direct actor gateway target", actorId } - : { - msg: "using query gateway target for action", - query: this.#actorResolutionState, - }, - ); + const { retryOnLifecycleBoundary } = await import("./lifecycle-errors"); + return await retryOnLifecycleBoundary( + async () => { + const target = getGatewayTarget(this.#actorResolutionState); + const actorId = + "directId" in target ? target.directId : undefined; + + logger().debug( + actorId + ? { + msg: "using direct actor gateway target", + actorId, + } + : { + msg: "using query gateway target for action", + query: this.#actorResolutionState, + }, + ); + + logger().debug({ + msg: "handling action", + name: opts.name, + encoding: this.#encoding, + }); - logger().debug({ - msg: "handling action", - name: opts.name, - encoding: this.#encoding, - }); - return await sendHttpRequest< - protocol.HttpActionRequest, - protocol.HttpActionResponse, - HttpActionRequestJson, - HttpActionResponseJson, - unknown[], - Response - >({ - url: `http://actor/action/${encodeURIComponent(opts.name)}`, - method: "POST", - headers: { - [HEADER_ENCODING]: this.#encoding, - ...(this.#params !== undefined - ? { - [HEADER_CONN_PARAMS]: JSON.stringify( - this.#params, + try { + return await sendHttpRequest< + protocol.HttpActionRequest, + protocol.HttpActionResponse, + HttpActionRequestJson, + HttpActionResponseJson, + unknown[], + Response + >({ + url: `http://actor/action/${encodeURIComponent(opts.name)}`, + method: "POST", + headers: { + [HEADER_ENCODING]: this.#encoding, + ...(this.#params !== undefined + ? { + [HEADER_CONN_PARAMS]: + JSON.stringify( + this.#params, + ), + } + : {}), + }, + body: opts.args, + encoding: this.#encoding, + customFetch: this.#driver.sendRequest.bind( + this.#driver, + target, ), + signal: opts?.signal, + requestVersion: + CLIENT_PROTOCOL_CURRENT_VERSION, + requestVersionedDataHandler: + HTTP_ACTION_REQUEST_VERSIONED, + responseVersion: + CLIENT_PROTOCOL_CURRENT_VERSION, + responseVersionedDataHandler: + HTTP_ACTION_RESPONSE_VERSIONED, + requestZodSchema: HttpActionRequestSchema, + responseZodSchema: HttpActionResponseSchema, + requestToJson: ( + args, + ): HttpActionRequestJson => ({ + args, + }), + requestToBare: ( + args, + ): protocol.HttpActionRequest => ({ + args: bufferToArrayBuffer(cbor.encode(args)), + }), + responseFromJson: (json): Response => + json.output as Response, + responseFromBare: (bare): Response => + cbor.decode( + new Uint8Array(bare.output), + ) as Response, + }); + } catch (err) { + if ( + actorId && + err instanceof ActorError && + isSchedulingError(err.group, err.code) + ) { + const schedulingError = + await checkForSchedulingError( + err.group, + err.code, + actorId, + this.#actorResolutionState, + this.#driver, + ); + if (schedulingError) { + throw schedulingError; + } } - : {}), + + throw err; + } }, - body: opts.args, - encoding: this.#encoding, - customFetch: this.#driver.sendRequest.bind( - this.#driver, - target, - ), - signal: opts?.signal, - requestVersion: CLIENT_PROTOCOL_CURRENT_VERSION, - requestVersionedDataHandler: HTTP_ACTION_REQUEST_VERSIONED, - responseVersion: CLIENT_PROTOCOL_CURRENT_VERSION, - responseVersionedDataHandler: HTTP_ACTION_RESPONSE_VERSIONED, - requestZodSchema: HttpActionRequestSchema, - responseZodSchema: HttpActionResponseSchema, - requestToJson: (args): HttpActionRequestJson => ({ - args, - }), - requestToBare: (args): protocol.HttpActionRequest => ({ - args: bufferToArrayBuffer(cbor.encode(args)), - }), - responseFromJson: (json): Response => json.output as Response, - responseFromBare: (bare): Response => - cbor.decode(new Uint8Array(bare.output)) as Response, - }); + { signal: opts.signal }, + ); } catch (err) { const { group, code, message, metadata } = deconstructError( err, @@ -222,19 +267,6 @@ export class ActorHandleRaw { true, ); - if (actorId && isSchedulingError(group, code)) { - const schedulingError = await checkForSchedulingError( - group, - code, - actorId, - this.#actorResolutionState, - this.#driver, - ); - if (schedulingError) { - throw schedulingError; - } - } - throw new ActorError(group, code, message, metadata); } } diff --git a/rivetkit-typescript/packages/rivetkit/src/client/lifecycle-errors.ts b/rivetkit-typescript/packages/rivetkit/src/client/lifecycle-errors.ts new file mode 100644 index 0000000000..5fc5507984 --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/src/client/lifecycle-errors.ts @@ -0,0 +1,285 @@ +import { ActorError, HttpRequestError } from "./errors"; + +export interface LifecycleBoundaryInfo { + kind: "request_retry" | "reconnect_only"; + source: "actor_error" | "transport_error"; + group?: string; + code?: string; + message: string; + legacy: boolean; +} + +function* walkErrorChain( + error: unknown, + maxDepth = 8, +): Generator { + let current = error; + let depth = 0; + + while (current !== undefined && current !== null && depth < maxDepth) { + yield current; + + if ( + typeof current === "object" && + "cause" in current && + (current as { cause?: unknown }).cause !== current + ) { + current = (current as { cause?: unknown }).cause; + depth += 1; + continue; + } + + break; + } +} + +function buildLifecycleBoundaryInfo( + kind: LifecycleBoundaryInfo["kind"], + source: LifecycleBoundaryInfo["source"], + message: string, + opts?: { + group?: string; + code?: string; + legacy?: boolean; + }, +): LifecycleBoundaryInfo { + return { + kind, + source, + group: opts?.group, + code: opts?.code, + message, + legacy: opts?.legacy ?? false, + }; +} + +function classifyActorError( + error: ActorError, +): LifecycleBoundaryInfo | undefined { + if ( + error.group === "actor" && + error.code === "stopping" && + error.message.includes("database accessed after actor stopped") + ) { + return undefined; + } + + if (error.group === "actor" && error.code === "restarting") { + return buildLifecycleBoundaryInfo( + "request_retry", + "actor_error", + error.message, + { + group: error.group, + code: error.code, + }, + ); + } + + // TODO(RVT-6193): Remove this legacy match after structured restart errors + // are authoritative everywhere. + if ( + error.group === "actor" && + error.code === "internal_error" && + error.message === "Actor is stopping" + ) { + return buildLifecycleBoundaryInfo( + "request_retry", + "actor_error", + error.message, + { + group: error.group, + code: error.code, + legacy: true, + }, + ); + } + + // TODO(RVT-6193): Remove this legacy match after connection admission uses + // actor.restarting consistently. + if ( + error.group === "actor" && + error.code === "stopping" && + error.message === + "Actor stopping: Cannot accept new connections while actor is stopping" + ) { + return buildLifecycleBoundaryInfo( + "request_retry", + "actor_error", + error.message, + { + group: error.group, + code: error.code, + legacy: true, + }, + ); + } + + if (error.group === "actor" && error.code === "stopped") { + return buildLifecycleBoundaryInfo( + "reconnect_only", + "actor_error", + error.message, + { + group: error.group, + code: error.code, + legacy: true, + }, + ); + } + + if (error.group === "ws" && error.code === "going_away") { + return buildLifecycleBoundaryInfo( + "reconnect_only", + "actor_error", + error.message, + { + group: error.group, + code: error.code, + legacy: true, + }, + ); + } + + return undefined; +} + +function classifyTransportError( + error: Error, +): LifecycleBoundaryInfo | undefined { + if (error.message.includes("database accessed after actor stopped")) { + return undefined; + } + + // TODO(RVT-6193): Remove this exact string match after the runner surfaces + // structured restart errors end to end. + if (/^Actor [A-Za-z0-9-]+ stopped$/.test(error.message)) { + return buildLifecycleBoundaryInfo( + "request_retry", + "transport_error", + error.message, + { legacy: true }, + ); + } + + // TODO(RVT-6193): Remove these exact string matches after transport + // shutdown paths are normalized to structured lifecycle errors. + if ( + error.message === "WebSocket connection closed during shutdown" || + error.message === "envoy shut down" || + error.message === "envoy shutting down" + ) { + return buildLifecycleBoundaryInfo( + "reconnect_only", + "transport_error", + error.message, + { legacy: true }, + ); + } + + return undefined; +} + +export function classifyLifecycleBoundaryError( + error: unknown, +): LifecycleBoundaryInfo | undefined { + for (const current of walkErrorChain(error)) { + if (current instanceof ActorError) { + const classified = classifyActorError(current); + if (classified) { + return classified; + } + continue; + } + + if (current instanceof HttpRequestError || current instanceof Error) { + const classified = classifyTransportError(current); + if (classified) { + return classified; + } + } + } + + return undefined; +} + +export function isRetryableLifecycleRequestError(error: unknown): boolean { + return classifyLifecycleBoundaryError(error)?.kind === "request_retry"; +} + +export function isRetryableLifecycleReconnectSignal(error: unknown): boolean { + const classified = classifyLifecycleBoundaryError(error); + return ( + classified?.kind === "reconnect_only" || + classified?.kind === "request_retry" + ); +} + +function throwIfAborted(signal?: AbortSignal) { + if (signal?.aborted) { + throw signal.reason ?? new Error("Operation aborted"); + } +} + +async function waitWithSignal(ms: number, signal?: AbortSignal) { + throwIfAborted(signal); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + + const onAbort = () => { + clearTimeout(timeout); + cleanup(); + reject(signal?.reason ?? new Error("Operation aborted")); + }; + + const cleanup = () => { + signal?.removeEventListener("abort", onAbort); + }; + + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +export async function retryOnLifecycleBoundary( + run: () => Promise, + opts?: { + maxAttempts?: number; + initialDelayMs?: number; + maxDelayMs?: number; + signal?: AbortSignal; + }, +): Promise { + const maxAttempts = opts?.maxAttempts ?? 5; + const initialDelayMs = opts?.initialDelayMs ?? 25; + const maxDelayMs = opts?.maxDelayMs ?? 200; + + let lastError: unknown; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + throwIfAborted(opts?.signal); + + try { + return await run(); + } catch (error) { + if (!isRetryableLifecycleRequestError(error)) { + throw error; + } + + lastError = error; + if (attempt === maxAttempts - 1) { + break; + } + + const delayMs = Math.min( + initialDelayMs * 2 ** attempt, + maxDelayMs, + ); + await waitWithSignal(delayMs, opts?.signal); + } + } + + throw lastError; +} diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/test-inline-client-driver.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/test-inline-client-driver.ts index f46f2a35e5..ff511c62f4 100644 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/test-inline-client-driver.ts +++ b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/test-inline-client-driver.ts @@ -216,7 +216,19 @@ export function createTestInlineClientDriver( actorRequest: Request, actorId: string, ): Promise { - return await this.sendRequest({ directId: actorId }, actorRequest); + const url = new URL(actorRequest.url); + const proxyPath = url.pathname.startsWith("/request/") + ? `${url.pathname}${url.search}` + : `/request/${url.pathname.startsWith("/") ? url.pathname.slice(1) : url.pathname}${url.search}`; + const proxyRequest = new Request(`http://actor${proxyPath}`, { + method: actorRequest.method, + headers: actorRequest.headers, + body: actorRequest.body, + signal: actorRequest.signal, + duplex: "half", + } as RequestInit); + + return await this.sendRequest({ directId: actorId }, proxyRequest); }, proxyWebSocket( c: HonoContext, diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-pragma-migration.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-pragma-migration.ts index df9f666815..a9749fba6f 100644 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-pragma-migration.ts +++ b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db-pragma-migration.ts @@ -1,8 +1,6 @@ import { describe, expect, test } from "vitest"; import type { DriverTestConfig } from "../mod"; -import { setupDriverTest, waitFor } from "../utils"; - -const SLEEP_WAIT_MS = 150; +import { setupDriverTest } from "../utils"; const REAL_TIMER_DB_TIMEOUT_MS = 180_000; export function runActorDbPragmaMigrationTests( @@ -98,7 +96,6 @@ export function runActorDbPragmaMigrationTests( // Sleep and wake await actor.triggerSleep(); - await waitFor(driverTestConfig, SLEEP_WAIT_MS); // After wake, onMigrate runs again but should not fail const version = await actor.getUserVersion(); diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db.ts index 01f692fd0d..9e907015f5 100644 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db.ts +++ b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-db.ts @@ -33,6 +33,8 @@ const HOT_ROW_UPDATES = 240; const INTEGRITY_SEED_COUNT = 64; const INTEGRITY_CHURN_COUNT = 120; +// TODO(RVT-6193): Sleep and wake routing should hide this transient lifecycle +// error from callers instead of forcing tests to special case it. function isActorStoppingDbError(error: unknown): boolean { return ( error instanceof Error && @@ -171,7 +173,6 @@ export function runActorDbTests(driverTestConfig: DriverTestConfig) { for (let i = 0; i < 3; i++) { await actor.triggerSleep(); - await waitFor(driverTestConfig, SLEEP_WAIT_MS); let countAfterWake = -1; let lastError: Error | undefined; @@ -476,7 +477,6 @@ export function runActorDbTests(driverTestConfig: DriverTestConfig) { ); await actor.triggerSleep(); - await waitFor(driverTestConfig, SLEEP_WAIT_MS + 100); expect((await actor.integrityCheck()).toLowerCase()).toBe( "ok", ); diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-sleep.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-sleep.ts index 72ddb826aa..452ba90520 100644 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-sleep.ts +++ b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-sleep.ts @@ -91,9 +91,6 @@ export function runActorSleepTests(driverTestConfig: DriverTestConfig) { // Trigger sleep await sleepActor.triggerSleep(); - // HACK: Wait for sleep to finish in background - await waitFor(driverTestConfig, 250); - // Get sleep count after restore { const { startCount, sleepCount } = await sleepActor.getCounts(); diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-state-zod-coercion.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-state-zod-coercion.ts index 581c67c164..9442b42a1e 100644 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-state-zod-coercion.ts +++ b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-state-zod-coercion.ts @@ -1,8 +1,6 @@ import { describe, expect, test } from "vitest"; import type { DriverTestConfig } from "../mod"; -import { setupDriverTest, waitFor } from "../utils"; - -const SLEEP_WAIT_MS = 150; +import { setupDriverTest } from "../utils"; export function runActorStateZodCoercionTests( driverTestConfig: DriverTestConfig, @@ -19,7 +17,6 @@ export function runActorStateZodCoercionTests( // Sleep and wake await actor.triggerSleep(); - await waitFor(driverTestConfig, SLEEP_WAIT_MS); const state = await actor.getState(); expect(state.count).toBe(42); @@ -49,7 +46,6 @@ export function runActorStateZodCoercionTests( // Sleep await actor.triggerSleep(); - await waitFor(driverTestConfig, SLEEP_WAIT_MS); // Wake and verify Zod parse preserved values const state = await actor.getState(); diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-workflow.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-workflow.ts index 2ee0d82a56..432f33a200 100644 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-workflow.ts +++ b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-workflow.ts @@ -7,15 +7,6 @@ import { import type { DriverTestConfig } from "../mod"; import { setupDriverTest, waitFor } from "../utils"; -function isActorStoppingConnectionError(error: unknown): boolean { - return ( - error instanceof Error && - error.message.includes( - "Actor stopping: Cannot accept new connections while actor is stopping", - ) - ); -} - export function runActorWorkflowTests(driverTestConfig: DriverTestConfig) { describe("Actor Workflow Tests", () => { test("replays steps and guards state access", async (c) => { @@ -398,13 +389,7 @@ export function runActorWorkflowTests(driverTestConfig: DriverTestConfig) { i++ ) { await waitFor(driverTestConfig, 100); - try { - state = await actor.getState(); - } catch (error) { - if (!isActorStoppingConnectionError(error)) { - throw error; - } - } + state = await actor.getState(); } expect(state.runCount).toBeGreaterThan(0); expect(state.sleepCount).toBeGreaterThan(0); @@ -484,7 +469,6 @@ export function runActorWorkflowTests(driverTestConfig: DriverTestConfig) { expect(state.wakeCount).toBe(1); await actor.triggerSleep(); - await waitFor(driverTestConfig, 250); let resumedState = await actor.getErrorState(); for ( diff --git a/rivetkit-typescript/packages/rivetkit/src/drivers/engine/actor-driver.ts b/rivetkit-typescript/packages/rivetkit/src/drivers/engine/actor-driver.ts index b0170d2b29..b805ab15c8 100644 --- a/rivetkit-typescript/packages/rivetkit/src/drivers/engine/actor-driver.ts +++ b/rivetkit-typescript/packages/rivetkit/src/drivers/engine/actor-driver.ts @@ -1225,10 +1225,15 @@ export class EngineActorDriver implements ActorDriver { return overlayResponse; } + const normalizedRequest = + this.#normalizeEnvoyHttpRequestForActorRouter(request); + if (this.#isDynamicActor(actorId)) { - return await this.#requireDynamicRuntime(actorId).fetch(request); + return await this.#requireDynamicRuntime(actorId).fetch( + normalizedRequest, + ); } - return await this.#actorRouter.fetch(request, { actorId }); + return await this.#actorRouter.fetch(normalizedRequest, { actorId }); } #routeOverlayRequest( @@ -1244,6 +1249,33 @@ export class EngineActorDriver implements ActorDriver { } } + #normalizeEnvoyHttpRequestForActorRouter(request: Request): Request { + const url = new URL(request.url); + if ( + url.pathname === "/" || + url.pathname === "/health" || + url.pathname === "/metadata" || + url.pathname.startsWith("/action/") || + url.pathname === "/queue" || + url.pathname.startsWith("/queue/") || + url.pathname === "/connect" || + url.pathname.startsWith("/websocket/") || + url.pathname.startsWith("/inspector/") || + url.pathname.startsWith("/request/") || + url.pathname.startsWith("/.test/") + ) { + return request; + } + + const normalizedPath = url.pathname.startsWith("/") + ? url.pathname.slice(1) + : url.pathname; + const normalizedUrl = new URL( + `http://actor/request/${normalizedPath}${url.search}`, + ); + return new Request(normalizedUrl, request); + } + #handleDynamicReloadOverlay(actorId: string): Response { if (!this.#isDynamicActor(actorId)) { return new Response("not a dynamic actor", { status: 404 }); @@ -1448,9 +1480,11 @@ export class EngineActorDriver implements ActorDriver { attachMessageListener(); } - wsHandler.onOpen(event, wsContext); - + // Attach close and error listeners before onOpen so an actor that + // immediately rejects the connection does not lose its close event. attachPostOpenListeners(); + + wsHandler.onOpen(event, wsContext); }); if (!isRawWebSocketPath) { diff --git a/rivetkit-typescript/packages/rivetkit/src/engine-client/mod.ts b/rivetkit-typescript/packages/rivetkit/src/engine-client/mod.ts index 5e9f5d2c1c..20e9bdd6ac 100644 --- a/rivetkit-typescript/packages/rivetkit/src/engine-client/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/engine-client/mod.ts @@ -291,7 +291,7 @@ export class RemoteEngineControlClient implements EngineControlClient { const gatewayUrl = this.#buildGatewayUrlForTarget( { directId: actorId }, - requestPath(actorRequest), + rawHttpProxyPath(actorRequest), ); return sendHttpRequestToGateway(this.#config, gatewayUrl, actorRequest); @@ -436,6 +436,18 @@ function requestPath(req: Request): string { return `${url.pathname}${url.search}`; } +function rawHttpProxyPath(req: Request): string { + const url = new URL(req.url); + if (url.pathname.startsWith("/request/")) { + return `${url.pathname}${url.search}`; + } + + const normalizedPath = url.pathname.startsWith("/") + ? url.pathname.slice(1) + : url.pathname; + return `/request/${normalizedPath}${url.search}`; +} + function apiActorToOutput(actor: ApiActor): ActorOutput { return { actorId: actor.actor_id,