Skip to content

Fix dangling stream readers, serialization bugs, and refactor abort reducer code#1647

Merged
pranaygp merged 10 commits into
pgp/serialize-abort-signalfrom
karthik/abort-signal-updates
May 2, 2026
Merged

Fix dangling stream readers, serialization bugs, and refactor abort reducer code#1647
pranaygp merged 10 commits into
pgp/serialize-abort-signalfrom
karthik/abort-signal-updates

Conversation

@karthikscale3
Copy link
Copy Markdown
Contributor

@karthikscale3 karthikscale3 commented Apr 7, 2026

Description

Follow-up to #1301 (AbortController/AbortSignal serialization). Fixes bugs uncovered while exercising the end-to-end path and cleans up duplicated reducer code.

What changed

Bug fixes:

  • Dangling stream reader hangreviveAbortController starts a stream reader that blocks on reader.read() indefinitely if no abort arrives, keeping the serverless function alive until the platform timeout. Introduced an ABORT_READER_CANCEL symbol stored on the controller/signal and an AbortController the reader races against. The new cancelAbortReaders(...args, thisVal, closureVars) helper walks step arguments after the step function returns (success or failure) and aborts every reader, letting Promise.race resolve cleanly.
  • Request.signal serialization — The Request reducer previously serialized any signal that was aborted or had ABORT_STREAM_NAME set, which caused plain native signals (e.g. from fetch timeouts or user AbortControllers passed via new Request(url, { signal })) to inherit stream+hook infrastructure they did not need. The reducer now only serializes signals tagged with ABORT_STREAM_NAME, so plain native signals are silently stripped during Request serialization.
  • Missing is_system migration — Added the 0010_add_is_system.sql Drizzle migration (workflow_hooks.is_system boolean default false), updated the journal, and plumbed isSystem through the hook_created event in suspension-handler, world-postgres, and world-local so system hooks (currently only the abort hook) persist correctly and do not collide with user hook tokens.
  • getAbortStreamIdFromToken helper — Replaced the fragile queueItem.token.replace('abrt_', '') + manual strm_${id}_system_abort concat in suspension-handler with a shared helper in util.ts that validates the token prefix and reuses getAbortStreamId. Eliminates a latent bug where a token without the abrt_ prefix would silently produce a garbage stream name.
  • DurableAgent timeout path in workflow VM — PR feat: serializable AbortController/AbortSignal #1301 added AbortController to the workflow VM globals, which flipped the existing typeof AbortController !== 'undefined' guard in DurableAgent.generate/.stream to true inside workflows. That caused the timeout block to call setTimeout, which the VM traps, crashing any DurableAgent call from a workflow with a timeout option. Added an inWorkflowVm check based on the presence of Symbol.for('WORKFLOW_CONTEXT') on globalThis (set by the workflow runtime before user code runs). When true, the timeout block is skipped, restoring the pre-feat: serializable AbortController/AbortSignal #1301 behavior (timeouts silently do not fire in workflows; use sleep + abort() for durable time bounds).
  • onabort setter on WorkflowAbortSignal — The VM-side signal only exposed listener-based abort hooks. Added an onabort getter/setter that matches the native AbortSignal contract, fires the handler immediately when assigned if the signal is already aborted, and is invoked by _setAborted alongside the listener list.

Refactors:

  • Deduplicated ~180 lines of reducer code — Extracted reduceAbortWithListener() (symbol mint + listener attach + serialize) and reduceAbortBySymbol() (read existing symbols + serialize) helpers from the three near-identical AbortController/AbortSignal reducer implementations in getExternalReducers, getWorkflowReducers, and getStepReducers. Introduced AbortInternals, AbortSignalLike, and AbortHolder types so the shared helpers work for both controllers and standalone signals.
  • setupAbortStreamReader + tagAbortPair helpers — Extracted the stream-reader wiring and symbol-stamping into focused helpers used by both reviveAbortController and the new reviveAbortSignal.
  • Signal-only revival path — Added reviveAbortSignal() used by getStepRevivers.AbortSignal and getExternalRevivers.AbortSignal. Previously those revived through reviveAbortController(...).signal, which installed the patched abort() method even though nothing could reach the controller. The dedicated path skips the patch overhead and, crucially, still stores ABORT_READER_CANCEL on the returned signal so cancelAbortReaders can clean up standalone-signal readers (fixing a latent leak).

Tests:

  • Added test covering stream reader propagation with a targeted getWorld mock that delivers an actual abort payload (the default mock closes the stream immediately and would mask the code path).
  • Added test verifying plain (non-workflow) Request.signal is stripped during serialization — the hydrated Request gets a fresh default signal.
  • Rewrote the existing "Request with signal round-trip" test to exercise the step hydration path and explicitly tag request.signal with ABORT_STREAM_NAME/ABORT_HOOK_TOKEN (the Request constructor clones the signal, so symbols from the source controller do not transfer automatically).
  • Updated step-handler.test.ts mock to include cancelAbortReaders.

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Error Error May 1, 2026 9:17pm
example-nextjs-workflow-webpack Error Error May 1, 2026 9:17pm
example-workflow Error Error May 1, 2026 9:17pm
workbench-astro-workflow Error Error May 1, 2026 9:17pm
workbench-express-workflow Error Error May 1, 2026 9:17pm
workbench-fastify-workflow Error Error May 1, 2026 9:17pm
workbench-hono-workflow Error Error May 1, 2026 9:17pm
workbench-nitro-workflow Error Error May 1, 2026 9:17pm
workbench-nuxt-workflow Error Error May 1, 2026 9:17pm
workbench-sveltekit-workflow Error Error May 1, 2026 9:17pm
workbench-vite-workflow Error Error May 1, 2026 9:17pm
workflow-docs Error Error May 1, 2026 9:17pm
workflow-swc-playground Error Error May 1, 2026 9:17pm
workflow-web Error Error May 1, 2026 9:17pm

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 7, 2026

⚠️ No Changeset found

Latest commit: ee30ed7

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 7, 2026

🧪 E2E Test Results

No test result files found.


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: failure
  • Local Prod: failure
  • Local Postgres: failure
  • Windows: failure

Check the workflow run for details.

@karthikscale3 karthikscale3 requested a review from pranaygp April 7, 2026 21:10
@karthikscale3 karthikscale3 marked this pull request as ready for review April 7, 2026 21:10
@karthikscale3 karthikscale3 requested a review from a team as a code owner April 7, 2026 21:10
@karthikscale3 karthikscale3 force-pushed the karthik/abort-signal-updates branch from 414942e to d786ebb Compare April 21, 2026 19:13
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 21, 2026

Deployment failed with the following error:

Vercel employees are required to have Two-Factor Authentication enabled.

View Documentation: https://vercel.com/docs/two-factor-authentication

Two leak paths the prior fix left uncovered:

- External signal aborted after serialization: verifies the listener
  attached by reduceAbortWithListener actually fires and writes the
  abort packet once the caller aborts later.
- Signal nested inside a Request: exposed a real leak. The Request
  constructor copies the signal to an internal AbortSignal, so the
  ABORT_READER_CANCEL symbol set by reviveAbortSignal never reached
  request.signal, and cancelAbortReaders' walker had no Request case
  so Object.values(request) returned []. Fixed both sides:
  - Request reviver copies abort-internal symbols via copyAbortInternals
  - Walker descends into Request.signal explicitly

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@VaguelySerious
Copy link
Copy Markdown
Member

Should probably be reviewed by Pranay and then merged into pgp/serialize-abort-signal before it's reviewed as a whole

Conflict resolution:
- packages/core/src/serialization.ts: kept PR's reviveAbortSignal() function
  (PR #1647's signal-only revival path) alongside the base branch's
  getCommonRevivers() function from the modular refactor

Co-authored-by: Cursor <cursoragent@cursor.com>
@pranaygp
Copy link
Copy Markdown
Contributor

pranaygp commented May 2, 2026

I'm resolving conflicts and merging your commits into my base PR from CLI @karthikscale3, ty!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants