From 32afb3f6c0c57fe009e489b834d757f417e141eb Mon Sep 17 00:00:00 2001 From: Kunal Roy Choudhury <71250874+kunalgoesbyken@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:39:35 +0530 Subject: [PATCH] fix(effect): reject actions on a torn-down instance with actor.aborted An action can reach the dispatcher after onSleep or onDestroy has removed the actor instance but before the request's abort signal fires. That path threw a plain Error, which core sanitized into rivetkit.internal_error, so clients that retry on actor.aborted gave up. Treat a missing instance as the same lifecycle exit as an interrupted action fiber. Fixes #5891 --- .../src/internal/ActionDispatcher.test.ts | 40 +++++++++++++++++++ .../effect/src/internal/ActionDispatcher.ts | 5 +-- 2 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 rivetkit-typescript/packages/effect/src/internal/ActionDispatcher.test.ts diff --git a/rivetkit-typescript/packages/effect/src/internal/ActionDispatcher.test.ts b/rivetkit-typescript/packages/effect/src/internal/ActionDispatcher.test.ts new file mode 100644 index 0000000000..f2b1c4573a --- /dev/null +++ b/rivetkit-typescript/packages/effect/src/internal/ActionDispatcher.test.ts @@ -0,0 +1,40 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Action, Actor } from "@rivetkit/effect"; +import * as Rivetkit from "rivetkit"; +import * as ActionDispatcher from "./ActionDispatcher.ts"; + +const Ping = Action.make("Ping"); + +const dispatchWithoutInstance = (abortSignal: AbortSignal) => { + const handlers = ActionDispatcher.make({ + actor: Actor.make("Probe", { actions: [Ping] }), + getInstance: () => undefined, + }); + const c = { + actorId: "probe", + abortSignal, + } as Rivetkit.ActionContextOf; + return handlers.Ping(c, undefined); +}; + +const assertActorAborted = async (dispatch: Promise) => { + const error = await dispatch.then( + () => assert.fail("expected the action to be rejected"), + (error: unknown) => error, + ); + assert.instanceOf(error, Rivetkit.RivetError); + assert.strictEqual(error.group, "actor"); + assert.strictEqual(error.code, "aborted"); +}; + +describe("internal/ActionDispatcher", () => { + it("rejects an action that reaches a torn-down instance before its signal aborts", async () => { + await assertActorAborted( + dispatchWithoutInstance(new AbortController().signal), + ); + }); + + it("rejects an action that reaches a torn-down instance after its signal aborts", async () => { + await assertActorAborted(dispatchWithoutInstance(AbortSignal.abort())); + }); +}); diff --git a/rivetkit-typescript/packages/effect/src/internal/ActionDispatcher.ts b/rivetkit-typescript/packages/effect/src/internal/ActionDispatcher.ts index eae860841c..db7ae811e6 100644 --- a/rivetkit-typescript/packages/effect/src/internal/ActionDispatcher.ts +++ b/rivetkit-typescript/packages/effect/src/internal/ActionDispatcher.ts @@ -66,10 +66,7 @@ export const make = < const traceMeta = readTraceMeta(meta); const instance = getInstance(c.actorId); - if (!instance) { - if (c.abortSignal.aborted) throw makeActorAbortedError(); - throw new Error("actor instance missing"); - } + if (!instance) throw makeActorAbortedError(); const actionEffect = Effect.gen(function* () { // The handler map is keyed by the same action