Skip to content

fix(server-core): preserve abortSignal when resumable streams are enabled#1281

Open
Jefsky wants to merge 1 commit into
VoltAgent:mainfrom
Jefsky:fix/resumable-stream-abortsignal
Open

fix(server-core): preserve abortSignal when resumable streams are enabled#1281
Jefsky wants to merge 1 commit into
VoltAgent:mainfrom
Jefsky:fix/resumable-stream-abortsignal

Conversation

@Jefsky
Copy link
Copy Markdown

@Jefsky Jefsky commented May 13, 2026

Summary

When resumableStream is enabled, the handler was unconditionally clearing the client's abortSignal before calling streamText(). This made it impossible for clients to cancel an in-progress LLM generation — clicking "Stop" would only stop the client-side display while the backend continued running to completion.

What changed

packages/server-core/src/handlers/agent.handlers.ts

  1. Saves and restores abortSignal — stores the client's signal in a local variable before clearing it, then restores it before streamText(). The client can now cancel the LLM stream as intended.

  2. Makes clearActiveStream fire-and-forget — instead of awaiting the cleanup, it calls .catch() so the cleanup runs in the background. This means a client abort mid-cleanup cannot block the handler.

  3. Moves resumableStreamAdapter declaration earlier — to make the early fire-and-forget cleanup possible within the resumableStreamEnabled block.

Root cause

// Before (buggy)
if (resumableStreamEnabled) {
  options.abortSignal = undefined;  // client can never cancel streamText()
}
options.resumableStream = resumableStreamEnabled;
const result = await agent.streamText(input, options);
// After (fixed)
const resumableStreamAdapter = deps.resumableStream;

if (resumableStreamEnabled) {
  const clientSignal = options.abortSignal;  // save
  // fire-and-forget cleanup
  resumableStreamAdapter.clearActiveStream({ ... }).catch(...);
  options.abortSignal = clientSignal;         // restore
}
options.resumableStream = resumableStreamEnabled;
const result = await agent.streamText(input, options);  // respects signal

Closes #1239


Summary by cubic

Fixes server-side cancellation when resumableStream is enabled by preserving the client abortSignal and making stream cleanup non-blocking. Clients can now reliably stop in-progress LLM generations.

  • Bug Fixes
    • Save and restore the client's abortSignal so agent.streamText() respects cancellation.
    • Run resumableStreamAdapter.clearActiveStream(...) fire-and-forget with .catch(...) to avoid blocking on cleanup.

Written for commit a8d90f8. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes
    • Improved chat streaming reliability and performance by optimizing how streaming signals are preserved and asynchronous stream cleanup is managed.

Review Change Stack

…bled

When resumableStream is enabled, the previous code unconditionally set
options.abortSignal = undefined before streamText(), making it
impossible for clients to cancel an in-progress generation.

The fix:
- Saves the client's abortSignal before clearing it
- Makes clearActiveStream fire-and-forget (no await) so client abort
  cannot block handler cleanup
- Restores the signal before calling streamText so cancellation works

Fixes VoltAgent#1239

Co-authored-by: Jefsky Agent <agent@example.com>
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 13, 2026

⚠️ No Changeset found

Latest commit: a8d90f8

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

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

📝 Walkthrough

Walkthrough

The PR fixes the issue where AbortSignal was unconditionally cleared when resumable streaming was enabled, blocking stream cancellation. The handler now preserves the abort signal for the LLM call and moves clearActiveStream cleanup to async fire-and-forget mode, decoupling cancellation from stream transport.

Changes

Abort Signal Preservation in Resumable Streams

Layer / File(s) Summary
Resumable stream setup with preserved abort signal
packages/server-core/src/handlers/agent.handlers.ts
The handler preserves options.abortSignal for the streaming request instead of clearing it, moves deps.resumableStream acquisition earlier, and performs clearActiveStream asynchronously without awaiting it, enabling cancellation while resumable streaming is active.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐰 A rabbit hops through signal streams so fine,
Abort codes now dance and flow as they align!
No more lost cancels when resumable streams gleam—
Fire-and-forget cleanup keeps the LLM's dream.
✨ Both threads can thrive, both paths can play!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: preserving abortSignal when resumable streams are enabled, which directly addresses the core bug.
Description check ✅ Passed The description comprehensively covers the bug, root cause, changes made, and includes issue reference. It follows the repository template with clear before/after code examples.
Linked Issues check ✅ Passed The PR successfully addresses issue #1239 by preserving abortSignal so clients can cancel LLM generations, and making cleanup fire-and-forget to prevent blocking on abort.
Out of Scope Changes check ✅ Passed All changes are scoped to the specific file and directly address the linked issue; no unrelated modifications were introduced.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/server-core/src/handlers/agent.handlers.ts (2)

321-321: 💤 Low value

Consider simplifying the save-and-restore pattern.

Lines 321 and 332 save options.abortSignal to clientSignal and then restore it, but no code between these lines modifies options.abortSignal. This pattern appears redundant unless it's intended as defensive programming for future changes.

If the intent is clarity, the current implementation is fine. Otherwise, you could simplify by removing both lines since the signal is never cleared in the new implementation.

Also applies to: 332-332

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server-core/src/handlers/agent.handlers.ts` at line 321, The
save-and-restore of the abort signal is redundant: remove the temporary variable
assignment "const clientSignal = options.abortSignal;" and the later restore
"options.abortSignal = clientSignal;" in the surrounding function (where
options.abortSignal is referenced) since nothing between them mutates
options.abortSignal; ensure no other code in that function expects the restore
before deleting these two lines.

319-333: ⚖️ Poor tradeoff

The fire-and-forget cleanup pattern is intentional but does create a potential race condition.

The comments explicitly document that clearActiveStream is fire-and-forget to avoid blocking the hot path and prevent client abort signals from delaying cleanup. However, when called without a streamId parameter (line 326), the operation will delete any active stream for the given context. Since createStream at line 351 is called asynchronously afterward and sets a new activeStreamId, a race exists where cleanup could delete the newly created stream if it completes after setActiveStreamId. This appears to be an intentional performance tradeoff rather than a hidden bug.

The save/restore pattern for abortSignal (lines 321 and 332) appears redundant—nothing modifies the signal between these two lines, and the comment suggests it's precautionary. If the signal was never actually cleared in this block, the save/restore is unnecessary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server-core/src/handlers/agent.handlers.ts` around lines 319 - 333,
clearActiveStream is being called fire-and-forget without a streamId which can
race with the later createStream/setActiveStreamId and may delete the
newly-created stream; update the call to
resumableStreamAdapter.clearActiveStream to pass the specific active stream id
(e.g., previousActiveStreamId) or otherwise provide a conditional/compare option
so cleanup only removes the intended stream, and remove the redundant
save/restore of options.abortSignal since nothing mutates it in this block
(references: resumableStreamAdapter.clearActiveStream, createStream /
setActiveStreamId, options.abortSignal).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/server-core/src/handlers/agent.handlers.ts`:
- Line 321: The save-and-restore of the abort signal is redundant: remove the
temporary variable assignment "const clientSignal = options.abortSignal;" and
the later restore "options.abortSignal = clientSignal;" in the surrounding
function (where options.abortSignal is referenced) since nothing between them
mutates options.abortSignal; ensure no other code in that function expects the
restore before deleting these two lines.
- Around line 319-333: clearActiveStream is being called fire-and-forget without
a streamId which can race with the later createStream/setActiveStreamId and may
delete the newly-created stream; update the call to
resumableStreamAdapter.clearActiveStream to pass the specific active stream id
(e.g., previousActiveStreamId) or otherwise provide a conditional/compare option
so cleanup only removes the intended stream, and remove the redundant
save/restore of options.abortSignal since nothing mutates it in this block
(references: resumableStreamAdapter.clearActiveStream, createStream /
setActiveStreamId, options.abortSignal).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c109407e-7906-4d7c-9b35-0c6e1e207e6e

📥 Commits

Reviewing files that changed from the base of the PR and between 08414ed and a8d90f8.

📒 Files selected for processing (1)
  • packages/server-core/src/handlers/agent.handlers.ts

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 1 file

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.

AbortSignal is unconditionally cleared when resumableStream is enabled, making stop/cancel non-functional

1 participant