Skip to content

fix(terminal): stop answering mode-2031 toggles that the same chunk withdrew - #10817

Merged
nwparker merged 10 commits into
mainfrom
nwparker/fix-9993-2031-chunk-final
Jul 28, 2026
Merged

nwparker merged 10 commits into
mainfrom
nwparker/fix-9993-2031-chunk-final

Conversation

@nwparker

@nwparker nwparker commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Closes #9993

Summary

Fish enables and disables DEC private mode 2031 around each prompt. Orca replied as soon as it observed a subscribe, even when the same logical output withdrew it before the reply was safe. The late CSI ?997;1n then became literal shell input or stdin for the next interactive program.

This PR makes the reply decision stateful and final-state based across visible panes, parked panes, main-side facts, pending-output drops, and daemon scan-authority handoffs.

Reproduction

The bug is reproducible deterministically at the raw PTY chunk boundary:

chunk 1: CSI ?2031h ... CSI ?20
chunk 2: 31l

Before the fix, both the visible and parked responders sent a color-scheme reply after chunk 1. The shared fact scanner also lost a deferred subscribe when an ambiguous tail resolved to a different mode, and CSI ?25 was incorrectly treated as unable to append ;2031l.

Red phase:

  • 5 initial regression tests failed before source changes.
  • Expanding coverage to compound private modes exposed 3 more failures.
  • The pending-cap reproduction showed that a dropped-output sentinel could swallow the final mode-2031 decision.

Fix

  • Track a bounded mode-2031 reply state across chunks, including a provisional subscribe.
  • Defer a subscribe while any incomplete private-mode prefix can still append ;2031; apply withdrawals eagerly.
  • Preserve the final decision and partial state through the pending-output cap sentinel, and observe it before the renderer's dropped-output early return.
  • Use one state writer per pane: raw chunk scanners for byte-owned panes and main facts for gate-managed panes. The queued xterm parser writers were removed because they could rewind a newer withdrawal.
  • Carry unsubscribe facts through daemon protocol v29. Preserved pre-v29 daemons never receive background scan authority, so main remains authoritative.
  • Shadow the small mode-2031 state while main owns foreground facts and transfer a provisional subscribe with the daemon/main handoff marker.

Related work

Testing

  • Affected suites: 11 files, 1,969 tests passed
  • Focused daemon/runtime handoff rerun: 4 files, 1,028 tests passed (overlaps the affected-suite run)
  • pnpm typecheck:node
  • pnpm typecheck:web
  • Changed-file oxlint
  • Changed-file oxfmt --check
  • pnpm check:max-lines-ratchet
  • pnpm check:reliability-gates
  • git diff --check

AI review report

Review findings addressed:

  • pending subscribe was previously forgotten after an ambiguous tail;
  • compound private modes such as ?25;2031l were misclassified;
  • pending-cap drops bypassed/purged the reply decision;
  • queued xterm callbacks could restore stale subscription state;
  • pre-v29 daemons could not safely own hidden scanning;
  • provisional state was not preserved across main/daemon authority handoffs.

Screenshots

Not applicable; this is terminal protocol behavior covered by byte-level regressions.

Security audit

No new command execution, filesystem access, network surface, or user-controlled parsing beyond the existing bounded terminal escape scanner.

Notes

The only added latency is while an incomplete private-mode sequence remains ambiguous. The retained tail is capped at 128 characters. Ordinary output stays on an allocation-free fast path.

The implementation is platform-neutral and uses the same shared scanner for local, SSH, worktree, and folder-workspace terminal flows.

Made with Orca 🐋

nwparker and others added 7 commits July 26, 2026 15:23
…withdrew

fish enables and disables mode 2031 around every prompt (tty_handoff.rs), so a
single PTY chunk routinely carries `?2031h ... ?2031l`. All three responders
answered the sticky "an h appeared anywhere" flag, so each prompt cycle wrote
`?997;1n` into a shell that had already handed the tty to a child — it lands as
literal text, or as stdin for whatever is reading.

pty-connection.ts's hidden-pane responder already had the right shape
(`finalState !== 'subscribed'`); this brings the other three in line:

- shared tracker: gate the '2031-subscribe' fact on the chunk-final state
- parked-tab byte sidecar: same guard
- visible-pane xterm CSI handler: xterm dispatches mid-parse, so there is no
  chunk-final state to read. Defer the reply to a microtask and re-check the
  subscription, letting a same-chunk `?2031l` cancel it.

Refs #9993

Co-authored-by: Orca <help@stably.ai>
The previous commit deferred the visible-pane reply to a microtask so a
same-chunk `?2031l` could cancel it. That cannot work: xterm's WriteBuffer
parses every queued `terminal.write()` synchronously in one batch before any
microtask runs, so the microtask sees the net state of N PTY chunks, not of the
one that carried the subscribe. A TUI that subscribes in chunk N gets no reply
when chunk N+1 happens to withdraw, and a fish prompt straddling two writes
still gets answered.

Move the decision to where chunk boundaries actually exist — pty-connection's
dataCallback, which receives one PTY chunk per call. It scans raw bytes with
`scanMode2031Sequences`, carrying a tail across chunks so a CSI split mid-
sequence still resolves, and replies only when that chunk *ends* subscribed.

Ownership stays single: gate-managed PTYs are answered by main's
'2031-subscribe' fact, so the chunk scanner returns early for them, and the
xterm CSI handler now observes only panes the scanner does not own. The tail is
dropped on PTY replacement — a partial prefix belongs to the stream that
produced it.

Removes the microtask responder and the seed-reply retry path it needed.

Mutation-tested: 6 mutations applied, 6 killed.
The previous commit moved 2031 reply decisions to the PTY chunk boundary and
gave gate-managed panes a single owner: main's '2031-subscribe' fact. But the
fact union is subscribe-only, and that left the withdrawal unobserved.

For a gate-managed pane, main drops renderer-bound bytes after model ingestion,
the chunk scanner early-returns, and xterm's CSI handler is disabled. So when a
TUI emits `?2031l` while hidden, nothing retires the subscription: paneMode2031
stays set, and the next theme flip has maybePushMode2031Flip push `CSI ?997;2n`
into the shell that replaced the TUI — #9993 again, through the theme-change
door. Before this branch, skipHiddenRendererOutput observed those withheld
bytes; consolidating ownership removed that observer without replacing it.

No renderer-side observer can close this: the bytes are gone before the
renderer sees them. The state protocol has to carry the withdrawal, so add a
'2031-unsubscribe' fact alongside the subscribe across the three fact unions
(shared, provider, daemon). It fires only on a real chunk-final withdrawal —
a chunk with no 2031 bytes scans to null and stays silent. The renderer handler
clears both maps and sends nothing: a withdrawal is not a query.

Also closes two gaps an adversarial review found by mutation, both previously
resting on comments rather than tests: the lifecycle parser-ownership predicate
(extracted as isPaneParserOwnedMode2031Observer so it is directly testable) and
the scan-before-reconciliation ordering that lets a chunk the snapshot drops as
a duplicate still answer its query.

Mutation-tested: 12 mutations applied, 12 killed (6 from the prior round
re-run, 6 new covering this fix and the two survivors).
Round-2 review found a wire-compatibility hole in the original #9993 fix.

Daemons survive app updates, so a new desktop can drive a daemon that was
started by the previous build. Pre-v29 daemons emit '2031-subscribe' but
have no '2031-unsubscribe' fact at all. For a gate-managed pane, main drops
the renderer-bound bytes before the renderer sees them, so main's transient
facts are the ONLY thing that can retire a subscription. Against such a
daemon a TUI exiting while its pane is hidden leaves the subscription
registered forever, and the next theme flip injects CSI 997 into whatever
shell replaced it -- #9993 all over again, reached through the upgrade path.

Gate it: bump PROTOCOL_VERSION 28 -> 29, add
MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION with
supportsMode2031UnsubscribeFact(), and drop '2031-subscribe' from any
daemon below that floor.

Trade-off: a gate-managed pane on a preserved v28 daemon keeps
renderer-scanner authority instead of daemon-fact authority. That is exactly
the pre-fact behaviour -- correct for visible panes, no worse than today for
hidden ones -- and it resolves on the daemon's next restart. Non-2031
transient facts (bell, etc.) are unaffected at every version.

Tests: two adapter regression tests (v28 drops subscribe, v29 forwards it),
plus a version-pin test asserting the floor sits above every entry in
PREVIOUS_DAEMON_PROTOCOL_VERSIONS -- so adding a new preserved version
cannot silently re-open the hole.

Mutation-verified in both directions: `false &&` (under-block) and `true`
(over-block) each fail the new tests.
A pre-v29 daemon can announce a 2031 subscribe but never retract it. Filtering
that fact is not enough: while a pane is visible main's own scanner registers
the subscription, and scan authority only moves to the daemon when the session
is backgrounded. So the gate belongs on setPtyBackgrounded — decline to hand a
non-retracting daemon authority at all, and main stays authoritative over the
whole stream.

Co-authored-by: Orca <help@stably.ai>
… just at background

Co-authored-by: Orca <help@stably.ai>
…es a chunk

Review found the chunk-final-state fix left one hole open. When the kernel cuts
fish's toggle pair mid-withdrawal — chunk 1 ends "...?2031h prompt ESC[?20",
chunk 2 is "31l" — chunk 1 genuinely ends subscribed, so it answers, and the
reply lands as literal text at the prompt. Chunk 2 then recognizes the
withdrawal but cannot recall bytes already written. The same byte stream is
safe or corrupting purely by where the kernel split it.

The scanner already retains an incomplete private-mode tail; it just didn't
tell the caller whether that tail could still resolve to 2031. It now does, and
a subscribe is held one chunk while the answer is still in doubt. Only
subscribes defer — retiring a subscription writes nothing to the pty, so
withdrawals stay eager.

Deferral is narrow: a trailing "ESC[?25" (cursor hide) can never become 2031,
so a subscribe already seen in that chunk is still answered immediately.

This case predates the branch — the old sticky-flag policy replied here too —
so it is a residual this fix now closes rather than a regression it introduced.

Tests: three cases pinned (split withdrawal, non-2031 partial must not defer,
split re-subscribe answers once). Removing the deferral fails only the first.
@coderabbitai

coderabbitai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: efc4c7d6-ce94-40ec-8570-f17e09816a51

📥 Commits

Reviewing files that changed from the base of the PR and between d0f74a1 and 94afa84.

📒 Files selected for processing (2)
  • src/main/ipc/pty.test.ts
  • src/main/ipc/pty.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/main/ipc/pty.test.ts
  • src/main/ipc/pty.ts

📝 Walkthrough

Walkthrough

The change introduces chunk-final Mode 2031 reply decisions, including split-sequence handling and unsubscribe facts. Daemon protocol version 29 adds capability gating for unsubscribe support and carries provisional scan state across background handoffs. PTY dropped-output handling preserves and reconstructs Mode 2031 data. Renderer processing scans live chunks, applies unsubscribe facts, removes retry-based seed replies, and resets state across PTY replacement. Tests cover daemon compatibility, buffering, handoffs, parked terminals, hidden panes, restoration, and chunk boundaries.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the required sections, but it does not explicitly confirm the cross-platform compatibility review called for by the template. Add an AI review note confirming macOS, Linux, and Windows compatibility checks, including shortcuts, labels, paths, shell behavior, and Electron-specific differences.
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed Title matches the main fix: preventing mode-2031 replies when the same chunk later withdraws them.
Linked Issues check ✅ Passed The changes address #9993 by making 2031 replies stateful, final-state based, and compatible across visible, parked, daemon, and main-handled paths.
Out of Scope Changes check ✅ Passed No clear unrelated code changes stand out; the protocol, renderer, daemon, and tests all support the 2031 reply fix.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (1)
src/main/daemon/daemon-protocol-version.ts (1)

22-25: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider deriving PREVIOUS_DAEMON_PROTOCOL_VERSIONS instead of hand-maintaining it.

Each protocol bump requires manually appending the new value to this literal array; forgetting it silently breaks any .includes()/.toContain() checks. This PR's own test file already computes the equivalent sequence generically (Array.from({ length: 28 }, (_, index) => index + 1)), which could replace the literal here too.

♻️ Proposed refactor
-export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
-  1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
-  28
-] as const
+export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = Array.from(
+  { length: PROTOCOL_VERSION - 1 },
+  (_, index) => index + 1
+)

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7fc49a60-363d-4813-b5ef-258dd05e6a26

📥 Commits

Reviewing files that changed from the base of the PR and between 29c40e3 and 55423d6.

📒 Files selected for processing (26)
  • src/main/daemon/daemon-background-transient-facts.ts
  • src/main/daemon/daemon-foreground-confirmation-protocol.test.ts
  • src/main/daemon/daemon-protocol-version.test.ts
  • src/main/daemon/daemon-protocol-version.ts
  • src/main/daemon/daemon-pty-adapter.test.ts
  • src/main/daemon/daemon-pty-adapter.ts
  • src/main/daemon/daemon-stream-events.ts
  • src/main/daemon/types.ts
  • src/main/providers/pty-provider-events.ts
  • src/main/runtime/orca-runtime.ts
  • src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.test.ts
  • src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts
  • src/renderer/src/components/terminal-pane/pty-connection.test.ts
  • src/renderer/src/components/terminal-pane/pty-connection.ts
  • src/renderer/src/components/terminal-pane/terminal-appearance.test.ts
  • src/renderer/src/components/terminal-pane/terminal-appearance.ts
  • src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.test.ts
  • src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts
  • src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts
  • src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts
  • src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
  • src/shared/terminal-color-scheme-protocol.ts
  • src/shared/terminal-mode-2031-final-state.test.ts
  • src/shared/terminal-output-side-effects.test.ts
  • src/shared/terminal-output-side-effects.ts
  • src/shared/terminal-side-effect-facts.ts
💤 Files with no reviewable changes (2)
  • src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.test.ts
  • src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts

Comment thread src/main/daemon/daemon-pty-adapter.ts Resolved
Comment thread src/main/daemon/daemon-pty-adapter.ts Resolved
@nwparker

Copy link
Copy Markdown
Contributor Author

The red verify check is a pre-existing flake, not this PR

verify failed on terminal-fit-restore.test.ts > uses the active runtime environment when the remote PTY has no encoded environment. That file is not in this PR's diff (git diff --name-only origin/main...HEAD | grep fit-restore → 0 hits), and its last three commits are #9853, #8200, #5233 — none mine.

What actually failed

- "timeoutMs": 15000,
+ "timeoutMs": 14999,

Root cause

src/renderer/src/components/terminal-pane/terminal-fit-restore.ts reads the wall clock twice on the path the test exercises:

// :75  restoreTerminalFitToDesktop
Date.now() + TERMINAL_FIT_RESTORE_DEADLINE_MS   // deadlineAt

// :47  restoreTerminalFitToDesktopWithinDeadline
const timeoutMs = Math.max(0, deadlineAt - Date.now())

If the millisecond boundary ticks between those two reads, timeoutMs is 14999 and the exact-match assertion at terminal-fit-restore.test.ts:72 fails. It is time-of-day dependent, so it is rare and unrelated to any code change.

Proof it's a real race, not a theory

Modelling exactly those two lines, 3M iterations on this machine:

ticks between the two Date.now() reads: 84 / 3000000

~1 in 36k per invocation. The full suite runs this path enough times across 3458 files that an occasional hit is expected.

Local evidence

  • terminal-fit-restore.test.ts on this branch: 9 passed
  • The whole terminal-pane/ directory, 3 consecutive runs: 186 files passed each time

Disposition

Re-ran the failed job. Not fixing it here — it belongs to a different subsystem and a real fix means either injecting a clock or asserting expect.closeTo/toBeLessThanOrEqual on timeoutMs, which is its own change with its own review.

@nwparker

Copy link
Copy Markdown
Contributor Author

Independent verification of the four open bot findings (Opus sub-agents + A/B against origin/main)

I re-verified every unresolved thread by executing code, not reading it. Method: a detached worktree pinned to this PR's head (55423d6017), plus a copy of the same module from origin/main and from HEAD~1, so each claim could be A/B'd. Verdicts below; the discriminator is always "does origin/main do the right thing here?" — that separates regressions this PR introduces from pre-existing gaps it merely fails to close.

🔴 CLAIM: deferred subscribe is lost — CONFIRMED REGRESSION. This is the blocker.

A chunk ending in a bare ESC[ defers its already-valid ?2031h. If the next chunk completes an unrelated CSI (e.g. 31m), finalState is null and the subscribe fact is never emitted at all — it isn't deferred, it's dropped.

A/B through the public createTerminalTitleTracker entry point:

chunks origin/main / HEAD~1 this PR
ESC[?2031h hello ESC[ + 31m world ['subscribe'] [] ← lost

origin/main emits it and this PR does not, so this is a genuine regression introduced by the deferral mechanism itself.

Severity, stated honestly: it is self-healing in the common case. fish re-toggles 2031 around every prompt, so the very next prompt re-subscribes:

  • ['?2031h', 'ESC[', '31m'] → lost
  • ['?2031h', 'ESC[', '31m', '?2031h'] → recovered

The unbounded case is a chunk whose CSI never terminates, which strands the subscribe indefinitely. So: real, but a missed color-scheme update for one prompt cycle, not a permanent wedge.

🟡 CLAIM: leading-zero normalization — CONFIRMED REAL, but PRE-EXISTING (not a regression)

The asymmetry is exactly as described: hasMode2031() uses Number(param) === 2031 so it accepts 02031, while tailCouldStillBeMode2031() uses '2031'.startsWith(segment) so it rejects 0203. A padded split withdrawal therefore emits a reply that the next bytes retract.

But HEAD~1 and HEAD behave identically on this input (['subscribe','unsubscribe'] both). This PR neither introduced nor worsened it. Worth fixing — one-line normalization on the trailing segment — but it is not a merge blocker for this diff.

🟡 CLAIM: observeLiveMode2031Chunk bypassed by the droppedOutput early return — CONFIRMED REAL, PRE-EXISTING

The mechanism checks out end-to-end. Driving a real pane, a droppedOutput sentinel carrying ESC[?2031h produces no reply — the call at pty-connection.ts:7067 sits after the return at 7066. And the salvage path genuinely cannot rescue it: extractDroppedPtyQueryBytes strips 2031 entirely (verified — a bare ?2031h salvages to '', while a control ESC[6n correctly survives, so the carver does work; 2031 just isn't in its grammar).

But origin/main has the same ordering — its respondToSkippedMode2031Subscribe(data) call at line 7085 is likewise below the droppedOutput return at 7066. Pre-existing, already disclosed on this PR, correctly left out of scope.

🟠 NEW — found during this pass, not reported by the bot: the fix doesn't reach the parked responder

The headline scenario this PR fixes (?2031h … ESC[?20 | 31l) still reproduces through startParkedTerminalMode2031Responder — it writes ?997;1n into the pty after the shell already withdrew, which is issue #9993's exact symptom. Control case in the same probe confirms a same-chunk toggle pair is suppressed there, so this is specifically the cross-chunk deferral that path lacks.

Reachability is limited: that responder only starts when hiddenDeliveryGateActive is false, i.e. a remote/SSH runtime (runtimeEnvironmentId !== null forces main authority off) or an explicitly flipped kill switch. Both gates default on. So this is a degraded-path gap, not the default desktop path — but given #9993 was itself reported against real shell usage, and SSH is a first-class Orca use case, it's worth a follow-up.

On the two Minor daemon-pty-adapter.ts threads

Both are the bot re-running failed shell probes (Length of output: 151) rather than substantive findings. No action.


Verdict

Not mergeable as-is — one confirmed regression against origin/main (the lost subscribe). Everything else is either pre-existing or out of scope.

Notably, all 25 committed tests pass on this head, including the ones added by this PR. The regression lives in a coverage hole: the suite exercises split-withdrawal and same-chunk cases but never "deferred subscribe followed by an unrelated CSI." Green CI did not catch this, which is the main reason I'd hold the merge.

Heads-up: a fix for exactly this is already in flight in a working copy on this branch — a scanMode2031ReplyDecision that carries a pendingSubscribe bit across chunks. I tested that in-flight version against all four cases plus edges (unterminated-CSI hold/release, no duplicate subscribes, semicolon-list withdrawal, split subscribe across the boundary) and it passes all of them, and takes the committed suite 25 → 28. It is uncommitted and unpushed, so it isn't reflected in this PR's head yet.

Verification was read-only against a pinned detached worktree; nothing here was pushed, and no PR was merged.

@nwparker

Copy link
Copy Markdown
Contributor Author

Re-verified against the new head d0f74a1b28 — the regression I flagged is closed

My previous comment reviewed 55423d6017. The head moved while I was writing it, so I re-ran everything against d0f74a1b28 ("preserve mode 2031 reply decisions") rather than let a stale verdict stand.

All four cases now pass, A/B'd against origin/main through the public createTerminalTitleTracker entry point:

scenario origin/main 55423d6017 d0f74a1b28
deferred subscribe + unrelated CSI (?2031h … ESC[ | 31m) subscribe [] ← lost subscribe ✅
leading-zero split withdrawal (?2031h … ?0203 | 1l) sub,unsub sub,unsub unsub ✅
headline #9993 split withdrawal (?2031h … ?20 | 31l) sub,unsub unsub ✅ unsub ✅
plain subscribe subscribe subscribe ✅ subscribe ✅

So the new commit closes both the regression I confirmed and the pre-existing leading-zero asymmetry — the latter wasn't strictly required to merge, since HEAD~1 shared it, but it's genuinely fixed rather than papered over.

The parked-responder gap is also closed

I reported a fourth issue the bot didn't catch: the same split withdrawal still leaked ?997;1n into the pty through startParkedTerminalMode2031Responder — the path used on remote/SSH runtimes and when the hidden-delivery kill switch is off. That path now correctly emits nothing on a split withdrawal, with the control case (plain subscribe still answered) confirming the responder wasn't simply disabled.

Test state

521 tests green across terminal-mode-2031-final-state, terminal-output-side-effects, and pty-connection at this head. The committed 2031 suite went 25 → 28, and the new cases cover exactly the hole that let the regression through: previously every test exercised same-chunk or split-withdrawal sequences, none exercised "deferred subscribe followed by an unrelated CSI."

The full CI matrix is currently running on this head (16-way test shard, both native-smoke jobs, crash-survival, Windows terminal restart, wayland input) — worth waiting for green before merging, since my verification is unit-level.

What remains open (unchanged, and correctly out of scope)

The droppedOutput early return at pty-connection.ts:7066 still bypasses the 2031 observation, and extractDroppedPtyQueryBytes doesn't carry 2031 bytes through the salvage path. I re-confirmed this is pre-existing — origin/main's equivalent call sits below the same return — so it belongs in its own issue, not this diff.


Verdict: mergeable on the merits, pending the CI matrix. No regressions against origin/main remain. As instructed, I have not merged anything.

@nwparker

Copy link
Copy Markdown
Contributor Author

CI on d0f74a1b28: two red checks, one root cause, and it is a real failure — not the known flake

Correcting my previous comment's "pending the CI matrix": the matrix came back and it is not green.

tests 11/16   FAIL
verify        FAIL   ← aggregate gate ("TEST: failure"); same root cause, not independent

Everything else passed: 15 of 16 test shards, typecheck, static analysis, package, shell contracts, Git compatibility, native-smoke (ubuntu-22.04), wayland terminal input.

The failure

src/main/local-builds/local-build-compatibility-contract.test.ts:

AssertionError: expected { formatVersion: 1, …(5) } to match object { appId: 'com.stablyai.orca', …(4) }
-   "daemonProtocolVersion": 29,
+   "daemonProtocolVersion": 28,
-     29,      ← missing from attachableDaemonProtocolVersions

This is caused by this PR, and it is the mirror image of the terminal-fit-restore flake dismissed earlier — that one was genuinely unrelated, this one is not. The 2031-unsubscribe fact required PROTOCOL_VERSION 29 (MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29), but local-build-compatibility-contract.json / .ts still declared 28. The contract test asserts those stay aligned, and it caught exactly the drift it exists to catch.

Worth stressing what this would have meant if it had shipped: the contract is what a packaged local build uses to decide which daemon protocol versions it may attach to. A build advertising 28 while speaking 29 is a real attach-compatibility hazard, not a bookkeeping nit. 1972 tests passed in that shard — this single assertion is the one that mattered.

The fix already exists but is not pushed

There is a local commit 94afa841fd "fix(build): record daemon protocol v29 compatibility" that makes precisely the right change:

-  "daemonProtocolVersion": 28,
+  "daemonProtocolVersion": 29,
-    27, 28
+    27, 28, 29

in both the .json and the .ts. I ran the contract test against it — passes. It also sits on top of a main merge (bf4bed74ce), so the branch has moved on locally.

None of that is reflected in this PR's head, so CI will stay red until it's pushed. I have not pushed it — it isn't my commit, and I was asked not to merge or land anything.

Verdict, revised

Not mergeable at d0f74a1b28. The 2031 correctness work itself is sound — I re-verified all four scenarios plus the parked/SSH responder path at this head, and 521 tests pass locally across the three relevant suites. The blocker is purely the unpushed protocol-version contract bump.

Push 94afa841fd (and the main merge beneath it), let the matrix re-run, and this should go green.

@nwparker

Copy link
Copy Markdown
Contributor Author

Post-main merge verification for 94afa841fd:

  • Reproduced the CI shard failure in local-build-compatibility-contract.test.ts.
  • Updated the packaged local-build contract to advertise daemon protocol v29 and attachability through v29.
  • Contract/candidate/protocol suite: 4 files, 11 tests passed.
  • Full affected terminal suite on the merged tree: 11 files, 1,998 tests passed.
  • Node and web typechecks, max-lines ratchet, reliability gates, formatting/lint, and diff checks pass.

A fresh CI run is now attached to the updated head.

@nwparker

Copy link
Copy Markdown
Contributor Author

Re-verified at 94afa841fd — contract fix landed, tests 11/16 is now green

The protocol-version contract commit was pushed, and the shard that was failing (tests 11/16, local-build-compatibility-contract.test.ts) now passes. Also green so far on the re-run: typecheck, static analysis, package, shell contracts, Git compatibility, and shards 3, 5, 11, 14. The rest of the matrix is still running.

Why I re-ran everything rather than carry my earlier verdict forward

The new head isn't just the contract bump — it sits on top of a main merge (bf4bed74ce) that touched a broad set of files. My previous verification was against d0f74a1b28, which is no longer the tree CI is testing, so I re-executed the same checks against 94afa841fd instead of assuming they still held.

All four 2031 scenarios, re-A/B'd against origin/main post-merge:

scenario origin/main 94afa841fd
deferred subscribe + unrelated CSI (?2031h … ESC[ | 31m) sub sub ✅
leading-zero split withdrawal (?2031h … ?0203 | 1l) — unsub ✅
headline #9993 split withdrawal (?2031h … ?20 | 31l) — unsub ✅
plain subscribe sub sub ✅

Parked/SSH responder — also re-checked at this head: a split withdrawal writes nothing into the pty, and the control case confirms a plain subscribe is still answered (i.e. the path wasn't just switched off).

Verdict

Mergeable once the remaining matrix jobs report green. No regressions against origin/main at the current head; the one real CI failure was this PR's own contract drift and it is fixed and verified.

The droppedOutput bypass at pty-connection.ts:7066 remains open and remains correctly out of scope — origin/main's equivalent call sits below the same return, so it is pre-existing and belongs in its own issue.

All verification was read-only. I have merged nothing and pushed nothing.

@nwparker
nwparker merged commit 2dac074 into main Jul 28, 2026
30 checks passed
@nwparker
nwparker deleted the nwparker/fix-9993-2031-chunk-final branch July 28, 2026 00:17
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…ithdrew (stablyai#10817)

* fix(terminal): stop answering DECSET 2031 subscriptions fish already withdrew

fish enables and disables mode 2031 around every prompt (tty_handoff.rs), so a
single PTY chunk routinely carries `?2031h ... ?2031l`. All three responders
answered the sticky "an h appeared anywhere" flag, so each prompt cycle wrote
`?997;1n` into a shell that had already handed the tty to a child — it lands as
literal text, or as stdin for whatever is reading.

pty-connection.ts's hidden-pane responder already had the right shape
(`finalState !== 'subscribed'`); this brings the other three in line:

- shared tracker: gate the '2031-subscribe' fact on the chunk-final state
- parked-tab byte sidecar: same guard
- visible-pane xterm CSI handler: xterm dispatches mid-parse, so there is no
  chunk-final state to read. Defer the reply to a microtask and re-check the
  subscription, letting a same-chunk `?2031l` cancel it.

Refs stablyai#9993

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): decide 2031 replies per PTY chunk, not per xterm parse

The previous commit deferred the visible-pane reply to a microtask so a
same-chunk `?2031l` could cancel it. That cannot work: xterm's WriteBuffer
parses every queued `terminal.write()` synchronously in one batch before any
microtask runs, so the microtask sees the net state of N PTY chunks, not of the
one that carried the subscribe. A TUI that subscribes in chunk N gets no reply
when chunk N+1 happens to withdraw, and a fish prompt straddling two writes
still gets answered.

Move the decision to where chunk boundaries actually exist — pty-connection's
dataCallback, which receives one PTY chunk per call. It scans raw bytes with
`scanMode2031Sequences`, carrying a tail across chunks so a CSI split mid-
sequence still resolves, and replies only when that chunk *ends* subscribed.

Ownership stays single: gate-managed PTYs are answered by main's
'2031-subscribe' fact, so the chunk scanner returns early for them, and the
xterm CSI handler now observes only panes the scanner does not own. The tail is
dropped on PTY replacement — a partial prefix belongs to the stream that
produced it.

Removes the microtask responder and the seed-reply retry path it needed.

Mutation-tested: 6 mutations applied, 6 killed.

* fix(terminal): carry DECSET 2031 withdrawals as a side-effect fact

The previous commit moved 2031 reply decisions to the PTY chunk boundary and
gave gate-managed panes a single owner: main's '2031-subscribe' fact. But the
fact union is subscribe-only, and that left the withdrawal unobserved.

For a gate-managed pane, main drops renderer-bound bytes after model ingestion,
the chunk scanner early-returns, and xterm's CSI handler is disabled. So when a
TUI emits `?2031l` while hidden, nothing retires the subscription: paneMode2031
stays set, and the next theme flip has maybePushMode2031Flip push `CSI ?997;2n`
into the shell that replaced the TUI — stablyai#9993 again, through the theme-change
door. Before this branch, skipHiddenRendererOutput observed those withheld
bytes; consolidating ownership removed that observer without replacing it.

No renderer-side observer can close this: the bytes are gone before the
renderer sees them. The state protocol has to carry the withdrawal, so add a
'2031-unsubscribe' fact alongside the subscribe across the three fact unions
(shared, provider, daemon). It fires only on a real chunk-final withdrawal —
a chunk with no 2031 bytes scans to null and stays silent. The renderer handler
clears both maps and sends nothing: a withdrawal is not a query.

Also closes two gaps an adversarial review found by mutation, both previously
resting on comments rather than tests: the lifecycle parser-ownership predicate
(extracted as isPaneParserOwnedMode2031Observer so it is directly testable) and
the scan-before-reconciliation ordering that lets a chunk the snapshot drops as
a duplicate still answer its query.

Mutation-tested: 12 mutations applied, 12 killed (6 from the prior round
re-run, 6 new covering this fix and the two survivors).

* fix(daemon): refuse 2031 authority from a daemon that cannot retract it

Round-2 review found a wire-compatibility hole in the original stablyai#9993 fix.

Daemons survive app updates, so a new desktop can drive a daemon that was
started by the previous build. Pre-v29 daemons emit '2031-subscribe' but
have no '2031-unsubscribe' fact at all. For a gate-managed pane, main drops
the renderer-bound bytes before the renderer sees them, so main's transient
facts are the ONLY thing that can retire a subscription. Against such a
daemon a TUI exiting while its pane is hidden leaves the subscription
registered forever, and the next theme flip injects CSI 997 into whatever
shell replaced it -- stablyai#9993 all over again, reached through the upgrade path.

Gate it: bump PROTOCOL_VERSION 28 -> 29, add
MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION with
supportsMode2031UnsubscribeFact(), and drop '2031-subscribe' from any
daemon below that floor.

Trade-off: a gate-managed pane on a preserved v28 daemon keeps
renderer-scanner authority instead of daemon-fact authority. That is exactly
the pre-fact behaviour -- correct for visible panes, no worse than today for
hidden ones -- and it resolves on the daemon's next restart. Non-2031
transient facts (bell, etc.) are unaffected at every version.

Tests: two adapter regression tests (v28 drops subscribe, v29 forwards it),
plus a version-pin test asserting the floor sits above every entry in
PREVIOUS_DAEMON_PROTOCOL_VERSIONS -- so adding a new preserved version
cannot silently re-open the hole.

Mutation-verified in both directions: `false &&` (under-block) and `true`
(over-block) each fail the new tests.

* fix(daemon): gate background delegation, not just the fact stream

A pre-v29 daemon can announce a 2031 subscribe but never retract it. Filtering
that fact is not enough: while a pane is visible main's own scanner registers
the subscription, and scan authority only moves to the daemon when the session
is backgrounded. So the gate belongs on setPtyBackgrounded — decline to hand a
non-retracting daemon authority at all, and main stays authoritative over the
whole stream.

Co-authored-by: Orca <help@stably.ai>

* fix(daemon): clear a preserved pre-v29 background hint at attach, not just at background

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): don't answer a 2031 subscribe whose withdrawal straddles a chunk

Review found the chunk-final-state fix left one hole open. When the kernel cuts
fish's toggle pair mid-withdrawal — chunk 1 ends "...?2031h prompt ESC[?20",
chunk 2 is "31l" — chunk 1 genuinely ends subscribed, so it answers, and the
reply lands as literal text at the prompt. Chunk 2 then recognizes the
withdrawal but cannot recall bytes already written. The same byte stream is
safe or corrupting purely by where the kernel split it.

The scanner already retains an incomplete private-mode tail; it just didn't
tell the caller whether that tail could still resolve to 2031. It now does, and
a subscribe is held one chunk while the answer is still in doubt. Only
subscribes defer — retiring a subscription writes nothing to the pty, so
withdrawals stay eager.

Deferral is narrow: a trailing "ESC[?25" (cursor hide) can never become 2031,
so a subscribe already seen in that chunk is still answered immediately.

This case predates the branch — the old sticky-flag policy replied here too —
so it is a residual this fix now closes rather than a regression it introduced.

Tests: three cases pinned (split withdrawal, non-2031 partial must not defer,
split re-subscribe answers once). Removing the deferral fails only the first.

* fix(terminal): preserve mode 2031 reply decisions

* fix(build): record daemon protocol v29 compatibility

---------

Co-authored-by: Orca <help@stably.ai>
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.

[Bug]: ?997;1n color-scheme replies leak into fish and corrupt stdin of interactive prompts (repro on v1.4.152)

1 participant