Skip to content

fix(cursor): surface a cancel we did not request instead of dropping the turn - #1994

Merged
lidge-jun merged 3 commits into
devfrom
cursor-call-cancel
Aug 18, 2026
Merged

fix(cursor): surface a cancel we did not request instead of dropping the turn#1994
lidge-jun merged 3 commits into
devfrom
cursor-call-cancel

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Summary

Surface a Cursor CANCEL we did not request instead of dropping the turn.

An unexpected NGHTTP2_CANCEL produced a turn with zero adapter events. The transport already had this right — both failure exits check this.expectedClose before swallowing a cancel — but the adapter re-decided the same question one layer up from the error code alone (cursor.ts:181 via isCursorBenignCancelError), with no provenance in scope. A cancel Cursor sent us was indistinguishable from one we sent, and the turn vanished: streaming degraded to adapter_eof, non-streaming returned completed.

The transport now raises a typed CursorUnexpectedCancelError when the cancel is not ours and no terminal has been emitted. Two details review forced:

  • The guard includes !emittedTerminal — the flag PR1 introduces. Tagging on !expectedClose alone would fire after a done/error was already queued, flipping a completed buffered turn to failed.
  • The error carries its own message rather than the raw NGHTTP2_CANCEL text. Left raw, it is re-matched by classifyCursorError and reported as "Cursor stream suspended" — a turn that failed unexpectedly would claim an intentional suspension.

Untagged cancels keep their existing benign classification, so nothing that returns silently today starts erroring without evidence.

Stacked on #1993 — this layer reads the emittedTerminal flag that PR introduces.

Verification

bun test tests/cursor-cancel-provenance.test.ts tests/cursor-hardening.test.ts

Covered by the stack-tip gate run on ssh lidge at ff4b0bb4e (typecheck, privacy:scan, audit:high, build:gui, full isolated suite — all green). Verified red-before-green when the provenance tag was disabled.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed. (devlog/_plan/260817_cursor_toolcall_decode/040_*.md; failure-path correctness, not a documented feature.)
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults. (Error messages are sanitized through the existing cursor-errors redaction path.)

…the turn

An unexpected NGHTTP2_CANCEL produced a turn with zero adapter events. The transport
already got this right - both failure exits check `this.expectedClose` before swallowing
a cancel - but the adapter re-decided the same question one layer up from the error CODE
alone (cursor.ts:181 via isCursorBenignCancelError), with no provenance in scope. A
cancel Cursor sent us was therefore indistinguishable from one we sent, and the turn
vanished: streaming degraded to adapter_eof, non-streaming returned "completed".

The transport now raises a typed CursorUnexpectedCancelError when the cancel is not ours
and no terminal has been emitted yet. Two details the audit forced:

- The guard includes !emittedTerminal. Tagging on !expectedClose alone would fire after a
  done/error was already queued, and buffered JSON processes both - flipping a completed
  turn to failed.
- The error carries its own message rather than the raw NGHTTP2_CANCEL text. Left raw, it
  is re-matched by classifyCursorError and reported as "Cursor stream suspended", so a
  turn that failed unexpectedly would claim an intentional suspension.

Untagged cancels keep their existing benign classification, so nothing that returns
silently today starts erroring without evidence. Verified red-before-green.
Review follow-up: wrapping the raw cancel hid its code from the per-turn `turn-failed`
diagnostic, so the one summary that exists to explain this failure was the one with no
errorCode. The wrapper now re-exposes the originating code.

The added test also guards the trap this creates: carrying NGHTTP2_CANCEL back onto the
error must NOT make isCursorBenignCancelError match it again. Provenance is checked
first, so the class stays non-benign.
@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^dev$
  • ^preview$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1b756583-5dc0-41ca-babb-ca4cb39ff511

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant