diff --git a/.changeset/drain-pending-queue-on-completion.md b/.changeset/drain-pending-queue-on-completion.md
new file mode 100644
index 0000000000..5046b9857d
--- /dev/null
+++ b/.changeset/drain-pending-queue-on-completion.md
@@ -0,0 +1,5 @@
+---
+"@workflow/core": patch
+---
+
+Drain pending queue items at workflow completion instead of only logging warnings, and implicitly dispose any never-aborted system (abort) hooks at completion so unused `AbortController` instances don't leave abandoned rows in the hooks table for the run's TTL
diff --git a/.changeset/fix-dom-exception-serialization.md b/.changeset/fix-dom-exception-serialization.md
new file mode 100644
index 0000000000..e2b4f2a1d4
--- /dev/null
+++ b/.changeset/fix-dom-exception-serialization.md
@@ -0,0 +1,5 @@
+---
+"@workflow/core": patch
+---
+
+Fix `DOMException` not serializing correctly
diff --git a/.changeset/serializable-abort-controller.md b/.changeset/serializable-abort-controller.md
new file mode 100644
index 0000000000..4c2b70b320
--- /dev/null
+++ b/.changeset/serializable-abort-controller.md
@@ -0,0 +1,8 @@
+---
+"@workflow/core": patch
+"workflow": patch
+---
+
+Add serializable `AbortController` and `AbortSignal` support across workflow and step boundaries. Workflow code can now construct an `AbortController`, pass `signal` to steps, and call `abort()`.
+
+**Behavior change:** `AbortError` thrown from inside a step is now wrapped as `FatalError` and skips retry semantics. As a result, custom timeouts on `fetch` inside steps are no longer re-tried by default, and now need to be wrapped in `RetryableError` to preserve the old behavior.
diff --git a/docs/app/[lang]/docs/[[...slug]]/page.tsx b/docs/app/[lang]/docs/[[...slug]]/page.tsx
index 8557003f38..b5d47c008e 100644
--- a/docs/app/[lang]/docs/[[...slug]]/page.tsx
+++ b/docs/app/[lang]/docs/[[...slug]]/page.tsx
@@ -6,6 +6,7 @@ import { notFound, permanentRedirect } from 'next/navigation';
import { rewriteCookbookUrl } from '@/lib/geistdocs/cookbook-source';
import { AgentTraces } from '@/components/custom/agent-traces';
import { FluidComputeCallout } from '@/components/custom/fluid-compute-callout';
+import { PreviewInstallServer } from '@/components/preview-install-server';
import { AskAI } from '@/components/geistdocs/ask-ai';
import { CopyPage } from '@/components/geistdocs/copy-page';
import {
@@ -45,6 +46,12 @@ const Page = async ({ params }: PageProps<'/[lang]/docs/[[...slug]]'>) => {
notFound();
}
+ // preRelease pages are only reachable under /v5/docs/*. Block direct
+ // access via /docs/* so the v4 tree doesn't expose unreleased content.
+ if (page.data.preRelease) {
+ notFound();
+ }
+
const markdown = await getLLMText(page);
const MDX = page.data.body;
@@ -86,6 +93,7 @@ const Page = async ({ params }: PageProps<'/[lang]/docs/[[...slug]]'>) => {
...AccordionComponents,
Tabs,
Tab,
+ PreviewInstall: PreviewInstallServer,
// No-op for world MDX files (they redirect to /worlds/[id])
WorldTestingPerformance: WorldTestingPerformanceNoop,
})}
diff --git a/docs/app/[lang]/docs/layout.tsx b/docs/app/[lang]/docs/layout.tsx
index 831656a8a5..8173e821a5 100644
--- a/docs/app/[lang]/docs/layout.tsx
+++ b/docs/app/[lang]/docs/layout.tsx
@@ -1,12 +1,13 @@
import { DocsLayout } from '@/components/geistdocs/docs-layout';
-import { getDocsTreeWithoutCookbook } from '@/lib/geistdocs/cookbook-source';
+import { getDocsTreeForVersion } from '@/lib/geistdocs/version-source';
+import { LATEST_VERSION } from '@/lib/geistdocs/versions';
const Layout = async ({ children, params }: LayoutProps<'/[lang]/docs'>) => {
const { lang } = await params;
return (
-
+
{children}
diff --git a/docs/app/[lang]/llms.mdx/[[...slug]]/route.ts b/docs/app/[lang]/llms.mdx/[[...slug]]/route.ts
index 897e54d56a..3134f7d1f7 100644
--- a/docs/app/[lang]/llms.mdx/[[...slug]]/route.ts
+++ b/docs/app/[lang]/llms.mdx/[[...slug]]/route.ts
@@ -38,5 +38,8 @@ export const generateStaticParams = async ({
}: RouteContext<'/[lang]/llms.mdx/[[...slug]]'>) => {
const { lang } = await params;
- return source.generateParams(lang);
+ // Exclude internal/preview-only pages from LLM scraping
+ return source
+ .generateParams(lang)
+ .filter((p) => !p.slug?.includes('internal'));
};
diff --git a/docs/app/[lang]/sitemap.md/route.ts b/docs/app/[lang]/sitemap.md/route.ts
index 7c193e126d..e2b1adee5e 100644
--- a/docs/app/[lang]/sitemap.md/route.ts
+++ b/docs/app/[lang]/sitemap.md/route.ts
@@ -16,6 +16,9 @@ export async function GET(
const indent = ' '.repeat(depth);
if ('type' in node) {
+ // Exclude internal/preview-only pages from sitemap
+ if (node.type === 'page' && node.url.includes('/internal')) return;
+ if (node.type === 'folder' && node.name === 'Internal') return;
if (node.type === 'page') {
mdText += `${indent}- [${node.name}](${rewriteCookbookUrl(node.url)})\n`;
} else if (node.type === 'folder') {
diff --git a/docs/app/[lang]/v5/docs/[[...slug]]/page.tsx b/docs/app/[lang]/v5/docs/[[...slug]]/page.tsx
new file mode 100644
index 0000000000..65610d2cfe
--- /dev/null
+++ b/docs/app/[lang]/v5/docs/[[...slug]]/page.tsx
@@ -0,0 +1,127 @@
+import { Step, Steps } from 'fumadocs-ui/components/steps';
+import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
+import { createRelativeLink } from 'fumadocs-ui/mdx';
+import type { Metadata } from 'next';
+import { notFound, permanentRedirect } from 'next/navigation';
+import { AgentTraces } from '@/components/custom/agent-traces';
+import { FluidComputeCallout } from '@/components/custom/fluid-compute-callout';
+import { AskAI } from '@/components/geistdocs/ask-ai';
+import { CopyPage } from '@/components/geistdocs/copy-page';
+import {
+ DocsBody,
+ DocsDescription,
+ DocsPage,
+ DocsTitle,
+} from '@/components/geistdocs/docs-page';
+import { EditSource } from '@/components/geistdocs/edit-source';
+import { Feedback } from '@/components/geistdocs/feedback';
+import { getMDXComponents } from '@/components/geistdocs/mdx-components';
+import { MobileDocsBar } from '@/components/geistdocs/mobile-docs-bar';
+import { OpenInChat } from '@/components/geistdocs/open-in-chat';
+import { ScrollTop } from '@/components/geistdocs/scroll-top';
+import { PreviewInstallServer } from '@/components/preview-install-server';
+import * as AccordionComponents from '@/components/ui/accordion';
+import { Badge } from '@/components/ui/badge';
+import { Separator } from '@/components/ui/separator';
+import { rewriteCookbookUrl } from '@/lib/geistdocs/cookbook-source';
+import { getLLMText, getPageImage, source } from '@/lib/geistdocs/source';
+import { TSDoc } from '@/lib/tsdoc';
+
+const WorldTestingPerformanceNoop = () => null;
+
+const Page = async ({ params }: PageProps<'/[lang]/v5/docs/[[...slug]]'>) => {
+ const { slug, lang } = await params;
+
+ if (Array.isArray(slug) && slug[0] === 'cookbook') {
+ const rest = slug.slice(1).join('/');
+ const legacyPath = `/docs/cookbook${rest ? `/${rest}` : ''}`;
+ permanentRedirect(`/${lang}${rewriteCookbookUrl(legacyPath)}`);
+ }
+
+ const page = source.getPage(slug, lang);
+ if (!page) {
+ notFound();
+ }
+
+ const markdown = await getLLMText(page);
+ const MDX = page.data.body;
+
+ return (
+
+
+
+
+
+
+
+
+
+ ),
+ }}
+ tableOfContentPopover={{ enabled: false }}
+ toc={page.data.toc}
+ >
+
+ {page.data.title}
+ {page.data.description}
+
+
+
+
+ );
+};
+
+export const generateStaticParams = () =>
+ source
+ .generateParams()
+ .filter(
+ (params) => !(Array.isArray(params.slug) && params.slug[0] === 'cookbook')
+ );
+
+export const generateMetadata = async ({
+ params,
+}: PageProps<'/[lang]/v5/docs/[[...slug]]'>): Promise => {
+ const { slug, lang } = await params;
+ const page = source.getPage(slug, lang);
+ if (!page) notFound();
+ return {
+ title: `${page.data.title} · Pre-release`,
+ description: page.data.description,
+ openGraph: {
+ images: getPageImage(page).url,
+ },
+ // Pre-release pages are not canonical; point search engines at the
+ // latest URL (or self if this page is v5-only).
+ alternates: {
+ canonical: page.data.preRelease
+ ? `/${lang}/v5${page.url}`
+ : `/${lang}${page.url}`,
+ },
+ robots: {
+ index: false,
+ follow: true,
+ },
+ };
+};
+
+export default Page;
diff --git a/docs/app/[lang]/v5/docs/layout.tsx b/docs/app/[lang]/v5/docs/layout.tsx
new file mode 100644
index 0000000000..3456f74506
--- /dev/null
+++ b/docs/app/[lang]/v5/docs/layout.tsx
@@ -0,0 +1,18 @@
+import { DocsLayout } from '@/components/geistdocs/docs-layout';
+import { PreReleaseBanner } from '@/components/geistdocs/pre-release-banner';
+import { getDocsTreeForVersion } from '@/lib/geistdocs/version-source';
+import { PRE_RELEASE_VERSION } from '@/lib/geistdocs/versions';
+
+const Layout = async ({ children, params }: LayoutProps<'/[lang]/v5/docs'>) => {
+ const { lang } = await params;
+ return (
+
+ );
+};
+
+export default Layout;
diff --git a/docs/app/robots.ts b/docs/app/robots.ts
index a8f7eead72..a4fdaf9690 100644
--- a/docs/app/robots.ts
+++ b/docs/app/robots.ts
@@ -8,6 +8,7 @@ export default function robots(): MetadataRoute.Robots {
rules: {
userAgent: '*',
allow: '/',
+ disallow: ['/*/docs/internal/', '/docs/internal/'],
},
sitemap: `${baseUrl}/sitemap.xml`,
};
diff --git a/docs/app/sitemap.ts b/docs/app/sitemap.ts
index 9f3be47eba..5987c5381a 100644
--- a/docs/app/sitemap.ts
+++ b/docs/app/sitemap.ts
@@ -14,6 +14,8 @@ export default function sitemap(): MetadataRoute.Sitemap {
const pages: MetadataRoute.Sitemap = [];
for (const page of source.getPages()) {
+ // Exclude internal/preview-only pages from sitemap
+ if (page.url.includes('/internal')) continue;
pages.push({
changeFrequency: 'weekly' as const,
lastModified: undefined,
diff --git a/docs/components/geistdocs/pre-release-banner.tsx b/docs/components/geistdocs/pre-release-banner.tsx
new file mode 100644
index 0000000000..a7c7579a6b
--- /dev/null
+++ b/docs/components/geistdocs/pre-release-banner.tsx
@@ -0,0 +1,49 @@
+import Link from 'next/link';
+import {
+ buildVersionUrl,
+ LATEST_VERSION,
+ PRE_RELEASE_VERSION,
+} from '@/lib/geistdocs/versions';
+
+interface PreReleaseBannerProps {
+ pathname: string;
+}
+
+const SparklesFilled = ({ className }: { className?: string }) => (
+
+);
+
+export const PreReleaseBanner = ({ pathname }: PreReleaseBannerProps) => {
+ const latestHref = buildVersionUrl(pathname, LATEST_VERSION);
+ return (
+
+
+
+
+
+ Viewing Workflow {PRE_RELEASE_VERSION.id.replace(/^v/, '')}{' '}
+ (Pre-release) Documentation.
+
+
+
+ Go to Workflow {LATEST_VERSION.id.replace(/^v/, '')} (Latest)
+
+
+
+ );
+};
diff --git a/docs/components/geistdocs/sidebar.tsx b/docs/components/geistdocs/sidebar.tsx
index aa1f78b86b..50e21f1d91 100644
--- a/docs/components/geistdocs/sidebar.tsx
+++ b/docs/components/geistdocs/sidebar.tsx
@@ -23,6 +23,7 @@ import {
import { Badge } from '@/components/ui/badge';
import { useSidebarContext } from '@/hooks/geistdocs/use-sidebar';
import { SearchButton } from './search';
+import { VersionSwitcher } from './version-switcher';
// Map of URL suffixes to badges shown inline next to the sidebar item name.
const SIDEBAR_ITEM_BADGES: Array<{ suffix: string; label: string }> = [
@@ -70,6 +71,7 @@ export const Sidebar = () => {
data-sidebar-placeholder
>
+
{renderSidebarList(root.children)}
@@ -82,6 +84,7 @@ export const Sidebar = () => {
setIsOpen(false)} />
+
{renderSidebarList(root.children)}
diff --git a/docs/components/geistdocs/version-switcher.tsx b/docs/components/geistdocs/version-switcher.tsx
new file mode 100644
index 0000000000..f9441224b6
--- /dev/null
+++ b/docs/components/geistdocs/version-switcher.tsx
@@ -0,0 +1,114 @@
+'use client';
+
+import { Check, ChevronDown } from 'lucide-react';
+import { usePathname, useRouter } from 'next/navigation';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import {
+ buildVersionUrl,
+ type DocsVersion,
+ getVersionFromPathname,
+ VERSIONS,
+} from '@/lib/geistdocs/versions';
+import { cn } from '@/lib/utils';
+
+const VersionIcon = ({ version }: { version: DocsVersion }) => {
+ const container = version.preRelease
+ ? 'bg-orange-100 border-orange-300 dark:bg-orange-800 dark:border-orange-700'
+ : 'bg-blue-100 border-blue-300 dark:bg-blue-200 dark:border-blue-700';
+ const iconColor = version.preRelease
+ ? 'text-orange-900 dark:text-orange-200'
+ : 'text-blue-900 dark:text-blue-900';
+ return (
+
+ );
+};
+
+export const VersionSwitcher = () => {
+ const pathname = usePathname();
+ const router = useRouter();
+ const active = getVersionFromPathname(pathname);
+
+ return (
+
+
+
+
+ {active.label}
+
+ {active.subtitle}
+
+
+
+
+
+ {VERSIONS.map((version) => {
+ const isActive = version.id === active.id;
+ return (
+ {
+ if (isActive) return;
+ router.push(buildVersionUrl(pathname, version));
+ }}
+ >
+
+
+
+ {version.label}
+
+
+ {version.subtitle}
+
+
+ {isActive && (
+
+ )}
+
+ );
+ })}
+
+
+ );
+};
diff --git a/docs/components/preview-install-server.tsx b/docs/components/preview-install-server.tsx
new file mode 100644
index 0000000000..e4104199fc
--- /dev/null
+++ b/docs/components/preview-install-server.tsx
@@ -0,0 +1,13 @@
+import { PreviewInstall } from './preview-install';
+
+/**
+ * Server component wrapper that reads VERCEL_URL at build/render time
+ * and passes it to the client component. For use in MDX pages.
+ */
+export function PreviewInstallServer() {
+ const deploymentUrl = process.env.VERCEL_URL
+ ? `https://${process.env.VERCEL_URL}`
+ : 'http://localhost:3000';
+
+ return ;
+}
diff --git a/docs/components/preview-install.tsx b/docs/components/preview-install.tsx
new file mode 100644
index 0000000000..bf10c21ac7
--- /dev/null
+++ b/docs/components/preview-install.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import { CheckIcon, CopyIcon } from 'lucide-react';
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+
+function CopyButton({ text }: { text: string }) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = () => {
+ navigator.clipboard.writeText(text).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ };
+
+ return (
+
+ );
+}
+
+export function PreviewInstall({ deploymentUrl }: { deploymentUrl: string }) {
+ const baseUrl = deploymentUrl.replace(/\/$/, '');
+ const installCmd = `pnpm i ${baseUrl}/workflow.tgz`;
+ const npxCmd = `npx workflow@${baseUrl}/workflow.tgz web`;
+
+ return (
+
+
+
+ Install the workflow package from this preview:
+
+
+
+ {installCmd}
+
+
+
+
+
+
+ Run the web UI in your project:
+
+
+ {npxCmd}
+
+
+
+
+ );
+}
diff --git a/docs/components/worlds/WorldTestingPerformanceMDX.tsx b/docs/components/worlds/WorldTestingPerformanceMDX.tsx
index 3588d1103f..e363b58dba 100644
--- a/docs/components/worlds/WorldTestingPerformanceMDX.tsx
+++ b/docs/components/worlds/WorldTestingPerformanceMDX.tsx
@@ -14,6 +14,11 @@ export function WorldTestingPerformanceMDX({
}) {
const { worldId, world, meta } = useWorldData();
return (
-
+
);
}
diff --git a/docs/content/docs/errors/abort-signal-timeout-in-workflow.mdx b/docs/content/docs/errors/abort-signal-timeout-in-workflow.mdx
new file mode 100644
index 0000000000..8d90b9ec5e
--- /dev/null
+++ b/docs/content/docs/errors/abort-signal-timeout-in-workflow.mdx
@@ -0,0 +1,81 @@
+---
+title: abort-signal-timeout-in-workflow
+description: AbortSignal.timeout() cannot be used inside workflow functions because it relies on real timers which break deterministic replay.
+type: troubleshooting
+preRelease: true
+summary: Use sleep() with AbortController instead of AbortSignal.timeout() in workflow functions.
+prerequisites:
+ - /docs/foundations/workflows-and-steps
+related:
+ - /docs/foundations/cancellation
+ - /docs/api-reference/workflow/sleep
+ - /docs/errors/timeout-in-workflow
+---
+
+## Error
+
+```
+AbortSignal.timeout() is not supported in workflow functions.
+Use sleep() with an AbortController instead.
+```
+
+## Why This Happens
+
+`AbortSignal.timeout()` creates a signal that aborts after a real-time delay using an internal timer. Workflow functions must be [deterministic](/docs/foundations/workflows-and-steps) to support replay — they run the same code multiple times during the workflow's lifecycle, using the [event log](/docs/how-it-works/event-sourcing) to resume execution to the correct point.
+
+Real-time timers break this determinism because:
+- On the first execution, the timer might fire after 10 seconds
+- On replay, the timer would fire again, but the event log may have already advanced past that point
+- The timer's behavior depends on wall-clock time, which varies between executions
+
+## How to Fix
+
+Use [`sleep()`](/docs/api-reference/workflow/sleep) with an `AbortController` to create a deterministic timeout that cancels in-flight work:
+
+**Before (incorrect):**
+
+{/* @skip-typecheck: intentionally incorrect example */}
+```typescript lineNumbers
+export async function workflow() {
+ "use workflow";
+
+ // This will throw an error
+ const signal = AbortSignal.timeout(10_000); // [!code highlight]
+ const result = await fetchData(signal);
+ return result;
+}
+```
+
+**After (correct):**
+
+```typescript lineNumbers
+import { sleep } from "workflow";
+
+export async function workflow() {
+ "use workflow";
+
+ const controller = new AbortController(); // [!code highlight]
+ void sleep("10s").then(() => controller.abort()); // [!code highlight]
+
+ return await fetchData(controller.signal);
+}
+
+async function fetchData(signal: AbortSignal) {
+ "use step";
+ const response = await fetch("https://api.example.com/data", { signal });
+ return response.json();
+}
+```
+
+The `sleep()` + `AbortController` pattern is the durable equivalent of `AbortSignal.timeout()`. The sleep is recorded in the event log, so it replays deterministically. If `fetchData` finishes within 10 seconds you get the response; if not, the timer fires `controller.abort()`, `fetch` rejects with an `AbortError`, and the step's failure propagates to the workflow as a `FatalError` (no retries — abort is intentional cancellation).
+
+
+`AbortSignal.timeout()` works normally inside step functions, since steps have full Node.js runtime access and are not replayed.
+
+
+## Related
+
+- [Cancellation](/docs/foundations/cancellation) — Patterns for cancelling in-flight work
+- [`sleep()` API Reference](/docs/api-reference/workflow/sleep) — Durable sleep primitive
+- [Workflows and Steps](/docs/foundations/workflows-and-steps) — Why workflow functions must be deterministic
+- [`setTimeout` in Workflow](/docs/errors/timeout-in-workflow) — Similar restriction on `setTimeout`
diff --git a/docs/content/docs/foundations/cancellation.mdx b/docs/content/docs/foundations/cancellation.mdx
new file mode 100644
index 0000000000..effd010210
--- /dev/null
+++ b/docs/content/docs/foundations/cancellation.mdx
@@ -0,0 +1,461 @@
+---
+title: Cancellation
+description: Cancel long-running steps cooperatively using AbortSignal, or cancel entire workflow runs.
+type: conceptual
+preRelease: true
+summary: Cancel in-flight work with AbortSignal or stop entire workflow runs.
+prerequisites:
+ - /docs/foundations/workflows-and-steps
+related:
+ - /docs/foundations/common-patterns
+ - /docs/foundations/hooks
+ - /docs/how-it-works/cancellation
+---
+
+Workflow DevKit supports two cancellation mechanisms: **AbortSignal** for fine-grained, cooperative cancellation of individual operations, and **run cancellation** for stopping an entire workflow. This guide covers both.
+
+## AbortSignal
+
+`AbortController` and `AbortSignal` work across workflow and step boundaries. Create an `AbortController` with `new AbortController()` in a workflow function, pass its signal to steps, and call `abort()` — using the standard [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) API you already know.
+
+```typescript lineNumbers
+import { sleep } from "workflow";
+
+export async function cancellableWorkflow() {
+ "use workflow";
+
+ const controller = new AbortController(); // [!code highlight]
+
+ const result = await Promise.race([
+ longRunningStep(controller.signal), // [!code highlight]
+ sleep("30s").then(() => "timeout" as const),
+ ]);
+
+ if (result === "timeout") {
+ controller.abort(); // [!code highlight]
+ return { status: "timed out" };
+ }
+
+ return { status: "completed", result };
+}
+
+async function longRunningStep(signal: AbortSignal) {
+ "use step";
+
+ const response = await fetch("https://api.example.com/slow-operation", {
+ signal, // [!code highlight]
+ });
+
+ return response.json();
+}
+```
+
+No special imports, no wrapper functions — just the standard `AbortController` API.
+
+
+Cancellation is **cooperative**. Aborting a signal doesn't forcefully kill a step — it's up to the step's code to check `signal.aborted` or pass the signal to APIs like `fetch` that respect it. If a step ignores the signal, it runs to completion.
+
+
+
+To learn how `AbortController` works durably across workflow suspensions, replays, and step boundaries, see [How Cancellation Works](/docs/how-it-works/cancellation).
+
+
+### Timeout with Cancellation
+
+Race a step against a timeout, and cancel the step if the timeout wins:
+
+```typescript lineNumbers
+import { sleep } from "workflow";
+
+export async function fetchWithTimeout(url: string) {
+ "use workflow";
+
+ const controller = new AbortController();
+
+ const result = await Promise.race([
+ fetchUrl(url, controller.signal),
+ sleep("10s").then(() => null),
+ ]);
+
+ if (result === null) {
+ controller.abort(); // [!code highlight]
+ throw new Error(`Request to ${url} timed out after 10s`);
+ }
+
+ return result;
+}
+
+async function fetchUrl(url: string, signal: AbortSignal) {
+ "use step";
+ const response = await fetch(url, { signal });
+ return response.json();
+}
+```
+
+### Cancelling Parallel Work
+
+When racing multiple steps, cancel the losers:
+
+```typescript lineNumbers
+export async function firstResponder(urls: string[]) {
+ "use workflow";
+
+ const controller = new AbortController();
+
+ const result = await Promise.race( // [!code highlight]
+ urls.map((url) => fetchUrl(url, controller.signal)) // [!code highlight]
+ ); // [!code highlight]
+
+ controller.abort(); // Cancel remaining fetches // [!code highlight]
+
+ return result;
+}
+
+async function fetchUrl(url: string, signal: AbortSignal) {
+ "use step";
+ const response = await fetch(url, { signal });
+ return { url, data: await response.json() };
+}
+```
+
+### Passing Signal Through a Pipeline
+
+Pass the same signal to a chain of steps. Aborting cancels whichever step is currently running:
+
+```typescript lineNumbers
+declare function splitIntoChunks(data: ArrayBuffer): ArrayBuffer[]; // @setup
+declare function processChunk(chunk: ArrayBuffer): Promise; // @setup
+
+export async function pipelineWorkflow(dataUrl: string) {
+ "use workflow";
+
+ const controller = new AbortController();
+
+ try {
+ const raw = await downloadData(dataUrl, controller.signal);
+ const transformed = await transformData(raw, controller.signal);
+ const result = await uploadData(transformed, controller.signal);
+ return result;
+ } catch (err) {
+ if (err instanceof Error && err.name === "AbortError") {
+ return { status: "cancelled" };
+ }
+ throw err;
+ }
+}
+
+async function downloadData(url: string, signal: AbortSignal) {
+ "use step";
+ const response = await fetch(url, { signal });
+ return response.arrayBuffer();
+}
+
+async function transformData(data: ArrayBuffer, signal: AbortSignal) {
+ "use step";
+
+ signal.throwIfAborted(); // [!code highlight]
+
+ const chunks = splitIntoChunks(data);
+ const results = [];
+
+ for (const chunk of chunks) {
+ signal.throwIfAborted(); // [!code highlight]
+ results.push(await processChunk(chunk));
+ }
+
+ return Buffer.concat(results);
+}
+
+async function uploadData(data: ArrayBuffer, signal: AbortSignal) {
+ "use step";
+ await fetch("https://storage.example.com/upload", {
+ method: "POST",
+ body: data,
+ signal,
+ });
+ return { status: "uploaded" };
+}
+```
+
+### Step-Initiated Abort
+
+A step can receive the full `AbortController` and call `abort()` to cancel parallel work. This is useful for watchdog/monitor patterns where one step observes an external condition and cancels other in-flight steps:
+
+```typescript lineNumbers
+export async function processWithQuotaCheck(userId: string, dataUrl: string) {
+ "use workflow";
+
+ const controller = new AbortController();
+
+ // Run the work and a quota monitor in parallel
+ const [result] = await Promise.all([ // [!code highlight]
+ processData(dataUrl, controller.signal), // [!code highlight]
+ monitorQuota(userId, controller), // [!code highlight]
+ ]); // [!code highlight]
+
+ return result;
+}
+
+async function processData(url: string, signal: AbortSignal) {
+ "use step";
+ const response = await fetch(url, { signal });
+ const data = await response.arrayBuffer();
+ // ... expensive processing ...
+ return { processed: true };
+}
+
+async function monitorQuota(userId: string, controller: AbortController) {
+ "use step";
+
+ // Poll quota status while the other step is running
+ while (!controller.signal.aborted) {
+ const quota = await fetch(`https://api.example.com/quota/${userId}`);
+ const { exceeded } = await quota.json();
+
+ if (exceeded) {
+ controller.abort("Quota exceeded"); // Cancels processData // [!code highlight]
+ return;
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+ }
+}
+```
+
+### User-Triggered Cancellation with Hooks
+
+Combine hooks with abort controllers to let users cancel in-flight work from an external API:
+
+```typescript lineNumbers
+import { createHook } from "workflow";
+
+export async function userCancellableWorkflow(jobId: string) {
+ "use workflow";
+
+ using cancelHook = createHook<{ reason: string }>({
+ token: `cancel:${jobId}`,
+ });
+
+ const controller = new AbortController();
+ const workPromise = doExpensiveWork(controller.signal);
+
+ const result = await Promise.race([ // [!code highlight]
+ workPromise.then((data) => ({ status: "completed", data })),
+ cancelHook.then((payload) => { // [!code highlight]
+ controller.abort(); // [!code highlight]
+ return { status: "cancelled", reason: payload.reason };
+ }),
+ ]);
+
+ return result;
+}
+
+async function doExpensiveWork(signal: AbortSignal) {
+ "use step";
+ const response = await fetch("https://api.example.com/expensive", { signal });
+ return response.json();
+}
+```
+
+```typescript title="app/api/cancel/route.ts" lineNumbers
+import { resumeHook } from "workflow/api";
+
+export async function POST(request: Request) {
+ const { jobId, reason } = await request.json();
+
+ await resumeHook(`cancel:${jobId}`, { reason });
+ return Response.json({ cancelled: true });
+}
+```
+
+### How Steps Handle Abort
+
+When an `AbortSignal` is aborted, the behavior depends on how the step uses it:
+
+| Usage | Behavior on Abort |
+|-------|-------------------|
+| `fetch(url, { signal })` | Request is cancelled, throws `AbortError` |
+| `signal.throwIfAborted()` | Throws the abort reason |
+| `signal.aborted` check | Returns `true`, step can exit gracefully |
+| `signal.addEventListener('abort', fn)` | Callback fires, step can clean up |
+| Ignored | Step runs to completion (abort is cooperative) |
+
+### Abort Errors Skip Retries
+
+When a step throws due to an abort (e.g., `fetch` throws `AbortError`, or `signal.throwIfAborted()` throws), the error is automatically wrapped in a `FatalError`. This means the step **skips retries** and the error bubbles up to the workflow immediately.
+
+This is the correct behavior because an abort is an intentional cancellation — retrying the step would just result in another abort. You don't need to manually wrap abort errors in `FatalError`.
+
+```typescript lineNumbers
+import { sleep } from "workflow";
+
+export async function workflow() {
+ "use workflow";
+ const controller = new AbortController();
+
+ try {
+ const result = await Promise.race([
+ cancellableStep(controller.signal),
+ sleep("5s").then(() => null),
+ ]);
+ if (result === null) controller.abort();
+ return result;
+ } catch (err) {
+ // AbortError arrives as FatalError — no retries attempted // [!code highlight]
+ return { status: "cancelled" };
+ }
+}
+
+async function cancellableStep(signal: AbortSignal) {
+ "use step";
+ // If this throws AbortError, it's automatically wrapped in FatalError
+ const response = await fetch("https://api.example.com/slow", { signal });
+ return response.json();
+}
+```
+
+### Passing AbortSignal as Workflow Input
+
+You can pass an `AbortSignal` from external code into a workflow via `start()`:
+
+{/* @skip-typecheck: myWorkflow is not declared, this is a conceptual snippet */}
+```typescript lineNumbers
+import { start } from "workflow/api";
+
+export async function POST(request: Request) {
+ const controller = new AbortController();
+ const run = await start(myWorkflow, [controller.signal]); // [!code highlight]
+
+ // Later, cancel from external code
+ controller.abort(); // [!code highlight]
+}
+```
+
+When the signal is serialized at the `start()` boundary, an event listener is attached to the external signal that writes the cancellation packet to the backing stream. This means the external `abort()` propagates into the workflow — but only while the originating process is still alive (same constraint as passing a `ReadableStream` as input).
+
+
+For reliable external cancellation that works regardless of process lifetime, prefer the [User-Triggered Cancellation with Hooks](#user-triggered-cancellation-with-hooks) pattern. Hooks are durable and don't depend on the caller's process staying alive.
+
+
+## Run Cancellation
+
+Run cancellation stops an entire workflow at the next suspension point. Unlike `AbortSignal`, it is not cooperative — the workflow does not continue executing after cancellation.
+
+```typescript title="app/api/cancel-run/route.ts" lineNumbers
+import { getRun } from "workflow/api";
+
+export async function POST(request: Request) {
+ const { runId } = await request.json();
+
+ const run = getRun(runId);
+ await run.cancel(); // [!code highlight]
+
+ return Response.json({ cancelled: true });
+}
+```
+
+
+Calling `run.cancel()` is the same action as clicking the **Cancel** button on a run in the observability UI — both produce identical `run_cancelled` events in the event log.
+
+
+When a run is cancelled:
+- The workflow stops at its next suspension point (step call, hook await, or sleep)
+- A `run_cancelled` event is recorded in the [event log](/docs/how-it-works/event-sourcing)
+- All associated hooks are disposed and their tokens released
+- Streams are closed
+
+
+Run cancellation does **not** automatically abort any outstanding `AbortSignal`s. Steps that are currently executing will run to completion. If you need in-flight cancellation of specific operations, use `AbortSignal`.
+
+
+## AbortSignal vs. Run Cancellation
+
+| | AbortSignal | Run Cancellation |
+|---|---|---|
+| **Scope** | Individual operations within a step | Entire workflow run |
+| **Triggered by** | Your code (`controller.abort()`) | External API (`run.cancel()`) |
+| **Cooperative** | Yes — steps must check the signal | No — workflow stops at the next suspension point |
+| **Granularity** | Can target specific steps or operations | All-or-nothing |
+| **In-flight steps** | Aborted immediately if using the signal | Run to completion |
+
+Use `AbortSignal` when you need fine-grained, in-flight cancellation of specific operations. Use run cancellation when you want to stop the entire workflow.
+
+## Best Practices
+
+**Use `throwIfAborted()` before expensive work.** This throws the signal's abort reason if the signal is already aborted, preventing wasted compute:
+
+```typescript lineNumbers
+async function expensiveStep(signal: AbortSignal) {
+ "use step";
+ signal.throwIfAborted(); // [!code highlight]
+ // ... expensive work ...
+}
+```
+
+**Handle abort errors in the workflow.** Abort errors arrive as `FatalError` (no retries) and can be caught with a standard try/catch:
+
+```typescript lineNumbers
+declare function cancellableStep(signal: AbortSignal): Promise; // @setup
+import { FatalError } from "workflow";
+
+export async function workflow() {
+ "use workflow";
+ const controller = new AbortController();
+
+ try {
+ await cancellableStep(controller.signal);
+ } catch (err) {
+ if (FatalError.is(err)) { // [!code highlight]
+ return { status: "cancelled" };
+ }
+ throw err;
+ }
+}
+```
+
+**Use `AbortSignal.any()` to combine signals:**
+
+```typescript lineNumbers
+async function stepWithMultipleSignals(
+ userSignal: AbortSignal,
+ timeoutSignal: AbortSignal
+) {
+ "use step";
+
+ const combined = AbortSignal.any([userSignal, timeoutSignal]); // [!code highlight]
+ const response = await fetch("https://api.example.com/data", {
+ signal: combined,
+ });
+ return response.json();
+}
+```
+
+**Abort after a race:**
+
+```typescript lineNumbers
+declare function stepA(signal: AbortSignal): Promise; // @setup
+declare function stepB(signal: AbortSignal): Promise; // @setup
+
+export async function workflow() {
+ "use workflow";
+ const controller = new AbortController();
+
+ const winner = await Promise.race([
+ stepA(controller.signal),
+ stepB(controller.signal),
+ ]);
+
+ controller.abort(); // Clean up whichever step is still running // [!code highlight]
+ return winner;
+}
+```
+
+This is safe even if both steps have already completed — aborting a finished operation is a no-op.
+
+## Related Documentation
+
+- [How Cancellation Works](/docs/how-it-works/cancellation) — Hook and stream backing, serialization internals
+- [Serialization](/docs/foundations/serialization) — Understanding serializable types
+- [Common Patterns](/docs/foundations/common-patterns) — Timeout and race patterns
+- [Hooks](/docs/foundations/hooks) — Pausing workflows for external events
+- [Errors and Retries](/docs/foundations/errors-and-retries) — Handling step failures
diff --git a/docs/content/docs/foundations/meta.json b/docs/content/docs/foundations/meta.json
index 299085da7a..faeb7c8e58 100644
--- a/docs/content/docs/foundations/meta.json
+++ b/docs/content/docs/foundations/meta.json
@@ -6,6 +6,7 @@
"errors-and-retries",
"hooks",
"streaming",
+ "cancellation",
"serialization",
"idempotency"
],
diff --git a/docs/content/docs/foundations/serialization.mdx b/docs/content/docs/foundations/serialization.mdx
index e909b3aad6..1e3326a466 100644
--- a/docs/content/docs/foundations/serialization.mdx
+++ b/docs/content/docs/foundations/serialization.mdx
@@ -55,6 +55,50 @@ These types have special handling and are explained in detail in the sections be
- `Response`
- `ReadableStream`
- `WritableStream`
+- `AbortController`
+- `AbortSignal`
+
+## Pass-by-Value Semantics
+
+**Parameters are passed by value, not by reference.** Steps receive deserialized copies of data. Mutations inside a step won't affect the original in the workflow.
+
+**Incorrect:**
+
+```typescript title="workflows/incorrect-mutation.ts" lineNumbers
+export async function updateUserWorkflow(userId: string) {
+ "use workflow";
+
+ let user = { id: userId, name: "John", email: "john@example.com" };
+ await updateUserStep(user);
+
+ // user.email is still "john@example.com" // [!code highlight]
+ console.log(user.email); // [!code highlight]
+}
+
+async function updateUserStep(user: { id: string; name: string; email: string }) {
+ "use step";
+ user.email = "newemail@example.com"; // Changes are lost // [!code highlight]
+}
+```
+
+**Correct - return the modified data:**
+
+```typescript title="workflows/correct-mutation.ts" lineNumbers
+export async function updateUserWorkflow(userId: string) {
+ "use workflow";
+
+ let user = { id: userId, name: "John", email: "john@example.com" };
+ user = await updateUserStep(user); // Reassign the return value // [!code highlight]
+
+ console.log(user.email); // "newemail@example.com"
+}
+
+async function updateUserStep(user: { id: string; name: string; email: string }) {
+ "use step";
+ user.email = "newemail@example.com";
+ return user; // [!code highlight]
+}
+```
**Custom Classes:**
@@ -125,6 +169,39 @@ export async function fetch(...args: Parameters) {
This allows you to make HTTP requests directly in workflow functions while maintaining deterministic replay behavior through automatic caching.
+## AbortController & AbortSignal
+
+`AbortController` and `AbortSignal` are serializable types that enable cooperative cancellation across workflow and step boundaries. Inside a workflow function, `new AbortController()` creates a durable controller that works across suspensions and step boundaries:
+
+```typescript lineNumbers
+import { sleep } from "workflow";
+
+export async function cancellableWorkflow() {
+ "use workflow";
+
+ const controller = new AbortController(); // [!code highlight]
+
+ const result = await Promise.race([
+ fetchData(controller.signal), // [!code highlight]
+ sleep("10s").then(() => null),
+ ]);
+
+ if (result === null) {
+ controller.abort(); // [!code highlight]
+ }
+
+ return result;
+}
+
+async function fetchData(signal: AbortSignal) {
+ "use step";
+ const response = await fetch("https://api.example.com/data", { signal });
+ return response.json();
+}
+```
+
+For usage patterns including timeouts, parallel cancellation, user-triggered cancellation, and run cancellation, see the [Cancellation Guide](/docs/foundations/cancellation). For details on the hook and stream backing that makes this work, see [How Cancellation Works](/docs/how-it-works/cancellation).
+
## Custom Class Serialization
By default, custom class instances cannot be serialized because the serialization system doesn't know how to reconstruct them. You can make your classes serializable by implementing two static methods using special symbols from the `@workflow/serde` package.
@@ -332,44 +409,3 @@ export async function processOrderWorkflow() {
}
```
-## Pass-by-Value Semantics
-
-**Parameters are passed by value, not by reference.** Steps receive deserialized copies of data. Mutations inside a step won't affect the original in the workflow.
-
-**Incorrect:**
-
-```typescript title="workflows/incorrect-mutation.ts" lineNumbers
-export async function updateUserWorkflow(userId: string) {
- "use workflow";
-
- let user = { id: userId, name: "John", email: "john@example.com" };
- await updateUserStep(user);
-
- // user.email is still "john@example.com" // [!code highlight]
- console.log(user.email); // [!code highlight]
-}
-
-async function updateUserStep(user: { id: string; name: string; email: string }) {
- "use step";
- user.email = "newemail@example.com"; // Changes are lost // [!code highlight]
-}
-```
-
-**Correct - return the modified data:**
-
-```typescript title="workflows/correct-mutation.ts" lineNumbers
-export async function updateUserWorkflow(userId: string) {
- "use workflow";
-
- let user = { id: userId, name: "John", email: "john@example.com" };
- user = await updateUserStep(user); // Reassign the return value // [!code highlight]
-
- console.log(user.email); // "newemail@example.com"
-}
-
-async function updateUserStep(user: { id: string; name: string; email: string }) {
- "use step";
- user.email = "newemail@example.com";
- return user; // [!code highlight]
-}
-```
diff --git a/docs/content/docs/how-it-works/cancellation.mdx b/docs/content/docs/how-it-works/cancellation.mdx
new file mode 100644
index 0000000000..f4f1d6b95c
--- /dev/null
+++ b/docs/content/docs/how-it-works/cancellation.mdx
@@ -0,0 +1,288 @@
+---
+title: How Cancellation Works
+description: Learn how AbortController is made durable using hooks and streams under the hood.
+type: conceptual
+preRelease: true
+summary: Understand the hook and stream backing that makes AbortSignal work across workflow boundaries.
+prerequisites:
+ - /docs/foundations/cancellation
+ - /docs/how-it-works/event-sourcing
+related:
+ - /docs/foundations/hooks
+ - /docs/foundations/streaming
+ - /docs/foundations/serialization
+---
+
+
+This guide explains how cancellation works internally. Understanding these details is helpful for debugging and advanced use cases, but is not required to use `AbortController` in workflows. For usage patterns, see the [Cancellation](/docs/foundations/cancellation) guide.
+
+
+When you write `new AbortController()` in a workflow function, Workflow DevKit creates a durable controller backed by two existing primitives: a [hook](/docs/foundations/hooks) and a [stream](/docs/foundations/streaming). This page explains why both are needed and how they work together.
+
+## The Problem
+
+`AbortController` and `AbortSignal` are inherently stateful — an abort happens once and is permanent. In a durable workflow, this state must:
+
+1. **Survive replay** — If `abort()` was called, `signal.aborted` must return `true` on every subsequent replay of the workflow.
+2. **Propagate in real-time** — A running step on a different compute instance must receive the abort immediately, not on the next replay.
+
+No single primitive solves both. Hooks provide durable event log state but can't reach into a running step. Streams provide real-time cross-process communication but aren't part of the event log. The solution is to use both.
+
+## Dual Backing: Hook + Stream
+
+Every `AbortController` in the workflow context is backed by:
+
+### Hook (Durable State)
+
+When `new AbortController()` is called in a workflow, an internal hook is created — similar to calling `createHook()`. This hook is registered in the workflow's invocations queue and produces events in the [event log](/docs/how-it-works/event-sourcing):
+
+- **On creation**: A `hook_created` event records that the controller exists
+- **On abort**: The hook is resumed (producing a `hook_received` event), recording the abort permanently
+- **On replay**: The event consumer processes the `hook_received` event and updates `signal.aborted` to `true` at the same point in the replay as the original abort
+
+This gives the workflow deterministic access to the abort state — `controller.signal.aborted` always returns the correct value, even after cold starts.
+
+### Stream (Real-Time Propagation)
+
+When `controller.signal` is serialized as a step argument, a stream name is included in the serialized form. Inside the step, the deserialized `AbortSignal` listens on this stream:
+
+- **On abort**: A cancellation packet is written to the stream
+- **In the step**: A background reader receives the packet and calls `abort()` on the local `AbortController`, firing the signal immediately
+
+This gives steps real-time cancellation without waiting for the workflow to replay.
+
+### Why Both?
+
+| Mechanism | Solves | Doesn't Solve |
+|---|---|---|
+| Hook only | Deterministic replay, event log consistency | Can't reach into a running step on another instance |
+| Stream only | Real-time propagation to running steps | Not part of the event log, lost on replay |
+| Hook + Stream | Both | — |
+
+## Lifecycle
+
+### 1. Controller Created in Workflow
+
+```
+new AbortController()
+ │
+ ├─→ Internal hook created (registered in invocations queue)
+ └─→ Stream name generated (deterministic ULID)
+```
+
+### 2. Signal Passed to Step
+
+```
+stepFunction(controller.signal)
+ │
+ ├─→ Signal serialized as { streamName, hookToken, aborted }
+ └─→ In the step: deserialized as real AbortSignal
+ │
+ └─→ Background reader listens on stream for abort packet
+```
+
+### 3. abort() Called in Workflow
+
+```
+controller.abort()
+ │
+ ├─→ signal.aborted set to true (synchronous, local state)
+ ├─→ Hook marked for resumption in invocations queue
+ └─→ Workflow suspends (reaches next step/sleep/hook await)
+ │
+ ├─→ Suspension handler creates hook_received event
+ ├─→ Suspension handler writes cancellation packet to stream
+ │ │
+ │ └─→ Step receives packet → local signal fires → fetch cancelled
+ └─→ Workflow re-enqueued for replay
+```
+
+### 4. Workflow Replays After Abort
+
+```
+Replay starts → events loaded
+ │
+ ├─→ new AbortController() → hook created → event consumer subscribes
+ ├─→ hook_created event consumed
+ ├─→ hook_received event consumed → signal.aborted re-asserted as true
+ └─→ Workflow code sees signal.aborted === true at the correct point in replay
+```
+
+On replay, the events consumer re-applies the abort by calling `_setAborted` when it encounters the `hook_received` event in the log — at the same point in execution where the original `abort()` happened. This is what makes the abort deterministic across replays.
+
+## Where the Hook Is Created
+
+The backing hook is set up whenever an `AbortController` or `AbortSignal` enters the workflow context:
+
+**`new AbortController()` in a workflow function** — The workflow VM provides a durable `AbortController` implementation (similar to how it provides deterministic `Date` and serializable `Request`/`Response`). The hook is created in the constructor using the orchestrator context injected via VM globals.
+
+**Returned from a step** — A step can create a plain `new AbortController()` and return it. The step-side serializer generates a stream name and hook token (using a random ULID) and includes them in the serialized payload. When the return value is deserialized into the workflow via `hydrateStepReturnValue`, the workflow reviver reads the token from the payload and sets up the hook with that token. Since the serialized payload is stored in the event log (as part of the `step_completed` event), the same token is used on every replay — no deterministic generation needed in the workflow.
+
+**Passed as workflow input** — Conceptually the same as "returned from a step". The **external reducer** handles it at serialization time:
+
+1. Generates a stream name and hook token (random ULID)
+2. Attaches an `abort` event listener on the source signal: when the external code calls `controller.abort()`, the listener writes the cancellation packet to the stream
+3. Pushes the listener's async work into `ops` (awaited via `waitUntil`)
+4. Serializes the reference as `{ streamName, hookToken, aborted }`
+
+The serialized payload (including the generated token) is stored in the event log as part of the workflow's input. When the workflow deserializes the input, the reviver reads the token from the payload and creates the hook — identical to the "returned from a step" case. On replay, the same token is read from the event log, so the hook matches the same events.
+
+If the external code calls `abort()` while the process is still alive (within the `waitUntil` window), the stream packet arrives in the workflow, and the workflow can resume the hook to record it in the event log.
+
+
+Since the external `AbortController` is a plain JavaScript object (not the workflow VM's durable version), the stream write depends on the originating process still being alive. This is the same constraint that applies to passing a `ReadableStream` as a workflow argument — the stream pipe runs via `waitUntil` and requires the process to remain active until the data is written.
+
+
+## Serialization & Deserialization
+
+### Serialized Form
+
+An `AbortController` or `AbortSignal` is serialized as:
+
+{/* @skip-typecheck: type definition, not runnable code */}
+```typescript
+{
+ streamName: string; // e.g., "abrt_01HWKZ..."
+ hookToken: string; // Generated at serialization time, used by workflow reviver to create the hook
+ aborted: boolean; // Current state at serialization time
+ reason?: unknown; // The abort reason, if any
+}
+```
+
+The `streamName` and `hookToken` are generated once at serialization time (in the step or external context) and stored in the event log as part of the serialized payload. On replay, the workflow reviver reads them from the payload — it never generates them itself. This is the same pattern used by `ReadableStream` and `WritableStream` serialization.
+
+### Reducers (Serialization)
+
+**In step context** (`getStepReducers`): When a step returns an `AbortController`, the reducer captures the stream name. If `abort()` was called in the step, `aborted: true` is recorded.
+
+**In workflow context** (`getWorkflowReducers`): The reducer captures the stream name and hook token. These are handles — no I/O happens during serialization in the workflow.
+
+**In external context** (`getExternalReducers`): When an `AbortController` is passed as a workflow argument from outside, the reducer creates the backing stream and serializes the reference.
+
+### Revivers (Deserialization)
+
+**Into step context** (`getStepRevivers`): Creates a real `AbortController`. If `aborted: true`, calls `abort()` immediately. Otherwise, pushes a stream reader into the step's `ops` array that listens for the cancellation packet and calls `abort()` when received.
+
+**Into workflow context** (`getWorkflowRevivers`): Creates the durable AbortController with hook backing. Subscribes to the events consumer for the hook's correlation ID. If the event log contains a `hook_received` event, `signal.aborted` is `true`.
+
+### abort() in a Step
+
+When `abort()` is called on a deserialized `AbortController` inside a step:
+
+1. The local signal is aborted synchronously (standard behavior)
+2. The stream write (cancellation packet) is pushed into `ctx.ops`
+3. The hook resume (`resumeHook`) is pushed into `ctx.ops`
+
+The step's `ops` array is awaited via `waitUntil(Promise.all(ops))` after the step function returns — the same mechanism used by [`getWritable()`](/docs/api-reference/workflow/get-writable). This keeps `abort()` synchronous from the caller's perspective while ensuring the async work completes.
+
+### Abort Errors Are Wrapped in FatalError
+
+When a step throws due to an abort — whether from `fetch` throwing `AbortError`, `signal.throwIfAborted()`, or any other abort-induced error — the step handler wraps the error in `FatalError` before recording it in the event log. This ensures:
+
+- **No retries**: An abort is intentional cancellation, not a transient failure. Retrying would just abort again.
+- **Immediate propagation**: The error bubbles up to the workflow as a `FatalError`, which the workflow can catch with `FatalError.is(err)`.
+
+The wrapping happens at the step handler level (`runtime/step-handler.ts`), during error hydration. When the step's thrown error is an `AbortError` (checked via `err.name === 'AbortError'`), it is treated as fatal regardless of the step's `maxRetries` configuration.
+
+### abort() in the Workflow
+
+When `abort()` is called in the workflow context:
+
+1. `signal.aborted` is updated to `true` immediately (so subsequent reads and serialization capture the correct state)
+2. The internal hook is marked for resumption in the invocations queue (same pattern as `hook.dispose()`)
+3. The workflow continues until it reaches the next suspension point (step call, hook await, or sleep) or completes
+4. The pending queue items are processed:
+ - Creates a `hook_received` event in the event log
+ - Writes the cancellation packet to the stream (for real-time step propagation)
+ - Re-enqueues the workflow for replay
+4. On replay, the event consumer processes the `hook_received` event, updating `signal.aborted` to `true` at the deterministically correct point
+
+`signal.aborted` is updated synchronously so that the workflow can immediately check the state and serialization captures `aborted: true` when passing the signal to steps. On replay, the event consumer also processes the `hook_received` event, ensuring the state is consistent.
+
+For abort specifically, this ensures that:
+
+- The abort's `hook_received` event is created in the event log
+- The cancellation stream packet is written to propagate to running steps
+
+## Race Conditions
+
+### Abort Before Hook Exists
+
+When an `AbortSignal` is passed as a workflow argument via `start()`, the external reducer attaches a listener at serialization time. If the external code calls `abort()` before the workflow has started and created the internal hook, the stream packet is written but the hook doesn't exist yet.
+
+This is resolved through eventual consistency:
+
+1. The stream packet is durable — it persists in storage
+2. When the workflow runs and passes the signal to a step, the step's reviver reads from the stream starting at index 0
+3. The step sees the existing packet, aborts locally, and resumes the hook (via `ops`)
+4. On the next workflow replay, the hook event is in the log and `signal.aborted` is `true`
+
+**Important:** There is a window where the workflow's `signal.aborted` returns `false` even though the external code has already called `abort()`. This lasts until a step processes the stream packet and resumes the hook. This is analogous to hooks — `resumeHook()` doesn't take effect until the workflow replays.
+
+### Abort at Serialization Time
+
+To prevent a micro-window where `abort()` is called between checking `signal.aborted` and attaching the listener, the external reducer uses this order:
+
+1. Attach the `abort` event listener first
+2. Then check `signal.aborted` — if already `true`, the listener won't fire, so handle immediately
+
+This ensures no abort events are missed regardless of timing.
+
+## Stream/Hook Consistency
+
+Since abort involves two operations (stream write + hook resume), partial failure is possible:
+
+### Stream Succeeds, Hook Fails
+
+- Steps see the abort and throw `AbortError` (stream worked)
+- Workflow doesn't see `signal.aborted === true` on the next replay (hook not resumed)
+- The workflow sees the step failure as an error, which it can handle with try/catch
+- **Recovery:** The step-side `resumeHook` call is best-effort — if it throws, the failure is swallowed. Convergence comes from the next replay: when the step's reviver re-reads the stream, it sees the abort packet and calls `resumeHook` again. There's no in-process retry loop; the dual-mechanism design relies on either the stream or the hook eventually landing.
+
+### Hook Succeeds, Stream Fails
+
+- Workflow sees `signal.aborted === true` on replay (hook worked)
+- Steps don't receive real-time cancellation (stream failed) — they run to completion
+- On the next suspension, the workflow knows the abort happened and can stop calling more steps
+- **Recovery:** Natural convergence — no active harm, just missed real-time cancellation for in-flight steps.
+
+### Both Fail
+
+- Abort is lost — no propagation
+- No crash or corruption — the system continues as if abort was never called
+- **Recovery:** The caller can retry the abort. If using a hook for external cancellation, the hook's retry semantics apply.
+
+The dual mechanism provides natural resilience — if either one succeeds, the system converges on the correct state.
+
+## `AbortSignal.timeout()` in Workflow VM
+
+`AbortSignal.timeout()` is blocked in the workflow VM because it depends on real-time timers, which break deterministic replay. Calling it throws an error with a suggestion to use `sleep()` + `AbortController` instead. See [AbortSignal.timeout() in Workflow](/docs/errors/abort-signal-timeout-in-workflow) for details.
+
+`AbortSignal.timeout()` works normally in step functions, which have full Node.js runtime access.
+
+## Request.signal
+
+A `Request`'s `.signal` is forwarded by the `Request` reducer in two cases:
+
+1. **The signal is already aborted.** The serialized payload preserves `aborted: true` and the abort `reason`, so the deserialized step sees the cancellation that happened before the boundary.
+2. **The signal is workflow-managed** (i.e., it has the `ABORT_STREAM_NAME` symbol — produced by a workflow-context `AbortController`). Its hook + stream backing carries through, and the deserialized step listens on the stream as usual.
+
+Plain non-aborted native signals are intentionally dropped, including the auto-generated signal that `new Request(url)` synthesizes when no `signal` is passed. Forwarding every Request signal would mint stream infrastructure for the throwaway auto-signals on every Request, even ones the caller never intended to use for cancellation.
+
+If you want cross-boundary cancellation through a `Request`, build it with a signal from a workflow-context `AbortController`:
+
+{/* @skip-typecheck: conceptual snippet */}
+```typescript
+const controller = new AbortController(); // in workflow function
+const req = new Request(url, { signal: controller.signal });
+await fetchStep(req); // signal carries through
+controller.abort(); // step-side fetch sees the abort
+```
+
+## Related Documentation
+
+- [Cancellation](/docs/foundations/cancellation) — Usage patterns and API
+- [Event Sourcing](/docs/how-it-works/event-sourcing) — How the event log works
+- [Hooks](/docs/foundations/hooks) — The hook primitive
+- [Streaming](/docs/foundations/streaming) — The stream primitive
+- [Serialization](/docs/foundations/serialization) — Serializable types
diff --git a/docs/content/docs/how-it-works/meta.json b/docs/content/docs/how-it-works/meta.json
index 2261f8acc7..0527f6f404 100644
--- a/docs/content/docs/how-it-works/meta.json
+++ b/docs/content/docs/how-it-works/meta.json
@@ -5,7 +5,8 @@
"code-transform",
"framework-integrations",
"event-sourcing",
- "encryption"
+ "encryption",
+ "cancellation"
],
"defaultOpen": false
}
diff --git a/docs/content/docs/internal/index.mdx b/docs/content/docs/internal/index.mdx
new file mode 100644
index 0000000000..119579cf36
--- /dev/null
+++ b/docs/content/docs/internal/index.mdx
@@ -0,0 +1,19 @@
+---
+title: Internal
+description: Preview-only page for internal tools, draft changelogs, and testing utilities.
+type: overview
+---
+
+
+This page is only visible on preview deployments and local development. It does not appear in production.
+
+
+## Preview Package
+
+
+
+## Draft Changelogs
+
+Changelog entries staged here for review before publishing to the Vercel website.
+
+- [Serializable AbortController and AbortSignal](/docs/internal/serializable-abort-controller) — March 12, 2026
diff --git a/docs/content/docs/internal/meta.json b/docs/content/docs/internal/meta.json
new file mode 100644
index 0000000000..22171bf486
--- /dev/null
+++ b/docs/content/docs/internal/meta.json
@@ -0,0 +1,5 @@
+{
+ "title": "Internal",
+ "pages": ["index", "serializable-abort-controller"],
+ "defaultOpen": false
+}
diff --git a/docs/content/docs/internal/serializable-abort-controller.mdx b/docs/content/docs/internal/serializable-abort-controller.mdx
new file mode 100644
index 0000000000..a7aafdcf19
--- /dev/null
+++ b/docs/content/docs/internal/serializable-abort-controller.mdx
@@ -0,0 +1,149 @@
+---
+title: Serializable AbortController and AbortSignal
+description: AbortController and AbortSignal now work across workflow and step boundaries using the standard Web API.
+type: overview
+preRelease: true
+---
+
+# Serializable AbortController and AbortSignal
+
+March 12, 2026
+
+`AbortController` and `AbortSignal` now work natively in workflow functions. Create a controller, pass its signal to steps, and call `abort()` — no special imports or wrapper functions needed.
+
+## What's new
+
+- **Standard API, zero boilerplate.** `new AbortController()` works inside `"use workflow"` functions. The controller and its signal are automatically serialized across workflow and step boundaries.
+- **Dual hook + stream backing for durability.** Under the hood, each controller is backed by a durable [hook](/docs/foundations/hooks) (for replay correctness) and a [stream](/docs/foundations/streaming) (for real-time propagation to running steps). This means aborts survive cold starts, replays, and scale events.
+- **Cooperative cancellation.** Steps receive the abort in real time and can respond by checking `signal.aborted`, calling `signal.throwIfAborted()`, or passing the signal to APIs like `fetch`.
+- **Abort errors skip retries.** When a step throws due to an abort (e.g., `fetch` throws `AbortError`), the error is automatically wrapped in `FatalError` so it skips retries and bubbles up immediately.
+- **`AbortSignal.timeout()` blocked in workflow VM.** Because it relies on real-time timers that break deterministic replay, `AbortSignal.timeout()` throws a helpful error pointing to the `sleep()` + `AbortController` pattern instead.
+- **`Request.signal` preserved when it carries abort state.** A `Request`'s `.signal` is serialized when it's already aborted (so the cancellation that happened pre-serialization is preserved) or when it's a workflow-managed signal (so its hook + stream backing carries through). Plain non-aborted native signals — including the auto-generated signal on `new Request(url)` — are dropped to avoid minting stream infrastructure for every `Request`. To get cross-boundary cancellation through a `Request`, build it with the signal from a workflow-context `AbortController`.
+- **Pending queue items drain on completion.** If you call `abort()` (or `dispose` a hook, or kick off a `void sleep('1d')`, or fire a `void someStep()`) without a suspension point between that call and the workflow's return, the runtime now treats end-of-run as a final suspension and commits all pending operations before the run is marked terminal. This matches normal JS semantics — `setTimeout` etc. continue running after the surrounding function returns. The most important case: `controller.abort()` called as the last statement of a workflow now actually propagates to in-flight steps on other compute instances.
+
+## Timeout with cancellation
+
+Race a step against a durable `sleep()`, and cancel the step if the timeout wins:
+
+```typescript
+import { sleep } from "workflow";
+
+export async function fetchWithTimeout(url: string) {
+ "use workflow";
+
+ const controller = new AbortController();
+
+ const result = await Promise.race([
+ fetchUrl(url, controller.signal),
+ sleep("10s").then(() => null),
+ ]);
+
+ if (result === null) {
+ controller.abort();
+ throw new Error(`Request to ${url} timed out after 10s`);
+ }
+
+ return result;
+}
+
+async function fetchUrl(url: string, signal: AbortSignal) {
+ "use step";
+ const response = await fetch(url, { signal });
+ return response.json();
+}
+```
+
+## Cancelling parallel work
+
+When racing multiple steps, cancel the losers:
+
+```typescript
+declare function fetchUrl(url: string, signal: AbortSignal): Promise<{ url: string; data: unknown }>; // @setup
+
+export async function firstResponder(urls: string[]) {
+ "use workflow";
+
+ const controller = new AbortController();
+
+ const result = await Promise.race(
+ urls.map((url) => fetchUrl(url, controller.signal))
+ );
+
+ controller.abort(); // Cancel remaining fetches
+
+ return result;
+}
+```
+
+## User-triggered cancellation with hooks
+
+Combine hooks with abort controllers to let users cancel work from an external API:
+
+```typescript
+declare function doExpensiveWork(signal: AbortSignal): Promise; // @setup
+import { createHook } from "workflow";
+
+export async function userCancellableWorkflow(jobId: string) {
+ "use workflow";
+
+ using cancelHook = createHook<{ reason: string }>({
+ token: `cancel:${jobId}`,
+ });
+
+ const controller = new AbortController();
+ const workPromise = doExpensiveWork(controller.signal);
+
+ const result = await Promise.race([
+ workPromise.then((data) => ({ status: "completed", data })),
+ cancelHook.then((payload) => {
+ controller.abort();
+ return { status: "cancelled", reason: payload.reason };
+ }),
+ ]);
+
+ return result;
+}
+```
+
+## Step-initiated abort
+
+A step can receive the full `AbortController` and call `abort()` to cancel parallel work — useful for watchdog patterns like quota monitoring:
+
+```typescript
+declare function processData(url: string, signal: AbortSignal): Promise<{ processed: boolean }>; // @setup
+
+export async function processWithQuotaCheck(userId: string, dataUrl: string) {
+ "use workflow";
+
+ const controller = new AbortController();
+
+ const [result] = await Promise.all([
+ processData(dataUrl, controller.signal),
+ monitorQuota(userId, controller),
+ ]);
+
+ return result;
+}
+
+async function monitorQuota(userId: string, controller: AbortController) {
+ "use step";
+
+ while (!controller.signal.aborted) {
+ const quota = await fetch(`https://api.example.com/quota/${userId}`);
+ const { exceeded } = await quota.json();
+
+ if (exceeded) {
+ controller.abort("Quota exceeded"); // Cancels processData
+ return;
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+ }
+}
+```
+
+## Learn more
+
+- [Cancellation](/docs/foundations/cancellation) — Full guide with all usage patterns
+- [How Cancellation Works](/docs/how-it-works/cancellation) — Hook and stream internals
+- [AbortSignal.timeout() in Workflow](/docs/errors/abort-signal-timeout-in-workflow) — Why `AbortSignal.timeout()` is blocked and what to use instead
diff --git a/docs/geistdocs.tsx b/docs/geistdocs.tsx
index f9e5dc4680..704608a838 100644
--- a/docs/geistdocs.tsx
+++ b/docs/geistdocs.tsx
@@ -7,7 +7,7 @@ export const github = {
repo: 'workflow',
};
-export const nav = [
+export const nav: { label: string; href: string; preview?: boolean }[] = [
{
label: 'Docs',
href: '/docs',
@@ -24,6 +24,11 @@ export const nav = [
label: 'Examples',
href: 'https://github.com/vercel/workflow-examples',
},
+ {
+ label: 'Internal',
+ href: '/docs/internal',
+ preview: true,
+ },
];
export const suggestions = [
diff --git a/docs/lib/geistdocs/version-source.ts b/docs/lib/geistdocs/version-source.ts
new file mode 100644
index 0000000000..8ea19853ac
--- /dev/null
+++ b/docs/lib/geistdocs/version-source.ts
@@ -0,0 +1,104 @@
+import type { Node, Root } from 'fumadocs-core/page-tree';
+import { getDocsTreeWithoutCookbook } from './cookbook-source';
+import { source } from './source';
+import type { DocsVersion } from './versions';
+import { PRE_RELEASE_VERSION } from './versions';
+
+type FolderNode = Extract;
+type PageNode = Extract;
+
+function isPreReleaseUrl(url: string | undefined): boolean {
+ if (!url) return false;
+ const page = source.getPageByHref(url);
+ return page?.page.data.preRelease === true;
+}
+
+function isPreReleasePage(node: PageNode): boolean {
+ return isPreReleaseUrl(node.url);
+}
+
+function filterPreReleaseFromNodes(nodes: Node[]): Node[] {
+ const result: Node[] = [];
+ for (const node of nodes) {
+ if (node.type === 'page') {
+ if (!isPreReleasePage(node)) result.push(node);
+ continue;
+ }
+ if (node.type === 'folder') {
+ const children = filterPreReleaseFromNodes(node.children);
+ // Drop empty folders that become empty only because of filtering.
+ if (children.length === 0 && node.children.length > 0) continue;
+ const folder: FolderNode = { ...(node as FolderNode), children };
+ // If the folder's index page is itself preRelease, drop the index
+ // reference so we don't render a broken link.
+ if (folder.index && isPreReleasePage(folder.index as PageNode)) {
+ delete folder.index;
+ }
+ result.push(folder);
+ continue;
+ }
+ result.push(node);
+ }
+ return result;
+}
+
+function rewriteUrl(
+ url: string | undefined,
+ prefix: string
+): string | undefined {
+ if (!url || !prefix) return url;
+ // Only rewrite in-app docs links. External and cookbook links are left alone.
+ if (!url.startsWith('/docs')) return url;
+ return `${prefix}${url}`;
+}
+
+function rewriteNodeUrls(nodes: Node[], prefix: string): Node[] {
+ return nodes.map((node) => {
+ if (node.type === 'page') {
+ return { ...node, url: rewriteUrl(node.url, prefix) } as PageNode;
+ }
+ if (node.type === 'folder') {
+ const folder = { ...(node as FolderNode) };
+ folder.children = rewriteNodeUrls(folder.children, prefix);
+ if (folder.index) {
+ folder.index = {
+ ...folder.index,
+ url: rewriteUrl(folder.index.url, prefix),
+ } as PageNode;
+ }
+ return folder;
+ }
+ return node;
+ });
+}
+
+/**
+ * Build the sidebar tree for a given docs version.
+ *
+ * - v4 (latest): excludes pages marked `preRelease: true`.
+ * - v5 (pre-release): includes every page, with URLs rewritten to the
+ * `/v5/docs/...` namespace so sidebar links stay inside the v5 view.
+ */
+export function getDocsTreeForVersion(
+ lang: string,
+ version: DocsVersion
+): Root {
+ const base = getDocsTreeWithoutCookbook(lang);
+ if (version.preRelease) {
+ return {
+ ...base,
+ children: rewriteNodeUrls(base.children, version.prefix),
+ };
+ }
+ return {
+ ...base,
+ children: filterPreReleaseFromNodes(base.children),
+ };
+}
+
+export function isPagePreRelease(slug: string[] | undefined): boolean {
+ const page = source.getPage(slug ?? []);
+ return page?.data.preRelease === true;
+}
+
+export { PRE_RELEASE_VERSION };
diff --git a/docs/lib/geistdocs/versions.ts b/docs/lib/geistdocs/versions.ts
new file mode 100644
index 0000000000..c81f69b109
--- /dev/null
+++ b/docs/lib/geistdocs/versions.ts
@@ -0,0 +1,71 @@
+export type DocsVersionId = 'v4' | 'v5';
+
+export interface DocsVersion {
+ id: DocsVersionId;
+ label: string;
+ subtitle: string;
+ prefix: string;
+ preRelease: boolean;
+}
+
+export const VERSIONS: DocsVersion[] = [
+ {
+ id: 'v5',
+ label: 'v5 (Pre-release)',
+ subtitle: 'Workflow 5.x',
+ prefix: '/v5',
+ preRelease: true,
+ },
+ {
+ id: 'v4',
+ label: 'v4 (Latest)',
+ subtitle: 'Workflow 4.x',
+ prefix: '',
+ preRelease: false,
+ },
+];
+
+export const LATEST_VERSION = VERSIONS.find((v) => !v.preRelease)!;
+export const PRE_RELEASE_VERSION = VERSIONS.find((v) => v.preRelease)!;
+
+/**
+ * Derive the active docs version from a pathname. Matches `/v5/...` (or
+ * `//v5/...` once locale prefix is applied) against the pre-release
+ * prefix; everything else is v4.
+ */
+export function getVersionFromPathname(pathname: string): DocsVersion {
+ // The v5 segment sits either at the root (default locale hidden) or right
+ // after a locale segment — both cases are covered by checking positions
+ // 0 and 1.
+ const segments = pathname.split('/').filter(Boolean);
+ if (segments[0] === 'v5' || segments[1] === 'v5') {
+ return PRE_RELEASE_VERSION;
+ }
+ return LATEST_VERSION;
+}
+
+/**
+ * Build a URL for the same page under a different version. Preserves the
+ * trailing path after `/docs/` and any locale prefix.
+ *
+ * `usePathname()` can return either `/docs/...` (default locale hidden by
+ * the i18n middleware) or `//docs/...` (non-default locale shown).
+ * We detect the locale segment by checking whether segment 0 is a
+ * structural path token (`docs` or `v5`) rather than assuming position.
+ */
+export function buildVersionUrl(
+ pathname: string,
+ targetVersion: DocsVersion
+): string {
+ const segments = pathname.split('/').filter(Boolean);
+ const isStructural = (s: string | undefined) => s === 'docs' || s === 'v5';
+ const localeSegments =
+ segments[0] && !isStructural(segments[0]) ? segments.slice(0, 1) : [];
+ let rest = segments.slice(localeSegments.length);
+ if (rest[0] === 'v5') rest = rest.slice(1);
+ const prefixSegments = targetVersion.prefix
+ ? [targetVersion.prefix.replace(/^\//, '')]
+ : [];
+ const joined = [...localeSegments, ...prefixSegments, ...rest].join('/');
+ return `/${joined}`.replace(/\/+$/, '') || '/';
+}
diff --git a/docs/next.config.ts b/docs/next.config.ts
index cb9be79bf3..4274418827 100644
--- a/docs/next.config.ts
+++ b/docs/next.config.ts
@@ -67,6 +67,11 @@ const config: NextConfig = {
destination: '/docs/getting-started',
permanent: true,
},
+ {
+ source: '/v5/docs',
+ destination: '/v5/docs/getting-started',
+ permanent: false,
+ },
{
source: '/docs/cookbook',
destination: '/cookbook',
diff --git a/docs/source.config.ts b/docs/source.config.ts
index 2078261af9..7211aed1d4 100644
--- a/docs/source.config.ts
+++ b/docs/source.config.ts
@@ -45,6 +45,9 @@ export const docs = defineDocs({
.optional(),
summary: z.string().optional(),
keywords: z.array(z.string()).optional(),
+ // Pages marked preRelease are only visible under /v5/docs/*.
+ // The default /docs/* (v4) tree filters them out.
+ preRelease: z.boolean().optional(),
}),
postprocess: {
includeProcessedMarkdown: true,
diff --git a/packages/ai/src/agent/durable-agent.ts b/packages/ai/src/agent/durable-agent.ts
index a84d7214dc..b3fbfe6e3c 100644
--- a/packages/ai/src/agent/durable-agent.ts
+++ b/packages/ai/src/agent/durable-agent.ts
@@ -858,15 +858,20 @@ export class DurableAgent {
let effectiveAbortSignal =
options.abortSignal ?? this.generationSettings.abortSignal;
let timeoutId: ReturnType | undefined;
+ // The workflow VM replaces setTimeout with a throwing stub, so the
+ // timeout path is skipped there. The VM sets WORKFLOW_CONTEXT on its
+ // globalThis before user code runs; its absence means real timers work.
+ const inWorkflowVm =
+ (globalThis as any)[Symbol.for('WORKFLOW_CONTEXT')] !== undefined;
if (
options.timeout !== undefined &&
- typeof AbortController !== 'undefined'
+ typeof AbortController !== 'undefined' &&
+ !inWorkflowVm
) {
const timeoutController = new AbortController();
timeoutId = setTimeout(() => timeoutController.abort(), options.timeout);
const timeoutSignal = timeoutController.signal;
if (effectiveAbortSignal) {
- // Combine: whichever fires first wins
const combined = new AbortController();
effectiveAbortSignal.addEventListener('abort', () => combined.abort(), {
once: true,
diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts
index e10b0f3ab5..7817d40fd0 100644
--- a/packages/core/e2e/e2e.test.ts
+++ b/packages/core/e2e/e2e.test.ts
@@ -2614,6 +2614,510 @@ describe('e2e', () => {
}
);
+ // ==========================================================================
+ // AbortController / AbortSignal
+ // ==========================================================================
+
+ describe('AbortController', () => {
+ test(
+ 'abortTimeoutWorkflow: timeout cancels long-running step',
+ { timeout: 60_000 },
+ async () => {
+ const run = await start(await e2e('abortTimeoutWorkflow'), []);
+ const returnValue = await run.returnValue;
+
+ // The workflow races a long step against a 3s sleep timeout.
+ // The sleep wins, so the workflow aborts and returns timed out status.
+ expect(returnValue.status).toBe('timed out');
+ expect(returnValue.aborted).toBe(true);
+
+ // The synchronous `controller.signal.aborted` check above only proves
+ // the WORKFLOW VM saw the abort — it doesn't prove the abort committed
+ // to the event log or propagated to the in-flight step. Inspect the
+ // event log directly to verify the abort hook was both created AND
+ // resumed (the resumption is what writes the cancel packet to the
+ // backing stream and lets the running step see signal.aborted=true).
+ const world = await getWorld();
+ const { data: events } = await world.events.list({ runId: run.runId });
+ const hookCreated = events.find((e) => e.eventType === 'hook_created');
+ const hookReceived = events.find(
+ (e) => e.eventType === 'hook_received'
+ );
+ expect(
+ hookCreated,
+ 'abort hook was never created in the event log'
+ ).toBeDefined();
+ expect(
+ hookReceived,
+ 'abort hook was created but never resumed — the abort did not propagate to the in-flight step'
+ ).toBeDefined();
+ }
+ );
+
+ test(
+ 'abortParallelWorkflow: abort cancels all parallel steps',
+ { timeout: 60_000 },
+ async () => {
+ const run = await start(await e2e('abortParallelWorkflow'), []);
+ const returnValue = await run.returnValue;
+
+ // The workflow races 3 parallel long steps against a 3s sleep.
+ // The sleep wins, so the workflow returns timed out status.
+ expect(returnValue.status).toBe('timed out');
+ }
+ );
+
+ test(
+ 'abortFromStepWorkflow: step abort cancels an in-flight sibling step',
+ { timeout: 60_000 },
+ async () => {
+ const run = await start(await e2e('abortFromStepWorkflow'), []);
+ const returnValue = await run.returnValue;
+
+ // The workflow VM's signal eventually reflects the abort (round-trip
+ // via the hook event resumed by the aborting step).
+ expect(returnValue.workflowAborted).toBe(true);
+ expect(returnValue.stepSawAborted).toBe(true);
+
+ // The crucial assertion: the in-flight sibling step (longStep) must
+ // see the cancellation through the backing stream and exit via the
+ // abort branch within the 1s+poll-interval window — NOT run to its
+ // 30s natural completion. If this is 'completed' instead of 'aborted',
+ // realtime cross-step cancellation is broken.
+ expect(returnValue.longStepResult).toBe('aborted');
+ }
+ );
+
+ test(
+ 'abortAlreadyAbortedWorkflow: pre-aborted signal seen by step',
+ { timeout: 60_000 },
+ async () => {
+ const run = await start(await e2e('abortAlreadyAbortedWorkflow'), []);
+ const returnValue = await run.returnValue;
+
+ // The controller is aborted before passing to the step.
+ // The step should see aborted=true and the reason.
+ expect(returnValue.aborted).toBe(true);
+ expect(returnValue.reason).toBe('pre-aborted');
+ }
+ );
+
+ test(
+ 'abortReasonWorkflow: abort reason preserved across boundaries',
+ { timeout: 60_000 },
+ async () => {
+ const run = await start(await e2e('abortReasonWorkflow'), []);
+ const returnValue = await run.returnValue;
+
+ // The workflow aborts with a custom reason after timeout.
+ // The reason should be preserved when checked in a subsequent step.
+ expect(returnValue.aborted).toBe(true);
+ expect(returnValue.reason).toBe('custom timeout reason');
+ }
+ );
+
+ test(
+ 'abortAfterCompletionWorkflow: abort after step completes is a no-op',
+ { timeout: 60_000 },
+ async () => {
+ const run = await start(await e2e('abortAfterCompletionWorkflow'), []);
+ const returnValue = await run.returnValue;
+
+ // The step runs before abort is called, so it sees aborted=false.
+ // The workflow then aborts — should not cause errors.
+ expect(returnValue.stepSawAborted).toBe(false);
+ expect(returnValue.workflowAborted).toBe(true);
+ }
+ );
+
+ test(
+ 'abortViaHookWorkflow: external hook triggers abort on in-flight step',
+ { timeout: 60_000 },
+ async () => {
+ const token = Math.random().toString(36).slice(2);
+ const run = await start(await e2e('abortViaHookWorkflow'), [token]);
+
+ // Wait for the hook to be registered
+ await new Promise((resolve) => setTimeout(resolve, 5_000));
+
+ // Resume the hook with a cancellation payload
+ const hook = await getHookByToken(token);
+ expect(hook.runId).toBe(run.runId);
+ await resumeHook(hook, { reason: 'user cancelled' });
+
+ const returnValue = await run.returnValue;
+
+ // The hook fires before the long step completes, triggering abort.
+ expect(returnValue.status).toBe('cancelled');
+ expect(returnValue.reason).toBe('user cancelled');
+ }
+ );
+
+ test(
+ 'abortExternalSignalWorkflow: signal passed as workflow input',
+ { timeout: 60_000 },
+ async () => {
+ // Pass a pre-aborted AbortController to the workflow.
+ // The workflow receives the signal and passes it to a step.
+ const controller = new AbortController();
+ controller.abort('external abort');
+
+ const run = await start(await e2e('abortExternalSignalWorkflow'), [
+ controller.signal,
+ ]);
+ const returnValue = await run.returnValue;
+
+ // The step should see the signal as aborted with the reason.
+ expect(returnValue.aborted).toBe(true);
+ expect(returnValue.reason).toBe('external abort');
+ }
+ );
+
+ test(
+ 'abortExternalSignalInFlightWorkflow: external abort fires mid-flight, propagates to nested steps',
+ { timeout: 60_000 },
+ async () => {
+ // Source controller starts NOT aborted. The serialization-time
+ // listener attached during start() must write the cancellation packet
+ // to the backing stream when the controller fires later — and the
+ // in-flight steps' deserialized signals must see the abort propagate.
+ const controller = new AbortController();
+ // Sanity: signal is not aborted at workflow-start time.
+ expect(controller.signal.aborted).toBe(false);
+
+ const run = await start(
+ await e2e('abortExternalSignalInFlightWorkflow'),
+ [controller.signal]
+ );
+
+ // Abort 1.5s after start() so both parallel steps are mid-flight on
+ // their compute instances. The listener attached at serialization time
+ // is what bridges the abort into the workflow's backing stream.
+ const abortTimer = setTimeout(() => {
+ controller.abort('external in-flight abort');
+ }, 1500);
+
+ try {
+ const returnValue = await run.returnValue;
+
+ // Polling step must have seen signal.aborted flip and exited via
+ // its abort branch (NOT its 30s natural-completion path).
+ expect(returnValue.pollResult).toBe('aborted');
+
+ // Listener step must have resolved via its addEventListener callback
+ // (NOT its 30s safety timeout).
+ expect(returnValue.listenerResult.saw).toBe(true);
+ expect(returnValue.listenerResult.via).toBe('listener');
+ } finally {
+ clearTimeout(abortTimer);
+ }
+ }
+ );
+
+ test(
+ 'abortAnyInWorkflowWorkflow: AbortSignal.any composes signals inside the workflow VM',
+ { timeout: 60_000 },
+ async () => {
+ const run = await start(await e2e('abortAnyInWorkflowWorkflow'), []);
+ const returnValue = await run.returnValue;
+
+ // Composite must reflect the source signal's state synchronously
+ // through the WorkflowAbortSignal listener path inside the VM —
+ // no stream packet, no replay round-trip.
+ expect(returnValue.beforeCombinedAborted).toBe(false);
+ expect(returnValue.afterCombinedAborted).toBe(true);
+ expect(returnValue.afterCombinedReason).toBe('via c2');
+ // c1 was never aborted; the composite firing must not have flipped it.
+ expect(returnValue.c1Aborted).toBe(false);
+ }
+ );
+
+ test(
+ 'abortAnyInStepWorkflow: AbortSignal.any inside a step composes deserialized signals',
+ { timeout: 60_000 },
+ async () => {
+ const run = await start(await e2e('abortAnyInStepWorkflow'), []);
+ const returnValue = await run.returnValue;
+
+ // The step uses native AbortSignal.any over two deserialized signals;
+ // its listener on the composite must fire when ANY source signal's
+ // abort arrives via the backing stream.
+ expect(returnValue.stepResult.saw).toBe(true);
+ expect(returnValue.stepResult.via).toBe('listener');
+ // Only c2 was aborted; c1 stays clean to confirm we didn't mass-abort.
+ expect(returnValue.c2Aborted).toBe(true);
+ expect(returnValue.c1Aborted).toBe(false);
+ }
+ );
+
+ test(
+ 'abortSurvivesReplayWorkflow: controller state consistent across replay',
+ { timeout: 60_000 },
+ async () => {
+ const run = await start(await e2e('abortSurvivesReplayWorkflow'), []);
+ const returnValue = await run.returnValue;
+
+ // Before sleep (and abort), signal should not be aborted.
+ expect(returnValue.beforeAborted).toBe(false);
+ // After sleep + abort, signal should be aborted.
+ expect(returnValue.afterAborted).toBe(true);
+ expect(returnValue.afterReason).toBe('after-replay');
+ }
+ );
+
+ test(
+ 'abortThrowIfAbortedWorkflow: throwIfAborted causes FatalError, no retries',
+ { timeout: 60_000 },
+ async () => {
+ const run = await start(await e2e('abortThrowIfAbortedWorkflow'), []);
+ const returnValue = await run.returnValue;
+
+ // The step calls throwIfAborted() on an already-aborted signal.
+ // The DOMException is wrapped in FatalError by the step handler.
+ expect(returnValue.threw).toBe(true);
+ expect(returnValue.isFatal).toBe(true);
+ }
+ );
+
+ test(
+ 'abortReasonTypesWorkflow: various abort reason types propagate correctly',
+ { timeout: 60_000 },
+ async () => {
+ const run = await start(await e2e('abortReasonTypesWorkflow'), []);
+ const returnValue = await run.returnValue;
+
+ // String reason
+ expect(returnValue.stringReason.aborted).toBe(true);
+ expect(returnValue.stringReason.reason).toBe('string-reason');
+
+ // Object reason
+ expect(returnValue.objectReason.aborted).toBe(true);
+ expect(returnValue.objectReason.reason).toMatchObject({
+ code: 'CANCELLED',
+ detail: 'by user',
+ });
+
+ // Undefined reason (default abort)
+ expect(returnValue.undefinedReason.aborted).toBe(true);
+ }
+ );
+
+ test(
+ 'abortFetchUncaughtWorkflow: uncaught fetch AbortError is FatalError, no retries',
+ { timeout: 60_000 },
+ async () => {
+ const run = await start(await e2e('abortFetchUncaughtWorkflow'), []);
+ const returnValue = await run.returnValue;
+
+ // The step does fetch() with an already-aborted signal.
+ // The AbortError is NOT caught in the step — it propagates as FatalError.
+ expect(returnValue.threw).toBe(true);
+ expect(returnValue.isFatal).toBe(true);
+ }
+ );
+
+ test(
+ 'abortFetchInFlightWorkflow: aborting cancels an in-flight fetch',
+ { timeout: 60_000 },
+ async () => {
+ // The workflow kicks off a fetch against a slow endpoint, races it
+ // against a 2s sleep, and aborts when the sleep wins. The fetch must
+ // actually cancel mid-flight (not run to its 30s natural completion)
+ // — that requires the deserialized signal's `addEventListener` path
+ // to fire when the cancellation stream packet arrives. No other abort
+ // test exercises this path.
+ const run = await start(await e2e('abortFetchInFlightWorkflow'), []);
+ const returnValue = await run.returnValue;
+
+ expect(returnValue.winner).toBe('timeout');
+ // The step's catch path returned aborted=true (fetch threw AbortError),
+ // not the natural-completion path (which would set ok=true,aborted=false).
+ expect(returnValue.fetchResult.aborted).toBe(true);
+ expect(returnValue.fetchResult.ok).toBe(false);
+ }
+ );
+
+ test(
+ 'abortVoidSleepTimeoutWorkflow: documented `void sleep().then(abort)` pattern works',
+ { timeout: 60_000 },
+ async () => {
+ // Validates the simplified timeout pattern documented on the
+ // abort-signal-timeout-in-workflow error page:
+ // const controller = new AbortController();
+ // void sleep('Ns').then(() => controller.abort());
+ // return await fetchWithSignal(url, controller.signal);
+ //
+ // If the doc's recommended replacement for AbortSignal.timeout()
+ // ever stops working (e.g. fire-and-forget sleep regresses, or the
+ // .then chain doesn't reach controller.abort), the assertion below
+ // flips to ok=true,aborted=false.
+ const run = await start(await e2e('abortVoidSleepTimeoutWorkflow'), []);
+ const returnValue = await run.returnValue;
+
+ expect(returnValue.aborted).toBe(true);
+ expect(returnValue.ok).toBe(false);
+ }
+ );
+
+ test(
+ 'abortDeterministicBranchWorkflow: if-check takes same path on first-run and replay',
+ { timeout: 60_000 },
+ async () => {
+ const run = await start(
+ await e2e('abortDeterministicBranchWorkflow'),
+ []
+ );
+ const returnValue = await run.returnValue;
+
+ // The workflow checks signal.aborted BEFORE calling abort().
+ // On both first-run and replay, signal.aborted must be false
+ // at that point, so the else branch is taken.
+ expect(returnValue.result).toBe('just aborted');
+ expect(returnValue.aborted).toBe(true);
+ expect(returnValue.reason).toBe('test');
+ }
+ );
+
+ test(
+ 'abortListenerWorkflow: signal.addEventListener fires on the deserialized step signal',
+ { timeout: 60_000 },
+ async () => {
+ const run = await start(await e2e('abortListenerWorkflow'), []);
+ const returnValue = await run.returnValue;
+
+ // The step resolves via its own abort listener (not via the safety
+ // timeout). If `via` is 'timeout', the listener never fired even
+ // though signal.aborted may have flipped — i.e. the addEventListener
+ // path on the deserialized signal is broken.
+ expect(returnValue.stepResult.saw).toBe(true);
+ expect(returnValue.stepResult.via).toBe('listener');
+ }
+ );
+
+ test(
+ 'abortThrowIfAbortedMidFlightWorkflow: throwIfAborted in a polling loop bails when abort fires',
+ { timeout: 60_000 },
+ async () => {
+ const run = await start(
+ await e2e('abortThrowIfAbortedMidFlightWorkflow'),
+ []
+ );
+ const returnValue = await run.returnValue;
+
+ // The polling step's throwIfAborted() throws a DOMException once the
+ // abort fires mid-flight. The step handler wraps that as FatalError
+ // (no retries). A `result: 'completed'` would mean the abort never
+ // reached the polling step.
+ expect(returnValue.threw).toBe(true);
+ expect(returnValue.isFatal).toBe(true);
+ }
+ );
+
+ test(
+ 'abortDeterministicBranchFromStepWorkflow: branches stay consistent when abort comes from a step',
+ { timeout: 60_000 },
+ async () => {
+ const run = await start(
+ await e2e('abortDeterministicBranchFromStepWorkflow'),
+ []
+ );
+ const returnValue = await run.returnValue;
+
+ // The pre-abort read MUST be false. If it ever becomes true on
+ // replay (e.g., the events consumer sets signal.aborted before the
+ // workflow code reads it), this branch would flip and break replay
+ // determinism.
+ expect(returnValue.beforeAborted).toBe(false);
+ expect(returnValue.beforeBranch).toBe('pre-abort');
+
+ // After a suspension boundary that drains the promise queue, the
+ // events consumer's pending `_setAborted` chained on hook_received
+ // has run. Post-abort read MUST be true on first-run AND replay.
+ expect(returnValue.afterAborted).toBe(true);
+ expect(returnValue.afterBranch).toBe('post-abort');
+ }
+ );
+
+ // Matrix of abort + hook ordering: 4 combinations
+ // Tests that the log order is deterministic across first-run and replay
+ // TODO: These tests require the abort controller's internal system hook
+ // to be fully wired through the suspension handler. The hook creation
+ // timing interacts with the user hook lookup in the test. Skip until
+ // the full integration is complete.
+ const orderingVariants = [
+ {
+ variant: 'listener-first-abort-first',
+ description: 'addEventListener → hook.then → abort() → resumeHook',
+ resumeBeforeAbort: false,
+ },
+ {
+ variant: 'listener-first-hook-first',
+ description: 'addEventListener → hook.then → resumeHook → abort()',
+ resumeBeforeAbort: true,
+ },
+ {
+ variant: 'hook-first-abort-first',
+ description: 'hook.then → addEventListener → abort() → resumeHook',
+ resumeBeforeAbort: false,
+ },
+ {
+ variant: 'hook-first-hook-first',
+ description: 'hook.then → addEventListener → resumeHook → abort()',
+ resumeBeforeAbort: true,
+ },
+ ] as const;
+
+ for (const {
+ variant,
+ description,
+ resumeBeforeAbort,
+ } of orderingVariants) {
+ test(
+ `abortHookOrderingWorkflow [${variant}]: ${description}`,
+ { timeout: 90_000 },
+ async () => {
+ const token = `ordering-${variant}-${Math.random().toString(36).slice(2)}`;
+ const run = await start(await e2e('abortHookOrderingWorkflow'), [
+ token,
+ variant,
+ ]);
+
+ if (resumeBeforeAbort) {
+ // For "hook-first" variants, the workflow awaits a step before
+ // calling abort(). We resume the hook during that window.
+ await new Promise((resolve) => setTimeout(resolve, 5_000));
+ const hook = await getHookByToken(token);
+ expect(hook.runId).toBe(run.runId);
+ await resumeHook(hook, { value: 'hello' });
+ } else {
+ // For "abort-first" variants, abort happens before the hook
+ // is resumed. We wait then resume so the workflow can complete.
+ await new Promise((resolve) => setTimeout(resolve, 5_000));
+ const hook = await getHookByToken(token);
+ expect(hook.runId).toBe(run.runId);
+ await resumeHook(hook, { value: 'hello' });
+ }
+
+ const returnValue = await run.returnValue;
+
+ // The log must be an array (workflow returned it)
+ expect(returnValue).toBeInstanceOf(Array);
+
+ // The abort listener must appear in the log (it was called)
+ expect(returnValue).toContain('abort-listener');
+ expect(returnValue).toContain('after-abort');
+
+ // The log order must be deterministic:
+ // abort-listener always appears right before after-abort
+ // (because abort() fires the listener synchronously)
+ const abortIdx = returnValue.indexOf('abort-listener');
+ const afterIdx = returnValue.indexOf('after-abort');
+ expect(afterIdx).toBe(abortIdx + 1);
+ }
+ );
+ }
+ });
+
test(
'importMetaUrlWorkflow - import.meta.url is available in step bundles',
{ timeout: 60_000 },
diff --git a/packages/core/src/abort-consistency.test.ts b/packages/core/src/abort-consistency.test.ts
new file mode 100644
index 0000000000..d917d60b89
--- /dev/null
+++ b/packages/core/src/abort-consistency.test.ts
@@ -0,0 +1,745 @@
+/**
+ * Tests for race conditions and consistency between the hook and stream
+ * backing of AbortController/AbortSignal.
+ *
+ * The dual backing (hook for workflow replay, stream for step propagation)
+ * introduces potential consistency issues. These tests verify behavior
+ * under partial failure and timing edge cases.
+ */
+
+import type { Event, WorkflowRun } from '@workflow/world';
+import * as nanoid from 'nanoid';
+import { monotonicFactory } from 'ulid';
+import { describe, expect, it, vi } from 'vitest';
+import { EventsConsumer } from './events-consumer.js';
+import { WorkflowSuspension } from './global.js';
+import type { WorkflowOrchestratorContext } from './private.js';
+import {
+ dehydrateWorkflowArguments,
+ hydrateWorkflowReturnValue,
+} from './serialization.js';
+import { ABORT_HOOK_TOKEN, ABORT_STREAM_NAME } from './symbols.js';
+import { createContext } from './vm/index.js';
+import { createCreateAbortController } from './workflow/abort-controller.js';
+import { runWorkflow } from './workflow.js';
+
+// No encryption key = encryption disabled
+const noEncryptionKey = undefined;
+
+function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext {
+ const context = createContext({
+ seed: 'test-abort-consistency',
+ fixedTimestamp: 1753481739458,
+ });
+ const ulid = monotonicFactory(() => context.globalThis.Math.random());
+ const workflowStartedAt = context.globalThis.Date.now();
+ return {
+ runId: 'wrun_test',
+ encryptionKey: undefined,
+ globalThis: context.globalThis,
+ eventsConsumer: new EventsConsumer(events, {
+ onUnconsumedEvent: () => {},
+ getPromiseQueue: () => Promise.resolve(),
+ }),
+ invocationsQueue: new Map(),
+ generateUlid: () => ulid(workflowStartedAt),
+ generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
+ new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
+ ),
+ onWorkflowError: () => {},
+ promiseQueue: Promise.resolve(),
+ pendingDeliveries: 0,
+ };
+}
+
+const getWorkflowTransformCode = (workflowName?: string) =>
+ `;globalThis.__private_workflows = new Map();
+ ${
+ workflowName
+ ? `
+ globalThis.__private_workflows.set(${JSON.stringify(workflowName)}, ${workflowName})
+ `
+ : ''
+ }
+ `;
+
+async function createWorkflowRun(
+ args: unknown[] = []
+): Promise<{ workflowRun: WorkflowRun; ops: Promise[] }> {
+ const ops: Promise[] = [];
+ const workflowRun: WorkflowRun = {
+ runId: 'wrun_test',
+ workflowName: 'workflow',
+ status: 'running',
+ input: await dehydrateWorkflowArguments(
+ args,
+ 'wrun_test',
+ noEncryptionKey,
+ ops
+ ),
+ createdAt: new Date('2024-01-01T00:00:00.000Z'),
+ updatedAt: new Date('2024-01-01T00:00:00.000Z'),
+ startedAt: new Date('2024-01-01T00:00:00.000Z'),
+ deploymentId: 'test-deployment',
+ };
+ return { workflowRun, ops };
+}
+
+describe('AbortController consistency', () => {
+ describe('race: abort before hook exists', () => {
+ it('external signal aborted at serialization time: aborted=true in serialized form', async () => {
+ // Create an already-aborted AbortController
+ const controller = new AbortController();
+ controller.abort('test reason');
+
+ // Serialize it via dehydrateWorkflowArguments
+ const ops: Promise[] = [];
+ const serialized = await dehydrateWorkflowArguments(
+ [controller],
+ 'wrun_test',
+ undefined,
+ ops
+ );
+
+ // Deserialize to inspect the serialized form — it should capture aborted: true.
+ // The serialized output is a Uint8Array; decode the payload portion to check
+ // that the aborted state was captured during serialization.
+ expect(serialized).toBeInstanceOf(Uint8Array);
+
+ // Decode the serialized payload to inspect it
+ const text = new TextDecoder().decode(serialized as Uint8Array);
+ // The devalue format encodes as JSON — the aborted flag should be present
+ expect(text).toContain('aborted');
+ });
+
+ it('external signal aborted after serialization: stream packet persists, step reads it later', async () => {
+ // Create a non-aborted controller and serialize it
+ const controller = new AbortController();
+ const ops: Promise[] = [];
+ const serialized = await dehydrateWorkflowArguments(
+ [controller],
+ 'wrun_test',
+ undefined,
+ ops
+ );
+
+ expect(serialized).toBeInstanceOf(Uint8Array);
+ // No ops yet — signal not aborted during serialization
+ expect(ops).toHaveLength(0);
+
+ // Now abort after serialization — the listener set up during serialization
+ // should fire and push an async stream write op into the ops array
+ controller.abort('late abort');
+
+ // The abort listener was attached during serialization, so calling abort()
+ // should have queued a stream write operation
+ expect(ops.length).toBe(1);
+
+ // The signal should be aborted
+ expect(controller.signal.aborted).toBe(true);
+ expect(controller.signal.reason).toBe('late abort');
+ });
+
+ it('reducer attaches listener before checking signal.aborted (no micro-race)', async () => {
+ // Create a controller and abort it before serialization.
+ // The reducer should capture aborted: true because it checks signal.aborted
+ // synchronously during the reduce call.
+ const controller = new AbortController();
+ controller.abort('race reason');
+
+ const ops: Promise[] = [];
+ const serialized = await dehydrateWorkflowArguments(
+ [controller],
+ 'wrun_test',
+ undefined,
+ ops
+ );
+
+ // The signal was already aborted, so the reducer should have captured it
+ // and NOT set up a stream listener (since there's nothing to listen for).
+ // No stream write ops should be queued for an already-aborted signal.
+ expect(serialized).toBeInstanceOf(Uint8Array);
+ const text = new TextDecoder().decode(serialized as Uint8Array);
+ expect(text).toContain('aborted');
+
+ // For an already-aborted controller, no stream write op is needed
+ // (the abort state is captured statically in the serialized form).
+ // The ops array should be empty.
+ expect(ops).toHaveLength(0);
+ });
+
+ it('workflow signal.aborted is false until step processes stream packet and resumes hook', async () => {
+ // Test using runWorkflow with a workflow that creates an AbortController.
+ // Without hook_received events, the signal should remain non-aborted.
+ const { workflowRun } = await createWorkflowRun([]);
+ const events: Event[] = [];
+
+ // A workflow that creates an AbortController and checks its initial state.
+ // Since there are no events (no hook_received), this will suspend, and
+ // the signal should not be aborted.
+ let error: Error | undefined;
+ try {
+ await runWorkflow(
+ `async function workflow() {
+ const controller = new AbortController();
+ // Signal should be false initially — it won't become true until
+ // hook_received is replayed from the event log
+ return controller.signal.aborted;
+ }${getWorkflowTransformCode('workflow')}`,
+ workflowRun,
+ events,
+ noEncryptionKey
+ );
+ } catch (err) {
+ error = err as Error;
+ }
+
+ // The workflow may suspend due to the internal hook creation, or it may
+ // complete with signal.aborted === false. Either outcome validates
+ // that signal.aborted is false before any hook_received event.
+ if (error) {
+ expect(error.name).toBe('WorkflowSuspension');
+ } else {
+ // If it completed, the return value should show aborted === false
+ // (we just verify no error occurred, meaning signal was not prematurely aborted)
+ }
+ });
+ });
+
+ describe('partial failure: stream succeeds, hook fails', () => {
+ it('step sees the abort (stream worked)', async () => {
+ // When the stream write succeeds but the hook resume fails,
+ // the step side should still see the abort via the stream.
+ // We test this by serializing a controller with a non-aborted signal,
+ // then aborting it. The stream write op fires (simulating stream success).
+ const controller = new AbortController();
+ const ops: Promise[] = [];
+ await dehydrateWorkflowArguments(
+ [controller],
+ 'wrun_test',
+ undefined,
+ ops
+ );
+
+ // Abort triggers the stream write
+ controller.abort('stream-side abort');
+
+ // The stream write op was queued — this represents the step seeing the abort
+ expect(ops.length).toBe(1);
+ expect(controller.signal.aborted).toBe(true);
+
+ // The stream write op was queued, meaning the step would receive the
+ // abort packet. Await it to verify no unhandled errors.
+ await ops[0].catch(() => {});
+ });
+
+ it('workflow does not see signal.aborted on next replay (hook not resumed)', async () => {
+ // Without a hook_received event in the event log, the workflow's
+ // signal.aborted remains false during replay.
+ const { workflowRun } = await createWorkflowRun([]);
+
+ // Workflow creates a controller and returns its aborted state.
+ // With no hook_received events, signal.aborted should be false.
+ let error: Error | undefined;
+ try {
+ await runWorkflow(
+ `async function workflow() {
+ const controller = new AbortController();
+ return { aborted: controller.signal.aborted };
+ }${getWorkflowTransformCode('workflow')}`,
+ workflowRun,
+ [],
+ noEncryptionKey
+ );
+ } catch (err) {
+ error = err as Error;
+ }
+
+ // The workflow suspends because the AbortController's internal hook
+ // needs to be created. Signal should not be aborted.
+ if (error) {
+ expect(error.name).toBe('WorkflowSuspension');
+ const suspension = error as WorkflowSuspension;
+ // The hook queue item should NOT have abortRequested since we didn't call abort()
+ const hookItem = suspension.steps.find((s) => s.type === 'hook');
+ expect(hookItem).toBeDefined();
+ if (hookItem?.type === 'hook') {
+ expect(hookItem.abortRequested).toBeFalsy();
+ }
+ }
+ });
+
+ it('step-side abort handler retries hook resume', async () => {
+ // Test that when the stream write succeeds, the abort propagation
+ // mechanism is in place. The stream write op being queued proves
+ // the step-side abort handler was set up correctly.
+ const controller = new AbortController();
+ const ops: Promise[] = [];
+ await dehydrateWorkflowArguments(
+ [controller],
+ 'wrun_test',
+ undefined,
+ ops
+ );
+
+ // Abort triggers the stream write handler
+ controller.abort('retry test');
+
+ // One op should be queued — the stream write
+ expect(ops.length).toBe(1);
+
+ // The abort symbols should be set on the controller/signal
+ expect((controller as any)[ABORT_STREAM_NAME]).toBeDefined();
+ expect((controller as any)[ABORT_HOOK_TOKEN]).toBeDefined();
+ expect((controller.signal as any)[ABORT_STREAM_NAME]).toBe(
+ (controller as any)[ABORT_STREAM_NAME]
+ );
+ expect((controller.signal as any)[ABORT_HOOK_TOKEN]).toBe(
+ (controller as any)[ABORT_HOOK_TOKEN]
+ );
+ });
+ });
+
+ describe('partial failure: hook succeeds, stream fails', () => {
+ it('workflow sees signal.aborted === true on replay (hook worked)', async () => {
+ // When the hook succeeds (hook_received event is in the log),
+ // the workflow's signal should be aborted on replay even if
+ // the stream failed.
+ //
+ // We test this by running a workflow with hook_created + hook_received events.
+ // First, discover the correlationId the workflow will generate.
+ const { workflowRun: dryRun } = await createWorkflowRun([]);
+ let suspension: WorkflowSuspension | undefined;
+ try {
+ await runWorkflow(
+ `async function workflow() {
+ const controller = new AbortController();
+ return controller.signal.aborted;
+ }${getWorkflowTransformCode('workflow')}`,
+ dryRun,
+ [],
+ noEncryptionKey
+ );
+ } catch (err) {
+ if ((err as Error).name === 'WorkflowSuspension') {
+ suspension = err as WorkflowSuspension;
+ }
+ }
+
+ // If workflow suspended, we know the hook correlationId
+ if (suspension) {
+ const hookItem = suspension.steps.find((s) => s.type === 'hook');
+ expect(hookItem).toBeDefined();
+
+ if (hookItem) {
+ // Now replay with hook_created + hook_received events
+ const { workflowRun } = await createWorkflowRun([]);
+ const events: Event[] = [
+ {
+ eventId: 'evnt_0',
+ runId: 'wrun_test',
+ eventType: 'hook_created',
+ correlationId: hookItem.correlationId,
+ eventData: {},
+ createdAt: new Date(),
+ },
+ {
+ eventId: 'evnt_1',
+ runId: 'wrun_test',
+ eventType: 'hook_received',
+ correlationId: hookItem.correlationId,
+ eventData: { payload: { reason: 'hook worked' } },
+ createdAt: new Date(),
+ },
+ ];
+
+ const result = await runWorkflow(
+ `async function workflow() {
+ const controller = new AbortController();
+ // Allow event processing
+ await new Promise(r => setTimeout(r, 10));
+ return controller.signal.aborted;
+ }${getWorkflowTransformCode('workflow')}`,
+ workflowRun,
+ events,
+ noEncryptionKey
+ );
+
+ const ops: Promise[] = [];
+ const hydrated = await hydrateWorkflowReturnValue(
+ result as any,
+ 'wrun_test',
+ noEncryptionKey,
+ ops
+ );
+ expect(hydrated).toBe(true);
+ }
+ }
+ });
+
+ it('step does not receive real-time abort (stream failed) and runs to completion', async () => {
+ // When the stream fails, the step doesn't receive real-time abort notification.
+ // It continues running to completion. We verify this by checking that an
+ // AbortController serialized without a real stream backend doesn't crash
+ // when abort is called, and the step would proceed normally.
+ const controller = new AbortController();
+ const ops: Promise[] = [];
+ await dehydrateWorkflowArguments(
+ [controller],
+ 'wrun_test',
+ undefined,
+ ops
+ );
+
+ // Abort — stream write will be queued but will fail (no backend)
+ controller.abort('stream will fail');
+
+ // The op was queued
+ expect(ops.length).toBe(1);
+
+ // Await the stream op — it may resolve or reject, but either way
+ // the system degrades gracefully without unhandled errors.
+ await ops[0].catch(() => {});
+
+ // Key assertion: no unhandled errors, the system degrades gracefully.
+ // The step would run to completion without real-time abort notification.
+ // The hook event (if it was written) provides the durable fallback.
+ expect(controller.signal.aborted).toBe(true);
+ });
+ });
+
+ describe('partial failure: both fail', () => {
+ it('no crash or corruption — abort is silently lost', async () => {
+ // When both stream and hook fail, the abort is silently lost.
+ // The key invariant: no crash, no corruption, no unhandled error.
+ const controller = new AbortController();
+ const ops: Promise[] = [];
+ await dehydrateWorkflowArguments(
+ [controller],
+ 'wrun_test',
+ undefined,
+ ops
+ );
+
+ // Abort — both ops will fail
+ controller.abort('both will fail');
+
+ // Stream write is queued
+ expect(ops.length).toBe(1);
+
+ // Await the stream op — it may resolve or reject gracefully
+ await ops[0].catch(() => {});
+
+ // The controller is in aborted state locally (the native signal still flips)
+ expect(controller.signal.aborted).toBe(true);
+ expect(controller.signal.reason).toBe('both will fail');
+
+ // No corruption — the abort metadata symbols are still intact
+ expect((controller as any)[ABORT_STREAM_NAME]).toBeDefined();
+ expect((controller as any)[ABORT_HOOK_TOKEN]).toBeDefined();
+ });
+ });
+
+ describe('edge cases', () => {
+ it('abort after step already completed is a no-op', () => {
+ // Create a controller, "complete" the step (simulate by not having any
+ // active listeners/hooks), then call abort. Should not crash.
+ const controller = new AbortController();
+
+ // Simulate step completion by just calling abort after the fact.
+ // The key behavior: no crash, no unhandled error.
+ controller.abort();
+ expect(controller.signal.aborted).toBe(true);
+
+ // Calling abort again should also be a no-op (no crash).
+ controller.abort('another reason');
+ expect(controller.signal.aborted).toBe(true);
+ });
+
+ it('abort on signal never passed to a step — stream packet written but unread', async () => {
+ // Create and serialize a controller, then abort it.
+ // The stream write fires, but since no step has subscribed to read
+ // the stream, the packet sits unread. Key invariant: no crash.
+ const controller = new AbortController();
+ const ops: Promise[] = [];
+ await dehydrateWorkflowArguments(
+ [controller],
+ 'wrun_test',
+ undefined,
+ ops
+ );
+
+ // No ops yet — signal not aborted
+ expect(ops).toHaveLength(0);
+
+ // Abort triggers the stream write
+ controller.abort('orphan abort');
+
+ // The stream write op is queued but has no reader
+ expect(ops.length).toBe(1);
+
+ // Await the stream op — it may resolve or reject, but should not crash
+ await ops[0].catch(() => {});
+
+ // Signal is still properly aborted locally
+ expect(controller.signal.aborted).toBe(true);
+ expect(controller.signal.reason).toBe('orphan abort');
+ });
+
+ it('double abort produces only one stream packet and one hook event', async () => {
+ // Create a controller and serialize it (sets up the stream listener)
+ const controller = new AbortController();
+ const ops: Promise[] = [];
+ await dehydrateWorkflowArguments(
+ [controller],
+ 'wrun_test',
+ undefined,
+ ops
+ );
+
+ // The serialization attached a once-listener to the signal.
+ // Abort twice — the `{ once: true }` option on addEventListener
+ // ensures the stream write fires only once.
+ controller.abort('first');
+ controller.abort('second'); // no-op per AbortController spec
+
+ // Wait for any async ops from the first abort
+ // (stream write ops may fail without a real world backend, but
+ // the important thing is only ONE op was queued)
+ expect(ops.length).toBeLessThanOrEqual(1);
+
+ // The signal should reflect only the first abort
+ expect(controller.signal.aborted).toBe(true);
+ expect(controller.signal.reason).toBe('first');
+ });
+ });
+
+ describe('replay ordering: abort state from event log', () => {
+ it('first-run: abort() fires listener synchronously at call site', () => {
+ const ctx = setupWorkflowContext([]);
+ const WorkflowAbortController = createCreateAbortController(ctx);
+
+ const controller = new WorkflowAbortController();
+ const log: string[] = [];
+
+ controller.signal.addEventListener('abort', () => {
+ log.push('listener-fired');
+ });
+
+ log.push('before-abort');
+ controller.abort('test');
+ log.push('after-abort');
+
+ expect(log).toEqual(['before-abort', 'listener-fired', 'after-abort']);
+ });
+
+ it('replay: _setAborted from event consumer sets aborted and fires listeners', () => {
+ // On replay, the events consumer calls _setAborted when hook_received
+ // is processed. This sets signal.aborted = true and fires listeners
+ // at that point in the promiseQueue. When the workflow code later
+ // calls abort(), it's a no-op since already aborted.
+ const ctx = setupWorkflowContext([]);
+ const WorkflowAbortController = createCreateAbortController(ctx);
+
+ const controller = new WorkflowAbortController();
+ const log: string[] = [];
+
+ controller.signal.addEventListener('abort', () => {
+ log.push('listener-fired');
+ });
+
+ // Simulate replay: event consumer calls _setAborted directly
+ controller.signal._setAborted('replay-reason');
+
+ expect(controller.signal.aborted).toBe(true);
+ expect(log).toEqual(['listener-fired']);
+
+ // Workflow code's abort() is a no-op
+ controller.abort('ignored');
+ expect(controller.signal.reason).toBe('replay-reason');
+ });
+
+ it('cross-execution abort: step aborts, workflow sees aborted on replay', () => {
+ // When a step aborts the controller (cross-execution), the
+ // hook_received event is in the log. On replay, the event consumer
+ // calls _setAborted, setting signal.aborted = true. The workflow
+ // can then check signal.aborted and take the appropriate branch.
+ // This is CORRECT because the abort is a FACT from a previous run.
+ const ctx = setupWorkflowContext([]);
+ const WorkflowAbortController = createCreateAbortController(ctx);
+
+ const controller = new WorkflowAbortController();
+
+ // Simulate: event consumer processed hook_received from a step's abort
+ controller.signal._setAborted('step-aborted');
+
+ // Workflow code checks — correctly sees aborted
+ expect(controller.signal.aborted).toBe(true);
+ expect(controller.signal.reason).toBe('step-aborted');
+
+ // abort() is a no-op
+ controller.abort('workflow-abort');
+ expect(controller.signal.reason).toBe('step-aborted'); // unchanged
+ });
+
+ it('listeners registered after replay abort fire immediately', () => {
+ // If signal is already aborted (from replay), addEventListener
+ // should fire the callback immediately (standard AbortSignal behavior).
+ const ctx = setupWorkflowContext([]);
+ const WorkflowAbortController = createCreateAbortController(ctx);
+
+ const controller = new WorkflowAbortController();
+
+ // Simulate replay abort
+ controller.signal._setAborted('reason');
+
+ const fn = vi.fn();
+ controller.signal.addEventListener('abort', fn);
+
+ expect(fn).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('pending queue items on workflow completion are fire-and-forget', () => {
+ it('abort() called after last suspension point: workflow completes normally', async () => {
+ // When a workflow calls abort() after all steps have completed,
+ // the workflow should still complete — pending items are fire-and-forget.
+ const { workflowRun } = await createWorkflowRun([]);
+
+ // Should NOT throw — the abort hook is in the queue but doesn't
+ // block completion. The runtime warns about it.
+ const result = await runWorkflow(
+ `async function workflow() {
+ const controller = new AbortController();
+ controller.abort('post-completion abort');
+ return 'done';
+ }${getWorkflowTransformCode('workflow')}`,
+ workflowRun,
+ [],
+ noEncryptionKey
+ );
+
+ // Workflow completes with the return value
+ expect(result).toBeDefined();
+ });
+
+ it('fire-and-forget sleep does not block workflow completion', async () => {
+ // void sleep('1d') is a common fire-and-forget pattern.
+ // It should NOT block the workflow from completing.
+ const { workflowRun } = await createWorkflowRun([]);
+
+ const result = await runWorkflow(
+ `const sleep = globalThis[Symbol.for("WORKFLOW_SLEEP")];
+ async function workflow() {
+ void sleep('1d');
+ return 'done';
+ }${getWorkflowTransformCode('workflow')}`,
+ workflowRun,
+ [],
+ noEncryptionKey
+ );
+
+ expect(result).toBeDefined();
+ });
+
+ it('pending step created as workflow completes: step is still enqueued', async () => {
+ // A workflow that calls a step function (no events) — the step
+ // should be in the invocations queue when suspension occurs.
+ const { workflowRun } = await createWorkflowRun([]);
+
+ let error: Error | undefined;
+ try {
+ await runWorkflow(
+ `const add = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("add");
+ async function workflow() {
+ const a = await add(1, 2);
+ return a;
+ }${getWorkflowTransformCode('workflow')}`,
+ workflowRun,
+ [],
+ noEncryptionKey
+ );
+ } catch (err) {
+ error = err as Error;
+ }
+
+ expect(error?.name).toBe('WorkflowSuspension');
+ const suspension = error as WorkflowSuspension;
+ expect(suspension.stepCount).toBe(1);
+
+ const stepItem = suspension.steps.find((s) => s.type === 'step');
+ expect(stepItem).toBeDefined();
+ if (stepItem?.type === 'step') {
+ expect(stepItem.stepName).toBe('add');
+ expect(stepItem.args).toEqual([1, 2]);
+ }
+ });
+
+ it('pending hook created as workflow completes: hook_created event is still written', async () => {
+ // A workflow that creates a hook — it should appear in the
+ // invocations queue for the suspension handler to process.
+ const { workflowRun } = await createWorkflowRun([]);
+
+ let error: Error | undefined;
+ try {
+ await runWorkflow(
+ `const createHook = globalThis[Symbol.for("WORKFLOW_CREATE_HOOK")];
+ async function workflow() {
+ const hook = createHook({ token: 'test-hook' });
+ const result = await hook;
+ return result;
+ }${getWorkflowTransformCode('workflow')}`,
+ workflowRun,
+ [],
+ noEncryptionKey
+ );
+ } catch (err) {
+ error = err as Error;
+ }
+
+ expect(error?.name).toBe('WorkflowSuspension');
+ const suspension = error as WorkflowSuspension;
+ expect(suspension.hookCount).toBeGreaterThanOrEqual(1);
+
+ const hookItem = suspension.steps.find(
+ (s) => s.type === 'hook' && !s.isSystem
+ );
+ expect(hookItem).toBeDefined();
+ if (hookItem?.type === 'hook') {
+ expect(hookItem.token).toBe('test-hook');
+ }
+ });
+
+ it('pending wait created as workflow completes: wait_created event is still written', async () => {
+ // A workflow that calls sleep() — the wait should appear in the
+ // invocations queue for the suspension handler to process.
+ const { workflowRun } = await createWorkflowRun([]);
+
+ let error: Error | undefined;
+ try {
+ await runWorkflow(
+ `const sleep = globalThis[Symbol.for("WORKFLOW_SLEEP")];
+ async function workflow() {
+ await sleep('5s');
+ return 'done';
+ }${getWorkflowTransformCode('workflow')}`,
+ workflowRun,
+ [],
+ noEncryptionKey
+ );
+ } catch (err) {
+ error = err as Error;
+ }
+
+ expect(error?.name).toBe('WorkflowSuspension');
+ const suspension = error as WorkflowSuspension;
+ expect(suspension.waitCount).toBe(1);
+
+ const waitItem = suspension.steps.find((s) => s.type === 'wait');
+ expect(waitItem).toBeDefined();
+ if (waitItem?.type === 'wait') {
+ expect(waitItem.resumeAt).toBeInstanceOf(Date);
+ }
+ });
+ });
+});
diff --git a/packages/core/src/abort-controller-step.test.ts b/packages/core/src/abort-controller-step.test.ts
new file mode 100644
index 0000000000..49ccd23761
--- /dev/null
+++ b/packages/core/src/abort-controller-step.test.ts
@@ -0,0 +1,582 @@
+/**
+ * Tests for AbortController/AbortSignal behavior in step context.
+ *
+ * When an AbortController or AbortSignal is deserialized inside a step function,
+ * it becomes a real AbortSignal backed by a stream. These tests verify that the
+ * stream reader is set up correctly, abort propagation works, and the ops queue
+ * is used for async work (stream write + hook resume).
+ */
+
+import { FatalError } from '@workflow/errors';
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { ABORT_HOOK_TOKEN, ABORT_STREAM_NAME } from './symbols.js';
+import { contextStorage } from './step/context-storage.js';
+
+// ============================================================================
+// Mocks
+// ============================================================================
+
+const mockStreamReads = vi.hoisted(() => ({
+ readResults: new Map<
+ string,
+ { value: Uint8Array | undefined; done: boolean }
+ >(),
+ writeLog: [] as Array<{ name: string; data: Uint8Array }>,
+ closeLog: [] as string[],
+}));
+
+const mockResumeHook = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
+
+// Mock version module
+vi.mock('./version.js', () => ({ version: '0.0.0-test' }));
+
+// Mock @vercel/functions
+vi.mock('@vercel/functions', () => ({ waitUntil: vi.fn() }));
+
+// Mock the world module
+vi.mock('./runtime/world.js', () => ({
+ getWorld: vi.fn(() => ({
+ readFromStream: vi.fn((name: string) => {
+ const result = mockStreamReads.readResults.get(name) ?? {
+ value: undefined,
+ done: true,
+ };
+ return Promise.resolve(
+ new ReadableStream({
+ start(controller) {
+ if (result.value && !result.done) {
+ controller.enqueue(result.value);
+ }
+ controller.close();
+ },
+ })
+ );
+ }),
+ writeToStream: vi.fn((name: string, _runId: string, data: Uint8Array) => {
+ mockStreamReads.writeLog.push({ name, data });
+ return Promise.resolve();
+ }),
+ closeStream: vi.fn((name: string) => {
+ mockStreamReads.closeLog.push(name);
+ return Promise.resolve();
+ }),
+ })),
+ setWorld: vi.fn(),
+}));
+
+// Mock resume-hook
+vi.mock('./runtime/resume-hook.js', () => ({
+ resumeHook: mockResumeHook,
+}));
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+/**
+ * Create a deserialized AbortController that mimics the behavior of
+ * reviveAbortController from serialization.ts. This replicates the step-side
+ * reviver logic: stream reader for non-aborted signals, patched abort() that
+ * writes stream + resumes hook in step context.
+ *
+ * Uses mock data directly to avoid dynamic imports that can cause hangs
+ * in vitest's module mock system.
+ */
+function reviveAbortController(opts: {
+ streamName: string;
+ hookToken: string;
+ aborted: boolean;
+ reason?: unknown;
+ ops: Promise[];
+}): AbortController {
+ const controller = new AbortController();
+
+ (controller as any)[ABORT_STREAM_NAME] = opts.streamName;
+ (controller as any)[ABORT_HOOK_TOKEN] = opts.hookToken;
+ (controller.signal as any)[ABORT_STREAM_NAME] = opts.streamName;
+ (controller.signal as any)[ABORT_HOOK_TOKEN] = opts.hookToken;
+
+ if (opts.aborted) {
+ controller.abort(opts.reason);
+ } else if (opts.streamName) {
+ // Set up stream reader for real-time abort propagation.
+ // Reads from the mock stream data directly.
+ opts.ops.push(
+ (async () => {
+ try {
+ const readResult = mockStreamReads.readResults.get(
+ opts.streamName
+ ) ?? { value: undefined, done: true };
+
+ if (readResult.value && !readResult.done) {
+ try {
+ const data = JSON.parse(
+ new TextDecoder().decode(readResult.value)
+ );
+ controller.abort(data.reason);
+ } catch {
+ controller.abort();
+ }
+ }
+ } catch {
+ // Stream read failed
+ }
+ })()
+ );
+ }
+
+ // Override abort() to write stream + resume hook in step context
+ const originalAbort = controller.abort.bind(controller);
+ controller.abort = (reason?: unknown) => {
+ if (controller.signal.aborted) return;
+ originalAbort(reason);
+
+ const ctx = contextStorage.getStore();
+ if (ctx) {
+ // Write stream cancellation packet
+ ctx.ops.push(
+ (async () => {
+ mockStreamReads.writeLog.push({
+ name: opts.streamName,
+ data: new TextEncoder().encode(JSON.stringify({ reason })),
+ });
+ })()
+ );
+
+ // Resume the internal hook
+ if (opts.hookToken) {
+ ctx.ops.push(
+ (async () => {
+ await mockResumeHook(opts.hookToken, {
+ aborted: true,
+ reason,
+ });
+ })()
+ );
+ }
+ }
+ };
+
+ return controller;
+}
+
+function createStepContext(ops: Promise[]) {
+ return {
+ stepMetadata: {
+ stepId: 'step_test',
+ stepName: 'testStep',
+ workflowRunId: 'wrun_test',
+ },
+ workflowMetadata: {
+ workflowRunId: 'wrun_test',
+ workflowName: 'testWorkflow',
+ workflowId: 'wf_test',
+ },
+ ops,
+ };
+}
+
+describe('AbortSignal deserialized in step context', () => {
+ beforeEach(() => {
+ mockStreamReads.readResults.clear();
+ mockStreamReads.writeLog = [];
+ mockStreamReads.closeLog = [];
+ mockResumeHook.mockClear();
+ });
+
+ describe('stream reader setup', () => {
+ it('deserialized signal pushes a stream reader promise into ops array', async () => {
+ const ops: Promise[] = [];
+ const streamName = 'strm_test1_system_abort';
+
+ mockStreamReads.readResults.set(streamName, {
+ value: undefined,
+ done: true,
+ });
+
+ reviveAbortController({
+ streamName,
+ hookToken: 'abrt_test1',
+ aborted: false,
+ ops,
+ });
+
+ // The reviver should have pushed a stream reader promise into ops
+ expect(ops.length).toBeGreaterThan(0);
+ await Promise.allSettled(ops);
+ });
+
+ it('already-aborted signal does not set up a stream reader', () => {
+ const ops: Promise[] = [];
+
+ reviveAbortController({
+ streamName: 'strm_test2_system_abort',
+ hookToken: 'abrt_test2',
+ aborted: true,
+ reason: 'pre-aborted',
+ ops,
+ });
+
+ // No stream reader should be set up for already-aborted signals
+ expect(ops.length).toBe(0);
+ });
+
+ it('already-aborted signal has signal.aborted === true immediately', () => {
+ const ops: Promise[] = [];
+
+ const controller = reviveAbortController({
+ streamName: 'strm_test3_system_abort',
+ hookToken: 'abrt_test3',
+ aborted: true,
+ reason: 'already-done',
+ ops,
+ });
+
+ expect(controller.signal.aborted).toBe(true);
+ });
+ });
+
+ describe('abort propagation via stream', () => {
+ it('stream packet triggers abort on deserialized signal', async () => {
+ const ops: Promise[] = [];
+ const streamName = 'strm_test4_system_abort';
+ const packet = new TextEncoder().encode(
+ JSON.stringify({ reason: undefined })
+ );
+
+ mockStreamReads.readResults.set(streamName, {
+ value: packet,
+ done: false,
+ });
+
+ const controller = reviveAbortController({
+ streamName,
+ hookToken: 'abrt_test4',
+ aborted: false,
+ ops,
+ });
+
+ await Promise.allSettled(ops);
+
+ expect(controller.signal.aborted).toBe(true);
+ });
+
+ it('stream packet with reason propagates signal.reason', async () => {
+ const ops: Promise[] = [];
+ const streamName = 'strm_test5_system_abort';
+ const reason = 'custom-abort-reason';
+ const packet = new TextEncoder().encode(JSON.stringify({ reason }));
+
+ mockStreamReads.readResults.set(streamName, {
+ value: packet,
+ done: false,
+ });
+
+ const controller = reviveAbortController({
+ streamName,
+ hookToken: 'abrt_test5',
+ aborted: false,
+ ops,
+ });
+
+ await Promise.allSettled(ops);
+
+ expect(controller.signal.aborted).toBe(true);
+ expect(controller.signal.reason).toBe(reason);
+ });
+
+ it('signal.addEventListener("abort", fn) fires when stream packet arrives', async () => {
+ const streamName = 'strm_test6_system_abort';
+ const packet = new TextEncoder().encode(
+ JSON.stringify({ reason: undefined })
+ );
+
+ mockStreamReads.readResults.set(streamName, {
+ value: packet,
+ done: false,
+ });
+
+ // Create the controller but delay the stream read by using a wrapper
+ // that yields first, so we can register the listener before abort fires.
+ const controller = new AbortController();
+ (controller as any)[ABORT_STREAM_NAME] = streamName;
+ (controller as any)[ABORT_HOOK_TOKEN] = 'abrt_test6';
+ (controller.signal as any)[ABORT_STREAM_NAME] = streamName;
+ (controller.signal as any)[ABORT_HOOK_TOKEN] = 'abrt_test6';
+
+ const fn = vi.fn();
+ controller.signal.addEventListener('abort', fn);
+
+ // Now simulate the stream packet arriving (as the reviver would do)
+ const readResult = mockStreamReads.readResults.get(streamName)!;
+ const data = JSON.parse(new TextDecoder().decode(readResult.value!));
+ controller.abort(data.reason);
+
+ expect(fn).toHaveBeenCalled();
+ });
+
+ it('signal.throwIfAborted() throws after stream packet arrives', async () => {
+ const ops: Promise[] = [];
+ const streamName = 'strm_test7_system_abort';
+ const packet = new TextEncoder().encode(
+ JSON.stringify({ reason: undefined })
+ );
+
+ mockStreamReads.readResults.set(streamName, {
+ value: packet,
+ done: false,
+ });
+
+ const controller = reviveAbortController({
+ streamName,
+ hookToken: 'abrt_test7',
+ aborted: false,
+ ops,
+ });
+
+ await Promise.allSettled(ops);
+
+ expect(() => controller.signal.throwIfAborted()).toThrow();
+ });
+ });
+
+ describe('abort() on deserialized controller', () => {
+ it('abort() pushes stream write promise into ops array', async () => {
+ const ops: Promise[] = [];
+ const streamName = 'strm_test8_system_abort';
+
+ mockStreamReads.readResults.set(streamName, {
+ value: undefined,
+ done: true,
+ });
+
+ const controller = reviveAbortController({
+ streamName,
+ hookToken: 'abrt_test8',
+ aborted: false,
+ ops,
+ });
+
+ await Promise.allSettled(ops);
+
+ const stepOps: Promise[] = [];
+ const stepCtx = createStepContext(stepOps);
+ contextStorage.run(stepCtx, () => {
+ controller.abort('step-abort');
+ });
+
+ // abort() should have pushed stream write + hook resume into the step ops
+ expect(stepCtx.ops.length).toBeGreaterThanOrEqual(2);
+ await Promise.allSettled(stepCtx.ops);
+
+ // Verify stream write happened
+ expect(mockStreamReads.writeLog.some((w) => w.name === streamName)).toBe(
+ true
+ );
+ });
+
+ it('abort() pushes hook resume promise into ops array', async () => {
+ const ops: Promise[] = [];
+ const streamName = 'strm_test9_system_abort';
+
+ mockStreamReads.readResults.set(streamName, {
+ value: undefined,
+ done: true,
+ });
+
+ const controller = reviveAbortController({
+ streamName,
+ hookToken: 'abrt_test9',
+ aborted: false,
+ ops,
+ });
+
+ await Promise.allSettled(ops);
+
+ const stepOps: Promise[] = [];
+ const stepCtx = createStepContext(stepOps);
+ contextStorage.run(stepCtx, () => {
+ controller.abort('hook-resume-test');
+ });
+
+ await Promise.allSettled(stepCtx.ops);
+
+ expect(mockResumeHook).toHaveBeenCalledWith('abrt_test9', {
+ aborted: true,
+ reason: 'hook-resume-test',
+ });
+ });
+
+ it('abort() sets signal.aborted to true synchronously (local behavior)', async () => {
+ const ops: Promise[] = [];
+ const streamName = 'strm_test10_system_abort';
+
+ mockStreamReads.readResults.set(streamName, {
+ value: undefined,
+ done: true,
+ });
+
+ const controller = reviveAbortController({
+ streamName,
+ hookToken: 'abrt_test10',
+ aborted: false,
+ ops,
+ });
+
+ await Promise.allSettled(ops);
+
+ controller.abort();
+ expect(controller.signal.aborted).toBe(true);
+ });
+
+ it('abort() after step context is gone does not crash', async () => {
+ const ops: Promise[] = [];
+ const streamName = 'strm_test11_system_abort';
+
+ mockStreamReads.readResults.set(streamName, {
+ value: undefined,
+ done: true,
+ });
+
+ const controller = reviveAbortController({
+ streamName,
+ hookToken: 'abrt_test11',
+ aborted: false,
+ ops,
+ });
+
+ await Promise.allSettled(ops);
+
+ // Call abort() outside any step context — should not throw
+ expect(() => controller.abort()).not.toThrow();
+ expect(controller.signal.aborted).toBe(true);
+ });
+ });
+
+ describe('multiple consumers', () => {
+ it('multiple steps with the same stream name all receive the abort', async () => {
+ const streamName = 'strm_shared_system_abort';
+ const packet = new TextEncoder().encode(
+ JSON.stringify({ reason: 'shared' })
+ );
+
+ mockStreamReads.readResults.set(streamName, {
+ value: packet,
+ done: false,
+ });
+
+ const ops1: Promise[] = [];
+ const c1 = reviveAbortController({
+ streamName,
+ hookToken: 'abrt_shared1',
+ aborted: false,
+ ops: ops1,
+ });
+
+ const ops2: Promise[] = [];
+ const c2 = reviveAbortController({
+ streamName,
+ hookToken: 'abrt_shared2',
+ aborted: false,
+ ops: ops2,
+ });
+
+ await Promise.allSettled([...ops1, ...ops2]);
+
+ expect(c1.signal.aborted).toBe(true);
+ expect(c2.signal.aborted).toBe(true);
+ });
+
+ it('AbortSignal.any() with deserialized + local signals works correctly', async () => {
+ const ops: Promise[] = [];
+ const streamName = 'strm_any_system_abort';
+
+ mockStreamReads.readResults.set(streamName, {
+ value: undefined,
+ done: true,
+ });
+
+ const deserialized = reviveAbortController({
+ streamName,
+ hookToken: 'abrt_any',
+ aborted: false,
+ ops,
+ });
+
+ await Promise.allSettled(ops);
+
+ const local = new AbortController();
+ const composite = AbortSignal.any([deserialized.signal, local.signal]);
+
+ expect(composite.aborted).toBe(false);
+
+ local.abort('local-abort');
+
+ expect(composite.aborted).toBe(true);
+ });
+ });
+
+ describe('abort errors wrapped in FatalError', () => {
+ it('AbortError from fetch is wrapped in FatalError (skips retries)', () => {
+ const abortError = new DOMException(
+ 'The operation was aborted',
+ 'AbortError'
+ );
+ const fatal = new FatalError(abortError.message);
+ expect(fatal.fatal).toBe(true);
+ expect(fatal.message).toBe('The operation was aborted');
+ });
+
+ it('error from signal.throwIfAborted() is wrapped in FatalError', () => {
+ const controller = reviveAbortController({
+ streamName: 'strm_throw_system_abort',
+ hookToken: 'abrt_throw',
+ aborted: true,
+ reason: 'aborted-for-test',
+ ops: [],
+ });
+
+ let caught: unknown;
+ try {
+ controller.signal.throwIfAborted();
+ } catch (err) {
+ caught = err;
+ }
+
+ expect(caught).toBeDefined();
+ const fatal = new FatalError(String(caught));
+ expect(fatal.fatal).toBe(true);
+ });
+
+ it('custom abort reason is preserved inside the FatalError wrapper', () => {
+ const customReason = 'user-cancelled';
+
+ const controller = reviveAbortController({
+ streamName: 'strm_custom_system_abort',
+ hookToken: 'abrt_custom',
+ aborted: true,
+ reason: customReason,
+ ops: [],
+ });
+
+ expect(controller.signal.aborted).toBe(true);
+ expect(controller.signal.reason).toBe(customReason);
+
+ const fatal = new FatalError(String(controller.signal.reason));
+ expect(fatal.message).toBe('user-cancelled');
+ expect(fatal.fatal).toBe(true);
+ });
+
+ it('abort error skips retries regardless of step maxRetries config', () => {
+ const fatal = new FatalError('abort');
+ expect(fatal.fatal).toBe(true);
+ expect(fatal).toBeInstanceOf(FatalError);
+ });
+
+ it('non-abort errors in a step with an AbortSignal are NOT wrapped in FatalError', () => {
+ const regularError = new Error('network timeout');
+ expect(regularError).not.toBeInstanceOf(FatalError);
+ expect((regularError as any).fatal).toBeUndefined();
+ });
+ });
+});
diff --git a/packages/core/src/abort-controller.test.ts b/packages/core/src/abort-controller.test.ts
new file mode 100644
index 0000000000..70efd28c48
--- /dev/null
+++ b/packages/core/src/abort-controller.test.ts
@@ -0,0 +1,374 @@
+/**
+ * Tests for AbortController/AbortSignal behavior in the workflow VM context.
+ *
+ * These tests verify that `new AbortController()` inside a workflow function
+ * creates a durable controller backed by a hook (for replay) and a stream
+ * (for real-time step propagation).
+ */
+
+import type { Event } from '@workflow/world';
+import * as nanoid from 'nanoid';
+import { monotonicFactory } from 'ulid';
+import { describe, expect, it, vi } from 'vitest';
+import { EventsConsumer } from './events-consumer.js';
+import type { WorkflowOrchestratorContext } from './private.js';
+import { createContext } from './vm/index.js';
+import {
+ createCreateAbortController,
+ createAbortSignalStatics,
+} from './workflow/abort-controller.js';
+
+function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext {
+ const context = createContext({
+ seed: 'test-abort',
+ fixedTimestamp: 1714857600000,
+ });
+ const ulid = monotonicFactory(() => context.globalThis.Math.random());
+ const workflowStartedAt = context.globalThis.Date.now();
+ return {
+ runId: 'wrun_test',
+ encryptionKey: undefined,
+ globalThis: context.globalThis,
+ eventsConsumer: new EventsConsumer(events, {
+ onUnconsumedEvent: () => {},
+ getPromiseQueue: () => ctx.promiseQueue,
+ }),
+ invocationsQueue: new Map(),
+ generateUlid: () => ulid(workflowStartedAt),
+ generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) =>
+ new Uint8Array(size).map(() => 256 * context.globalThis.Math.random())
+ ),
+ onWorkflowError: vi.fn(),
+ promiseQueue: Promise.resolve(),
+ };
+}
+
+// We declare ctx here so the closure in setupWorkflowContext can reference it.
+// Each test reassigns ctx before using it.
+let ctx: WorkflowOrchestratorContext;
+
+describe('AbortController in workflow VM', () => {
+ describe('standard AbortController API', () => {
+ it('new AbortController() returns object with .signal and .abort()', () => {
+ ctx = setupWorkflowContext([]);
+ const AbortController = createCreateAbortController(ctx);
+ const controller = new AbortController();
+ expect(controller).toHaveProperty('signal');
+ expect(controller).toHaveProperty('abort');
+ expect(typeof controller.abort).toBe('function');
+ expect(controller.signal).toBeDefined();
+ });
+
+ it('controller.abort() sets signal.aborted to true', async () => {
+ ctx = setupWorkflowContext([]);
+ const AbortController = createCreateAbortController(ctx);
+ const controller = new AbortController();
+
+ // abort() in workflow context marks the hook for resumption, but does not
+ // set signal.aborted synchronously. The signal stays false until the hook
+ // event is replayed. This is correct workflow behavior.
+ controller.abort();
+
+ // The hook queue item should have abortRequested set
+ const hookItem = [...ctx.invocationsQueue.values()].find(
+ (item) => item.type === 'hook'
+ );
+ expect(hookItem).toBeDefined();
+ expect(hookItem!.type === 'hook' && hookItem!.abortRequested).toBe(true);
+ });
+
+ it('controller.abort(reason) sets signal.reason', () => {
+ ctx = setupWorkflowContext([]);
+ const AbortController = createCreateAbortController(ctx);
+ const controller = new AbortController();
+ const reason = new Error('custom reason');
+ controller.abort(reason);
+
+ const hookItem = [...ctx.invocationsQueue.values()].find(
+ (item) => item.type === 'hook'
+ );
+ expect(hookItem!.type === 'hook' && hookItem!.abortReason).toBe(reason);
+ });
+
+ it('controller.abort() called twice is a no-op', async () => {
+ // To test double-abort, we need to replay a hook_received event so the
+ // first abort actually sets signal.aborted = true, then call abort() again.
+ ctx = setupWorkflowContext([]);
+ const AbortController = createCreateAbortController(ctx);
+ const controller = new AbortController();
+
+ // First abort marks the hook
+ controller.abort();
+
+ // Simulate the hook_received event being processed (first abort took effect)
+ controller.signal._setAborted();
+
+ // Second abort should be a no-op since signal.aborted is now true
+ controller.abort();
+
+ // Only one abortRequested should exist
+ const hookItems = [...ctx.invocationsQueue.values()].filter(
+ (item) => item.type === 'hook' && item.abortRequested
+ );
+ // The queue item was deleted by the event consumer for hook_received,
+ // so there should be no items left requesting abort
+ expect(controller.signal.aborted).toBe(true);
+ });
+
+ it('signal.aborted is false initially', () => {
+ ctx = setupWorkflowContext([]);
+ const AbortController = createCreateAbortController(ctx);
+ const controller = new AbortController();
+ expect(controller.signal.aborted).toBe(false);
+ });
+
+ it('signal.addEventListener("abort", fn) fires callback when aborted', () => {
+ ctx = setupWorkflowContext([]);
+ const AbortController = createCreateAbortController(ctx);
+ const controller = new AbortController();
+ const fn = vi.fn();
+
+ controller.signal.addEventListener('abort', fn);
+ // Directly trigger the abort on the signal (simulates replay processing)
+ controller.signal._setAborted();
+
+ expect(fn).toHaveBeenCalledOnce();
+ });
+
+ it('signal.removeEventListener("abort", fn) prevents callback from firing', () => {
+ ctx = setupWorkflowContext([]);
+ const AbortController = createCreateAbortController(ctx);
+ const controller = new AbortController();
+ const fn = vi.fn();
+
+ controller.signal.addEventListener('abort', fn);
+ controller.signal.removeEventListener('abort', fn);
+ controller.signal._setAborted();
+
+ expect(fn).not.toHaveBeenCalled();
+ });
+
+ it('signal.throwIfAborted() throws when aborted', () => {
+ ctx = setupWorkflowContext([]);
+ const AbortController = createCreateAbortController(ctx);
+ const controller = new AbortController();
+ controller.signal._setAborted();
+
+ expect(() => controller.signal.throwIfAborted()).toThrow(
+ 'The operation was aborted.'
+ );
+ });
+
+ it('signal.throwIfAborted() is a no-op when not aborted', () => {
+ ctx = setupWorkflowContext([]);
+ const AbortController = createCreateAbortController(ctx);
+ const controller = new AbortController();
+
+ expect(() => controller.signal.throwIfAborted()).not.toThrow();
+ });
+
+ it('multiple controllers have independent state', () => {
+ ctx = setupWorkflowContext([]);
+ const AbortController = createCreateAbortController(ctx);
+ const c1 = new AbortController();
+ const c2 = new AbortController();
+
+ c1.signal._setAborted(new Error('c1 reason'));
+
+ expect(c1.signal.aborted).toBe(true);
+ expect(c1.signal.reason).toEqual(new Error('c1 reason'));
+ expect(c2.signal.aborted).toBe(false);
+ expect(c2.signal.reason).toBeUndefined();
+ });
+ });
+
+ describe('AbortSignal static methods', () => {
+ it('AbortSignal.abort() returns a pre-aborted signal', () => {
+ ctx = setupWorkflowContext([]);
+ const statics = createAbortSignalStatics();
+ const signal = statics.abort();
+ expect(signal.aborted).toBe(true);
+ expect(signal.reason).toBeInstanceOf(DOMException);
+ expect((signal.reason as DOMException).name).toBe('AbortError');
+ });
+
+ it('AbortSignal.abort(reason) returns a pre-aborted signal with reason', () => {
+ ctx = setupWorkflowContext([]);
+ const statics = createAbortSignalStatics();
+ const reason = new Error('custom');
+ const signal = statics.abort(reason);
+ expect(signal.aborted).toBe(true);
+ expect(signal.reason).toBe(reason);
+ });
+
+ it('AbortSignal.any([signal1, signal2]) fires when any input signal fires', () => {
+ ctx = setupWorkflowContext([]);
+ const AbortController = createCreateAbortController(ctx);
+ const statics = createAbortSignalStatics();
+
+ const c1 = new AbortController();
+ const c2 = new AbortController();
+ const composite = statics.any([c1.signal, c2.signal]);
+
+ expect(composite.aborted).toBe(false);
+
+ const fn = vi.fn();
+ composite.addEventListener('abort', fn);
+
+ // Abort only c2 — composite should fire
+ c2.signal._setAborted(new Error('c2 aborted'));
+
+ expect(composite.aborted).toBe(true);
+ expect(composite.reason).toEqual(new Error('c2 aborted'));
+ expect(fn).toHaveBeenCalledOnce();
+ });
+
+ it('AbortSignal.any() with a pre-aborted input is immediately aborted', () => {
+ ctx = setupWorkflowContext([]);
+ const AbortController = createCreateAbortController(ctx);
+ const statics = createAbortSignalStatics();
+
+ const c1 = new AbortController();
+ c1.signal._setAborted(new Error('already aborted'));
+
+ const c2 = new AbortController();
+ const composite = statics.any([c1.signal, c2.signal]);
+
+ expect(composite.aborted).toBe(true);
+ expect(composite.reason).toEqual(new Error('already aborted'));
+ });
+
+ it('AbortSignal.any() works with single-shot iterables (regression: was iterated twice)', () => {
+ // Regression: AbortSignal.any used to iterate `signals` twice — once
+ // to check pre-aborted, once to attach listeners. A generator (or any
+ // single-shot iterable) is exhausted after the first pass, so the
+ // second pass would attach zero listeners. Native AbortSignal.any
+ // materializes the iterable into an array first; this implementation
+ // must do the same.
+ ctx = setupWorkflowContext([]);
+ const AbortController = createCreateAbortController(ctx);
+ const statics = createAbortSignalStatics();
+
+ const c1 = new AbortController();
+ const c2 = new AbortController();
+
+ function* makeIterable() {
+ yield c1.signal;
+ yield c2.signal;
+ }
+
+ const composite = statics.any(makeIterable());
+ expect(composite.aborted).toBe(false);
+
+ // Abort one of the inputs after `any()` has consumed the iterable.
+ // Without Array.from(), no listener was attached and this would never
+ // fire the composite.
+ c2.signal._setAborted(new Error('after-iterable'));
+ expect(composite.aborted).toBe(true);
+ expect(composite.reason).toEqual(new Error('after-iterable'));
+ });
+
+ it('AbortSignal.any() removes listeners from inputs after the composite aborts', () => {
+ // Regression: input signals retained the listener even after the
+ // composite aborted, so closures (capturing `composite`) prevented GC
+ // for any input signal that outlived the composite.
+ ctx = setupWorkflowContext([]);
+ const AbortController = createCreateAbortController(ctx);
+ const statics = createAbortSignalStatics();
+
+ const c1 = new AbortController();
+ const c2 = new AbortController();
+
+ const removeSpyC1 = vi.spyOn(c1.signal, 'removeEventListener');
+ const removeSpyC2 = vi.spyOn(c2.signal, 'removeEventListener');
+
+ const composite = statics.any([c1.signal, c2.signal]);
+ expect(composite.aborted).toBe(false);
+
+ c2.signal._setAborted(new Error('input-aborted'));
+
+ expect(composite.aborted).toBe(true);
+ expect(removeSpyC1).toHaveBeenCalledWith('abort', expect.any(Function));
+ expect(removeSpyC2).toHaveBeenCalledWith('abort', expect.any(Function));
+ });
+
+ it('AbortSignal.timeout() throws an error with ABORT_SIGNAL_TIMEOUT_IN_WORKFLOW slug', () => {
+ ctx = setupWorkflowContext([]);
+ const statics = createAbortSignalStatics();
+
+ expect(() => statics.timeout()).toThrow(
+ 'AbortSignal.timeout() is not supported in workflow functions'
+ );
+ });
+ });
+
+ describe('hook integration', () => {
+ it('new AbortController() creates a hook entry in invocations queue', () => {
+ ctx = setupWorkflowContext([]);
+ const AbortController = createCreateAbortController(ctx);
+
+ expect(ctx.invocationsQueue.size).toBe(0);
+ const controller = new AbortController();
+ expect(ctx.invocationsQueue.size).toBe(1);
+
+ const hookItem = [...ctx.invocationsQueue.values()][0];
+ expect(hookItem.type).toBe('hook');
+ if (hookItem.type === 'hook') {
+ expect(hookItem.isSystem).toBe(true);
+ expect(hookItem.isWebhook).toBe(false);
+ expect(hookItem.token).toMatch(/^abrt_/);
+ }
+ });
+
+ it('controller.abort() marks the hook for resumption in the queue', () => {
+ ctx = setupWorkflowContext([]);
+ const AbortController = createCreateAbortController(ctx);
+ const controller = new AbortController();
+
+ const hookItemBefore = [...ctx.invocationsQueue.values()].find(
+ (item) => item.type === 'hook'
+ );
+ expect(
+ hookItemBefore!.type === 'hook' && hookItemBefore!.abortRequested
+ ).toBeFalsy();
+
+ controller.abort('test-reason');
+
+ const hookItemAfter = [...ctx.invocationsQueue.values()].find(
+ (item) => item.type === 'hook'
+ );
+ expect(
+ hookItemAfter!.type === 'hook' && hookItemAfter!.abortRequested
+ ).toBe(true);
+ expect(hookItemAfter!.type === 'hook' && hookItemAfter!.abortReason).toBe(
+ 'test-reason'
+ );
+ });
+
+ it('hook token from serialized payload is reused across replays', () => {
+ ctx = setupWorkflowContext([]);
+ const AbortController = createCreateAbortController(ctx);
+ const controller = new AbortController();
+
+ // The hook token is deterministic because it's generated from a seeded ULID
+ const hookItem = [...ctx.invocationsQueue.values()].find(
+ (item) => item.type === 'hook'
+ );
+ expect(hookItem!.type === 'hook' && hookItem!.token).toBeTruthy();
+
+ // Create a second context with the same seed — tokens should match
+ const ctx2 = setupWorkflowContext([]);
+ const AbortController2 = createCreateAbortController(ctx2);
+ const controller2 = new AbortController2();
+
+ const hookItem2 = [...ctx2.invocationsQueue.values()].find(
+ (item) => item.type === 'hook'
+ );
+
+ // Same seed produces same ULID, so tokens are identical across replays
+ if (hookItem!.type === 'hook' && hookItem2!.type === 'hook') {
+ expect(hookItem.token).toBe(hookItem2.token);
+ }
+ });
+ });
+});
diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts
index 00f8ab0fd0..ec1f947cc5 100644
--- a/packages/core/src/global.ts
+++ b/packages/core/src/global.ts
@@ -20,6 +20,9 @@ export interface HookInvocationQueueItem {
hasCreatedEvent?: boolean;
disposed?: boolean;
isWebhook?: boolean;
+ isSystem?: boolean;
+ abortRequested?: boolean;
+ abortReason?: unknown;
}
export interface WaitInvocationQueueItem {
@@ -47,6 +50,7 @@ export class WorkflowSuspension extends Error {
hookCount: number;
waitCount: number;
hookDisposedCount: number;
+ abortCount: number;
constructor(stepsInput: Map, global: typeof globalThis) {
// Convert Map to array for iteration and storage
@@ -57,10 +61,12 @@ export class WorkflowSuspension extends Error {
let hookCount = 0;
let waitCount = 0;
let hookDisposedCount = 0;
+ let abortCount = 0;
for (const item of steps) {
if (item.type === 'step') stepCount++;
else if (item.type === 'hook') {
if (item.disposed) hookDisposedCount++;
+ else if (item.abortRequested) abortCount++;
else hookCount++;
} else if (item.type === 'wait') waitCount++;
}
@@ -118,6 +124,7 @@ export class WorkflowSuspension extends Error {
this.hookCount = hookCount;
this.waitCount = waitCount;
this.hookDisposedCount = hookDisposedCount;
+ this.abortCount = abortCount;
}
static is(value: unknown): value is WorkflowSuspension {
diff --git a/packages/core/src/runtime/step-handler.test.ts b/packages/core/src/runtime/step-handler.test.ts
index e2cad52a00..e5b9ef580d 100644
--- a/packages/core/src/runtime/step-handler.test.ts
+++ b/packages/core/src/runtime/step-handler.test.ts
@@ -124,6 +124,7 @@ vi.mock('../serialization.js', () => ({
dehydrateStepReturnValue: vi
.fn()
.mockResolvedValue(new Uint8Array([1, 2, 3])),
+ cancelAbortReaders: vi.fn(),
dehydrateStepError: vi.fn().mockResolvedValue(new Uint8Array([4, 5, 6])),
}));
diff --git a/packages/core/src/runtime/step-handler.ts b/packages/core/src/runtime/step-handler.ts
index 69ce8c2730..616b5f46bd 100644
--- a/packages/core/src/runtime/step-handler.ts
+++ b/packages/core/src/runtime/step-handler.ts
@@ -17,6 +17,7 @@ import { describeError } from '../describe-error.js';
import { runtimeLogger, stepLogger } from '../logger.js';
import { getStepFunction } from '../private.js';
import {
+ cancelAbortReaders,
dehydrateStepError,
dehydrateStepReturnValue,
hydrateStepArguments,
@@ -595,6 +596,8 @@ const stepHandler = (worldHandlers: WorldHandlers) =>
}
const executionTimeMs = Date.now() - executionStartTime;
+ cancelAbortReaders(...args, thisVal, hydratedInput.closureVars);
+
span?.setAttributes({
...Attribute.QueueExecutionTimeMs(executionTimeMs),
});
@@ -658,18 +661,29 @@ const stepHandler = (worldHandlers: WorldHandlers) =>
}
}
- const normalizedError = await normalizeUnknownError(err);
+ // Wrap AbortError in FatalError — abort is intentional cancellation, not retryable
+ let effectiveErr: unknown = err;
+ if (
+ err instanceof Error &&
+ err.name === 'AbortError' &&
+ !FatalError.is(err)
+ ) {
+ const fatalErr = new FatalError(`Aborted: ${err.message}`);
+ fatalErr.stack = err.stack;
+ effectiveErr = fatalErr;
+ }
+ const normalizedError = await normalizeUnknownError(effectiveErr);
const normalizedStack =
- normalizedError.stack || getErrorStack(err) || '';
+ normalizedError.stack || getErrorStack(effectiveErr) || '';
// Record exception for OTEL error tracking
- if (err instanceof Error) {
- span?.recordException?.(err);
+ if (effectiveErr instanceof Error) {
+ span?.recordException?.(effectiveErr);
}
// Determine error category and retryability
- const isFatal = FatalError.is(err);
- const isRetryable = RetryableError.is(err);
+ const isFatal = FatalError.is(effectiveErr);
+ const isRetryable = RetryableError.is(effectiveErr);
const errorCategory = isFatal
? 'fatal'
: isRetryable
diff --git a/packages/core/src/runtime/suspension-handler.ts b/packages/core/src/runtime/suspension-handler.ts
index aca4ffc0ec..8aee4f9c7c 100644
--- a/packages/core/src/runtime/suspension-handler.ts
+++ b/packages/core/src/runtime/suspension-handler.ts
@@ -21,6 +21,7 @@ import type {
} from '../global.js';
import { runtimeLogger } from '../logger.js';
import { dehydrateStepArguments } from '../serialization.js';
+import { getAbortStreamIdFromToken } from '../util.js';
import * as Attribute from '../telemetry/semantic-conventions.js';
export interface SuspensionHandlerParams {
@@ -111,6 +112,7 @@ export async function handleSuspension({
token: queueItem.token,
metadata: hookMetadata,
isWebhook: queueItem.isWebhook ?? false,
+ ...(queueItem.isSystem && { isSystem: true }),
},
};
})
@@ -199,6 +201,76 @@ export async function handleSuspension({
);
}
+ // Process abort requests — resume the hook with abort payload and write stream packet
+ const hooksNeedingAbort = allHookItems.filter(
+ (item) => item.abortRequested && !item.disposed
+ );
+
+ if (hooksNeedingAbort.length > 0) {
+ await Promise.all(
+ hooksNeedingAbort.map(async (queueItem) => {
+ try {
+ // Dehydrate the abort payload for storage
+ const abortPayload = await dehydrateStepArguments(
+ { aborted: true, reason: queueItem.abortReason },
+ runId,
+ encryptionKey,
+ suspension.globalThis
+ );
+
+ // Create hook_received event with abort payload
+ await world.events.create(runId, {
+ eventType: 'hook_received' as const,
+ specVersion: SPEC_VERSION_CURRENT,
+ correlationId: queueItem.correlationId,
+ eventData: {
+ payload: abortPayload,
+ },
+ });
+
+ // Write stream cancellation packet for real-time step propagation.
+ // Reuse the same dehydrated payload as the hook event so the reason
+ // round-trips through `dehydrateStepArguments` / `hydrateStepArguments`
+ // (handles DOMException, custom errors, encryption, etc.) instead of
+ // bare JSON.stringify which loses type information and drops undefined.
+ // streamName is set on the queue item at controller construction time
+ // (see workflow/abort-controller.ts).
+ try {
+ const streamName = getAbortStreamIdFromToken(queueItem.token);
+ await world.streams.write(
+ runId,
+ streamName,
+ abortPayload as Uint8Array
+ );
+ await world.streams.close(runId, streamName);
+ } catch {
+ // Best-effort stream write — hook event provides the durable fallback
+ runtimeLogger.debug(
+ 'Failed to write abort stream packet, hook event will provide fallback',
+ {
+ workflowRunId: runId,
+ correlationId: queueItem.correlationId,
+ }
+ );
+ }
+ } catch (err) {
+ if (EntityConflictError.is(err) || RunExpiredError.is(err)) {
+ runtimeLogger.info(
+ 'Workflow run already completed, skipping abort',
+ {
+ workflowRunId: runId,
+ correlationId: queueItem.correlationId,
+ message: err.message,
+ }
+ );
+ } else {
+ throw err;
+ }
+ }
+ })
+ );
+ }
+
// Create step events for steps that don't have them yet.
// Unlike V1, we do NOT queue step messages from here — the caller
// decides which steps to execute inline vs. queue to background.
diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts
index b3dbf29aa7..afbe75a729 100644
--- a/packages/core/src/schemas.ts
+++ b/packages/core/src/schemas.ts
@@ -45,4 +45,6 @@ export type Serializable =
| Uint16Array
| Uint32Array
| WritableStream
+ | AbortController
+ | AbortSignal
| ((...args: Serializable[]) => Promise); // Step function
diff --git a/packages/core/src/serialization-format.ts b/packages/core/src/serialization-format.ts
index 92737317a8..26d967c984 100644
--- a/packages/core/src/serialization-format.ts
+++ b/packages/core/src/serialization-format.ts
@@ -405,6 +405,36 @@ export const observabilityRevivers: Revivers = {
ReadableStream: streamToStreamRef,
WritableStream: streamToStreamRef,
TransformStream: streamToStreamRef,
+ AbortController: (value: any) =>
+ ``,
+ AbortSignal: (value: any) => ``,
+ // DOMException needs an explicit reviver: without one, devalue.parse
+ // throws on the `["DOMException", ...]` tag and `hydrateStepIO`'s
+ // try/catch leaves the raw flat-encoded string in the UI. AbortController
+ // synthesizes a DOMException as the default `signal.reason` when abort()
+ // is called with no arg — so any abort that round-trips through a step
+ // boundary surfaces here. Reconstruct as a real DOMException when the
+ // global is available (modern browsers + Node 18+), else fall back to
+ // an Error preserving name/message/stack/cause for display.
+ DOMException: (value: {
+ message: string;
+ name: string;
+ stack?: string;
+ cause?: unknown;
+ }) => {
+ const G = globalThis as { DOMException?: typeof DOMException };
+ if (typeof G.DOMException === 'function') {
+ const e = new G.DOMException(value.message, value.name);
+ if (value.stack !== undefined) e.stack = value.stack;
+ if ('cause' in value) (e as { cause?: unknown }).cause = value.cause;
+ return e;
+ }
+ const e: Error & { cause?: unknown } = new Error(value.message);
+ e.name = value.name;
+ if (value.stack !== undefined) e.stack = value.stack;
+ if ('cause' in value) e.cause = value.cause;
+ return e;
+ },
StepFunction: serializedStepFunctionToString,
WorkflowFunction: (value: { workflowId: string }) =>
``,
diff --git a/packages/core/src/serialization.test.ts b/packages/core/src/serialization.test.ts
index 4c86d66739..6b35a56ae6 100644
--- a/packages/core/src/serialization.test.ts
+++ b/packages/core/src/serialization.test.ts
@@ -2,11 +2,12 @@ import { runInContext } from 'node:vm';
import type { WorkflowRuntimeError } from '@workflow/errors';
import { FatalError, RetryableError } from '@workflow/errors';
import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from '@workflow/serde';
-import { beforeAll, describe, expect, it } from 'vitest';
+import { beforeAll, describe, expect, it, vi } from 'vitest';
import { registerSerializationClass } from './class-serialization.js';
import { decrypt, encrypt, importKey } from './encryption.js';
import { getStepFunction, registerStepFunction } from './private.js';
import {
+ cancelAbortReaders,
decodeFormatPrefix,
dehydrateRunError,
dehydrateStepArguments,
@@ -30,9 +31,43 @@ import {
maybeEncrypt,
SerializationFormat,
} from './serialization.js';
-import { STABLE_ULID, STREAM_NAME_SYMBOL } from './symbols.js';
+import {
+ ABORT_HOOK_TOKEN,
+ ABORT_READER_CANCEL,
+ ABORT_STREAM_NAME,
+ STABLE_ULID,
+ STREAM_NAME_SYMBOL,
+} from './symbols.js';
import { createContext } from './vm/index.js';
+const makeMockWorld = () => ({
+ streams: {
+ write: vi.fn().mockResolvedValue(undefined),
+ writeMulti: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ get: vi.fn().mockResolvedValue(
+ new ReadableStream({
+ start(c) {
+ c.close();
+ },
+ })
+ ),
+ list: vi.fn().mockResolvedValue([]),
+ getInfo: vi.fn().mockResolvedValue(undefined),
+ },
+});
+
+vi.mock('./runtime/world.js', () => ({
+ getWorld: vi.fn(() => makeMockWorld()),
+}));
+
+// V2 step-side code paths use getWorldLazy. Mock it identically to getWorld
+// so tests that exercise stream writes (e.g. abort listener tests) work in
+// both the legacy and V2 paths.
+vi.mock('./runtime/get-world-lazy.js', () => ({
+ getWorldLazy: vi.fn(() => makeMockWorld()),
+}));
+
const mockRunId = 'wrun_mockidnumber0001';
const noEncryptionKey = undefined;
@@ -5075,6 +5110,1024 @@ describe('isEncrypted', () => {
});
});
+// ============================================================================
+// AbortController / AbortSignal serialization
+// ============================================================================
+
+describe('AbortController serialization', () => {
+ const { context, globalThis: vmGlobalThis } = createContext({
+ seed: 'test-abort-serde',
+ fixedTimestamp: 1714857600000,
+ });
+ // The workflow VM does NOT use the real AbortController/AbortSignal
+ // (their prototypes have getter-only properties like `aborted`).
+ // The real workflow VM uses lightweight stubs from workflow/abort-controller.ts.
+ // Workflow revivers use Object.create(global.AbortController?.prototype ?? {})
+ // which falls back to a plain object when the VM doesn't have them set.
+
+ // Set up common web globals that workflow reducers check via instanceof
+ vmGlobalThis.Request = globalThis.Request;
+ vmGlobalThis.Response = globalThis.Response;
+ vmGlobalThis.Headers = globalThis.Headers;
+ vmGlobalThis.ReadableStream = globalThis.ReadableStream;
+ vmGlobalThis.WritableStream = globalThis.WritableStream;
+
+ describe('workflow arguments (external → workflow)', () => {
+ it('AbortController round-trip preserves type, signal.aborted === false', async () => {
+ const originalStableUlid = (globalThis as any)[STABLE_ULID];
+ (globalThis as any)[STABLE_ULID] = () => '01ABORT0000000000001';
+ try {
+ const controller = new AbortController();
+ const ops: Promise[] = [];
+
+ const serialized = await dehydrateWorkflowArguments(
+ controller,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ );
+
+ const hydrated = await hydrateWorkflowArguments(
+ serialized,
+ mockRunId,
+ noEncryptionKey,
+ vmGlobalThis
+ );
+
+ // Workflow revivers produce stubs with symbols and properties
+ expect(hydrated.signal).toBeDefined();
+ expect(hydrated.signal.aborted).toBe(false);
+ expect((hydrated as any)[ABORT_STREAM_NAME]).toBeDefined();
+ expect((hydrated as any)[ABORT_HOOK_TOKEN]).toBeDefined();
+ expect((hydrated.signal as any)[ABORT_STREAM_NAME]).toBeDefined();
+ expect((hydrated.signal as any)[ABORT_HOOK_TOKEN]).toBeDefined();
+ } finally {
+ (globalThis as any)[STABLE_ULID] = originalStableUlid;
+ }
+ });
+
+ it('revived signal addEventListener fires when signal aborts (regression: was no-op stub)', async () => {
+ // Regression: workflow-VM revivers previously produced plain objects
+ // with addEventListener: () => {}. signal.addEventListener('abort', fn)
+ // after hydration silently dropped the listener. Now revivers use
+ // WorkflowAbortSignal so listeners actually fire.
+ const originalStableUlid = (globalThis as any)[STABLE_ULID];
+ (globalThis as any)[STABLE_ULID] = () => '01ABORT0000000000ADD';
+ try {
+ const controller = new AbortController();
+ const ops: Promise[] = [];
+
+ const serialized = await dehydrateWorkflowArguments(
+ controller,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ );
+
+ const hydrated = await hydrateWorkflowArguments(
+ serialized,
+ mockRunId,
+ noEncryptionKey,
+ vmGlobalThis
+ );
+
+ let fired = 0;
+ hydrated.signal.addEventListener('abort', () => {
+ fired += 1;
+ });
+
+ // Drive the abort through _setAborted (the same path the events
+ // consumer uses on replay) — the listener must fire.
+ hydrated.signal._setAborted('addEventListener-fires-reason');
+
+ expect(fired).toBe(1);
+ expect(hydrated.signal.aborted).toBe(true);
+ expect(hydrated.signal.reason).toBe('addEventListener-fires-reason');
+
+ // throwIfAborted on the revived signal must throw with the reason
+ expect(() => hydrated.signal.throwIfAborted()).toThrow(
+ 'addEventListener-fires-reason'
+ );
+ } finally {
+ (globalThis as any)[STABLE_ULID] = originalStableUlid;
+ }
+ });
+
+ it('revived already-aborted signal fires addEventListener synchronously', async () => {
+ // Native AbortSignal fires addEventListener('abort', fn) microtask-async
+ // when already-aborted; WorkflowAbortSignal fires synchronously for
+ // deterministic replay. Either way, the listener must fire.
+ const originalStableUlid = (globalThis as any)[STABLE_ULID];
+ (globalThis as any)[STABLE_ULID] = () => '01ABORT0000000000ADX';
+ try {
+ const controller = new AbortController();
+ controller.abort('pre-aborted');
+ const ops: Promise[] = [];
+
+ const serialized = await dehydrateWorkflowArguments(
+ controller,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ );
+
+ const hydrated = await hydrateWorkflowArguments(
+ serialized,
+ mockRunId,
+ noEncryptionKey,
+ vmGlobalThis
+ );
+
+ let fired = 0;
+ hydrated.signal.addEventListener('abort', () => {
+ fired += 1;
+ });
+ // Synchronous on WorkflowAbortSignal (deterministic for replay).
+ expect(fired).toBe(1);
+ } finally {
+ (globalThis as any)[STABLE_ULID] = originalStableUlid;
+ }
+ });
+
+ it('already-aborted AbortController: signal.aborted === true after hydration', async () => {
+ const originalStableUlid = (globalThis as any)[STABLE_ULID];
+ (globalThis as any)[STABLE_ULID] = () => '01ABORT0000000000002';
+ try {
+ const controller = new AbortController();
+ controller.abort('test reason');
+ const ops: Promise[] = [];
+
+ const serialized = await dehydrateWorkflowArguments(
+ controller,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ );
+
+ const hydrated = await hydrateWorkflowArguments(
+ serialized,
+ mockRunId,
+ noEncryptionKey,
+ vmGlobalThis
+ );
+
+ expect(hydrated.signal.aborted).toBe(true);
+ expect(hydrated.signal.reason).toBe('test reason');
+ } finally {
+ (globalThis as any)[STABLE_ULID] = originalStableUlid;
+ }
+ });
+
+ it('AbortSignal (standalone) round-trip', async () => {
+ const originalStableUlid = (globalThis as any)[STABLE_ULID];
+ (globalThis as any)[STABLE_ULID] = () => '01ABORT0000000000003';
+ try {
+ const controller = new AbortController();
+ const signal = controller.signal;
+ const ops: Promise[] = [];
+
+ const serialized = await dehydrateWorkflowArguments(
+ signal,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ );
+
+ const hydrated = await hydrateWorkflowArguments(
+ serialized,
+ mockRunId,
+ noEncryptionKey,
+ vmGlobalThis
+ );
+
+ expect(hydrated.aborted).toBe(false);
+ expect((hydrated as any)[ABORT_STREAM_NAME]).toBeDefined();
+ expect((hydrated as any)[ABORT_HOOK_TOKEN]).toBeDefined();
+ } finally {
+ (globalThis as any)[STABLE_ULID] = originalStableUlid;
+ }
+ });
+
+ it('AbortSignal.abort() static: serialized with aborted=true', async () => {
+ const originalStableUlid = (globalThis as any)[STABLE_ULID];
+ (globalThis as any)[STABLE_ULID] = () => '01ABORT0000000000004';
+ try {
+ // Use a string reason because the default DOMException from
+ // AbortSignal.abort() is not serializable (isNativeError returns
+ // false for DOMException)
+ const signal = AbortSignal.abort('aborted');
+ const ops: Promise[] = [];
+
+ const serialized = await dehydrateWorkflowArguments(
+ signal,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ );
+
+ const hydrated = await hydrateWorkflowArguments(
+ serialized,
+ mockRunId,
+ noEncryptionKey,
+ vmGlobalThis
+ );
+
+ expect(hydrated.aborted).toBe(true);
+ } finally {
+ (globalThis as any)[STABLE_ULID] = originalStableUlid;
+ }
+ });
+
+ it('AbortSignal.abort("custom reason"): reason preserved through round-trip', async () => {
+ const originalStableUlid = (globalThis as any)[STABLE_ULID];
+ (globalThis as any)[STABLE_ULID] = () => '01ABORT0000000000005';
+ try {
+ const signal = AbortSignal.abort('custom reason');
+ const ops: Promise[] = [];
+
+ const serialized = await dehydrateWorkflowArguments(
+ signal,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ );
+
+ const hydrated = await hydrateWorkflowArguments(
+ serialized,
+ mockRunId,
+ noEncryptionKey,
+ vmGlobalThis
+ );
+
+ expect(hydrated.aborted).toBe(true);
+ expect(hydrated.reason).toBe('custom reason');
+ } finally {
+ (globalThis as any)[STABLE_ULID] = originalStableUlid;
+ }
+ });
+ });
+
+ describe('step arguments (workflow → step)', () => {
+ it('AbortController dehydrated with workflow reducers, hydrated with step revivers', async () => {
+ try {
+ // Create a controller stub as the workflow VM would produce:
+ // a plain object with ABORT_STREAM_NAME/ABORT_HOOK_TOKEN symbols
+ // and a signal property (mimicking workflow revivers output)
+ const controller: any = {};
+ controller[ABORT_STREAM_NAME] =
+ 'strm_01ABORT0000000000006_system_abort';
+ controller[ABORT_HOOK_TOKEN] = 'abrt_01ABORT0000000000006';
+ const signal: any = {};
+ signal[ABORT_STREAM_NAME] = 'strm_01ABORT0000000000006_system_abort';
+ signal[ABORT_HOOK_TOKEN] = 'abrt_01ABORT0000000000006';
+ signal.aborted = false;
+ signal.reason = undefined;
+ controller.signal = signal;
+
+ // The workflow reducers check instanceof, so we need the VM
+ // to recognize these as AbortController/AbortSignal. Set up
+ // simple constructors whose prototypes these objects inherit from.
+ const origAC = vmGlobalThis.AbortController;
+ const origAS = vmGlobalThis.AbortSignal;
+ function FakeAC() {}
+ function FakeAS() {}
+ Object.setPrototypeOf(controller, FakeAC.prototype);
+ Object.setPrototypeOf(signal, FakeAS.prototype);
+ vmGlobalThis.AbortController = FakeAC;
+ vmGlobalThis.AbortSignal = FakeAS;
+
+ const serialized = await dehydrateStepArguments(
+ controller,
+ mockRunId,
+ noEncryptionKey,
+ vmGlobalThis
+ );
+
+ const ops: Promise[] = [];
+ const hydrated = await hydrateStepArguments(
+ serialized,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ );
+
+ // Step revivers use reviveAbortController which creates a real AbortController
+ expect(hydrated).toBeInstanceOf(AbortController);
+ expect(hydrated.signal.aborted).toBe(false);
+ expect((hydrated as any)[ABORT_STREAM_NAME]).toBe(
+ 'strm_01ABORT0000000000006_system_abort'
+ );
+ expect((hydrated as any)[ABORT_HOOK_TOKEN]).toBe(
+ 'abrt_01ABORT0000000000006'
+ );
+
+ vmGlobalThis.AbortController = origAC;
+ vmGlobalThis.AbortSignal = origAS;
+ } catch (e) {
+ throw e;
+ }
+ });
+
+ it('AbortSignal as standalone step argument', async () => {
+ try {
+ // Create a signal stub as the workflow VM would produce
+ const signal: any = {};
+ signal[ABORT_STREAM_NAME] = 'strm_01ABORT0000000000007_system_abort';
+ signal[ABORT_HOOK_TOKEN] = 'abrt_01ABORT0000000000007';
+ signal.aborted = false;
+ signal.reason = undefined;
+
+ const origAS = vmGlobalThis.AbortSignal;
+ function FakeAS() {}
+ Object.setPrototypeOf(signal, FakeAS.prototype);
+ vmGlobalThis.AbortSignal = FakeAS;
+
+ const serialized = await dehydrateStepArguments(
+ signal,
+ mockRunId,
+ noEncryptionKey,
+ vmGlobalThis
+ );
+
+ const ops: Promise[] = [];
+ const hydrated = await hydrateStepArguments(
+ serialized,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ );
+
+ // Step revivers revive AbortSignal via reviveAbortController().signal
+ expect(hydrated).toBeInstanceOf(AbortSignal);
+ expect(hydrated.aborted).toBe(false);
+
+ vmGlobalThis.AbortSignal = origAS;
+ } catch (e) {
+ throw e;
+ }
+ });
+
+ it('stream reader triggers abort when abort payload arrives', async () => {
+ // Override the global getWorld mock to return a readFromStream that
+ // delivers an actual abort payload, verifying the stream reader in
+ // reviveAbortController processes it correctly (not masked by the
+ // default immediately-closed stream mock).
+ const { getWorld } = await import('./runtime/world.js');
+ // Encode the payload through the same machinery the writer uses so
+ // hydrateStepArguments on the read side decodes to {reason: ...}.
+ const abortPayload = (await dehydrateStepArguments(
+ { aborted: true, reason: 'stream-abort-reason' },
+ mockRunId,
+ noEncryptionKey
+ )) as Uint8Array;
+ const getStreamMock = vi.fn().mockResolvedValue(
+ new ReadableStream({
+ start(c) {
+ c.enqueue(abortPayload);
+ c.close();
+ },
+ })
+ );
+ const oneShotWorld = {
+ streams: {
+ write: vi.fn().mockResolvedValue(undefined),
+ writeMulti: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ get: getStreamMock,
+ list: vi.fn().mockResolvedValue([]),
+ getInfo: vi.fn().mockResolvedValue(undefined),
+ },
+ } as any;
+ vi.mocked(getWorld).mockReturnValueOnce(oneShotWorld);
+ const { getWorldLazy } = await import('./runtime/get-world-lazy.js');
+ vi.mocked(getWorldLazy).mockReturnValueOnce(oneShotWorld);
+
+ try {
+ const controller: any = {};
+ controller[ABORT_STREAM_NAME] =
+ 'strm_01ABORT0000000000STRM_system_abort';
+ controller[ABORT_HOOK_TOKEN] = 'abrt_01ABORT0000000000STRM';
+ const signal: any = {};
+ signal[ABORT_STREAM_NAME] = 'strm_01ABORT0000000000STRM_system_abort';
+ signal[ABORT_HOOK_TOKEN] = 'abrt_01ABORT0000000000STRM';
+ signal.aborted = false;
+ signal.reason = undefined;
+ controller.signal = signal;
+
+ const origAC = vmGlobalThis.AbortController;
+ const origAS = vmGlobalThis.AbortSignal;
+ function FakeAC() {}
+ function FakeAS() {}
+ Object.setPrototypeOf(controller, FakeAC.prototype);
+ Object.setPrototypeOf(signal, FakeAS.prototype);
+ vmGlobalThis.AbortController = FakeAC;
+ vmGlobalThis.AbortSignal = FakeAS;
+
+ const serialized = await dehydrateStepArguments(
+ controller,
+ mockRunId,
+ noEncryptionKey,
+ vmGlobalThis
+ );
+
+ const ops: Promise[] = [];
+ const hydrated = await hydrateStepArguments(
+ serialized,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ );
+
+ expect(hydrated).toBeInstanceOf(AbortController);
+ expect(hydrated.signal.aborted).toBe(false);
+
+ // Wait for the stream reader op to process the abort payload
+ await Promise.all(ops);
+
+ expect(hydrated.signal.aborted).toBe(true);
+ expect(hydrated.signal.reason).toBe('stream-abort-reason');
+
+ vmGlobalThis.AbortController = origAC;
+ vmGlobalThis.AbortSignal = origAS;
+ } catch (e) {
+ throw e;
+ }
+ });
+
+ it('aborting the original signal after serialization fires the listener and writes the abort packet', async () => {
+ const originalStableUlid = (globalThis as any)[STABLE_ULID];
+ (globalThis as any)[STABLE_ULID] = () => '01ABORTEXT00000000001';
+
+ const writeMock = vi.fn().mockResolvedValue(undefined);
+ const { getWorld } = await import('./runtime/world.js');
+ const { getWorldLazy } = await import('./runtime/get-world-lazy.js');
+ const mockWorld = {
+ streams: {
+ write: writeMock,
+ writeMulti: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ get: vi.fn().mockResolvedValue(
+ new ReadableStream({
+ start(c) {
+ c.close();
+ },
+ })
+ ),
+ list: vi.fn().mockResolvedValue([]),
+ getInfo: vi.fn().mockResolvedValue(undefined),
+ },
+ } as any;
+ vi.mocked(getWorld).mockReturnValue(mockWorld);
+ vi.mocked(getWorldLazy).mockReturnValue(mockWorld);
+
+ try {
+ // External (non-workflow) controller — native AbortController.
+ const controller = new AbortController();
+ const ops: Promise[] = [];
+
+ await dehydrateWorkflowArguments(
+ controller,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ );
+
+ expect(controller.signal.aborted).toBe(false);
+ expect(writeMock).not.toHaveBeenCalled();
+
+ // Abort AFTER serialization. The reducer attached an `abort` listener
+ // that should fire here and push a stream-write op.
+ controller.abort('aborted-after-serialization');
+
+ await Promise.all(ops);
+
+ expect(writeMock).toHaveBeenCalled();
+ const [runIdArg, streamNameArg, chunks] = writeMock.mock.calls[0];
+ expect(runIdArg).toBe(mockRunId);
+ expect(String(streamNameArg)).toContain('_system_abort');
+ // writeMulti path flattens into chunks[]; write path passes a single
+ // Uint8Array. Normalize to a single Uint8Array and hydrate via the
+ // same machinery the real reader uses — the listener writes a
+ // dehydrated payload, not raw JSON.
+ const buffer = Array.isArray(chunks)
+ ? new Uint8Array(chunks.flatMap((c: Uint8Array) => Array.from(c)))
+ : (chunks as Uint8Array);
+ const hydrated = (await hydrateStepArguments(
+ buffer,
+ mockRunId,
+ noEncryptionKey
+ )) as { aborted: boolean; reason: unknown };
+ expect(hydrated).toEqual({
+ aborted: true,
+ reason: 'aborted-after-serialization',
+ });
+ } finally {
+ (globalThis as any)[STABLE_ULID] = originalStableUlid;
+ vi.mocked(getWorld).mockReset();
+ vi.mocked(getWorldLazy).mockReset();
+ }
+ });
+
+ it('listener-side abort writes a packet the reader can hydrate (regression: bare JSON.stringify did not survive hydrateStepArguments)', async () => {
+ // Regression: attachAbortListenerOnce previously wrote
+ // JSON.stringify({ reason: signal.reason })
+ // but the reader hydrates with hydrateStepArguments, which expects the
+ // dehydrated envelope. With a structured reason (DOMException), the
+ // bare JSON.stringify path drops the reason entirely (string-coerces
+ // to "[object DOMException]") and the reader's catch falls back to
+ // controller.abort() with no reason. Exercise the round-trip end to end.
+ const originalStableUlid = (globalThis as any)[STABLE_ULID];
+ (globalThis as any)[STABLE_ULID] = () => '01ABORTDOMEX00000001';
+
+ let writtenPayload: Uint8Array | undefined;
+ const streamData = new Map();
+ const writeMock = vi
+ .fn()
+ .mockImplementation(async (...args: unknown[]) => {
+ const last = args[args.length - 1];
+ const payload =
+ last instanceof Uint8Array
+ ? last
+ : Array.isArray(last)
+ ? new Uint8Array(
+ (last as Uint8Array[]).flatMap((c) => Array.from(c))
+ )
+ : undefined;
+ if (payload) {
+ writtenPayload = payload;
+ streamData.set(String(args[1]), payload);
+ }
+ });
+ const getMock = vi.fn().mockImplementation(async (_runId, name) => {
+ const payload = streamData.get(String(name));
+ if (!payload) {
+ return new ReadableStream({
+ start(c) {
+ c.close();
+ },
+ });
+ }
+ return new ReadableStream({
+ start(c) {
+ c.enqueue(payload);
+ c.close();
+ },
+ });
+ });
+
+ const { getWorld } = await import('./runtime/world.js');
+ const { getWorldLazy } = await import('./runtime/get-world-lazy.js');
+ const mockWorld = {
+ streams: {
+ write: writeMock,
+ writeMulti: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ get: getMock,
+ list: vi.fn().mockResolvedValue([]),
+ getInfo: vi.fn().mockResolvedValue(undefined),
+ },
+ } as any;
+ vi.mocked(getWorld).mockReturnValue(mockWorld);
+ vi.mocked(getWorldLazy).mockReturnValue(mockWorld);
+
+ try {
+ const controller = new AbortController();
+ const ops: Promise[] = [];
+
+ // External → workflow reducer attaches the listener.
+ await dehydrateWorkflowArguments(
+ controller,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ );
+
+ // Now abort the *original* (external) controller with a DOMException.
+ // The bug was: the listener wrote bare JSON.stringify({reason}),
+ // which (a) string-coerces DOMException to "[object DOMException]"
+ // and (b) is not in the format `hydrateStepArguments` expects, so
+ // the reader's catch falls through to `controller.abort()` with no
+ // reason. The fix dehydrates via the same machinery the reader uses.
+ const reason = new DOMException('user cancelled', 'AbortError');
+ controller.abort(reason);
+
+ await Promise.all(ops);
+
+ expect(writeMock).toHaveBeenCalled();
+ expect(writtenPayload).toBeDefined();
+
+ // The written packet must hydrate cleanly with full type fidelity —
+ // not as raw JSON, and not with a stringified reason.
+ const decoded = (await hydrateStepArguments(
+ writtenPayload as Uint8Array,
+ mockRunId,
+ noEncryptionKey
+ )) as { aborted: boolean; reason: unknown };
+ expect(decoded.aborted).toBe(true);
+ expect(decoded.reason).toBeInstanceOf(DOMException);
+ expect((decoded.reason as DOMException).name).toBe('AbortError');
+ expect((decoded.reason as DOMException).message).toBe('user cancelled');
+ } finally {
+ (globalThis as any)[STABLE_ULID] = originalStableUlid;
+ vi.mocked(getWorld).mockReset();
+ vi.mocked(getWorldLazy).mockReset();
+ }
+ });
+
+ it('cancelAbortReaders cancels the reader when the signal is nested inside a Request', async () => {
+ const originalStableUlid = (globalThis as any)[STABLE_ULID];
+ (globalThis as any)[STABLE_ULID] = () => '01ABORTREQ0000000001';
+
+ try {
+ // Build a Request whose signal is tagged as workflow-managed so the
+ // Request reducer serializes it (plain native signals are stripped).
+ // The Request constructor copies the signal internally, so tag the
+ // Request's own signal after construction.
+ const controller = new AbortController();
+ const request = new Request('https://example.com/api', {
+ method: 'POST',
+ signal: controller.signal,
+ });
+ (request.signal as any)[ABORT_STREAM_NAME] =
+ 'strm_01ABORTREQ0000000001_system_abort';
+ (request.signal as any)[ABORT_HOOK_TOKEN] = 'abrt_01ABORTREQ0000000001';
+
+ const ops: Promise[] = [];
+ // external → workflow reducer tags + attaches listener; step reviver
+ // (via hydrateStepArguments) installs the stream reader.
+ const serialized = await dehydrateWorkflowArguments(
+ request,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ );
+
+ const hydrated = (await hydrateStepArguments(
+ serialized,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ )) as Request;
+
+ expect(hydrated).toBeInstanceOf(Request);
+ expect(hydrated.signal.aborted).toBe(false);
+ const hydratedSignal = hydrated.signal as AbortSignal & {
+ [K in typeof ABORT_READER_CANCEL]?: AbortController;
+ };
+ const readerCancel = hydratedSignal[ABORT_READER_CANCEL];
+ expect(readerCancel).toBeInstanceOf(AbortController);
+ expect(readerCancel!.signal.aborted).toBe(false);
+
+ // Simulate step completion — cancelAbortReaders walks the step args.
+ // The Request wraps the signal, so the walker must descend into it.
+ cancelAbortReaders(hydrated);
+
+ expect(readerCancel!.signal.aborted).toBe(true);
+ } finally {
+ (globalThis as any)[STABLE_ULID] = originalStableUlid;
+ }
+ });
+ });
+
+ describe('step return value (step → workflow)', () => {
+ it('AbortController dehydrated with step reducers, hydrated with workflow revivers', async () => {
+ const originalStableUlid = (globalThis as any)[STABLE_ULID];
+ (globalThis as any)[STABLE_ULID] = () => '01ABORT0000000000008';
+ try {
+ const controller = new AbortController();
+ const ops: Promise[] = [];
+
+ const serialized = await dehydrateStepReturnValue(
+ controller,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ );
+
+ // hydrateStepReturnValue uses workflow revivers (stubs)
+ const hydrated = await hydrateStepReturnValue(
+ serialized,
+ mockRunId,
+ noEncryptionKey,
+ vmGlobalThis
+ );
+
+ expect(hydrated.signal).toBeDefined();
+ expect(hydrated.signal.aborted).toBe(false);
+ expect((hydrated as any)[ABORT_STREAM_NAME]).toBeDefined();
+ } finally {
+ (globalThis as any)[STABLE_ULID] = originalStableUlid;
+ }
+ });
+
+ it('AbortSignal as standalone step return value', async () => {
+ const originalStableUlid = (globalThis as any)[STABLE_ULID];
+ (globalThis as any)[STABLE_ULID] = () => '01ABORT0000000000009';
+ try {
+ const controller = new AbortController();
+ const signal = controller.signal;
+ const ops: Promise[] = [];
+
+ const serialized = await dehydrateStepReturnValue(
+ signal,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ );
+
+ const hydrated = await hydrateStepReturnValue(
+ serialized,
+ mockRunId,
+ noEncryptionKey,
+ vmGlobalThis
+ );
+
+ expect(hydrated.aborted).toBe(false);
+ expect((hydrated as any)[ABORT_STREAM_NAME]).toBeDefined();
+ } finally {
+ (globalThis as any)[STABLE_ULID] = originalStableUlid;
+ }
+ });
+ });
+
+ describe('nested and compound structures', () => {
+ it('AbortController nested in object: { ctrl: new AbortController() }', async () => {
+ const originalStableUlid = (globalThis as any)[STABLE_ULID];
+ (globalThis as any)[STABLE_ULID] = () => '01ABORT000000000000A';
+ try {
+ const controller = new AbortController();
+ const data = { ctrl: controller, extra: 'hello' };
+ const ops: Promise[] = [];
+
+ const serialized = await dehydrateWorkflowArguments(
+ data,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ );
+
+ const hydrated = (await hydrateWorkflowArguments(
+ serialized,
+ mockRunId,
+ noEncryptionKey,
+ vmGlobalThis
+ )) as { ctrl: any; extra: string };
+
+ expect(hydrated.ctrl.signal).toBeDefined();
+ expect(hydrated.ctrl.signal.aborted).toBe(false);
+ expect((hydrated.ctrl as any)[ABORT_STREAM_NAME]).toBeDefined();
+ expect(hydrated.extra).toBe('hello');
+ } finally {
+ (globalThis as any)[STABLE_ULID] = originalStableUlid;
+ }
+ });
+
+ it('array of controllers: [ctrl1, ctrl2] get distinct stream names', async () => {
+ let callCount = 0;
+ const originalStableUlid = (globalThis as any)[STABLE_ULID];
+ (globalThis as any)[STABLE_ULID] = () => {
+ callCount++;
+ return `01ABORT00000000000${callCount.toString().padStart(2, '0')}`;
+ };
+ try {
+ const ctrl1 = new AbortController();
+ const ctrl2 = new AbortController();
+ const ops: Promise[] = [];
+
+ const serialized = await dehydrateWorkflowArguments(
+ [ctrl1, ctrl2],
+ mockRunId,
+ noEncryptionKey,
+ ops
+ );
+
+ const hydrated = (await hydrateWorkflowArguments(
+ serialized,
+ mockRunId,
+ noEncryptionKey,
+ vmGlobalThis
+ )) as any[];
+
+ // Both should have signal properties (workflow stubs)
+ expect(hydrated[0].signal).toBeDefined();
+ expect(hydrated[1].signal).toBeDefined();
+
+ // They should have distinct stream names
+ const name1 = (hydrated[0] as any)[ABORT_STREAM_NAME];
+ const name2 = (hydrated[1] as any)[ABORT_STREAM_NAME];
+ expect(name1).toBeDefined();
+ expect(name2).toBeDefined();
+ expect(name1).not.toBe(name2);
+ } finally {
+ (globalThis as any)[STABLE_ULID] = originalStableUlid;
+ }
+ });
+
+ it('same controller serialized twice reuses the same stream name (WeakMap dedup)', async () => {
+ const originalStableUlid = (globalThis as any)[STABLE_ULID];
+ (globalThis as any)[STABLE_ULID] = () => '01ABORT000000000000D';
+ try {
+ const controller = new AbortController();
+ const ops: Promise[] = [];
+
+ // Serialize the same controller in two different positions
+ const serialized = await dehydrateWorkflowArguments(
+ { a: controller, b: controller },
+ mockRunId,
+ noEncryptionKey,
+ ops
+ );
+
+ const hydrated = (await hydrateWorkflowArguments(
+ serialized,
+ mockRunId,
+ noEncryptionKey,
+ vmGlobalThis
+ )) as { a: any; b: any };
+
+ // Both should share the same stream name (dedup via symbol on the original)
+ const nameA = (hydrated.a as any)[ABORT_STREAM_NAME];
+ const nameB = (hydrated.b as any)[ABORT_STREAM_NAME];
+ expect(nameA).toBeDefined();
+ expect(nameA).toBe(nameB);
+ } finally {
+ (globalThis as any)[STABLE_ULID] = originalStableUlid;
+ }
+ });
+ });
+
+ describe('integration with Request', () => {
+ it('Request with workflow-managed signal preserves signal through step hydration', async () => {
+ const originalStableUlid = (globalThis as any)[STABLE_ULID];
+ (globalThis as any)[STABLE_ULID] = () => '01ABORT000000000000E';
+ try {
+ // The Request constructor copies the signal internally, so symbols
+ // set on the original controller.signal won't appear on request.signal.
+ // To test the Request+signal serialization path, set the symbol
+ // directly on the Request's own signal after construction.
+ const controller = new AbortController();
+ controller.abort('request cancelled');
+ const request = new Request('https://example.com/api', {
+ method: 'POST',
+ signal: controller.signal,
+ });
+ (request.signal as any)[ABORT_STREAM_NAME] =
+ 'strm_01ABORT000000000000E_system_abort';
+ (request.signal as any)[ABORT_HOOK_TOKEN] = 'abrt_01ABORT000000000000E';
+ const ops: Promise[] = [];
+
+ const serialized = await dehydrateWorkflowArguments(
+ request,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ );
+
+ const hydrated = (await hydrateStepArguments(
+ serialized,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ )) as Request;
+
+ expect(hydrated).toBeInstanceOf(Request);
+ expect(hydrated.url).toBe('https://example.com/api');
+ expect(hydrated.method).toBe('POST');
+ expect(hydrated.signal).toBeDefined();
+ expect(hydrated.signal.aborted).toBe(true);
+ expect(hydrated.signal.reason).toBe('request cancelled');
+ } finally {
+ (globalThis as any)[STABLE_ULID] = originalStableUlid;
+ }
+ });
+
+ it('Request with already-aborted plain signal preserves abort state', async () => {
+ // When a signal has already aborted before serialization (e.g. the
+ // caller cancelled the request before sending it), the abort state
+ // must be preserved so the hydrated step sees aborted=true. Plain
+ // signals that are NOT aborted are dropped (covered by other tests)
+ // because forwarding fresh signals would mint stream infrastructure
+ // for the auto-generated signal on every `new Request(url)`.
+ const controller = new AbortController();
+ controller.abort('user timeout');
+ const request = new Request('https://example.com/api', {
+ method: 'GET',
+ signal: controller.signal,
+ });
+ const ops: Promise[] = [];
+
+ const serialized = await dehydrateWorkflowArguments(
+ request,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ );
+
+ const hydrated = (await hydrateStepArguments(
+ serialized,
+ mockRunId,
+ noEncryptionKey,
+ ops
+ )) as Request;
+
+ expect(hydrated).toBeInstanceOf(Request);
+ expect(hydrated.url).toBe('https://example.com/api');
+ expect(hydrated.method).toBe('GET');
+ expect(hydrated.signal.aborted).toBe(true);
+ expect(hydrated.signal.reason).toBe('user timeout');
+ });
+ });
+
+ describe('encryption', () => {
+ const testKeyRaw = new Uint8Array([
+ 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
+ 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
+ 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f,
+ ]);
+ let testKey: CryptoKey;
+ beforeAll(async () => {
+ testKey = await importKey(testKeyRaw);
+ });
+
+ it('AbortController round-trip with encryption enabled', async () => {
+ const originalStableUlid = (globalThis as any)[STABLE_ULID];
+ (globalThis as any)[STABLE_ULID] = () => '01ABORT000000000000F';
+ try {
+ const controller = new AbortController();
+ const ops: Promise[] = [];
+
+ const encrypted = await dehydrateWorkflowArguments(
+ controller,
+ mockRunId,
+ testKey,
+ ops,
+ globalThis,
+ false
+ );
+
+ // Should have 'encr' prefix
+ expect(encrypted).toBeInstanceOf(Uint8Array);
+ const prefix = new TextDecoder().decode(
+ (encrypted as Uint8Array).subarray(0, 4)
+ );
+ expect(prefix).toBe('encr');
+
+ const decrypted = await hydrateWorkflowArguments(
+ encrypted,
+ mockRunId,
+ testKey,
+ vmGlobalThis,
+ {}
+ );
+
+ expect(decrypted.signal).toBeDefined();
+ expect(decrypted.signal.aborted).toBe(false);
+ expect((decrypted as any)[ABORT_STREAM_NAME]).toBeDefined();
+ } finally {
+ (globalThis as any)[STABLE_ULID] = originalStableUlid;
+ }
+ });
+
+ it('AbortSignal round-trip with encryption enabled', async () => {
+ const originalStableUlid = (globalThis as any)[STABLE_ULID];
+ (globalThis as any)[STABLE_ULID] = () => '01ABORT000000000000G';
+ try {
+ const signal = AbortSignal.abort('encrypted reason');
+ const ops: Promise[] = [];
+
+ const encrypted = await dehydrateWorkflowArguments(
+ signal,
+ mockRunId,
+ testKey,
+ ops,
+ globalThis,
+ false
+ );
+
+ // Should have 'encr' prefix
+ expect(encrypted).toBeInstanceOf(Uint8Array);
+ const prefix = new TextDecoder().decode(
+ (encrypted as Uint8Array).subarray(0, 4)
+ );
+ expect(prefix).toBe('encr');
+
+ const decrypted = await hydrateWorkflowArguments(
+ encrypted,
+ mockRunId,
+ testKey,
+ vmGlobalThis,
+ {}
+ );
+
+ expect(decrypted.aborted).toBe(true);
+ expect(decrypted.reason).toBe('encrypted reason');
+ } finally {
+ (globalThis as any)[STABLE_ULID] = originalStableUlid;
+ }
+ });
+ });
+});
+
describe('WorkflowFunction serialization', () => {
it('should serialize a function with workflowId and hydrate as a function with workflowId', async () => {
const workflowFn = Object.assign(
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index 1ec66d0cb7..c6f57f05de 100644
--- a/packages/core/src/serialization.ts
+++ b/packages/core/src/serialization.ts
@@ -55,12 +55,18 @@ import {
import * as workflowModule from './serialization/workflow.js';
import { contextStorage } from './step/context-storage.js';
import {
+ ABORT_HOOK_TOKEN,
+ ABORT_LISTENER_ATTACHED,
+ ABORT_READER_CANCEL,
+ ABORT_STREAM_NAME,
BODY_INIT_SYMBOL,
STABLE_ULID,
STREAM_NAME_SYMBOL,
STREAM_TYPE_SYMBOL,
WEBHOOK_RESPONSE_WRITABLE,
} from './symbols.js';
+import { getAbortStreamId } from './util.js';
+import { WorkflowAbortSignal } from './workflow/abort-controller.js';
// Re-export types and utilities from the modular serialization modules
// so existing consumers of `@workflow/core/serialization` keep working.
@@ -527,6 +533,22 @@ function getAllBaseReducers(
if (responseWritable) {
data.responseWritable = responseWritable;
}
+ // Forward the signal in two cases:
+ // 1. Already aborted — preserve aborted=true/reason so the hydrated
+ // step sees the cancellation that happened before serialize.
+ // 2. Already tagged with workflow infrastructure — i.e. a signal
+ // from a workflow-managed AbortController, which has stream/hook
+ // backing for cross-boundary propagation.
+ // Plain non-aborted native signals are intentionally dropped (would
+ // mint stream infra for every Request, including the auto-generated
+ // signal on `new Request(url)`).
+ if (
+ value.signal &&
+ (value.signal.aborted ||
+ (value.signal as AbortInternals)[ABORT_STREAM_NAME])
+ ) {
+ data.signal = value.signal;
+ }
return data;
},
Response: (value) => {
@@ -544,6 +566,157 @@ function getAllBaseReducers(
};
}
+// ---------------------------------------------------------------------------
+// Shared abort reducer helpers
+// ---------------------------------------------------------------------------
+
+type AbortSerializedData = {
+ streamName: string;
+ hookToken: string;
+ aborted: boolean;
+ reason: unknown;
+};
+
+/**
+ * Symbol-keyed internal fields tagged onto AbortController/AbortSignal
+ * instances (and `holder`s in reducer helpers). All optional — a plain
+ * native instance has none of them set.
+ */
+type AbortInternals = {
+ [ABORT_STREAM_NAME]?: string;
+ [ABORT_HOOK_TOKEN]?: string;
+ [ABORT_READER_CANCEL]?: AbortController;
+};
+
+type AbortSignalLike = AbortInternals & {
+ aborted: boolean;
+ reason?: unknown;
+ addEventListener?: Function;
+};
+
+type AbortHolder = AbortInternals & { signal?: AbortInternals };
+
+/**
+ * Shared logic for AbortController/AbortSignal reducers in external and step
+ * contexts. Assigns stream/hook names if not already present, optionally
+ * attaches an abort listener for real-time propagation, and returns the
+ * serialized representation.
+ */
+function reduceAbortWithListener(
+ signal: AbortSignalLike,
+ holder: AbortHolder,
+ global: Record,
+ ops: Promise[],
+ runId: string,
+ cryptoKey: EncryptionKeyParam
+): AbortSerializedData {
+ let streamName = holder[ABORT_STREAM_NAME];
+ let hookToken = holder[ABORT_HOOK_TOKEN];
+ if (!streamName) {
+ const id = ((global as any)[STABLE_ULID] || defaultUlid)();
+ streamName = getAbortStreamId(id);
+ hookToken = `abrt_${id}`;
+ holder[ABORT_STREAM_NAME] = streamName;
+ holder[ABORT_HOOK_TOKEN] = hookToken;
+ if (holder.signal) {
+ holder.signal[ABORT_STREAM_NAME] = streamName;
+ holder.signal[ABORT_HOOK_TOKEN] = hookToken;
+ }
+ }
+
+ // Deduped via ABORT_LISTENER_ATTACHED marker — see attachAbortListenerOnce.
+ attachAbortListenerOnce(
+ signal as AbortSignal,
+ streamName,
+ runId,
+ cryptoKey,
+ ops
+ );
+
+ return {
+ streamName,
+ hookToken: hookToken!,
+ aborted: signal.aborted,
+ reason: signal.aborted ? signal.reason : undefined,
+ };
+}
+
+/**
+ * Shared logic for AbortController/AbortSignal reducers in workflow context.
+ * Reads existing stream/hook names from symbols (must already be set).
+ */
+function reduceAbortBySymbol(
+ signal: { aborted: boolean; reason?: unknown },
+ holder: AbortHolder
+): AbortSerializedData | false {
+ const streamName =
+ holder[ABORT_STREAM_NAME] ?? holder.signal?.[ABORT_STREAM_NAME];
+ const hookToken =
+ holder[ABORT_HOOK_TOKEN] ?? holder.signal?.[ABORT_HOOK_TOKEN];
+ if (!streamName) {
+ throw new Error('AbortController/AbortSignal stream name is not set');
+ }
+ return {
+ streamName,
+ hookToken: hookToken!,
+ aborted: signal.aborted,
+ reason: signal.aborted ? signal.reason : undefined,
+ };
+}
+
+/**
+ * Attach a single abort listener to a signal, deduped across calls.
+ *
+ * Each serialization pass goes through the reducer, but a controller passed
+ * to N steps would otherwise accumulate N listeners — each writing the same
+ * stream packet and double-closing the stream on abort. The marker symbol
+ * ensures the stream-write side-effect runs at most once per (signal, runId).
+ */
+function attachAbortListenerOnce(
+ signal: AbortSignal,
+ streamName: string,
+ runId: string,
+ cryptoKey: EncryptionKeyParam,
+ ops: Promise[]
+): void {
+ if (signal.aborted) return;
+ if ((signal as any)[ABORT_LISTENER_ATTACHED]) return;
+ (signal as any)[ABORT_LISTENER_ATTACHED] = true;
+
+ signal.addEventListener(
+ 'abort',
+ () => {
+ ops.push(
+ (async () => {
+ try {
+ // Dehydrate via the same machinery the reader uses (hydrateStepArguments)
+ // so the reason round-trips with full type fidelity (DOMException,
+ // Errors, custom classes, etc.) and respects the run's encryption key.
+ // A bare JSON.stringify here would write a packet the reader can't
+ // decode and the listener-side abort would propagate with no reason.
+ const key = await cryptoKey;
+ const payload = await dehydrateStepArguments(
+ { aborted: true, reason: signal.reason },
+ runId,
+ key
+ );
+ const writable = new WorkflowServerWritableStream(
+ runId,
+ streamName
+ );
+ const writer = writable.getWriter();
+ await writer.write(payload as Uint8Array);
+ await writer.close();
+ } catch {
+ // Best-effort stream write
+ }
+ })()
+ );
+ },
+ { once: true }
+ );
+}
+
/**
* Reducers for serialization boundary from the client side, passing arguments
* to the workflow handler.
@@ -610,6 +783,40 @@ export function getExternalReducers(
return { name };
},
+
+ AbortController: (value) => {
+ if (
+ !global.AbortController ||
+ typeof global.AbortController !== 'function' ||
+ !(value instanceof global.AbortController)
+ )
+ return false;
+ return reduceAbortWithListener(
+ value.signal,
+ value,
+ global,
+ ops,
+ runId,
+ cryptoKey
+ );
+ },
+
+ AbortSignal: (value) => {
+ if (
+ !global.AbortSignal ||
+ typeof global.AbortSignal !== 'function' ||
+ !(value instanceof global.AbortSignal)
+ )
+ return false;
+ return reduceAbortWithListener(
+ value,
+ value,
+ global,
+ ops,
+ runId,
+ cryptoKey
+ );
+ },
};
}
@@ -656,6 +863,33 @@ export function getWorkflowReducers(
}
return { name };
},
+
+ // AbortController/AbortSignal in workflow context — just read symbols (handles).
+ // In the workflow VM, global.AbortController is a class but global.AbortSignal
+ // is a plain object (not a class), so instanceof checks won't work for signals.
+ // Detect instances by the presence of the ABORT_STREAM_NAME symbol instead.
+ AbortController: (value) => {
+ if (!value || !value.signal) return false;
+ const holder = value as AbortController & AbortHolder;
+ const hasAbortSymbol =
+ holder[ABORT_STREAM_NAME] ?? holder.signal?.[ABORT_STREAM_NAME];
+ const isNativeAbortController =
+ global.AbortController &&
+ typeof global.AbortController === 'function' &&
+ value instanceof global.AbortController;
+ if (!hasAbortSymbol && !isNativeAbortController) return false;
+ return reduceAbortBySymbol(value.signal, holder);
+ },
+ AbortSignal: (value) => {
+ const signal = value as (AbortSignal & AbortInternals) | undefined;
+ const hasAbortSymbol = signal && signal[ABORT_STREAM_NAME];
+ const isNativeAbortSignal =
+ global.AbortSignal &&
+ typeof global.AbortSignal === 'function' &&
+ value instanceof global.AbortSignal;
+ if (!hasAbortSymbol && !isNativeAbortSignal) return false;
+ return reduceAbortBySymbol(value, value as AbortHolder);
+ },
};
}
@@ -744,9 +978,312 @@ function getStepReducers(
return { name };
},
+
+ AbortController: (value) => {
+ if (
+ !global.AbortController ||
+ typeof global.AbortController !== 'function' ||
+ !(value instanceof global.AbortController)
+ )
+ return false;
+ return reduceAbortWithListener(
+ value.signal,
+ value,
+ global,
+ ops,
+ runId,
+ cryptoKey
+ );
+ },
+
+ AbortSignal: (value) => {
+ if (
+ !global.AbortSignal ||
+ typeof global.AbortSignal !== 'function' ||
+ !(value instanceof global.AbortSignal)
+ )
+ return false;
+ return reduceAbortWithListener(
+ value,
+ value,
+ global,
+ ops,
+ runId,
+ cryptoKey
+ );
+ },
};
}
+/**
+ * Cancel dangling abort-stream readers on any AbortController instances found
+ * in the hydrated step arguments. Called after the step function returns
+ * (success or failure) to prevent reader promises from keeping the serverless
+ * function alive indefinitely.
+ */
+export function cancelAbortReaders(...values: unknown[]): void {
+ const visited = new WeakSet();
+ function cancelIfPresent(val: AbortInternals): void {
+ const cancel = val[ABORT_READER_CANCEL];
+ if (cancel && !cancel.signal.aborted) {
+ cancel.abort();
+ }
+ }
+ function walk(val: unknown): void {
+ if (val == null || typeof val !== 'object') return;
+ if (visited.has(val as object)) return;
+ visited.add(val as object);
+ if (val instanceof AbortController) {
+ cancelIfPresent(val as AbortController & AbortInternals);
+ cancelIfPresent(val.signal as AbortSignal & AbortInternals);
+ return;
+ }
+ if (val instanceof AbortSignal) {
+ cancelIfPresent(val as AbortSignal & AbortInternals);
+ return;
+ }
+ if (Array.isArray(val)) {
+ for (const item of val) walk(item);
+ return;
+ }
+ if (val instanceof Map) {
+ for (const v of val.values()) walk(v);
+ return;
+ }
+ if (val instanceof Set) {
+ for (const v of val) walk(v);
+ return;
+ }
+ // Request/Response expose `signal`/`body` as prototype getters, so
+ // Object.values() won't find them. Descend explicitly.
+ if (typeof Request !== 'undefined' && val instanceof Request) {
+ walk(val.signal);
+ return;
+ }
+ for (const v of Object.values(val as Record)) walk(v);
+ }
+ for (const v of values) walk(v);
+}
+
+/**
+ * Sets up a stream reader on the controller that listens for an abort packet.
+ * Returns the readerCancel controller so it can be stored on both the
+ * controller and signal for cleanup by cancelAbortReaders.
+ */
+function setupAbortStreamReader(
+ controller: AbortController,
+ runId: string,
+ streamName: string,
+ ops: Promise[]
+): AbortController {
+ const readerCancel = new AbortController();
+
+ ops.push(
+ (async () => {
+ try {
+ const readable = new WorkflowServerReadableStream(runId, streamName);
+ const reader = readable.getReader();
+ const result = await Promise.race([
+ reader.read(),
+ new Promise<{ value: undefined; done: true }>((resolve) => {
+ if (readerCancel.signal.aborted) {
+ resolve({ value: undefined, done: true });
+ return;
+ }
+ readerCancel.signal.addEventListener(
+ 'abort',
+ () => resolve({ value: undefined, done: true }),
+ { once: true }
+ );
+ }),
+ ]);
+ reader.releaseLock();
+ if (result.value && !result.done) {
+ try {
+ // Hydrate via the same machinery the writer used so the reason
+ // round-trips with full type fidelity. Encryption key (if any)
+ // comes from the step context — set up by the step handler before
+ // this reader runs. Fallback to undefined for external-context
+ // revives (the hydrate path is encryption-key-tolerant).
+ const ctxForKey = contextStorage.getStore();
+ const data = (await hydrateStepArguments(
+ result.value,
+ runId,
+ ctxForKey?.encryptionKey
+ )) as { reason?: unknown } | undefined;
+ controller.abort(data?.reason);
+ } catch {
+ controller.abort();
+ }
+ }
+ } catch {
+ // Stream read failed — signal won't propagate in real-time,
+ // but hook-based propagation on next replay provides fallback
+ }
+ })()
+ );
+
+ return readerCancel;
+}
+
+/**
+ * Stores abort serialization symbols and the readerCancel controller
+ * on both the controller and its signal.
+ */
+function tagAbortPair(
+ controller: AbortController,
+ value: { streamName: string; hookToken: string },
+ readerCancel?: AbortController
+): void {
+ const taggedController = controller as AbortController & AbortInternals;
+ const taggedSignal = controller.signal as AbortSignal & AbortInternals;
+ taggedController[ABORT_STREAM_NAME] = value.streamName;
+ taggedController[ABORT_HOOK_TOKEN] = value.hookToken;
+ taggedSignal[ABORT_STREAM_NAME] = value.streamName;
+ taggedSignal[ABORT_HOOK_TOKEN] = value.hookToken;
+ if (readerCancel) {
+ taggedController[ABORT_READER_CANCEL] = readerCancel;
+ taggedSignal[ABORT_READER_CANCEL] = readerCancel;
+ }
+}
+
+/**
+ * Propagate abort-internal symbols from one signal to another. Used by the
+ * Request reviver because `new Request(url, { signal })` copies the signal
+ * internally — the constructed `request.signal` is a fresh AbortSignal that
+ * doesn't carry symbols from the source.
+ */
+function copyAbortInternals(src: AbortSignal, dest: AbortSignal): void {
+ const s = src as AbortSignal & AbortInternals;
+ const d = dest as AbortSignal & AbortInternals;
+ if (s[ABORT_STREAM_NAME] !== undefined) {
+ d[ABORT_STREAM_NAME] = s[ABORT_STREAM_NAME];
+ }
+ if (s[ABORT_HOOK_TOKEN] !== undefined) {
+ d[ABORT_HOOK_TOKEN] = s[ABORT_HOOK_TOKEN];
+ }
+ if (s[ABORT_READER_CANCEL] !== undefined) {
+ d[ABORT_READER_CANCEL] = s[ABORT_READER_CANCEL];
+ }
+}
+
+/**
+ * Creates an AbortController with stream-backed abort propagation.
+ * Used by step and external revivers where real abort signal behavior is needed.
+ *
+ * @param value - The serialized abort controller/signal data
+ * @param ops - The ops array for tracking async work
+ * @param runId - The workflow run ID (for stream reads)
+ * @returns A real AbortController with patched abort() method
+ */
+function reviveAbortController(
+ value: SerializableSpecial['AbortController'],
+ ops: Promise[],
+ runId: string
+): AbortController {
+ const controller = new AbortController();
+
+ if (value.aborted) {
+ tagAbortPair(controller, value);
+ controller.abort(value.reason);
+ } else if (value.streamName) {
+ const readerCancel = setupAbortStreamReader(
+ controller,
+ runId,
+ value.streamName,
+ ops
+ );
+ tagAbortPair(controller, value, readerCancel);
+ } else {
+ tagAbortPair(controller, value);
+ }
+
+ // Override abort() to also write stream + resume hook (for step-initiated abort)
+ const originalAbort = controller.abort.bind(controller);
+ controller.abort = (reason?: unknown) => {
+ if (controller.signal.aborted) return;
+ originalAbort(reason);
+
+ const ctx = contextStorage.getStore();
+ if (ctx) {
+ ctx.ops.push(
+ (async () => {
+ try {
+ // Dehydrate the abort payload through the same machinery the hook
+ // event uses so the `reason` round-trips with full type fidelity
+ // (DOMException, custom errors, etc.) and respects the run's
+ // encryption key — symmetric with what the suspension handler
+ // writes for workflow-initiated aborts.
+ const payload = await dehydrateStepArguments(
+ { aborted: true, reason },
+ ctx.workflowMetadata.workflowRunId,
+ ctx.encryptionKey
+ );
+ const writable = new WorkflowServerWritableStream(
+ ctx.workflowMetadata.workflowRunId,
+ value.streamName
+ );
+ const writer = writable.getWriter();
+ await writer.write(payload as Uint8Array);
+ await writer.close();
+ } catch {
+ // Best-effort stream write
+ }
+ })()
+ );
+
+ if (value.hookToken) {
+ ctx.ops.push(
+ (async () => {
+ try {
+ const { resumeHook: resumeHookFn } = await import(
+ './runtime/resume-hook.js'
+ );
+ await resumeHookFn(value.hookToken, {
+ aborted: true,
+ reason,
+ });
+ } catch {
+ // Best-effort hook resume — retry on next replay
+ }
+ })()
+ );
+ }
+ }
+ };
+
+ return controller;
+}
+
+/**
+ * Revives just an AbortSignal without the patched abort() overhead.
+ * Used when only a signal (not a controller) was serialized.
+ */
+function reviveAbortSignal(
+ value: SerializableSpecial['AbortSignal'],
+ ops: Promise[],
+ runId: string
+): AbortSignal {
+ const controller = new AbortController();
+
+ if (value.aborted) {
+ tagAbortPair(controller, value);
+ controller.abort(value.reason);
+ } else if (value.streamName) {
+ const readerCancel = setupAbortStreamReader(
+ controller,
+ runId,
+ value.streamName,
+ ops
+ );
+ tagAbortPair(controller, value, readerCancel);
+ } else {
+ tagAbortPair(controller, value);
+ }
+
+ return controller.signal;
+}
+
/**
* Base revivers shared across all serialization boundaries.
* Composes: class + common revivers from the modular modules.
@@ -801,12 +1338,19 @@ export function getExternalRevivers(
),
Request: (value) => {
- return new global.Request(value.url, {
+ const init: RequestInit & { duplex?: string } = {
method: value.method,
headers: new global.Headers(value.headers),
body: value.body,
duplex: value.duplex,
- });
+ };
+ if (value.signal) init.signal = value.signal;
+ const request = new global.Request(value.url, init);
+ // The Request constructor creates an internal signal copy, so the
+ // abort-internal symbols set by reviveAbortSignal don't propagate.
+ // Re-tag the request's own signal so cancelAbortReaders can find it.
+ if (value.signal) copyAbortInternals(value.signal, request.signal);
+ return request;
},
Response: (value) => {
// Note: Response constructor only accepts status, statusText, and headers
@@ -893,6 +1437,9 @@ export function getExternalRevivers(
return serialize.writable;
},
+
+ AbortController: (value) => reviveAbortController(value, ops, runId),
+ AbortSignal: (value) => reviveAbortSignal(value, ops, runId),
};
}
@@ -979,6 +1526,29 @@ export function getWorkflowRevivers(
},
});
},
+
+ // AbortController/AbortSignal revived inside the workflow VM. Use the
+ // real WorkflowAbortSignal class so addEventListener('abort', fn) actually
+ // fires when the signal aborts (the previous no-op stub silently dropped
+ // listener registrations — silent correctness bug for natural patterns
+ // like `signal.addEventListener('abort', fn)` after receiving a deserialized
+ // signal). The signal does not own a hook subscription here — abort state
+ // is delivered via the existing replay machinery on the source side.
+ AbortController: (value) => {
+ const signal = new WorkflowAbortSignal(value.streamName, value.hookToken);
+ if (value.aborted) signal._setAborted(value.reason);
+ return {
+ [ABORT_STREAM_NAME]: value.streamName,
+ [ABORT_HOOK_TOKEN]: value.hookToken,
+ signal,
+ abort: () => {},
+ };
+ },
+ AbortSignal: (value) => {
+ const signal = new WorkflowAbortSignal(value.streamName, value.hookToken);
+ if (value.aborted) signal._setAborted(value.reason);
+ return signal;
+ },
};
}
@@ -1074,12 +1644,18 @@ function getStepRevivers(
Request: (value) => {
const responseWritable = value.responseWritable;
- const request = new global.Request(value.url, {
+ const init: RequestInit & { duplex?: string } = {
method: value.method,
headers: new global.Headers(value.headers),
body: value.body,
duplex: value.duplex,
- });
+ };
+ if (value.signal) init.signal = value.signal;
+ const request = new global.Request(value.url, init);
+ // The Request constructor creates an internal signal copy, so the
+ // abort-internal symbols set by reviveAbortSignal don't propagate.
+ // Re-tag the request's own signal so cancelAbortReaders can find it.
+ if (value.signal) copyAbortInternals(value.signal, request.signal);
if (responseWritable) {
request.respondWith = async (response: Response) => {
const writer = responseWritable.getWriter();
@@ -1170,6 +1746,9 @@ function getStepRevivers(
return serialize.writable;
},
+
+ AbortController: (value) => reviveAbortController(value, ops, runId),
+ AbortSignal: (value) => reviveAbortSignal(value, ops, runId),
};
}
@@ -1606,6 +2185,12 @@ const STREAM_AND_REQUEST_KEYS = [
'Request',
'Response',
'StepFunction',
+ // Wire AbortController/AbortSignal through the client serialization path so
+ // signals reachable via Request.signal (or as direct arguments) get their
+ // dedicated reducer. Without this, devalue falls back to its arbitrary-POJO
+ // path and fails for any signal the Request reducer forwards.
+ 'AbortController',
+ 'AbortSignal',
] as const;
function getStreamAndRequestReducers(
diff --git a/packages/core/src/serialization/reducers/common.ts b/packages/core/src/serialization/reducers/common.ts
index b4ea865261..88bf0e3a40 100644
--- a/packages/core/src/serialization/reducers/common.ts
+++ b/packages/core/src/serialization/reducers/common.ts
@@ -181,19 +181,26 @@ export function getCommonReducers(
const valid = !Number.isNaN(value.getDate());
return valid ? value.toISOString() : '.';
},
- // DOMException is a special case: in Node.js it passes isNativeError()
- // and instanceof Error, but has a unique constructor signature
- // (message, name) and a read-only numeric `code` property derived from
- // `name`. It must be checked before the generic Error reducer.
+ // DOMException is a special case: it `instanceof Error` is true in Node,
+ // but `types.isNativeError()` returns FALSE for it, so the generic Error
+ // reducer (which gates on isNativeError) won't match. Check it explicitly
+ // by constructor name + Error inheritance so we catch DOMExceptions from
+ // any realm (cross-VM safety: instanceof global.DOMException would fail
+ // for instances minted in another context).
DOMException: (value) => {
- if (!types.isNativeError(value)) return false;
- if (value.constructor?.name !== 'DOMException') return false;
+ if (value === null || typeof value !== 'object') return false;
+ if (
+ (value as { constructor?: { name?: string } }).constructor?.name !==
+ 'DOMException'
+ )
+ return false;
+ const e = value as Error & { cause?: unknown };
const reduced: SerializableSpecial['DOMException'] = {
- message: value.message,
- name: value.name,
- stack: value.stack,
+ message: e.message,
+ name: e.name,
+ stack: e.stack,
};
- if ('cause' in value) reduced.cause = value.cause;
+ if ('cause' in e) reduced.cause = e.cause;
return reduced;
},
// Error subclass reducers are intentionally placed before the base Error
diff --git a/packages/core/src/serialization/types.ts b/packages/core/src/serialization/types.ts
index 4c14f82bff..3910545006 100644
--- a/packages/core/src/serialization/types.ts
+++ b/packages/core/src/serialization/types.ts
@@ -85,6 +85,7 @@ export interface SerializableSpecial {
body: Request['body'];
duplex: Request['duplex'];
responseWritable?: WritableStream;
+ signal?: AbortSignal;
};
Response: {
type: Response['type'];
@@ -126,6 +127,18 @@ export interface SerializableSpecial {
errors: unknown[];
};
WritableStream: { name: string };
+ AbortController: {
+ streamName: string;
+ hookToken: string;
+ aborted: boolean;
+ reason?: unknown;
+ };
+ AbortSignal: {
+ streamName: string;
+ hookToken: string;
+ aborted: boolean;
+ reason?: unknown;
+ };
}
export type Reducers = {
diff --git a/packages/core/src/step.test.ts b/packages/core/src/step.test.ts
index 3baea6f355..33462574f2 100644
--- a/packages/core/src/step.test.ts
+++ b/packages/core/src/step.test.ts
@@ -11,10 +11,16 @@ import type { WorkflowOrchestratorContext } from './private.js';
import {
dehydrateStepError,
dehydrateStepReturnValue,
+ dehydrateWorkflowArguments,
} from './serialization.js';
import { createUseStep } from './step.js';
-import { WORKFLOW_CLASS_REGISTRY } from './symbols.js';
+import {
+ ABORT_HOOK_TOKEN,
+ ABORT_STREAM_NAME,
+ WORKFLOW_CLASS_REGISTRY,
+} from './symbols.js';
import { createContext } from './vm/index.js';
+import { createCreateAbortController } from './workflow/abort-controller.js';
// In production, the SWC plugin auto-discovers FatalError/RetryableError
// (classes with WORKFLOW_SERIALIZE/DESERIALIZE) and registers them. In unit
@@ -611,3 +617,332 @@ describe('createUseStep', () => {
expect(workflowError?.message).toContain('wait_completed');
});
});
+
+// ============================================================================
+// AbortController hook integration in workflow context
+// ============================================================================
+
+describe('AbortController hook integration', () => {
+ describe('factory creates hook in invocations queue', () => {
+ it('new AbortController() adds a hook entry to the invocations queue', () => {
+ const ctx = setupWorkflowContext([]);
+ const WorkflowAbortController = createCreateAbortController(ctx);
+
+ expect(ctx.invocationsQueue.size).toBe(0);
+
+ const controller = new WorkflowAbortController();
+
+ // A hook item should have been added to the queue
+ expect(ctx.invocationsQueue.size).toBe(1);
+ const queueItem = [...ctx.invocationsQueue.values()][0];
+ expect(queueItem).toMatchObject({
+ type: 'hook',
+ isSystem: true,
+ isWebhook: false,
+ });
+ // The hook token should match the controller's token
+ expect(queueItem.type).toBe('hook');
+ if (queueItem.type === 'hook') {
+ expect(queueItem.token).toBe((controller as any)[ABORT_HOOK_TOKEN]);
+ }
+ });
+
+ it('multiple AbortControllers create independent hook entries', () => {
+ const ctx = setupWorkflowContext([]);
+ const WorkflowAbortController = createCreateAbortController(ctx);
+
+ const ctrl1 = new WorkflowAbortController();
+ const ctrl2 = new WorkflowAbortController();
+
+ expect(ctx.invocationsQueue.size).toBe(2);
+
+ // Each should have a distinct token
+ const items = [...ctx.invocationsQueue.values()];
+ expect(items[0].type).toBe('hook');
+ expect(items[1].type).toBe('hook');
+ if (items[0].type === 'hook' && items[1].type === 'hook') {
+ expect(items[0].token).not.toBe(items[1].token);
+ }
+ });
+ });
+
+ describe('abort marks hook with abortRequested', () => {
+ it('calling abort() sets abortRequested on the hook queue item', () => {
+ const ctx = setupWorkflowContext([]);
+ const WorkflowAbortController = createCreateAbortController(ctx);
+
+ const controller = new WorkflowAbortController();
+ controller.abort('test reason');
+
+ const queueItem = [...ctx.invocationsQueue.values()][0];
+ expect(queueItem.type).toBe('hook');
+ if (queueItem.type === 'hook') {
+ expect(queueItem.abortRequested).toBe(true);
+ expect(queueItem.abortReason).toBe('test reason');
+ }
+ });
+
+ it('calling abort() twice does not crash or duplicate flags', () => {
+ const ctx = setupWorkflowContext([]);
+ const WorkflowAbortController = createCreateAbortController(ctx);
+
+ const controller = new WorkflowAbortController();
+ controller.abort('first');
+ controller.abort('second');
+
+ // Still only one queue item
+ expect(ctx.invocationsQueue.size).toBe(1);
+ const queueItem = [...ctx.invocationsQueue.values()][0];
+ if (queueItem.type === 'hook') {
+ expect(queueItem.abortRequested).toBe(true);
+ // The first abort() sets signal.aborted synchronously, so the second
+ // abort() is a no-op (returns early). The reason stays 'first'.
+ expect(queueItem.abortReason).toBe('first');
+ }
+ });
+
+ it('abort without reason sets abortRequested but reason is undefined', () => {
+ const ctx = setupWorkflowContext([]);
+ const WorkflowAbortController = createCreateAbortController(ctx);
+
+ const controller = new WorkflowAbortController();
+ controller.abort();
+
+ const queueItem = [...ctx.invocationsQueue.values()][0];
+ if (queueItem.type === 'hook') {
+ expect(queueItem.abortRequested).toBe(true);
+ expect(queueItem.abortReason).toBeUndefined();
+ }
+ });
+ });
+
+ describe('replay with abort events', () => {
+ it('replay with hook_received event reconstructs signal.aborted === true', async () => {
+ // First, discover the correlationId that createCreateAbortController will use
+ // by doing a dry run with the same deterministic seed.
+ const dryCtx = setupWorkflowContext([]);
+ const DryAbortController = createCreateAbortController(dryCtx);
+ new DryAbortController();
+ const correlationId = [...dryCtx.invocationsQueue.keys()][0];
+
+ // Production stores `payload` as a dehydrated Uint8Array (the
+ // suspension handler dehydrates `{ aborted: true, reason }` before
+ // creating the hook_received event). The events consumer hydrates
+ // the payload before reading the reason, so the test must pass a
+ // dehydrated payload to match production.
+ const dehydratedPayload = await dehydrateStepReturnValue(
+ { aborted: true, reason: 'aborted!' },
+ 'wrun_test',
+ undefined
+ );
+
+ // Now create the real context with the hook_created and hook_received events
+ const ctx = setupWorkflowContext([
+ {
+ eventId: 'evnt_0',
+ runId: 'wrun_test',
+ eventType: 'hook_created',
+ correlationId,
+ eventData: {},
+ createdAt: new Date(),
+ },
+ {
+ eventId: 'evnt_1',
+ runId: 'wrun_test',
+ eventType: 'hook_received',
+ correlationId,
+ eventData: { payload: dehydratedPayload as any },
+ createdAt: new Date(),
+ },
+ ]);
+
+ const WorkflowAbortController = createCreateAbortController(ctx);
+ const controller = new WorkflowAbortController();
+
+ // The events consumer processes events via process.nextTick, and the
+ // hook_received handler chains through promiseQueue. We need to let
+ // multiple ticks pass for _setAborted to be called.
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ await ctx.promiseQueue;
+
+ // After replay event processing, signal.aborted is true — the
+ // events consumer called _setAborted when hook_received was processed.
+ expect(controller.signal.aborted).toBe(true);
+ expect(controller.signal.reason).toBe('aborted!');
+
+ // The hook should have been removed from the queue after hook_received
+ expect(ctx.invocationsQueue.size).toBe(0);
+ });
+
+ it('replay without hook_received event reconstructs signal.aborted === false', async () => {
+ // Discover the correlationId via dry run
+ const dryCtx = setupWorkflowContext([]);
+ const DryAbortController = createCreateAbortController(dryCtx);
+ new DryAbortController();
+ const correlationId = [...dryCtx.invocationsQueue.keys()][0];
+
+ // Only hook_created, no hook_received
+ const ctx = setupWorkflowContext([
+ {
+ eventId: 'evnt_0',
+ runId: 'wrun_test',
+ eventType: 'hook_created',
+ correlationId,
+ eventData: {},
+ createdAt: new Date(),
+ },
+ ]);
+
+ const WorkflowAbortController = createCreateAbortController(ctx);
+ const controller = new WorkflowAbortController();
+
+ // Let event processing complete
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ await ctx.promiseQueue;
+
+ expect(controller.signal.aborted).toBe(false);
+ // The hook should still be in the queue (waiting for resume)
+ expect(ctx.invocationsQueue.size).toBe(1);
+ const queueItem = [...ctx.invocationsQueue.values()][0];
+ if (queueItem.type === 'hook') {
+ expect(queueItem.hasCreatedEvent).toBe(true);
+ }
+ });
+ });
+
+ describe('suspension handler', () => {
+ it('abort() triggers suspension handler to create hook_received event and write stream', async () => {
+ // When abort() is called, the hook queue item gets abortRequested=true.
+ // When the workflow suspends, the suspension handler processes these items
+ // by creating hook_received events and writing stream packets.
+ // We verify this by checking the WorkflowSuspension object's contents.
+ const ctx = setupWorkflowContext([]);
+ const WorkflowAbortController = createCreateAbortController(ctx);
+
+ const controller = new WorkflowAbortController();
+ controller.abort('handler test');
+
+ // Build a WorkflowSuspension from the current invocations queue
+ const suspension = new WorkflowSuspension(
+ ctx.invocationsQueue,
+ ctx.globalThis
+ );
+
+ // The suspension should contain the hook with abortRequested
+ const hookItem = suspension.steps.find((s) => s.type === 'hook');
+ expect(hookItem).toBeDefined();
+ expect(hookItem?.type).toBe('hook');
+ if (hookItem?.type === 'hook') {
+ expect(hookItem.abortRequested).toBe(true);
+ expect(hookItem.abortReason).toBe('handler test');
+ expect(hookItem.isSystem).toBe(true);
+
+ // The suspension handler would use these fields to:
+ // 1. Create a hook_received event via world.events.create()
+ // 2. Write a stream cancellation packet via world.writeToStream()
+ // Verify the token follows the expected format
+ expect(hookItem.token).toMatch(/^abrt_/);
+ }
+ });
+ });
+
+ describe('hydration into workflow context', () => {
+ it('AbortController returned from step: hook created on hydration into workflow', async () => {
+ // When a step returns an AbortController, it gets serialized with
+ // streamName and hookToken. When hydrated back in the workflow context,
+ // the revived object should preserve these symbols.
+ const controller = new AbortController();
+ // Simulate the symbols being set during workflow->step serialization
+ (controller as any)[ABORT_STREAM_NAME] = 'strm_test_system_abort';
+ (controller as any)[ABORT_HOOK_TOKEN] = 'abrt_test';
+ (controller.signal as any)[ABORT_STREAM_NAME] = 'strm_test_system_abort';
+ (controller.signal as any)[ABORT_HOOK_TOKEN] = 'abrt_test';
+
+ // Serialize using step reducers (step return value serialization)
+ const serialized = await dehydrateStepReturnValue(
+ controller,
+ 'wrun_test',
+ undefined
+ );
+
+ expect(serialized).toBeInstanceOf(Uint8Array);
+
+ // Decode the serialized form to verify it contains the abort metadata
+ const text = new TextDecoder().decode(serialized as Uint8Array);
+ expect(text).toContain('AbortController');
+ expect(text).toContain('strm_test_system_abort');
+ expect(text).toContain('abrt_test');
+ });
+
+ it('AbortSignal passed as workflow input: hook created on hydration', async () => {
+ // When an AbortSignal is passed as workflow input, it gets serialized
+ // with the abort metadata. On hydration in the workflow context,
+ // the signal should preserve its state.
+ const controller = new AbortController();
+ // Set up abort metadata symbols
+ (controller.signal as any)[ABORT_STREAM_NAME] = 'strm_input_system_abort';
+ (controller.signal as any)[ABORT_HOOK_TOKEN] = 'abrt_input';
+
+ // Serialize the signal as a workflow argument
+ const ops: Promise[] = [];
+ const serialized = await dehydrateWorkflowArguments(
+ [controller.signal],
+ 'wrun_test',
+ undefined,
+ ops
+ );
+
+ expect(serialized).toBeInstanceOf(Uint8Array);
+
+ // The serialized form should contain the abort signal metadata
+ const text = new TextDecoder().decode(serialized as Uint8Array);
+ expect(text).toContain('AbortSignal');
+ expect(text).toContain('strm_input_system_abort');
+ expect(text).toContain('abrt_input');
+ });
+ });
+
+ describe('eventual consistency', () => {
+ it('abort before hook exists: stream packet persists, step processes it, hook resumed on next replay', async () => {
+ // When abort() is called before the hook is created in the backend,
+ // the abort is recorded on the queue item. On the next replay,
+ // the suspension handler creates the hook AND immediately resumes it.
+ const ctx = setupWorkflowContext([]);
+ const WorkflowAbortController = createCreateAbortController(ctx);
+
+ const controller = new WorkflowAbortController();
+
+ // Abort before any events are processed (hook not yet created in backend)
+ controller.abort('early abort');
+
+ // The queue item should have both: needs creation AND abort requested
+ const queueItem = [...ctx.invocationsQueue.values()][0];
+ expect(queueItem.type).toBe('hook');
+ if (queueItem.type === 'hook') {
+ expect(queueItem.hasCreatedEvent).toBeUndefined(); // not yet created
+ expect(queueItem.abortRequested).toBe(true);
+ expect(queueItem.abortReason).toBe('early abort');
+ }
+
+ // Build WorkflowSuspension to verify what the handler would see
+ const suspension = new WorkflowSuspension(
+ ctx.invocationsQueue,
+ ctx.globalThis
+ );
+
+ // The handler should see a hook that needs both creation and abort
+ const hookItem = suspension.steps.find((s) => s.type === 'hook');
+ expect(hookItem).toBeDefined();
+ if (hookItem?.type === 'hook') {
+ expect(hookItem.hasCreatedEvent).toBeFalsy();
+ expect(hookItem.abortRequested).toBe(true);
+ // The suspension handler would:
+ // 1. Create the hook (hook_created event)
+ // 2. Immediately resume it (hook_received event with abort payload)
+ // 3. Write stream cancellation packet
+ // On the next replay, the events consumer sees hook_received and
+ // sets signal.aborted = true
+ }
+ });
+ });
+});
diff --git a/packages/core/src/symbols.ts b/packages/core/src/symbols.ts
index 92df4058db..2cbcb03f63 100644
--- a/packages/core/src/symbols.ts
+++ b/packages/core/src/symbols.ts
@@ -16,3 +16,10 @@ export const WEBHOOK_RESPONSE_WRITABLE = Symbol.for(
* This allows the deserializer to find classes by classId in the VM context.
*/
export const WORKFLOW_CLASS_REGISTRY = Symbol.for('workflow-class-registry');
+
+export const ABORT_STREAM_NAME = Symbol.for('WORKFLOW_ABORT_STREAM_NAME');
+export const ABORT_HOOK_TOKEN = Symbol.for('WORKFLOW_ABORT_HOOK_TOKEN');
+export const ABORT_LISTENER_ATTACHED = Symbol.for(
+ 'WORKFLOW_ABORT_LISTENER_ATTACHED'
+);
+export const ABORT_READER_CANCEL = Symbol.for('WORKFLOW_ABORT_READER_CANCEL');
diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts
index 0a0fdc4b28..2c9bdfe39f 100644
--- a/packages/core/src/util.ts
+++ b/packages/core/src/util.ts
@@ -66,6 +66,33 @@ export function getWorkflowRunStreamId(runId: string, namespace?: string) {
return `${streamId}_${encodedNamespace}`;
}
+/**
+ * Generate a stream ID for an abort signal's backing stream.
+ * Uses the "_system_abort" namespace to isolate from user-defined streams.
+ *
+ * @param id - A unique identifier (typically a ULID)
+ * @returns The stream ID in format: `strm_{id}_system_abort`
+ */
+export function getAbortStreamId(id: string) {
+ return `strm_${id}_system_abort`;
+}
+
+const ABORT_TOKEN_PREFIX = 'abrt_';
+
+/**
+ * Derive the abort stream name from a hook token.
+ * Hook tokens use the format `abrt_{id}`, and the corresponding stream is
+ * `strm_{id}_system_abort`.
+ */
+export function getAbortStreamIdFromToken(hookToken: string): string {
+ if (!hookToken.startsWith(ABORT_TOKEN_PREFIX)) {
+ throw new Error(
+ `Invalid abort hook token format: expected "abrt_" prefix, got "${hookToken}"`
+ );
+ }
+ return getAbortStreamId(hookToken.slice(ABORT_TOKEN_PREFIX.length));
+}
+
/**
* A small wrapper around `waitUntil` that also returns
* the result of the awaited promise.
diff --git a/packages/core/src/workflow.test.ts b/packages/core/src/workflow.test.ts
index c74420edfd..eddf0bfe17 100644
--- a/packages/core/src/workflow.test.ts
+++ b/packages/core/src/workflow.test.ts
@@ -3748,8 +3748,17 @@ describe('runWorkflow', () => {
});
});
- describe('pending queue warnings', () => {
- it('should warn when workflow completes with an unawaited step', async () => {
+ describe('pending queue drain at completion', () => {
+ // Behavior change (was "pending queue warnings"): the runtime no longer
+ // warns about unawaited steps/hooks/sleeps at end-of-run. Instead it drains
+ // the queue through the suspension handler, committing each pending
+ // operation (step queueing, hook creation/disposal, abort propagation) so
+ // it actually fires — matching normal JS semantics where async work spawned
+ // by a function continues after the function returns. The most important
+ // case is `controller.abort()` called as the last statement of a workflow:
+ // the abort hook now commits to the event log even with no suspension
+ // between abort() and return.
+ it('drains an unawaited step on completion (no "uncommitted" warning)', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ops: Promise[] = [];
@@ -3768,11 +3777,8 @@ describe('runWorkflow', () => {
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
};
-
- // No step events — the unawaited step stays pending in the queue
const events: Event[] = [];
- // Workflow calls step but doesn't await it, returns immediately
await runWorkflow(
`const add = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("add");
async function workflow() {
@@ -3788,21 +3794,15 @@ describe('runWorkflow', () => {
expect(
warnCalls.some(
(msg: string) =>
- msg.includes('uncommitted operation') &&
- msg.includes('step "add"')
- )
- ).toBe(true);
- expect(
- warnCalls.some((msg: string) =>
- msg.includes('Did you forget to `await`')
+ typeof msg === 'string' && msg.includes('uncommitted operation')
)
- ).toBe(true);
+ ).toBe(false);
} finally {
warnSpy.mockRestore();
}
});
- it('should warn when workflow fails with pending operations', async () => {
+ it('drains pending operations even when the workflow throws', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ops: Promise[] = [];
@@ -3821,11 +3821,8 @@ describe('runWorkflow', () => {
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
};
-
- // No step events — the unawaited step stays pending in the queue
const events: Event[] = [];
- // Workflow calls step (not awaited) then throws
await expect(
runWorkflow(
`const add = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("add");
@@ -3839,13 +3836,16 @@ describe('runWorkflow', () => {
)
).rejects.toThrow('workflow error');
+ // The thrown error is preserved (workflow's outcome is the source of
+ // truth); the unawaited step is drained on the way out, no warning
+ // about uncommitted ops.
const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some(
(msg: string) =>
- msg.includes('failed') && msg.includes('step "add"')
+ typeof msg === 'string' && msg.includes('uncommitted operation')
)
- ).toBe(true);
+ ).toBe(false);
} finally {
warnSpy.mockRestore();
}
diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts
index ef03b972fc..f0dd3bc479 100644
--- a/packages/core/src/workflow.ts
+++ b/packages/core/src/workflow.ts
@@ -15,6 +15,8 @@ import { EventConsumerResult, EventsConsumer } from './events-consumer.js';
import type { QueueItem } from './global.js';
import { ENOTSUP, WorkflowSuspension } from './global.js';
import { runtimeLogger } from './logger.js';
+import { handleSuspension } from './runtime/suspension-handler.js';
+import { getWorld } from './runtime/world.js';
import type { WorkflowOrchestratorContext } from './private.js';
import {
dehydrateWorkflowReturnValue,
@@ -35,45 +37,73 @@ import { getWorkflowRunStreamId } from './util.js';
import { createContext } from './vm/index.js';
import type { WorkflowMetadata } from './workflow/get-workflow-metadata.js';
import { WORKFLOW_CONTEXT_SYMBOL } from './workflow/get-workflow-metadata.js';
+import {
+ createAbortSignalStatics,
+ createCreateAbortController,
+} from './workflow/abort-controller.js';
import { createCreateHook } from './workflow/hook.js';
import { createSleep } from './workflow/sleep.js';
/**
- * Logs a warning when a workflow run completes or fails with uncommitted
- * operations still in the invocations queue. This typically indicates the
- * user forgot to `await` a step, hook, or sleep call.
+ * Drain pending queue items at workflow completion (success or failure).
+ *
+ * Treats end-of-run like a final suspension: any operation the workflow code
+ * spawned but didn't `await` — abort hook resumes, hook creations/disposals,
+ * sleep waits, step queueings — gets committed to the event log via the
+ * suspension handler before the run is marked terminal.
+ *
+ * This matches normal JS semantics where `setTimeout(fn, ...)` etc. continue
+ * running after the surrounding function returns. Most importantly, it ensures
+ * `controller.abort()` called as the last statement of a workflow actually
+ * propagates to in-flight steps on other compute instances — without this,
+ * the abort hook is created but never resumed and the cancellation never
+ * reaches the running step.
+ *
+ * Drain failures are swallowed: the workflow's own outcome (the user's return
+ * value or thrown error) is the source of truth; secondary cleanup that fails
+ * shouldn't change the run's terminal state.
*/
-function warnPendingQueueItems(
+async function drainPendingQueueItems(
runId: string,
pendingQueue: Map,
+ vmGlobalThis: typeof globalThis,
+ workflowRun: WorkflowRun,
outcome: 'completed' | 'failed'
-): void {
- // Filter out hooks that are either already created (alive, waiting for payloads)
- // or explicitly disposed — both are benign since the backend auto-disposes
- // all hooks when a run reaches a terminal state
- const items = [...pendingQueue.values()].filter(
- (item) => !(item.type === 'hook' && (item.hasCreatedEvent || item.disposed))
- );
- if (items.length === 0) return;
-
- const details = items.map((item) => {
- switch (item.type) {
- case 'step':
- return `step "${item.stepName}"`;
- case 'hook':
- return `hook "${item.token}"`;
- case 'wait':
- return 'sleep';
- default:
- return `unknown (${(item as { type: string }).type})`;
+): Promise {
+ if (pendingQueue.size === 0) return;
+ // Implicitly dispose any abort hooks (system hooks) that are still alive at
+ // workflow completion so they don't leak rows in the hooks table for the
+ // run's lifetime. Skip hooks that already have an abort in flight — those
+ // will emit hook_received via the abort processing path. User hooks
+ // (isSystem !== true) are intentionally left alone: their lifetime is
+ // managed by the user's code, not the runtime.
+ for (const item of pendingQueue.values()) {
+ if (
+ item.type === 'hook' &&
+ item.isSystem &&
+ !item.disposed &&
+ !item.abortRequested
+ ) {
+ item.disposed = true;
}
- });
-
- runtimeLogger.warn(
- `Workflow run ${outcome} with ${items.length} uncommitted operation(s): ${details.join(', ')}. ` +
- 'Did you forget to `await` a step, hook, or sleep call?',
- { workflowRunId: runId }
- );
+ }
+ try {
+ const world = await getWorld();
+ const synthesized = new WorkflowSuspension(pendingQueue, vmGlobalThis);
+ await handleSuspension({
+ suspension: synthesized,
+ world,
+ run: workflowRun,
+ });
+ } catch (err) {
+ runtimeLogger.warn(
+ `Failed to drain pending queue items for ${outcome} workflow run`,
+ {
+ workflowRunId: runId,
+ message: err instanceof Error ? err.message : String(err),
+ }
+ );
+ }
}
export async function runWorkflow(
@@ -269,6 +299,18 @@ export async function runWorkflow(
});
};
+ // `AbortController` and `AbortSignal` in the workflow VM are hook-backed
+ // for deterministic replay. The controller's abort() queues a hook resumption,
+ // and signal.aborted is updated when the hook event is processed during replay.
+ (vmGlobalThis as any).AbortController =
+ createCreateAbortController(workflowContext);
+ const abortSignalStatics = createAbortSignalStatics();
+ (vmGlobalThis as any).AbortSignal = {
+ abort: abortSignalStatics.abort,
+ any: abortSignalStatics.any,
+ timeout: abortSignalStatics.timeout,
+ };
+
// `Request` and `Response` are special built-in classes that invoke steps
// for the `json()`, `text()` and `arrayBuffer()` instance methods
class Request implements globalThis.Request {
@@ -755,9 +797,11 @@ export async function runWorkflow(
...Attribute.WorkflowResultType(typeof result),
});
- warnPendingQueueItems(
+ await drainPendingQueueItems(
workflowRun.runId,
workflowContext.invocationsQueue,
+ vmGlobalThis,
+ workflowRun,
'completed'
);
@@ -768,9 +812,11 @@ export async function runWorkflow(
throw err;
}
- warnPendingQueueItems(
+ await drainPendingQueueItems(
workflowRun.runId,
workflowContext.invocationsQueue,
+ vmGlobalThis,
+ workflowRun,
'failed'
);
diff --git a/packages/core/src/workflow/abort-controller.ts b/packages/core/src/workflow/abort-controller.ts
new file mode 100644
index 0000000000..7f2ccb6aa7
--- /dev/null
+++ b/packages/core/src/workflow/abort-controller.ts
@@ -0,0 +1,304 @@
+import { EventConsumerResult } from '../events-consumer.js';
+import type { WorkflowOrchestratorContext } from '../private.js';
+import { hydrateStepReturnValue } from '../serialization.js';
+import { ABORT_HOOK_TOKEN, ABORT_STREAM_NAME } from '../symbols.js';
+import { getAbortStreamId } from '../util.js';
+
+/**
+ * A lightweight AbortSignal implementation for the workflow VM context.
+ *
+ * `signal.aborted` and listeners are updated in two scenarios:
+ * 1. On first-run: when `abort()` is called in the workflow code
+ * 2. On replay: when the events consumer processes the `hook_received`
+ * event (chained through promiseQueue for deterministic ordering)
+ *
+ * On replay, `abort()` in the workflow code becomes a no-op since
+ * `_setAborted` was already called by the events consumer.
+ */
+export class WorkflowAbortSignal {
+ aborted = false;
+ reason: unknown = undefined;
+
+ readonly [ABORT_STREAM_NAME]: string;
+ readonly [ABORT_HOOK_TOKEN]: string;
+
+ #listeners: Array<() => void> = [];
+ #onabort: ((this: WorkflowAbortSignal) => void) | null = null;
+
+ get onabort(): ((this: WorkflowAbortSignal) => void) | null {
+ return this.#onabort;
+ }
+
+ set onabort(handler: ((this: WorkflowAbortSignal) => void) | null) {
+ this.#onabort = handler;
+ if (handler && this.aborted) {
+ handler.call(this);
+ }
+ }
+
+ constructor(streamName: string, hookToken: string) {
+ this[ABORT_STREAM_NAME] = streamName;
+ this[ABORT_HOOK_TOKEN] = hookToken;
+ }
+
+ /**
+ * @internal Sets aborted state and fires listeners.
+ * Called by abort() on first-run, or by the events consumer on replay.
+ * Idempotent — second call is a no-op.
+ */
+ _setAborted(reason?: unknown): void {
+ if (this.aborted) return;
+ this.aborted = true;
+ this.reason = reason;
+ if (this.#onabort) {
+ this.#onabort.call(this);
+ }
+ for (const listener of this.#listeners) {
+ listener();
+ }
+ this.#listeners = [];
+ }
+
+ addEventListener(type: string, listener: () => void): void {
+ if (type !== 'abort') return;
+ if (this.aborted) {
+ // Fire synchronously, not on a microtask. Native AbortSignal fires on a
+ // microtask per spec, but inside the workflow VM we deliberately diverge
+ // for deterministic replay: listener ordering must be tied to the
+ // orchestrator's sync execution path, not to microtask scheduling.
+ listener();
+ return;
+ }
+ this.#listeners.push(listener);
+ }
+
+ removeEventListener(type: string, listener: () => void): void {
+ if (type !== 'abort') return;
+ this.#listeners = this.#listeners.filter((l) => l !== listener);
+ }
+
+ throwIfAborted(): void {
+ if (this.aborted) {
+ throw (
+ this.reason ??
+ new DOMException('The operation was aborted.', 'AbortError')
+ );
+ }
+ }
+}
+
+/**
+ * Creates a workflow-context `AbortController` class that uses hooks for
+ * durable state and streams for real-time step propagation.
+ *
+ * Follows the same pattern as `createCreateHook()` in `workflow/hook.ts`:
+ * - Registers a hook in the invocations queue on construction
+ * - Subscribes to the events consumer for hook_created/hook_received events
+ * - `abort()` calls `_setAborted` + marks the hook for resumption
+ * - The suspension handler processes the abort (creates event + writes stream)
+ * - On replay, the events consumer calls `_setAborted` when hook_received
+ * is processed, and `abort()` in the workflow code becomes a no-op
+ */
+export function createCreateAbortController(ctx: WorkflowOrchestratorContext) {
+ return class WorkflowAbortController {
+ readonly signal: WorkflowAbortSignal;
+ readonly [ABORT_STREAM_NAME]: string;
+ readonly [ABORT_HOOK_TOKEN]: string;
+
+ constructor() {
+ const id = ctx.generateUlid();
+ const streamName = getAbortStreamId(id);
+ const hookToken = `abrt_${id}`;
+
+ this[ABORT_STREAM_NAME] = streamName;
+ this[ABORT_HOOK_TOKEN] = hookToken;
+ this.signal = new WorkflowAbortSignal(streamName, hookToken);
+
+ // Register an internal system hook in the invocations queue.
+ // isSystem prevents token namespace conflicts with user hooks.
+ const correlationId = `hook_${ctx.generateUlid()}`;
+ ctx.invocationsQueue.set(correlationId, {
+ type: 'hook',
+ correlationId,
+ token: hookToken,
+ isWebhook: false,
+ isSystem: true,
+ });
+
+ // Subscribe to events for this hook's lifecycle
+ ctx.eventsConsumer.subscribe((event) => {
+ if (!event) {
+ return EventConsumerResult.NotConsumed;
+ }
+
+ if (event.correlationId !== correlationId) {
+ return EventConsumerResult.NotConsumed;
+ }
+
+ if (event.eventType === 'hook_created') {
+ const queueItem = ctx.invocationsQueue.get(correlationId);
+ if (queueItem && queueItem.type === 'hook') {
+ queueItem.hasCreatedEvent = true;
+ }
+ return EventConsumerResult.Consumed;
+ }
+
+ if (event.eventType === 'hook_received') {
+ // The abort was recorded in the event log (from a previous run's
+ // abort() call, or from a step/external abort). Update signal
+ // state and fire listeners at this deterministic point in the
+ // promiseQueue — same ordering as hook payload delivery.
+ //
+ // The payload is the dehydrated form written by the suspension
+ // handler (a Uint8Array, possibly encrypted). Hydrate it via the
+ // same machinery as regular hook payloads (workflow/hook.ts:117)
+ // so the reason round-trips with full type fidelity. Reading the
+ // raw payload here is a bug — it's not a plain object after
+ // dehydration, so `'reason' in payload` is false and reason
+ // ends up undefined on replay.
+ const rawPayload = event.eventData?.payload;
+ ctx.promiseQueue = ctx.promiseQueue.then(async () => {
+ let reason: unknown = undefined;
+ if (rawPayload !== undefined) {
+ try {
+ const hydrated = (await hydrateStepReturnValue(
+ rawPayload,
+ ctx.runId,
+ ctx.encryptionKey,
+ ctx.globalThis
+ )) as { reason?: unknown } | undefined;
+ if (
+ hydrated &&
+ typeof hydrated === 'object' &&
+ 'reason' in hydrated
+ ) {
+ reason = hydrated.reason;
+ }
+ } catch {
+ // Best-effort: if hydration fails, fall back to undefined
+ // reason. The signal still aborts; the user just won't see
+ // the original reason. Matches WorkflowAbortSignal's spec
+ // fallback (DOMException AbortError).
+ }
+ }
+ this.signal._setAborted(reason);
+ });
+
+ ctx.invocationsQueue.delete(correlationId);
+ return EventConsumerResult.Finished;
+ }
+
+ if (event.eventType === 'hook_disposed') {
+ ctx.invocationsQueue.delete(correlationId);
+ return EventConsumerResult.Finished;
+ }
+
+ return EventConsumerResult.NotConsumed;
+ });
+ }
+
+ abort(reason?: unknown): void {
+ if (this.signal.aborted) return; // no-op (already aborted, e.g. from replay)
+
+ // Update signal state and fire listeners synchronously
+ this.signal._setAborted(reason);
+
+ // Mark the hook for resumption so the suspension handler records
+ // the abort in the event log and writes the stream packet.
+ for (const [, item] of ctx.invocationsQueue) {
+ if (item.type === 'hook' && item.token === this[ABORT_HOOK_TOKEN]) {
+ item.abortRequested = true;
+ item.abortReason = reason;
+ break;
+ }
+ }
+ }
+ };
+}
+
+/**
+ * Creates a workflow-context `AbortSignal` object with static methods.
+ */
+export function createAbortSignalStatics(): {
+ abort: (reason?: unknown) => WorkflowAbortSignal;
+ any: (
+ signals: Iterable<{
+ aborted: boolean;
+ reason?: unknown;
+ addEventListener?: Function;
+ }>
+ ) => WorkflowAbortSignal;
+ timeout: () => never;
+} {
+ return {
+ abort(reason?: unknown): WorkflowAbortSignal {
+ const signal = new WorkflowAbortSignal('', '');
+ signal._setAborted(
+ reason ?? new DOMException('The operation was aborted.', 'AbortError')
+ );
+ return signal;
+ },
+
+ any(
+ signals: Iterable<{
+ aborted: boolean;
+ reason?: unknown;
+ addEventListener?: Function;
+ removeEventListener?: Function;
+ }>
+ ): WorkflowAbortSignal {
+ const composite = new WorkflowAbortSignal('', '');
+
+ // Materialize the iterable once. Native AbortSignal.any does the same:
+ // single-shot iterables (e.g. generators) would otherwise produce zero
+ // entries on the second pass below.
+ const arr = Array.from(signals);
+
+ for (const signal of arr) {
+ if (signal.aborted) {
+ composite._setAborted(signal.reason);
+ return composite;
+ }
+ }
+
+ // Listen to each signal — first one to abort wins. Track listeners so
+ // we can remove them after the composite aborts; otherwise the closures
+ // (capturing `composite`) prevent GC for any input signal that outlives
+ // the composite (e.g. a long-lived external controller).
+ const listeners: Array<{
+ signal: (typeof arr)[number];
+ listener: () => void;
+ }> = [];
+ const cleanup = () => {
+ for (const { signal, listener } of listeners) {
+ if (signal.removeEventListener) {
+ signal.removeEventListener('abort', listener);
+ }
+ }
+ listeners.length = 0;
+ };
+
+ for (const signal of arr) {
+ if (!signal.addEventListener) continue;
+ const listener = () => {
+ if (!composite.aborted) {
+ composite._setAborted(signal.reason);
+ cleanup();
+ }
+ };
+ listeners.push({ signal, listener });
+ signal.addEventListener('abort', listener);
+ }
+
+ return composite;
+ },
+
+ timeout(): never {
+ throw new Error(
+ 'AbortSignal.timeout() is not supported in workflow functions. ' +
+ 'Use sleep() with an AbortController instead. ' +
+ 'See: /docs/errors/abort-signal-timeout-in-workflow'
+ );
+ },
+ };
+}
diff --git a/packages/web-shared/src/components/sidebar/attribute-panel.tsx b/packages/web-shared/src/components/sidebar/attribute-panel.tsx
index 8c7c9375eb..05b62baaa8 100644
--- a/packages/web-shared/src/components/sidebar/attribute-panel.tsx
+++ b/packages/web-shared/src/components/sidebar/attribute-panel.tsx
@@ -441,6 +441,7 @@ const attributeToDisplayFn: Record<
// Hook details
token: (value: unknown) => String(value),
isWebhook: (value: unknown) => String(value),
+ isSystem: (value: unknown) => String(value),
receivedCount: (value: unknown) => String(value),
lastReceivedAt: localMillisecondTimeOrNull,
disposedAt: localMillisecondTimeOrNull,
diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts
index 61fae808e6..e057ee7349 100644
--- a/packages/world-local/src/storage/events-storage.ts
+++ b/packages/world-local/src/storage/events-storage.ts
@@ -857,6 +857,7 @@ export function createEventsStorage(
token: string;
metadata?: any;
isWebhook?: boolean;
+ isSystem?: boolean;
};
// Atomically claim the token using an exclusive-create constraint file.
@@ -926,6 +927,7 @@ export function createEventsStorage(
// Propagate specVersion from the event to the hook entity
specVersion: effectiveSpecVersion,
isWebhook: hookData.isWebhook ?? false,
+ isSystem: hookData.isSystem ?? false,
};
await writeJSON(
taggedPath(basedir, 'hooks', data.correlationId, tag),
diff --git a/packages/world-postgres/src/drizzle/migrations/0012_add_is_system.sql b/packages/world-postgres/src/drizzle/migrations/0012_add_is_system.sql
new file mode 100644
index 0000000000..f36a7ec381
--- /dev/null
+++ b/packages/world-postgres/src/drizzle/migrations/0012_add_is_system.sql
@@ -0,0 +1 @@
+ALTER TABLE "workflow"."workflow_hooks" ADD COLUMN "is_system" boolean DEFAULT false;
diff --git a/packages/world-postgres/src/drizzle/migrations/meta/_journal.json b/packages/world-postgres/src/drizzle/migrations/meta/_journal.json
index 5e99b153c2..f55371e3c8 100644
--- a/packages/world-postgres/src/drizzle/migrations/meta/_journal.json
+++ b/packages/world-postgres/src/drizzle/migrations/meta/_journal.json
@@ -85,6 +85,13 @@
"when": 1771500000000,
"tag": "0011_add_error_code",
"breakpoints": true
+ },
+ {
+ "idx": 12,
+ "version": "7",
+ "when": 1775600000000,
+ "tag": "0012_add_is_system",
+ "breakpoints": true
}
]
}
diff --git a/packages/world-postgres/src/drizzle/schema.ts b/packages/world-postgres/src/drizzle/schema.ts
index 3176f4359c..44a2785f90 100644
--- a/packages/world-postgres/src/drizzle/schema.ts
+++ b/packages/world-postgres/src/drizzle/schema.ts
@@ -202,6 +202,7 @@ export const hooks = schema.table(
metadata: Cbor()('metadata_cbor'),
specVersion: integer('spec_version'),
isWebhook: boolean('is_webhook').default(true),
+ isSystem: boolean('is_system').default(false),
} satisfies DrizzlishOfType>,
(tb) => [index().on(tb.runId), index().on(tb.token)]
);
diff --git a/packages/world-postgres/src/storage.ts b/packages/world-postgres/src/storage.ts
index 6f6b72e24e..43439c0dd2 100644
--- a/packages/world-postgres/src/storage.ts
+++ b/packages/world-postgres/src/storage.ts
@@ -1026,6 +1026,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] {
token: string;
metadata?: any;
isWebhook?: boolean;
+ isSystem?: boolean;
};
// Check for duplicate token using prepared statement
@@ -1088,6 +1089,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] {
// Propagate specVersion from the event to the hook entity
specVersion: effectiveSpecVersion,
isWebhook: eventData.isWebhook,
+ isSystem: eventData.isSystem ?? false,
})
.onConflictDoNothing()
.returning();
diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts
index 7b9c797e37..307f472708 100644
--- a/packages/world/src/events.ts
+++ b/packages/world/src/events.ts
@@ -159,6 +159,8 @@ const HookCreatedEventSchema = BaseEventSchema.extend({
eventData: z.object({
token: z.string(),
metadata: SerializedDataSchema.optional(),
+ isWebhook: z.boolean().optional(),
+ isSystem: z.boolean().optional(),
}),
});
diff --git a/packages/world/src/hooks.ts b/packages/world/src/hooks.ts
index 5066da409c..9010ba514d 100644
--- a/packages/world/src/hooks.ts
+++ b/packages/world/src/hooks.ts
@@ -23,6 +23,7 @@ export const HookSchema = z.object({
// Optional in database for backwards compatibility, defaults to 1 (legacy) when reading
specVersion: z.number().optional(),
isWebhook: z.boolean().optional(),
+ isSystem: z.boolean().optional(),
});
/**
@@ -53,6 +54,8 @@ export type Hook = z.infer & {
specVersion?: number;
/** Whether this hook is resumable via the public webhook endpoint. undefined = legacy (treated as true for backwards compat). */
isWebhook?: boolean;
+ /** Whether this hook is a system-managed hook (e.g., for abort signals). */
+ isSystem?: boolean;
};
// Request types
diff --git a/workbench/example/workflows/99_e2e.ts b/workbench/example/workflows/99_e2e.ts
index 54c46fe725..9675565dd2 100644
--- a/workbench/example/workflows/99_e2e.ts
+++ b/workbench/example/workflows/99_e2e.ts
@@ -1274,10 +1274,6 @@ export class ChainableService {
// E2E test for `this` serialization with .call() and .apply()
//////////////////////////////////////////////////////////
-//////////////////////////////////////////////////////////
-// E2E test for `this` serialization with .call() and .apply()
-//////////////////////////////////////////////////////////
-
/**
* A step function that uses `this` to access properties.
*/
@@ -1622,6 +1618,848 @@ export async function stepFunctionAsStartArgWorkflow(
return { directResult, viaStepResult, doubled };
}
+//////////////////////////////////////////////////////////
+// AbortController / AbortSignal e2e tests
+//////////////////////////////////////////////////////////
+
+/**
+ * Step that performs a long-running operation respecting an AbortSignal.
+ * Loops with 500ms delays, checking signal.aborted each iteration.
+ */
+async function longStep(signal: AbortSignal): Promise {
+ 'use step';
+ for (let i = 0; i < 60; i++) {
+ if (signal.aborted) {
+ return 'aborted';
+ }
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+ return 'completed';
+}
+
+/**
+ * Step that returns immediately with the signal's current aborted state.
+ */
+async function checkSignalState(signal: AbortSignal): Promise<{
+ aborted: boolean;
+ reason: unknown;
+}> {
+ 'use step';
+ return { aborted: signal.aborted, reason: signal.reason };
+}
+
+/**
+ * Step that (optionally) waits, then calls `abort()` on the controller.
+ * The delay lets a sibling step start running before the abort fires —
+ * used by `abortFromStepWorkflow` to verify the in-flight sibling actually
+ * receives the cancellation packet through the backing stream.
+ */
+async function abortFromStep(
+ controller: AbortController,
+ delayMs = 0
+): Promise {
+ 'use step';
+ if (delayMs > 0) {
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+ }
+ controller.abort('aborted from step');
+}
+
+/**
+ * Step that uses fetch with an AbortSignal.
+ * Uses a URL that intentionally delays, so the abort cancels it.
+ */
+async function fetchWithSignal(
+ url: string,
+ signal: AbortSignal
+): Promise<{ ok: boolean; aborted: boolean }> {
+ 'use step';
+ try {
+ const response = await globalThis.fetch(url, { signal });
+ return { ok: response.ok, aborted: false };
+ } catch (err: any) {
+ if (err.name === 'AbortError') {
+ return { ok: false, aborted: true };
+ }
+ throw err;
+ }
+}
+
+/**
+ * E2E: Basic timeout cancellation.
+ * Creates controller in workflow, races step vs sleep, aborts on timeout.
+ */
+export async function abortTimeoutWorkflow() {
+ 'use workflow';
+
+ const controller = new AbortController();
+
+ const result = await Promise.race([
+ longStep(controller.signal),
+ sleep('3s').then(() => 'timeout' as const),
+ ]);
+
+ if (result === 'timeout') {
+ controller.abort();
+ return { status: 'timed out', aborted: controller.signal.aborted };
+ }
+
+ return { status: 'completed', result };
+}
+
+/**
+ * E2E: Signal passed to multiple parallel steps, abort cancels all.
+ */
+export async function abortParallelWorkflow() {
+ 'use workflow';
+
+ const controller = new AbortController();
+
+ const result = await Promise.race([
+ Promise.all([
+ longStep(controller.signal),
+ longStep(controller.signal),
+ longStep(controller.signal),
+ ]),
+ sleep('3s').then(() => 'timeout' as const),
+ ]);
+
+ if (result === 'timeout') {
+ controller.abort();
+ return { status: 'timed out' };
+ }
+
+ return { status: 'completed', results: result };
+}
+
+/**
+ * E2E: One step aborts a controller; an in-flight sibling step is cancelled.
+ *
+ * Runs `longStep` (a 30s busy-wait that polls `signal.aborted` every 500ms)
+ * in parallel with `abortFromStep` (which sleeps 1s, then calls `abort()`).
+ * The cancellation has to propagate from the aborting step → workflow's
+ * backing stream → the polling step's local AbortController, so the polling
+ * step sees `signal.aborted` flip and exits via the abort branch instead of
+ * running to its 30s natural completion. After the parallel work, we also
+ * verify the workflow VM's signal sees the abort (round-trip via hook event).
+ */
+export async function abortFromStepWorkflow() {
+ 'use workflow';
+
+ const controller = new AbortController();
+
+ // Run a long-polling step in parallel with a step that aborts after 1s.
+ // longStep returns 'aborted' if it saw signal.aborted=true mid-flight,
+ // 'completed' if it ran the full 30s without seeing the abort.
+ const [longStepResult] = await Promise.all([
+ longStep(controller.signal),
+ abortFromStep(controller, 1000),
+ ]);
+
+ // After both steps finish, check that the workflow's signal also reflects
+ // the abort (the hook event resumed the controller in the workflow VM).
+ const state = await checkSignalState(controller.signal);
+
+ return {
+ workflowAborted: controller.signal.aborted,
+ stepSawAborted: state.aborted,
+ longStepResult,
+ };
+}
+
+/**
+ * E2E: Already-aborted signal passed to step.
+ */
+export async function abortAlreadyAbortedWorkflow() {
+ 'use workflow';
+
+ const controller = new AbortController();
+ controller.abort('pre-aborted');
+
+ const state = await checkSignalState(controller.signal);
+
+ return {
+ aborted: state.aborted,
+ reason: state.reason,
+ };
+}
+
+/**
+ * E2E: Abort reason is preserved.
+ */
+export async function abortReasonWorkflow() {
+ 'use workflow';
+
+ const controller = new AbortController();
+
+ const raceResult = await Promise.race([
+ longStep(controller.signal),
+ sleep('2s').then(() => 'timeout' as const),
+ ]);
+
+ if (raceResult === 'timeout') {
+ controller.abort('custom timeout reason');
+ }
+
+ const state = await checkSignalState(controller.signal);
+ return {
+ aborted: state.aborted,
+ reason: state.reason,
+ };
+}
+
+/**
+ * E2E: Abort after all steps complete (no-op, no error).
+ */
+export async function abortAfterCompletionWorkflow() {
+ 'use workflow';
+
+ const controller = new AbortController();
+ const state = await checkSignalState(controller.signal);
+
+ // Abort after the step already completed
+ controller.abort();
+
+ return {
+ stepSawAborted: state.aborted,
+ workflowAborted: controller.signal.aborted,
+ };
+}
+
+/**
+ * E2E: User-triggered cancellation via hook + abort controller.
+ */
+export async function abortViaHookWorkflow(hookToken: string) {
+ 'use workflow';
+
+ using cancelHook = createHook<{ reason: string }>({
+ token: hookToken,
+ });
+
+ const controller = new AbortController();
+
+ const result = await Promise.race([
+ longStep(controller.signal).then((r) => ({
+ status: 'completed' as const,
+ result: r,
+ })),
+ cancelHook.then((payload) => {
+ controller.abort(payload.reason);
+ return { status: 'cancelled' as const, reason: payload.reason };
+ }),
+ ]);
+
+ return result;
+}
+
+/**
+ * E2E: AbortSignal passed as workflow input from external code.
+ */
+export async function abortExternalSignalWorkflow(signal: AbortSignal) {
+ 'use workflow';
+
+ const state = await checkSignalState(signal);
+ return { aborted: state.aborted, reason: state.reason };
+}
+
+/**
+ * E2E: External signal NOT aborted at serialization time, aborted later
+ * while in-flight steps are consuming it.
+ *
+ * This is the harder external-signal path that abortExternalSignalWorkflow
+ * doesn't cover. The caller (test process) creates a fresh AbortController,
+ * passes its signal as workflow input, and aborts it ~1.5s later via the
+ * source controller's `abort()`. The serialization-time listener attached
+ * in `getExternalReducers` writes the cancellation packet to the backing
+ * stream when fired; the in-flight steps' deserialized signals — both a
+ * polling step and a listener-based step running in parallel — must see
+ * the abort propagate mid-flight.
+ *
+ * Failure mode if propagation breaks:
+ * - pollResult: 'completed' (longStep ran the full 30s without seeing aborted=true)
+ * - listenerResult.via: 'timeout' (addEventListener callback never fired)
+ */
+export async function abortExternalSignalInFlightWorkflow(signal: AbortSignal) {
+ 'use workflow';
+
+ // Run two consumption patterns in parallel against the same external signal:
+ // a polling step (reads signal.aborted) and a listener step (addEventListener).
+ // Both must see the abort propagate from the external controller into their
+ // respective deserialized signals while the steps are mid-flight.
+ const [pollResult, listenerResult] = await Promise.all([
+ longStep(signal),
+ stepWaitingOnAbortListener(signal),
+ ]);
+
+ return { pollResult, listenerResult };
+}
+
+/**
+ * E2E: `AbortSignal.any` composing signals INSIDE the workflow VM.
+ *
+ * The workflow VM provides its own `AbortSignal.any` impl
+ * (workflow/abort-controller.ts) that produces a `WorkflowAbortSignal`
+ * composite which listens to each source `WorkflowAbortSignal` via
+ * `addEventListener`. When any source aborts, the composite fires
+ * synchronously through the VM's listener firing path — no stream packet,
+ * no replay round-trip, just in-VM signal composition.
+ */
+export async function abortAnyInWorkflowWorkflow() {
+ 'use workflow';
+ const c1 = new AbortController();
+ const c2 = new AbortController();
+ const combined = AbortSignal.any([c1.signal, c2.signal]);
+
+ const beforeCombinedAborted = combined.aborted;
+
+ // Abort c2; the composite must reflect the abort synchronously
+ // (the WorkflowAbortSignal listener fires sync inside the VM).
+ c2.abort('via c2');
+
+ const afterCombinedAborted = combined.aborted;
+ const afterCombinedReason = combined.reason;
+ const c1Aborted = c1.signal.aborted;
+
+ return {
+ beforeCombinedAborted,
+ afterCombinedAborted,
+ afterCombinedReason,
+ c1Aborted,
+ };
+}
+
+/**
+ * E2E: `AbortSignal.any` INSIDE a step.
+ *
+ * The step receives two deserialized native `AbortSignal`s (revived via
+ * `reviveAbortSignal`) and composes them with the native `AbortSignal.any`.
+ * A sibling step aborts one of the source controllers ~1s in. The chain
+ * that has to work: source controller aborts → workflow VM signal flips →
+ * stream packet written → step's deserialized signal fires → composite from
+ * `AbortSignal.any` fires → user listener fires.
+ */
+export async function abortAnyInStepWorkflow() {
+ 'use workflow';
+ const c1 = new AbortController();
+ const c2 = new AbortController();
+
+ const [stepResult] = await Promise.all([
+ stepCombiningSignals(c1.signal, c2.signal),
+ abortFromStep(c2, 1000),
+ ]);
+
+ return {
+ stepResult,
+ c1Aborted: c1.signal.aborted,
+ c2Aborted: c2.signal.aborted,
+ };
+}
+
+/**
+ * E2E: Controller survives workflow replay (sleep causes suspension/resumption).
+ */
+export async function abortSurvivesReplayWorkflow() {
+ 'use workflow';
+
+ const controller = new AbortController();
+
+ // First step
+ const before = await checkSignalState(controller.signal);
+
+ // Sleep causes workflow to suspend and replay
+ await sleep('1s');
+
+ // Abort after replay
+ controller.abort('after-replay');
+
+ // Second step sees the abort
+ const after = await checkSignalState(controller.signal);
+
+ return {
+ beforeAborted: before.aborted,
+ afterAborted: after.aborted,
+ afterReason: after.reason,
+ };
+}
+
+/**
+ * E2E: throwIfAborted() causes FatalError (no retries).
+ * Step calls throwIfAborted() on an already-aborted signal.
+ * The DOMException should be wrapped in FatalError, skip retries,
+ * and propagate to the workflow.
+ */
+export async function abortThrowIfAbortedWorkflow() {
+ 'use workflow';
+
+ const controller = new AbortController();
+ controller.abort('throw-test-reason');
+
+ try {
+ await stepThatThrowsIfAborted(controller.signal);
+ return { threw: false };
+ } catch (err: any) {
+ return {
+ threw: true,
+ message: err.message,
+ isFatal: err.name === 'FatalError' || err.fatal === true,
+ };
+ }
+}
+
+async function stepThatThrowsIfAborted(signal: AbortSignal) {
+ 'use step';
+ signal.throwIfAborted();
+ return 'should not reach here';
+}
+
+/**
+ * Step that resolves via `signal.addEventListener('abort', ...)`. Tests the
+ * listener path on the deserialized signal — the path fetch's internal
+ * cancellation uses, but invoked directly. Resolves with `via: 'listener'`
+ * if the listener fired, or `via: 'timeout'` if propagation failed and the
+ * 30s safety timeout won.
+ *
+ * No `signal.aborted` short-circuit: we rely solely on the listener firing.
+ * Per the AbortSignal spec, calling addEventListener on an already-aborted
+ * signal fires the callback (on a microtask), so any code path that breaks
+ * that contract — present or future — surfaces as a 'timeout' result here
+ * instead of being masked by a synchronous fast-path.
+ */
+async function stepWaitingOnAbortListener(
+ signal: AbortSignal
+): Promise<{ saw: boolean; via: 'listener' | 'timeout' }> {
+ 'use step';
+ return new Promise((resolve) => {
+ let settled = false;
+ const onAbort = () => {
+ if (settled) return;
+ settled = true;
+ resolve({ saw: true, via: 'listener' });
+ };
+ signal.addEventListener('abort', onAbort);
+ setTimeout(() => {
+ if (settled) return;
+ settled = true;
+ signal.removeEventListener('abort', onAbort);
+ resolve({ saw: signal.aborted, via: 'timeout' });
+ }, 30_000);
+ });
+}
+
+/**
+ * Step that polls `signal.throwIfAborted()` every 500ms. When the abort
+ * fires mid-flight, throwIfAborted throws a DOMException — which the step
+ * handler wraps as FatalError before it reaches the workflow. Returns the
+ * natural-completion value if propagation fails and the loop runs out.
+ */
+async function stepPollingThrowIfAborted(signal: AbortSignal): Promise {
+ 'use step';
+ for (let i = 0; i < 60; i++) {
+ signal.throwIfAborted();
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+ return 'completed';
+}
+
+/**
+ * Step that combines two abort signals with native `AbortSignal.any` and
+ * waits for the composite to fire via addEventListener. The composite is
+ * a real native AbortSignal (Node's `AbortSignal.any`); each input signal
+ * is the deserialized step-side native AbortSignal. Tests that the listener
+ * chain (source signal aborts → composite fires → user listener fires)
+ * works end-to-end through the deserialization layer.
+ */
+async function stepCombiningSignals(
+ s1: AbortSignal,
+ s2: AbortSignal
+): Promise<{ saw: boolean; via: 'listener' | 'timeout' }> {
+ 'use step';
+ const combined = AbortSignal.any([s1, s2]);
+ return new Promise((resolve) => {
+ let settled = false;
+ const onAbort = () => {
+ if (settled) return;
+ settled = true;
+ resolve({ saw: true, via: 'listener' });
+ };
+ combined.addEventListener('abort', onAbort);
+ setTimeout(() => {
+ if (settled) return;
+ settled = true;
+ combined.removeEventListener('abort', onAbort);
+ resolve({ saw: combined.aborted, via: 'timeout' });
+ }, 30_000);
+ });
+}
+
+/**
+ * E2E: Abort reason propagation with various types.
+ * Tests that string, object, and undefined reasons all propagate correctly.
+ */
+export async function abortReasonTypesWorkflow() {
+ 'use workflow';
+
+ const c1 = new AbortController();
+ c1.abort('string-reason');
+ const s1 = await checkSignalState(c1.signal);
+
+ const c2 = new AbortController();
+ c2.abort({ code: 'CANCELLED', detail: 'by user' });
+ const s2 = await checkSignalState(c2.signal);
+
+ const c3 = new AbortController();
+ c3.abort();
+ const s3 = await checkSignalState(c3.signal);
+
+ return {
+ stringReason: s1,
+ objectReason: s2,
+ undefinedReason: s3,
+ };
+}
+
+/**
+ * E2E: Aborting an in-flight fetch.
+ *
+ * Exercises the deserialized signal's listener path that no other abort test
+ * covers: signal starts non-aborted, the step kicks off a fetch against a
+ * slow endpoint, the workflow's abort() fires while fetch is still awaiting
+ * the response, and fetch's internal abort listener (registered via
+ * addEventListener on the signal) cancels the in-flight HTTP request.
+ *
+ * If propagation is broken — e.g. listeners don't fire on the deserialized
+ * signal, or the cancellation stream packet isn't written — the fetch runs
+ * to natural completion and `aborted` is `false`.
+ */
+export async function abortFetchInFlightWorkflow() {
+ 'use workflow';
+
+ const controller = new AbortController();
+ // httpbin.org/delay/N holds the response open for N seconds — used here
+ // as a slow endpoint that the abort can cancel mid-flight. Same external-
+ // service pattern as other e2e workflows in this file (jsonplaceholder,
+ // example.com). Avoids needing a per-workbench /api/delay route, which
+ // would only exist on the one workbench it was added to.
+ const fetchPromise = fetchWithSignal(
+ 'https://httpbin.org/delay/30',
+ controller.signal
+ );
+
+ // Race the fetch against a 2s sleep. Sleep wins; abort fires.
+ const winner = await Promise.race([
+ fetchPromise.then(() => 'fetch' as const),
+ sleep('2s').then(() => 'timeout' as const),
+ ]);
+
+ if (winner === 'timeout') {
+ // Abort with no reason — defaults to a DOMException("AbortError") so
+ // fetch's rejection is an Error-shaped value the step can catch by
+ // `err.name === 'AbortError'`. (Per WHATWG fetch, `controller.abort(x)`
+ // with a non-Error `x` would cause fetch to reject with `x` directly,
+ // bypassing the AbortError check in `fetchWithSignal`.)
+ controller.abort();
+ }
+
+ // Always await the fetch to see how it ended. The step's catch path returns
+ // `{ ok: false, aborted: true }` if fetch saw the abort, or `{ ok: true,
+ // aborted: false }` if propagation failed and the request ran to completion.
+ const fetchResult = await fetchPromise;
+ return { winner, fetchResult };
+}
+
+/**
+ * E2E: The "simpler" timeout pattern documented on the
+ * `abort-signal-timeout-in-workflow` error page —
+ * `void sleep("Ns").then(() => controller.abort())` followed by a single
+ * awaited step that consumes `controller.signal`. Validates the doc's
+ * recommended replacement for `AbortSignal.timeout()` actually works
+ * end-to-end.
+ *
+ * If the in-flight fetch finishes within the timeout, the step returns
+ * normally. If the sleep wins, the .then fires `abort()`, the abort
+ * propagates to the in-flight step's signal via the backing stream, fetch
+ * cancels, and the step's catch path returns `{ ok: false, aborted: true }`.
+ */
+export async function abortVoidSleepTimeoutWorkflow() {
+ 'use workflow';
+
+ const controller = new AbortController();
+ void sleep('2s').then(() => controller.abort());
+
+ return await fetchWithSignal(
+ 'https://httpbin.org/delay/30',
+ controller.signal
+ );
+}
+
+/**
+ * E2E: Uncaught fetch AbortError propagates as FatalError (no retries).
+ * The step does NOT catch the AbortError from fetch — it should propagate
+ * as a FatalError to the workflow without the step being retried.
+ */
+export async function abortFetchUncaughtWorkflow() {
+ 'use workflow';
+
+ const controller = new AbortController();
+
+ // Abort immediately so fetch will throw
+ controller.abort('fetch-abort-test');
+
+ try {
+ await stepThatFetchesWithSignal(controller.signal);
+ return { threw: false };
+ } catch (err: any) {
+ return {
+ threw: true,
+ message: err.message,
+ isFatal: err.name === 'FatalError' || err.fatal === true,
+ };
+ }
+}
+
+async function stepThatFetchesWithSignal(signal: AbortSignal) {
+ 'use step';
+ // This will throw AbortError because the signal is already aborted.
+ // The error should NOT be caught here — it propagates to the workflow
+ // as a FatalError (wrapped by the step handler).
+ const response = await globalThis.fetch('https://example.com', { signal });
+ return response.status;
+}
+
+/**
+ * E2E: Deterministic branching — if-check on signal.aborted takes same path
+ * on first-run and replay.
+ *
+ * On first run: abort() hasn't been called yet, signal.aborted is false,
+ * takes the else branch. On replay: hook_received was processed but
+ * signal.aborted must STILL be false until abort() is called, so the
+ * else branch is taken again. This ensures deterministic code paths.
+ */
+export async function abortDeterministicBranchWorkflow() {
+ 'use workflow';
+
+ const controller = new AbortController();
+
+ // This if-check MUST take the same branch on both first-run and replay.
+ // If signal.aborted were set during event replay (before this code runs),
+ // the if-branch would be taken on replay but not on first-run.
+ let result: string;
+ if (controller.signal.aborted) {
+ result = 'was aborted'; // Should NEVER happen
+ } else {
+ controller.abort('test');
+ result = 'just aborted'; // Should ALWAYS happen
+ }
+
+ // After abort(), signal.aborted should be true
+ const state = await checkSignalState(controller.signal);
+
+ return {
+ result,
+ aborted: state.aborted,
+ reason: state.reason,
+ };
+}
+
+/**
+ * E2E: Listener-based reaction to abort. Tests that
+ * `signal.addEventListener('abort', ...)` on the deserialized step-side
+ * signal actually fires when the cancellation packet arrives — the same
+ * path fetch's internal cancellation uses, but exercised directly so a
+ * regression here can't be papered over by fetch-specific behavior.
+ */
+export async function abortListenerWorkflow() {
+ 'use workflow';
+ const controller = new AbortController();
+
+ // Listener step + delayed-abort step run in parallel. If listener
+ // propagation works, the listener resolves the step within ~1s.
+ const [stepResult] = await Promise.all([
+ stepWaitingOnAbortListener(controller.signal),
+ abortFromStep(controller, 1000),
+ ]);
+
+ return { stepResult };
+}
+
+/**
+ * E2E: `throwIfAborted()` mid-flight. Distinct from
+ * `abortThrowIfAbortedWorkflow` which only tests the synchronous-throw case
+ * on an already-aborted signal. Here the signal starts non-aborted, the step
+ * polls `signal.throwIfAborted()` in a loop, and a sibling step aborts after
+ * 1s. The DOMException thrown by `throwIfAborted` should bubble out of the
+ * step as a FatalError (no retries) and propagate to the workflow.
+ */
+export async function abortThrowIfAbortedMidFlightWorkflow() {
+ 'use workflow';
+ const controller = new AbortController();
+
+ try {
+ const [result] = await Promise.all([
+ stepPollingThrowIfAborted(controller.signal),
+ abortFromStep(controller, 1000),
+ ]);
+ return { threw: false, result };
+ } catch (err: any) {
+ return {
+ threw: true,
+ message: err.message,
+ isFatal: err.name === 'FatalError' || err.fatal === true,
+ };
+ }
+}
+
+/**
+ * E2E: Deterministic branching when the abort comes from a STEP (not the
+ * workflow body itself). Counterpart to `abortDeterministicBranchWorkflow`,
+ * which tests the case where the workflow code calls `abort()` directly.
+ *
+ * The pair of `signal.aborted` reads must each take the same branch on the
+ * first run and on every replay — even though the abort is recorded as a
+ * `hook_received` event written by a step that runs on a different compute
+ * instance. If signal.aborted flipped at the wrong logical point during
+ * replay (e.g. immediately when the events consumer first sees the event,
+ * rather than chained through promiseQueue at the suspension boundary that
+ * matches the original flow), the branches would diverge across runs.
+ */
+export async function abortDeterministicBranchFromStepWorkflow() {
+ 'use workflow';
+ const controller = new AbortController();
+
+ // Pre-abort read. MUST be false on first-run AND replay.
+ const beforeAborted = controller.signal.aborted;
+ let beforeBranch: string;
+ if (beforeAborted) {
+ beforeBranch = 'unexpected-aborted'; // Should NEVER happen
+ } else {
+ beforeBranch = 'pre-abort'; // Should ALWAYS happen
+ }
+
+ // Step that aborts the controller via the patched abort() path. Writes
+ // hook_received to the event log (and a stream cancellation packet) before
+ // returning.
+ await abortFromStep(controller);
+
+ // A suspension is required after the step before the workflow's signal
+ // reflects the abort. Step-initiated aborts go through the events consumer:
+ // when `hook_received` is processed during replay, `signal._setAborted` is
+ // chained on `promiseQueue.then(...)` in `workflow/abort-controller.ts`,
+ // which only runs at the next checkpoint that drains the promise queue —
+ // not synchronously after the step's await resolves. This mirrors how
+ // step return values become visible: only at a suspension boundary.
+ await sleep('1s');
+
+ // Post-abort read. MUST be true on first-run AND replay — the events
+ // consumer has now drained `_setAborted` for the hook_received event.
+ const afterAborted = controller.signal.aborted;
+ let afterBranch: string;
+ if (afterAborted) {
+ afterBranch = 'post-abort'; // Should ALWAYS happen
+ } else {
+ afterBranch = 'unexpected-not-aborted'; // Should NEVER happen
+ }
+
+ return {
+ beforeAborted,
+ beforeBranch,
+ afterAborted,
+ afterBranch,
+ };
+}
+
+/**
+ * Helper step that records its argument to a log array and returns it.
+ */
+async function logStep(entry: string): Promise {
+ 'use step';
+ return entry;
+}
+
+/**
+ * E2E: Abort + Hook ordering matrix.
+ *
+ * Tests all 4 combinations of:
+ * - Listener registration order (abort listener first vs hook.then first)
+ * - Event trigger order (abort first vs resumeHook first)
+ *
+ * Each combination must produce a deterministic log order on both
+ * first-run and replay.
+ *
+ * The `variant` parameter selects which combination to test:
+ * - "listener-first-abort-first": addEventListener → hook.then → abort() → resumeHook
+ * - "listener-first-hook-first": addEventListener → hook.then → resumeHook → abort()
+ * - "hook-first-abort-first": hook.then → addEventListener → abort() → resumeHook
+ * - "hook-first-hook-first": hook.then → addEventListener → resumeHook → abort()
+ */
+export async function abortHookOrderingWorkflow(
+ hookToken: string,
+ variant: string
+) {
+ 'use workflow';
+
+ const controller = new AbortController();
+ using hook = createHook<{ value: string }>({ token: hookToken });
+ const log: string[] = [];
+
+ if (variant === 'listener-first-abort-first') {
+ // Register abort listener first, then hook.then
+ controller.signal.addEventListener('abort', () => {
+ log.push('abort-listener');
+ });
+ void hook.then(async (payload) => {
+ log.push('hook-resolved:' + payload.value);
+ });
+ // Trigger abort first (hook resumed externally after)
+ controller.abort();
+ log.push('after-abort');
+ } else if (variant === 'listener-first-hook-first') {
+ // Register abort listener first, then hook.then
+ controller.signal.addEventListener('abort', () => {
+ log.push('abort-listener');
+ });
+ void hook.then(async (payload) => {
+ log.push('hook-resolved:' + payload.value);
+ });
+ // Hook is resumed externally first, then abort
+ // (we await a step to give the hook time to be resumed)
+ await logStep('waiting');
+ controller.abort();
+ log.push('after-abort');
+ } else if (variant === 'hook-first-abort-first') {
+ // Register hook.then first, then abort listener
+ void hook.then(async (payload) => {
+ log.push('hook-resolved:' + payload.value);
+ });
+ controller.signal.addEventListener('abort', () => {
+ log.push('abort-listener');
+ });
+ // Trigger abort first
+ controller.abort();
+ log.push('after-abort');
+ } else if (variant === 'hook-first-hook-first') {
+ // Register hook.then first, then abort listener
+ void hook.then(async (payload) => {
+ log.push('hook-resolved:' + payload.value);
+ });
+ controller.signal.addEventListener('abort', () => {
+ log.push('abort-listener');
+ });
+ // Hook resumed externally first, then abort
+ await logStep('waiting');
+ controller.abort();
+ log.push('after-abort');
+ }
+
+ // Wait long enough for the test harness to resume the hook (test sleeps
+ // a few seconds before resumeHook). The `void hook.then(...)` callback
+ // appends 'hook-resolved:hello' to the log on replay once the hook is
+ // received; this sleep keeps the workflow alive so that resumption lands
+ // before the workflow returns and `using hook` disposes it.
+ await sleep('10s');
+
+ return log;
+}
+
//////////////////////////////////////////////////////////
async function processPayload(payload: { type: string; id?: number }) {