Skip to content

fix(app): held keys for one terminal go out through one ordered send queue (P0 1193) - #29919

Merged
lawrencecchen merged 5 commits into
fix/p0-1193-key-lanesfrom
fix/p0-1193-followup-ordered-sends
Nov 1, 2026
Merged

lawrencecchen merged 5 commits into
fix/p0-1193-key-lanesfrom
fix/p0-1193-followup-ordered-sends

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

P0 1193 family (lead 00:2xZ, from the re-review of #29693 at bc84781): two typing runs held for one terminal whose view is gone could reach that terminal out of order. Each held .type run for a terminal with no view is its own daemon send. One run can need several requests (text, then a key such as Return), and between them it awaits a reply; the connection takes other work meanwhile, so a later run for the same terminal ran in between. With "ab" + Return held, then a key for another terminal, then "cd", the shell got "ab", "cd", Return.

Root fix: one ordered send queue per terminal. DaemonService.sendInOrder(_:to:_:) (CmuxNextDaemonService) runs each send to a surface after the one before it. The ticket opens at the call, as send(_:_:)'s does; a failed send is logged and the next send still runs; the queue entry clears after the last send. The held-key daemon path (CreationInputCoordinator.type) uses it. The queued task keeps its service alive, as send(_:_:) does, so its ticket always closes and a send whose connection is gone is reported as "not connected" (review FIX at a51d0a8). CmuxNextApp: -10 lines.

Base: stacked on #29693's branch fix/p0-1193-followup-v2; after #29693 merges, the base moves to feat-cmux-next (no rebase).

Testing

Red-first: SecondCreationKeysTests.twoHeldRunsForAnEvictedTerminalKeepTheirOrder for the order fix (two creations with no early view; "ab", Return for terminal A, "x" for B, "cd" for A; A's workspace hibernates so only the daemon can type; A's shell must get 61 62 0d 63 64). OrderedSendReleaseTests.anOrderedSendWhoseServiceIsGoneFailsAndClosesItsTicket for the review FIX (two sends queued in a DaemonCommandScope, the service released before their turn; each must fail and close its ticket).

Ref What it is Job Result
fd0d7c3 #29693's head + the test only 86629ed7bb65ace8c2ab036a the suite ran 3 times; each time only the new test failed, on its outcome: the shell got 61 62 63 64 0d ("ab", "cd", Return)
a51d0a8 the fix, before the review FIX 49d3e24b272233904affb05d the suite ran 3 times, 11 of 11 each; HeldKeyInputTests 3 of 3
59a8741 a51d0a8 + OrderedSendReleaseTests only (review FIX red) aed37e4a78a8e8857543d051 the 1 test ran and failed on its outcome: both sends whose service was gone reported success ([nil, nil]), the scope stayed busy (ticket open), 0 failures recorded (want 2)
ded9410 (this head) the fix + the review FIX (strong capture) f9a684a619873b48e6011967 OrderedSendReleaseTests 1 of 1; SecondCreationKeysTests ran 3 times, 11 of 11 each; HeldKeyInputTests 3 of 3

Display steps (console session, CMUX_TEST_REQUIRE_GUI=1), each execution counted in the log. Compiles: head bb40d2709adffee001fb9294, red 4f7623c4b97910fc88db196b. Review-FIX compiles: head 74988f2532c6851464f29e56, red 33aaa7536438d8d83cb87c54.

Observed / Not run

Observed: the runs above. Not run: Other writers to the same terminal (its view's attachment path, TerminalSession.sendBytes, the send-key in TerminalHooks) are not in this queue; they need a live view, and were unordered with the old send too (follow-up). Other callers of DaemonService.send that write to one surface (they keep their old path; only held keys use the queue now).

Performance

No perf numbers: a correctness fix. A send to a surface now waits for the earlier send to the same surface (one reply round trip); sends to other surfaces do not wait. No work moves onto the main thread. perf claim: none.

Changelog

Fixed: Keys typed for a pane while new panes open arrive in the order they were typed, also when that pane is hidden

Proof

No capture: key order inside the daemon path; the evidence is the fleet tests above.

… they were typed (P0 1193)

Re-review of #29693 at bc84781 (lead 00:2xZ: same input-order class as the P0): each held .type run for a terminal
with no view is its own daemon send (a Task), and a run of several requests ('ab', then Return) can let a later run of
the same terminal ('cd') in between them. Red on #29693's head bc84781.
…dered send queue per terminal (P0 1193)

Each held .type run for a terminal whose view is gone was its own DaemonService.send (its own Task). A run of several
requests ('ab', then Return) awaits a reply between them, and the connection actor takes new work meanwhile, so a later
run for the same terminal ('cd') could run between them: the shell got 'ab', 'cd', Return. DaemonService.sendInOrder
chains every ordered send to one surface after the one before it (a per-surface queue; the ticket opens at the call,
a failure is logged and the next send still runs; the entry clears after the last send). The held-key daemon path uses
it. It lives in CmuxNextDaemonService, so CmuxNextApp does not grow.
Test: SecondCreationKeysTests.twoHeldRunsForAnEvictedTerminalKeepTheirOrder (red on fd0d7c3).

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 4 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService.swift">

<violation number="1" location="Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService.swift:40">
P1: `orderedSends` survives daemon shutdown, allowing queued held-key tasks to cross connection sessions. Cancel or invalidate this per-connection queue during shutdown so an old task cannot send to a replacement daemon or block new input.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

/// Set while an administrator turned this machine's feature off: no endpoint, no re-attach.
public let policyBlock = PolicyBlock()
/// The last ordered send to each terminal surface (``sendInOrder(_:to:_:)``): the next one waits for it.
@ObservationIgnored var orderedSends: [SurfaceID: (serial: UInt64, work: ActionWork)] = [:]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: orderedSends survives daemon shutdown, allowing queued held-key tasks to cross connection sessions. Cancel or invalidate this per-connection queue during shutdown so an old task cannot send to a replacement daemon or block new input.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService.swift, line 40:

<comment>`orderedSends` survives daemon shutdown, allowing queued held-key tasks to cross connection sessions. Cancel or invalidate this per-connection queue during shutdown so an old task cannot send to a replacement daemon or block new input.</comment>

<file context>
@@ -36,6 +36,9 @@ public final class DaemonService {
     /// Set while an administrator turned this machine's feature off: no endpoint, no re-attach.
     public let policyBlock = PolicyBlock()
+    /// The last ordered send to each terminal surface (``sendInOrder(_:to:_:)``): the next one waits for it.
+    @ObservationIgnored var orderedSends: [SurfaceID: (serial: UInt64, work: ActionWork)] = [:]
+    @ObservationIgnored var orderedSendSerial: UInt64 = 0
     /// Retry Now and Restart cmux-tui while the local daemon cannot be reached (cx-jniyz.1).
</file context>

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (independent review subagent): FIX at a51d0a8
reviewer-session: 07f7de47-4a15-41b8-b431-055761d38c5e
author-session: a728800f37f9342b9
R9: REVIEW.md 9 (errors swallowed or reported as success) and 10 (cleanup on every path): DaemonService+OrderedSends.swift:16
perf claim: none

Verdict: FIX (1 defect, one-line fix)

The design is correct and the test proves the named cause. One path in the new primitive drops the input, reports success and leaves the command ticket open. Fix that path, then this can ship.

Defect (must fix)

  1. Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService+OrderedSends.swift:14-16: Task { [weak self] in ... guard let self else { return nil } ... }.
    • When the DaemonService is gone at the time this send's turn comes, the task returns nil (success) to workTracker. The body never runs, so the held keys are lost and nobody is told (REVIEW.md 9: "don't report success after dropping input").
    • The ticket from openTicket(label) at line 10 never closes (REVIEW.md 10: teardown on every path). An action scope that holds this ticket waits until its control timeout, and then it reports a timeout, not the real cause.
    • This can occur: remote DaemonService instances are released (CloudMachineSession.swift:94, SSHMachineSession.swift:76, ServerMachineSession.swift:52/55), and held keys go to "another machine's terminal too" (CreationInputCoordinator.swift:169). The previous task in the chain keeps self only while its own body runs.
    • send(_:_:) (DaemonService.swift:474-481) and the model queue PersonalPlacementQueue.enqueue (closes its ticket on every outcome) do not have this gap.
    • Fix: capture self strongly (Task { [self] in), as send does. The lifetime is bounded (see Liveness), and failure(...) then closes the ticket and returns a "not connected" failure when the connection is gone. Or, keep weak but close the ticket and return a failure on the nil path.

Checked and correct

  • Order. previous is read and the new entry is written in the same main-actor turn (lines 10 and 21), so the chain follows call order. The scheduler order of Tasks has no effect. await previous?.value on Task<_, Never> does not throw and does not return early on cancellation. A cancelled send (an action scope's onCancel, ActionRegistry+TrackedWork.swift:77) still waits for the send before it, and the next send waits until the cancelled one has ended. A cancelled send can lose its own remaining requests (for example the Return after "ab"), but no later bytes go before it. When self is gone, every later link also does nothing, so no send runs out of order. All requests use the same connection, and that connection writes them in call order.
  • Liveness. A failed send ends its body at the first throw, and the next send runs. A hung request fails after DaemonConnection.defaultRequestTimeout = 2 s (DaemonConnection.swift:136, applied through configuration.requestTimeout; nil only in tests, DaemonConnectionConfiguration.swift:30). So one send blocks later sends for at most 2 s x its request count (HeldKeyInput joins text runs into one request; each key is one request). The heartbeat and a disconnect make pending requests fail sooner. This is the same bound as send's requests. There is no new unbounded wait.
  • Cleanup. if orderedSends[surface]?.serial == serial { orderedSends[surface] = nil } runs on the main actor after the body. An earlier send never removes a newer entry, and the last send removes the entry. There is no race (all writes are main-actor, and the Task inherits the main actor from the default isolation, Package.swift:145). There is no leak beyond the bounded chain. The state is per DaemonService instance, @ObservationIgnored, and is not a singleton.
  • Tickets and tracking. The ticket opens synchronously at the call, and workTracker?(work) gets the task at once, the same as send. So an action scope waits for a queued send also while it waits on an earlier send. A later action's scope can now wait for an earlier action's send to the same terminal. That is the intended order and is bounded.
  • Main thread / Swift 6. There is no blocking call. The body runs isolated to the connection actor (DaemonCommandBody). The task-owner: comment is present. Compile at the head is green (bb40d2709adffee001fb9294, done).

Not blocking: other writers to the same terminal

These writers stay unordered against this queue. They were also unordered with the old send, so this PR does not make them worse:

  • the view path view.typeHeldKey (CreationInputCoordinator.swift:168) and live typing go through the view's TerminalAttachment, a different connection;
  • TerminalSession.sendBytes (give-back input, view input queue);
  • TerminalHooks.swift:20 Cmd chords through send("send-key").

All of them need a live view. The remaining window: a held run goes through the daemon because the view is gone, the view comes back, and the person types (or a later held run takes the view path) before the daemon queue drains (about one round trip, more for a long queue). This is the same bug class (input order to one terminal) but a narrower case, and the PR says that it covers only held keys. I recommend a follow-up: a held run takes the view path only after that surface's ordered queue is empty, or live input waits for it.

Test: SecondCreationKeysTests.twoHeldRunsForAnEvictedTerminalKeepTheirOrder

  • The red ref fd0d7c3 is bc84781 plus the test only (git diff --stat bc84781571b fd0d7c38f21: one test file, +43). fd0d7c3 to a51d0a8 is the fix only.
  • Red job 86629ed7bb65ace8c2ab036a (state failed, exit 1, args SecondCreationKeysTests x3): the log shows 3 test runs ("Test run with 11 tests in 1 suite failed" at 3 lines). In each run, only this test failed, at SecondCreationKeysTests.swift:595 Expectation failed: inOrder. The screens show K42x411879616263640dK, K42x675056616263640dK and K42x508638616263640dK: "ab", "cd", Return. That is the named cause (run 2 went between run 1's text and key). All 10 other tests passed in each run. These reds count: the test failed on its outcome, not on a setup timeout.
  • Green job 49d3e24b272233904affb05d (done, exit 0): 3 runs with 11 of 11 passed in each (this test 0.94 s, 0.80 s, 0.96 s). HeldKeyInputTests passed (3 tests).
  • The test makes sure that every key is held and that the first terminal's view is evicted before the creations resolve. Thus only the daemon path can type, and the test checks the exact 5 bytes in order.

Evidence used

git diff bc84781571b a51d0a85518; read DaemonService.swift (send, failure, workTracker), DaemonService+Scope.swift, PersonalPlacementQueue.swift, ActionRegistry+TrackedWork.swift, LineTransport.swift (deadline), DaemonConnection.swift (request timeout), CreationInputCoordinator.swift, TerminalHooks.swift, TerminalSession.sendBytes; cmux-ci status and cmux-ci log for 86629ed7bb65ace8c2ab036a, 49d3e24b272233904affb05d and bb40d2709adffee001fb9294. Not run: no build or test run by me.

…es and a lost send is reported (P0 1193)

Review of #29919 at a51d0a8 (FIX, REVIEW.md rows 9 and 10): sendInOrder's Task captured the DaemonService weakly
and returned nil (success) when it was gone at its turn: the held keys were lost with no report, and the ticket opened
at the call never closed, so an action scope waited for its control timeout. Remote services are released (Cloud, SSH,
Server sessions) and held keys can go to remote terminals. Now the Task keeps the service, as send(_:_:) does:
failure(...) closes the ticket and reports 'not connected' when the connection is gone.
No red test: it needs a DaemonService released while a send waits in its queue, which the app test harness cannot make
deterministically; the change is the same capture send(_:_:) has used.
… ticket (P0 1193)

The same test as the red ref fix/p0-1193-followup-ordered-release-red (25a36d3, a51d0a8 + the test only), on the head
that already has the fix (a6f62ea, pushed before the test). Red on 25a36d3.
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (independent review subagent): SHIP at ded9410
reviewer-session: 3a5ef3df-84cf-4c04-969e-fb3f701bddaa
author-session: a728800f37f9342b9
R9: pass
perf claim: none

Verdict: SHIP

This is a re-review after my FIX at a51d0a8 (weak self in sendInOrder lost keys and reported success, and left the ticket open). The defect is fixed, and a red-first test proves the fix. I found no new defects in the full PR diff.

What changed since a51d0a8

  • DaemonService+OrderedSends.swift:16: the queued task now captures the service strongly (Task { ... }), as send(_:_:) does. The guard let self else { return nil } path is gone. Thus each queued send runs failure(label, ticket:, body). That call closes the ticket on every path, and it returns "not connected" when the connection is gone.
  • New test Tests/CmuxNextAppTests/OrderedSendReleaseTests.swift: two ordered sends are queued in a DaemonCommandScope, then the test releases its reference to the service before their turn. The test requires 2 tracked sends, 2 failures returned, an idle scope, and 2 failures recorded in the scope.

Checked

  • Full PR diff (merge base = origin/fix/p0-1193-followup-v2 tip bc84781; 5 files, +101/-1): the files are CreationInputCoordinator.swift (1 line), DaemonService+OrderedSends.swift, DaemonService.swift (2 stored properties), and the two tests. There is nothing else.
  • Order, liveness, cleanup, tickets, tracking: my findings at a51d0a8 stay valid. The chain is built in one main-actor turn, so the sends run in call order. A cancelled send still waits for the send before it. Each request has the 2 s defaultRequestTimeout, so a hung send blocks the queue for a limited time. The entry is removed by serial on the main actor. The ticket opens at the call, and workTracker gets the task at once.
  • Lifetime of the strong capture: the cycle service -> orderedSends -> Task -> closure -> service is temporary. The last send removes the entry. An earlier Task is held only by the next task's previous capture, and the Task releases its closure when it ends. A released remote service stays alive until its queue drains (bounded as above). This is the same lifetime as send(_:_:).
  • Swift 6 / main thread: no blocking call. The task-owner: comment is updated.

Test evidence (read-only: cmux-ci status/log)

  • Red ref 59a8741 = a51d0a8 + the test only (git diff --stat: one test file, +29). Job aed37e4a78a8e8857543d051 (failed, exit 1, CMUX_TEST_REQUIRE_GUI=1): 1 test run. The test failed on its outcome at OrderedSendReleaseTests.swift:25-27: results [nil, nil] (success), scope.isIdle false, scope.failures.count 0. That is the named cause. This red counts.
  • Green at the head, job f9a684a619873b48e6011967 (done, exit 0): OrderedSendReleaseTests 1 of 1. SecondCreationKeysTests ran 3 times ("Test run with 11 tests ... passed" 3 times), and twoHeldRunsForAnEvictedTerminalKeepTheirOrder passed in each run. HeldKeyInputTests 3 of 3. The job built the head, so the head compiles with its tests.
  • Earlier evidence for the order test (from my review at a51d0a8): red job 86629ed7bb65ace8c2ab036a, 3 of 3 runs failed on their outcome with the bytes "ab", "cd", Return.

Not blocking (same as before)

Other writers to the same terminal (the view's TerminalAttachment path, TerminalSession.sendBytes, TerminalHooks send-key) stay unordered against this queue. They were also unordered before this PR. They need a live view, and the remaining case is narrower. I recommend a follow-up: a held run takes the view path only after that surface's ordered queue is empty.

Not run by me: no build or test run.

@lawrencecchen lawrencecchen added risk:normal Risk tier (docs/ci/risk-tiers.md) ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds labels Nov 1, 2026
Base automatically changed from fix/p0-1193-followup-v2 to fix/p0-1193-key-lanes November 1, 2026 03:41
@lawrencecchen
lawrencecchen merged commit b22ba9f into fix/p0-1193-key-lanes Nov 1, 2026
55 checks passed
@lawrencecchen
lawrencecchen deleted the fix/p0-1193-followup-ordered-sends branch November 1, 2026 03:41
@lawrencecchen lawrencecchen removed the ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds label Nov 1, 2026
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Combined into #29301 with a plain merge (no force push), lead 03:50Z: the merge service lands only PRs based on feat-cmux-next. #29301 head 1d942e4 contains this PR's reviewed head. This PR stays open until #29301 merges; then it closes as included.

lawrencecchen added a commit that referenced this pull request Nov 1, 2026
…0 1193, Cmd-D twice) (#29301)

* test(app): keys typed for the second of two pending creations never reach the first terminal, and back (P0 1193)

Tip watcher smoke, feat 5097a27 on cmux18, step 23: Cmd-D, a line at once, Cmd-D, a line at once: both lines
ran in the FIRST new terminal and the second got nothing. SecondCreationKeysTests drives two creation holds on two real
terminals: keys typed after the second creation began must wait for the second terminal; keys held for the first
creation must reach the first terminal, also when the second one has the keyboard first. Red on this base.

* fix(app): each held key goes only to the terminal it was typed for (P0 1193)

After Cmd-D, a line, Cmd-D, a line typed at once, both lines ran in the first new terminal (tip watcher smoke on
feat 5097a27, 2 of 3 rounds on cmux18). CreationInputBuffer.takesKeysEarly(tab:) was true for the early tab of
ANY pending creation: until the second creation's focus landed, focus still named the first one's early view and the
first creation was still pending (its reply is slower under load, and the second create waits for it), so
CreationInputCoordinator.capture let the second line's keys, and the keys held before them, go to the first terminal.

Each held key now carries a lane: the creation whose tab the focus named, or was on its way to, when it was typed
(FocusState.typingTab). An early view takes only its own lane's keys, and only when its terminal view is the
window's first responder; a key typed for another creation waits for that one. When the hold ends, a lane whose
terminal is not the focused one gets its plain text by that terminal's input (TerminalSession.sendBytes, by the
terminal id its create chose); the focused lane, keys of no lane and chords run again as typed, as before.

The perf line's rule (050651b, "only the newest pending creation's early tab") is not ported as is: it sends
keys held for the first creation to the second terminal when the second gets the keyboard first.

CreationHoldEnd and CreationHoldLog move to CmuxNextFocus (as on the perf line), so CmuxNextApp gains no lines.
Test: SecondCreationKeysTests (red on 0383621).

* test(app): a held Ctrl-C and arrow key reach only their own terminal, as 03 and cursor-up (P0 1193)

Review of ad8c348 (coordinator): held keys that are not plain text (Ctrl chords, arrows, Return, Option-as-Alt)
for a terminal that is not focused still ran in the focused pane; a held Ctrl-C there stops another process. Byte
readers in both terminals show which one gets the held Ctrl-C and Up.

* fix(app): a held key for a terminal that is not focused is encoded by that terminal's own surface (P0 1193)

Review of ad8c348 (coordinator): only plain-text held keys went to their own terminal; Ctrl chords, arrows, Return
and Option-as-Alt keys still ran in the focused pane. Now every held key of a lane whose terminal is not focused goes
to that terminal through TerminalSurfaceView.typeHeldKey: its Ghostty key encoder (modifiers, Option-as-Alt setting,
cursor and keypad modes, Kitty protocol), written to that terminal as a typed key, with no input method. It never
falls back to the focused pane; keys of a terminal whose view is gone are dropped with it (logged), as a closed
creation's are. Command chords are app actions: they run again as typed, in the focus at the hold's end, as before.
Test: SecondCreationKeysTests.heldControlAndArrowKeysReachOnlyTheirOwnTerminal.

* test(app): Cmd-D, ls, Up, Ctrl-C, Cmd-D, a line, held: each key runs at its place in its own terminal (P0 1193)

cmux-ad 11:15Z review of #29301: a lane holding a non-text key (an arrow, Ctrl-C) or a Command chord went back to
the focused terminal as a whole. The new test needs held keys that run again to be visible in a test window, which is
never key: CreationInputCoordinator.runKey is the seam (NSApplication.sendEvent by default, unchanged).

* fix(app): every held key runs at its place, for its own terminal; only Command chords go through app dispatch (P0 1193)

cmux-ad 11:15Z review: take(focused:writer:) sent a lane with any key that is not plain text back to "the rest", which
ran in the focused terminal, and ran Command chords after every typed key. Now the hold's end is one ordered list
(CreationInputBuffer.HeldKey): a key of a lane whose terminal is not focused is typed by that terminal's own Ghostty
encoder (TerminalSurfaceView.typeHeldKey: arrows, Ctrl chords, Return, Option-as-Alt, keypad and cursor modes); a
Command chord, the focused lane's keys and keys of no lane run again as typed (runKey), each at its typing position. A
Command chord that starts another creation still takes the keys after it into that creation's hold. TerminalSession
.hasKeyboard is the one "this terminal has the keyboard" check.
Test: SecondCreationKeysTests.eachHeldKeyRunsAtItsPlaceInItsOwnTerminal (red at the seam commit).

* test(app): SecondCreationKeysTests: no #require nested in #require (compile 2c7852b3)

* test(app): a held app shortcut acts on the pane it was typed for; held keys survive a workspace switch; Cmd-W closes only its pending split (P0 1193)

cmux-ad 11:2xZ review of #29301: a held Command chord ran in the focus at the hold's end (a held Cmd-D split the
focused pane, not the first terminal's), and keys of a terminal whose view was gone were dropped. Cmd-W is never held
(a close key acts at once, CreationInputBuffer.holds); its test guards that rule in the reported sequence.

* fix(app): a held app shortcut runs on the terminal it was typed for; a hidden or far terminal still gets its held keys (P0 1193)

cmux-ad 11:2xZ review of #29301:
- A held Command chord of a lane whose terminal is not focused ran in the focus at the hold's end. Now an app
  shortcut runs with that terminal's tab as its target (RegistryKeyBindings.run target:) and that terminal's key
  context, as a key press there; handlers resolve a tab target to its pane (AppActionContext.paneController,
  daemonPane). A Command chord that is no app shortcut is that terminal's key (a TUI's Cmd keys).
- Keys of a terminal whose view is gone were dropped. A hidden terminal's view (another workspace, an unselected
  tab) still exists in the content cache and types them; with no view (evicted, another machine's), the daemon types
  them with that terminal's own modes (send-key / send, HeldKeyInput), on the machine its creation was on. Only a
  closed terminal takes its keys with it.
DecidedKeyEvents moves to CmuxNextFocusUI (pure AppKit, the key router's), so CmuxNextApp shrinks.
Tests: SecondCreationKeysTests.eachHeldKeyRunsAtItsPlaceInItsOwnTerminal (split position),
heldKeysReachTheirTerminalAfterItsWorkspaceWasSwitchedAway, cmdWTypedForAPendingSplitClosesThatSplitOnly.

* test seam(debug): debug.creation.hold / debug.creation.release hold a creation's daemon reply until released (P0 1193)

cmux-ad 11:25Z: the 1193 journey controls the race instead of chasing it with load or timestamps. debug.creation.hold
{n} arms the n-th creation (split, New Column, New Row, new terminal tab) that starts after it: the daemon makes the
pane, and the app applies the reply only at debug.creation.release {n} (no n: all, disarmed); a held reply nobody
releases goes on after 30 s. DEBUG and bench builds only (a release router drops debug.* methods); no product timing.
Registered on the existing debug.daemon line, so CmuxNextApp does not grow.

* fix(app): a key held for a terminal with no view honours macos-option-as-alt, as its view's encoder does (P0 1193)

cmux-ad 11:3xZ: on the daemon path an Option key sent what macOS composed even when Ghostty's macos-option-as-alt
(true, left, right) makes Option Alt, so another byte reached the terminal than a key press in its view sends.
HeldKeyInput reads the same setting (GhosttyRuntime.optionAsAltValue) and sends alt+<base key> (ESC and the key).
Test: HeldKeyInputTests (the mapping per setting and side, the config read, the order of text, arrows, Ctrl-C and
Return). It needs the new API, so it is committed with the fix; it has no red commit of its own.

* test seam(debug): debug.creation.hold builds its reply one key per line in the control socket's JSON type (P0 1193)

pa-d's Debug builds 8a86e9ae and c1cfa1dc: "the compiler is unable to type-check this expression in reasonable time"
at the reply's one-expression literal, which also named a JSONValue that CmuxNextDaemon defines too. Build it as
debug.daemon does: the call's own params type, one key per line.

* test seam(debug): debug.creation.release answers "resumed", the creations whose waiting reply it let go (P0 1193)

pa-d's journey review: "released" lists every number a release named, so it cannot prove that creation 1 waited.
"resumed" lists only the creations this call found waiting and resumed.

* test seam(debug): debug.creation.hold {stand_in: [n]} keeps creation n's stand-in out of the store until its release (P0 1193)

pa-d's rounds (cmux18, no hog): with only creation 1's reply held, the red ref PASSED: creation 2's stand-in landed
the focus within a frame, before d2's first key. The 1193 state needs creation 2's focus asked for (in its key's turn)
and not landed while d2 is typed, as when the main thread ran the keys before it laid the new pane out (the hog).
stand_in makes that state on purpose: the intent waits, the reply waits too, and the release shows the stand-in first
("shown" in its reply). DEBUG and bench builds only.

* test(app): SecondCreationKeysTests make both terminals by splits, and every wait says what it waited for and what it saw (P0 1193)

Red step 5e829f0c5c0ff1f0d9702c6a (red ref 25b488f) was no valid red: 5 tests stopped in setup at line 50
("timed out: panes [3: conversation 2, 6: pty 5]"): the test app's first pane is an agent chat, not a terminal, so
the wait for two terminal panes never held. The Cmd-W guard read the new pane's currentTabKey in the split's own turn
(nil: the pane shows its stand-in in a later frame). Now two splits make both terminals, each focused with the keyboard
before the test body; the Cmd-W test starts from a terminal and reads the stand-in's tab from its model.

* test(app): SecondCreationKeysTests red on the outcome the person sees, not on the hold mechanism (P0 1193)

Red step f78bf3a645e937c0a98ffb98 (red ref 656454a), coordinator's reading:
- Four tests failed on "were the keys held": the setup took the keyboard away (makeFirstResponder(nil)) and opened an
  early tab, but the app's focus gives the terminal the keyboard back, so on the base the keys went straight to it.
  Now lane 1's creation has no early view and the keyboard stays: the keys are held on any build, and the builds
  differ only in where the hold delivers them. Every test checks first which terminal got which line, then how.
- wait-for's timeout threw (line 141); a timeout is now the answer "the line did not run there".
- The Cmd-W guard required a pane from the second Cmd-D; on the base that Cmd-D made none (the focus stayed on the
  closed stand-in, a defect of its own, reported apart). The guard now checks only what it guards: the closed split
  is gone, the pane it was typed from stays, and no key reached another terminal.

* test(app): a held Control shortcut runs on its own terminal's pane; a replayed creation keeps the other terminal's keys (P0 1193 follow-up)

Review findings 1 and 2 of #29301 (receipt at 201b1e4): only Command chords of a non-focused lane went through
app dispatch, so a held Ctrl-` was typed into that terminal as a byte; and when a replayed key opened a new creation,
prepend gave every later key the new creation's lane, so keys held for another terminal ran in the new split. Red on
201b1e4.

* fix(app): a held chord of any kind runs on the terminal it was typed for; a replay keeps each key's own terminal (P0 1193 follow-up)

Review findings of #29301 (receipt at 201b1e4):
1. A held Control chord (Ctrl-`, Ctrl-Tab, Ctrl-1..9) of a lane whose terminal is not focused was typed into that
   terminal; only Command chords went through app dispatch. Now every chord (Command or Control) goes through it with
   that terminal's tab as the target and its key context; a chord that is no app shortcut (Ctrl-C) is still that
   terminal's key.
2. When a replayed key opened a new creation, prepend gave every later key the new creation's lane. Now each key keeps
   the terminal it was typed for (HeldKey carries its lane, also .run); only the keys of the issuing key's own lane
   move to the new creation (typed after that lane's Cmd-D). Keys of another hold keep their terminal in the new hold
   (adopted lanes).
3. A split's held keys record the split pane's machine (PaneHandlers passes daemon.machineID), not the window's, so
   the daemon path reaches a remote split whose view was evicted.
Tests: SecondCreationKeysTests.aHeldControlShortcutRunsOnTheTerminalItWasTypedFor, aReplayedCreationKeepsTheOtherTerminalsKeys.

* test(app): held keys never run into another view that has the keyboard when the hold ends (P0 1193 follow-up)

Coordinator 17:2xZ, from #29530's review: the claim 'the 2 s bound counts from the hold's start' does not hold (a
pending creation keeps its keys with no bound; the bound counts from the reply). The real gap: at the hold's end the
focused lane's keys run again as typed into whatever has the keyboard, so a text view that took the keyboard while the
focus names the new terminal gets the line. Red on 307d8e1.

* fix(app): at a hold's end a key runs as typed only into its terminal when that terminal has the keyboard (P0 1193 follow-up)

Coordinator 17:2xZ (from #29530's review): the focused lane's keys ran again through NSApplication.sendEvent into
whatever had the keyboard, so a text view that took the keyboard while the focus named the new terminal (or a hold
that ended at its bound after the reply, the focus not landed) got the keys typed for the terminal. Now the hold's end
names a focused terminal only when its shown view has the keyboard (keyboardTerminal, the check the early view already
used); otherwise every key of that lane is typed by the terminal's own encoder. Keys typed for no creation run as
before.
Test: SecondCreationKeysTests.heldKeysNeverRunIntoAnotherViewWhenTheHoldEnds (red on 923890f).

* test(app): the hold-end case is a remount, not a text field; the replay test reports what the held Cmd-D did (P0 1193 follow-up)

Red step 5eaa419c578b1eb69e40b3ce (923890f): heldKeysNeverRunIntoAnotherViewWhenTheHoldEnds could not set up its
state (a text view that takes the keyboard moves the focus model to it: 'saw focus textField'), so the keys already
went to the terminal. Its case is a terminal view out of the window (a remount) when the hold ends at its bound:
heldKeysReachTheirTerminalWhenItsViewIsOffAtTheHoldsEnd. aReplayedCreationKeepsTheOtherTerminalsKeys failed with
the finding-2 fix too ('the held Cmd-D made no pane', the line not in the second terminal); its report now names the
router's last action, the refusals, the hold and the panes, to find why.

* test(app): heldKeysReachTheirTerminalWhenItsViewIsOffAtTheHoldsEnd records the issue, then returns (compile 03dd1fc3)

* test(app): SecondCreationKeysTests imports CmuxNextTerminal for TerminalSurfaceView (compile 7ac95c03)

* test(app): SecondCreationKeysTests start from a quiet create chain; the replay test reports whether the held split's work ended (P0 1193 follow-up)

Green af9e85983193c69b0d21b551 (afe87dd): aReplayedCreationKeepsTheOtherTerminalsKeys still fails: after 20 s a hold
is open, no refusal, and the held Cmd-D's split stand-in (handle >= ProvisionalPane.firstHandle) is not confirmed, so
that split never got its reply; the line typed for the second terminal waits in its hold (a pending creation has no
bound). A split's request first waits for the workspace's create chain. The setup now waits (10 s at most) for that
chain to end and says so if it does not; the replay report says whether the held split's work ended.

* test(app): the replay test waits for the held split from the app before it reads a screen (P0 1193 follow-up)

Green 9e51a7799b581c49a599666e (bbf38e5): the create chain was quiet, and at the end the held Cmd-D's split pane was
confirmed with no hold open: the split DID end, but more than 20 s late. The daemon runs a connection's requests in
order on one dispatcher thread (ConnectionSurfaceScheduler.dispatch / next_runnable_index), and the test's 20 s
wait-for for the second terminal's line ran on the app's own connection: it held the split's request back, while the
line waited for that split. A rig cycle, not a product stall. The test now waits for the split's pane and the end of its
hold from app state first.

* test(app): keys held for an evicted terminal arrive in the order they were typed (P0 1193 follow-up)

Review of #29693 at bd1112a (defect 1, receipt FIX): a Control chord that is no app shortcut (Ctrl-C) became its own
.command and its own daemon send, which can pass the send before it when the terminal's view is gone: "sleep 5",
Return, Ctrl-C could arrive as "sleep 5", Ctrl-C, Return. The test evicts the first terminal's hidden view, so only
the daemon types the held 'ab', Return, Ctrl-C, and reads the bytes the shell got. Red on bd1112a.

* fix(app): a held chord that is no app shortcut stays in its terminal's typing run, so its keys keep their order (P0 1193 follow-up)

Review of #29693 at bd1112a (defect 1): take(focused:) made every Control chord a .command; one that resolved no
binding then went out by runCommand -> type as a separate daemon send, which can interleave with the send before it
(DaemonService.send starts a Task per call; the connection takes new work while a request waits). Now take(focused:)
asks the coordinator whether a chord runs an app shortcut in its terminal's context (shortcut(_:tab:in:), the lookup
runCommand already made); only those become .command, and every other key of the lane stays in its .type run (one send
per run). Also the comments the review named: HeldKey cases, runCommand, the bound-end log.
Test: SecondCreationKeysTests.heldKeysForAnEvictedTerminalKeepTheirOrder (red on 149a88c).

* test(app): the evicted-terminal order test hibernates the hidden workspace, the product path that frees its views (P0 1193 follow-up)

Red d9560494d32c52f5f9b93ab0 (149a88c) stopped in setup: evictHiddenTerminal leaves a view a pane still presents,
and the hidden workspace's pane still presented it. AppServices.hibernateWorkspace unmounts the workspace first, then
frees its surfaces.

* test(app): the evicted-terminal order test reads the screen without a blocking wait (P0 1193 follow-up)

Red 6317fcf34f4610341c20d846 (19685b4) and green 9f791d638a2329763877196c (1df5e5c) failed alike: the byte
reader got no byte in 20 s ('R42x...' and no result line). The same rig cycle as test 8: resolve flushes in a later
turn, so the test's 20 s wait-for went out first on the app's connection, whose requests run in order, and the held
keys' send-key waited behind it. The test now waits for the hold to end from app state, then polls read-screen (which
returns at once and runs after the sends before it).

* test(app): the byte-reader checks join the screen's lines before they match (P0 1193 follow-up)

Green 83cae9e44f1555ab3c44ab80 (c8bac42) failed eachHeldKeyRunsAtItsPlaceInItsOwnTerminal at :304 with the right
bytes on screen: 'K42x4027896c731b' / '5b4103K', the result line wrapped in a narrow pane, so the daemon's wait-for
regex did not match across the line break. The three byte-reader checks (tests 3, 4, 10) now poll read-screen and match
on the joined lines (readerGot).

* test(app): closing a replay-opened split keeps the line carried for another terminal (P0 1193)

Re-review of #29693 at bc84781 (lower finding 1, which I wrongly closed as unreachable in #29758): a held Cmd-D
that runs again at a hold's end opens a new hold with only that split's creation (its awaited set is empty) plus the
keys carried for other terminals; a Cmd-W on that split before its reply dropped every key, also the carried line.
Red on #29693's head bc84781.

* fix(app): closing a split drops only the keys with no terminal of their own, never a line carried for another terminal (P0 1193)

CreationInputBuffer.cancel(tab:) dropped every held key once nothing waited (514770e, from before keys had lanes).
A hold that a replayed Cmd-D opened has an empty awaited set, so a Cmd-W on that split before its reply also dropped the
lines carried into it for other terminals. Now, with nothing waiting, only keys with no lane or a lane with no tab go
with the close (they have no terminal of their own and must not run into whatever has the focus); a key whose lane
names a terminal stays and goes to it when the hold ends (the coordinator flushes after a cancel). This differs from
the closed #29758, which also kept tab-less lanes.
Test: CreationInputHoldTests.closingAReplayOpenedSplitKeepsTheLineCarriedForAnotherTerminal (red on c313b63).

* test(app): two held runs for one evicted terminal arrive in the order they were typed (P0 1193)

Re-review of #29693 at bc84781 (lead 00:2xZ: same input-order class as the P0): each held .type run for a terminal
with no view is its own daemon send (a Task), and a run of several requests ('ab', then Return) can let a later run of
the same terminal ('cd') in between them. Red on #29693's head bc84781.

* fix(app): held keys for a terminal with no view go out through one ordered send queue per terminal (P0 1193)

Each held .type run for a terminal whose view is gone was its own DaemonService.send (its own Task). A run of several
requests ('ab', then Return) awaits a reply between them, and the connection actor takes new work meanwhile, so a later
run for the same terminal ('cd') could run between them: the shell got 'ab', 'cd', Return. DaemonService.sendInOrder
chains every ordered send to one surface after the one before it (a per-surface queue; the ticket opens at the call,
a failure is logged and the next send still runs; the entry clears after the last send). The held-key daemon path uses
it. It lives in CmuxNextDaemonService, so CmuxNextApp does not grow.
Test: SecondCreationKeysTests.twoHeldRunsForAnEvictedTerminalKeepTheirOrder (red on fd0d7c3).

* fix(app): an ordered send keeps its service alive, so its ticket closes and a lost send is reported (P0 1193)

Review of #29919 at a51d0a8 (FIX, REVIEW.md rows 9 and 10): sendInOrder's Task captured the DaemonService weakly
and returned nil (success) when it was gone at its turn: the held keys were lost with no report, and the ticket opened
at the call never closed, so an action scope waited for its control timeout. Remote services are released (Cloud, SSH,
Server sessions) and held keys can go to remote terminals. Now the Task keeps the service, as send(_:_:) does:
failure(...) closes the ticket and reports 'not connected' when the connection is gone.
No red test: it needs a DaemonService released while a send waits in its queue, which the app test harness cannot make
deterministically; the change is the same capture send(_:_:) has used.

* test(app): an ordered send whose service is gone fails and closes its ticket (P0 1193)

The same test as the red ref fix/p0-1193-followup-ordered-release-red (25a36d3, a51d0a8 + the test only), on the head
that already has the fix (a6f62ea, pushed before the test). Red on 25a36d3.

* test(app): OrderedSendReleaseTests imports CmuxNextActions for ActionWork (compile 456a0d92)

---------

Co-authored-by: cmux-bot[bot] <348531757+cmux-bot[bot]@users.noreply.github.com>

This branch was previously deployed

1 inactive deployment
ci-routing-agent — ded9410c Deployed Nov 1, 2026 by lawrencecchen via cmux-tui tree preflight #92831
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant