Skip to content

test(split): #28436 was the test window, not the key path: type after the hold, and cover typing during it - #28810

Merged
lawrencecchen merged 47 commits into
feat-cmux-nextfrom
fix/28436-split-keys
Nov 1, 2026
Merged

lawrencecchen merged 47 commits into
feat-cmux-nextfrom
fix/28436-split-keys

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Summary

#28436 ("a line typed after a quick double Cmd-D can miss the new pane") was a test rig fault. It was not a product race. SplitOfPendingSplitTests typed while the window's creation hold was still open. The hold kept the keys, and when the focus landed it replayed them through NSApp.sendEvent. NSApp.sendEvent delivers only to the key window, and a test window is never key, so no terminal got a byte. When a person types, the replay goes to the window the person types in, because that window is the key window.

  • The test now waits for the hold to end before it types (as NewTerminalOnPendingSplitTests already does), and it expects the hold to end on focusLanded.

  • A new test, aLineTypedDuringTheHoldRunsInTheSecondPane, follows the person's path: it types at once after the second Cmd-D, during the hold. It expects these:

    • the first split is still pending when the second Cmd-D comes;
    • no split is refused;
    • some keys are held;
    • the line runs in the second pane;
    • the hold ends on focusLanded.

    Its replay runs each key the way the app does (the key router first, then the window) into the test's window, because a test window is never key.

  • Its red form, aReplayThatDropsTheHeldKeysFailsTheCheck, runs the same body with a replay that drops the held keys. That is the shape of P0: a line typed after a quick double Cmd-D can miss the new pane (rare race, after #28331) #28436: NSApp.sendEvent with no key window delivers the keys nowhere. The test wraps the body in withKnownIssue { ... } matching: { the comment contains "did not run in the second pane" }, with no isIntermittent. The red form is strict for two reasons:

    • When a withKnownIssue block records no issue, Swift Testing records a failure. So if the dropping replay ever lets the line through, this test fails.
    • An issue that does not match (any other failure in the body) still fails the test.
  • The hold's replay is a seam passed at init (PATTERNS P1, cmux-ad item 884). The closure type is HeldKeyReplay. AppDelegate passes { event, _, _ in NSApp.sendEvent(event) } to AppServices, AppServices passes it to KeyRouter, and KeyRouter passes it to CreationInputCoordinator (private let replay). KeyRouter's parameter defaults to the same NSApp.sendEvent call, so its existing test callers do not change. There is no settable property, no static and no global. The app's behavior is the same: each held key still goes through NSApp.sendEvent to the key window, with the same replaying flag and re-entrancy checks.

  • CreationInputCoordinator.HoldEnd moves to CmuxNextFocusUI as CreationHoldEnd, with the same cases. With that move, and with isHolding and hasPendingCreation now one-line expressions with the same results, CmuxNextApp does not grow (SW14).

  • When the typed line does not show, the test now records where it went: every pane's screen, how the hold ended, each terminal view's gate and keyboard, each key's path, and the input journal.

Evidence

Diagnostic run 3, job eae2fd9426eb367df8bf1803 (head 9adddb3, cmux18-perf), had 8 attempts: 3 passed and 5 failed. Each failed attempt reported:

key path (20.13 seconds ago): typing: hold open true, window key false, app NSApplication, second view surface true; e: intercepted true holding true; ... CR: intercepted true holding true
after: hold open false, last end Optional(CmuxNextApp.CreationInputCoordinator.HoldEnd.focusLanded)
pane ... has-keyboard true in-second true screen: sh-3.2$
  • All 24 keys went into the hold. The hold then ended on focusLanded, so the keys were replayed.
  • The second pane had its surface, had the keyboard, and showed only an empty prompt. The other pane showed the same.
  • The input journal had no input attach record for any terminal. The replay reached no window, because window key false.
  • The 3 passing attempts typed after the hold had ended.

Diagnostic run 2, job cd8cdc7cb8cb5af49528587f (head bee8e76, cmux23-perf), had 5 attempts: 3 passed and 2 failed. The report did not record the key path yet. Its 2 failed attempts show the same state: lastHoldEnd was focusLanded, the second pane had the keyboard, both screens showed only sh-3.2$, and the journal had no input record. Run 1, job 4bb1096c16feda23e4637ccf, had 1 attempt, and it passed.

The person's path: a person's window is the key window. In product code, CmuxApplication.sendEvent passes each key to KeyRouter.interceptKeyDown, and CreationInputCoordinator.capture holds the key. When every creation of the hold has replied and the focus landed with a responder that takes keys, flush(in:) replays the held keys in order through NSApp.sendEvent, which delivers them to the key window. aLineTypedDuringTheHoldRunsInTheSecondPane covers this path. It replays into the test's window, which the rig cannot make key.

Testing

Each count is per attempt. A FLAKY result counts as a failure.

Head Job Worker aSecondSplitOnAPendingSplitsPaneMakesASecondPane aLineTypedDuringTheHoldRunsInTheSecondPane
a87ac4f e55adfd211649348f4325973 cmux24-perf 1 pass (with NewTerminalOnPendingSplitTests 1 pass) -
a87ac4f 138dbc39987611790c6570c4 cmux19-perf 12 pass -
a87ac4f 2416056e7ad4b150b552c022 cmux19-perf 10 pass -
a1d971b 53258233ceea53bea7212460 cmux21-perf 12 pass 12 pass
a1d971b fe72b9b00893f7be77443e7c cmux24-perf 12 pass 12 pass
16a5309 (feat-cmux-next merged in) a26206d636f911feb6e69be2 cmux24-perf 12 pass 12 pass
16a5309 (feat-cmux-next merged in) 7b4d9383bc2d1e7e2c0bb692 cmux19-perf 12 pass 12 pass
435d465 (seam at init) 0d24d13186268d3cc6e82155 cmux (light) 1 pass 1 pass
435d465 (seam at init) 745851a36dfa78263b76f1fc cmux22-perf 12 pass 12 pass
435d465 (seam at init) 8cc154b06ceee4f05c504618 cmux23-perf 12 pass 12 pass

Red form aReplayThatDropsTheHeldKeysFailsTheCheck at 435d465: in each of the 25 attempts (1 + 12 + 12), the check failed on its own assertion, as the red form requires:

━ Test aReplayThatDropsTheHeldKeysFailsTheCheck() recorded a known issue at SplitOfPendingSplitTests.swift:89:29: Issue recorded
↳ the line typed during the hold (24 keys held) did not run in the second pane: wait-for failed: wait-for failed: timeout waiting for pattern
  hold: last end Optional(CmuxNextFocusUI.CreationHoldEnd.focusLanded), holding now false
━ Test aReplayThatDropsTheHeldKeysFailsTheCheck() passed after 20.727 seconds with 1 known issue.

Line 89 is the Issue.record("the line typed during the hold ... did not run in the second pane ...") of the shared body. In each job, the same process ran the red form and the real form with the same keys and the same hold; only the replay was different.

At 4f78e2c, jobs 3a80f141fbe1d98d28777b40 and ecaf648482fc6decab4e2afa did not compile the test target (the new init parameter had no default yet), so no test ran. Those two jobs count as neither a pass nor a fail. Release app compile 8acde2072db8f9a0885cdd56 at 4f78e2c passed.

Two jobs at e431de4 (04f59f971280f46394aa4429, 27608ab5cdbd03c17ee2ae19) stopped before any test ran. They hit a base red that is not from this PR: "generated file is stale ... Native/OptChat/optchat-chief/inspector/index.html". e20297d fixed it on feat-cmux-next, and 16a5309 merges that fix in. Those two jobs count as neither a pass nor a fail.

The rewritten test passed 96 of 96 attempts. Before this change, with the #28331 fix, it failed 5 of 20 (and 7 of 14 in the diagnostic runs above). The during-hold test passed 73 of 73, and in each attempt it expects that at least one key was typed during the hold.

Each command: cmux-ci run --class light --pool perf-lead --script scripts/ci/package-test-lane.sh --ref <head> --arg=suite --arg=Packages/macOS/CmuxNext --arg=SplitOfPendingSplitTests ... (the suite named once per attempt).

Observed / Not run

Observed: the fleet jobs above, and the prepush gate (no new violations) on each push.

Not run:

  • A GUI run in a real key window (scripts/cmux-next/typeahead-gate.sh ... split). I read the product's replay path, but this PR does not run it in a key window.
  • A red form that drops the keys inside the real NSApp.sendEvent path. The red form drops them in the injected replay instead, which is the same effect that run 3 recorded: the replay reaches no window.

Follow-ups (from the review at 435d465, non-blocking)

  • KeyRouter's heldKeyReplay default (NSApp.sendEvent) is a fallback in production source (P1). The only production construction (AppServices) passes the root's replay. Follow-up: remove the default and give the tests a test-only init. Also make heldKeyReplay private.
  • Two comments still describe the old replay path (AppServices+TestKeyWindow.swift:9, CreationInputCoordinator.swift:18).

Changelog

none

Closes #28436.

…(P0 new terminal)

Fleet repro on v2.2.9-opt (cmux17, cmux20, cmux21): Cmd-T then Ctrl-`. The
new-tab missed the client's 5 s start deadline behind the New Tab page's
new-conversation-tab, and the app took the Ctrl-` stand-in back. Red on the
base: newTab throws terminalStartTimedOut at the client deadline.
…in back on a client clock (P0 new terminal)

v2.2.9-opt P0 (Lawrence): at times Ctrl-` made no new terminal. Fleet repro
(4/4 on cmux17, cmux20, cmux21): Cmd-T, then Ctrl-`. The New Tab page's
new-conversation-tab got no reply for seconds; the new-tab queued behind it on
the app's connection missed the 5 s spawn deadline; DaemonService rejected the
intent and the stand-in vanished, while cmux-tui still made the tab. The keys
typed for the new terminal went to the old one.

A new-tab with terminal-placement-env-v1 carries the stand-in's client-chosen
terminal id, and cmux-tui does not replay a new-tab sent again (splits re-send
with their client keys; new-tab cannot). So it now waits for its reply or the
connection's end (LineTransport fails pending requests on close): the daemon's
outcome decides, no client deadline does (principled-audit R5, R-L5). The shell
integration arguments are added through one helper for both paths (R1).

Regression test: NewTabLateStartTests (red on the base at 7340d85).
…n a client deadline

Ctrl-` and Cmd-D must always give a terminal that takes keys (principled-audit
R3, P0 2026-10-30). The one rollback point of every intent
(DaemonService.intendNowReporting) now stops DEBUG builds when it takes a
terminal stand-in (a New Terminal Tab's provisional tab, a terminal split's
pane) back for a client deadline: only the daemon's answer or the connection's
end may do that. TerminalStandInRollbackInvariantTests pins the judgement.
…its like a split, the late-start wait has no give-up

Review of the P0 fix (FIX at 9dc00b5): the DEBUG R3 check would stop the app
on two paths that still rolled a terminal stand-in back on a client clock:
- New Row (row-client-keys-v1) sent new-row with the 5 s start deadline: it
  now waits through a late start under its client keys, as a split does.
- The keyed late-start wait (splits, columns, panes, create-terminal) gave up
  after a fixed 120 s budget and on cancellation threw the timeout: it now ends
  on the reply, the daemon's refusal, the connection's end, or
  CancellationError (principled-audit R5, R-L5). The daemon replays each keyed
  pass, so a re-send changes nothing.
Also: the newTab doc moved above its attribute.

Regression test: NewRowLateStartTests (red at the commit before this one).
…lies; R3 covers every new terminal

Lead decision on the P0: Cmd-N is a new terminal too. Its create-workspace
asked again only until a fixed 10 s budget, and its first create-terminal was
sent once with the 5 s start deadline; either failure closed the workspace the
person already saw (WorkspaceCreation.withTerminal) while the daemon still made
it. Both steps are keyed (mutation id; terminal id + mutation id) and the
daemon replays them, so both now wait through late replies until the reply, a
refusal, the connection's end or cancellation (R5, R-L5):
- DaemonConnection.createWorkspaceWaitingForReply, createTerminalWaitingForStart(_:)
  and one shared loop, waitingThroughLateReplies.
- WorkspaceCreation.create (every workspace with a terminal) uses them; its
  rollback is checked in DEBUG (DaemonService.checkTerminalRollback).
- The R3 predicate covers .createWorkspace with a terminal tab.

Regression test: WorkspaceWithTerminalLateCreateTests (red at the commit before).
…al; R3 checked where the wait ends (review FIX at 70e92ae)

Review: WorkspaceCreation.create's DEBUG check judged every error of the
caller's terminal closure, but four callers still made their first terminal
with the 5 s start deadline (Open Folder / createAndShow and its callers, agent
fork, agent teams, incognito window), and a later step's 2 s timeout (configure,
incognito tab) would also have tripped it: DEBUG builds would stop there.
- Those callers make their first terminal with createTerminalWaitingForStart
  (keyed, replayed by the daemon), as Cmd-N does.
- The R3 check moves to the two waits themselves (requestWaitingForStart,
  createWorkspaceWaitingForReply): DEBUG stops if either ever ends on a late
  start, which their loop never does. A caller's own later step is not judged.
- WorkspaceWithTerminalLateCreateTests also makes the first terminal start late
  (the workspace part's red is ceac05e).
…e first-terminal path for every new workspace

Lead decisions on the P0 (R-L4, R1):
- waitingThroughLateReplies keeps waiting with no end, but a re-send (it only
  repairs a lost request; the daemon replays keyed creates) is spaced by
  full-jitter exponential backoff from 1 s, capped at 30 s
  (Backoff.fullJitter, the shared retry type), not at the fixed 2 s / 5 s
  deadline: no retry storm on a hung daemon. Cancellation ends it during the
  backoff wait.
- WorkspaceCreation.firstTerminal is the one way the first terminal of a new
  workspace is made (Open Folder, fork, teams, incognito); the test uses it.
- Tests: the scripted daemons of NewRowLateStartTests and
  WorkspaceWithTerminalLateCreateTests end their start once and replay later
  asks at once, as cmux-tui does (before, each ask was answered a fixed delay
  after it arrived, which no deadline shorter than that delay could ever see).
  LateReplyBackoffTests pins the spacing.
…ane_id_exists), as cmux-tui does (third review)
…s not replay new-row (third review FIX at e4e6774)

The third review found that a keyed new-row sent again after a late start is
refused pane_id_exists by cmux-tui (mux/rows.rs commits it unkeyed;
resource_topology/client_ids.rs refuses a held pane id), so the re-send made the
app take the row back on that refusal. New Row now goes the way new-tab goes:
sent once, answered by its reply or the connection's end (requestAwaitingStart,
R5, R-L5). requestPaneCreation is again split, new-pane, new-pane-right only.

Regression test: NewRowLateStartTests (its daemon now refuses a second ask; red
at the commit before this one).
…pane (P0; bench-arms 02dcba2e: 12/12 made none)
…e, not its stand-in

Lead P0 evidence (bench-arms 02dcba2e1c9e447c7cad0594): in a quick pair of
splits the second Cmd-D / Cmd-Shift-D made no pane within 5 s, 12 of 12.
Cause: the second split targets the focused pane, which is the first split's
stand-in (ProvisionalPane), and PaneSplitCommand sent that stand-in's handle,
which the daemon does not know; the refusal took the second pane back. New
Column and New Row already resolve the target at send time and run in the
workspace's create order; the split did neither.
- PaneSplitCommand resolves its target at send time
  (DaemonStore.daemonHandle(showing:)) and waits until the store has applied
  its own reply, as NewColumnCommand / NewRowCommand do.
- PaneHandlers.split's optimistic path joins the workspace's create chain
  (WorkspaceContentController.pendingCreate), as createInKeysTurn does: it goes
  out once the create before it is in the store (R1, one path).
The wait is the earlier request's own outcome, no timer (R5).

Regression test: SplitOfPendingSplitTests (red at the commit before).
…d-D and checks it runs in that pane's shell (the P0 invariant: a terminal that takes keys)
…-keys-v1 as cmux-tui does (the app sends a row's client keys under it)
…o stand-in; Ctrl-` on a pending split's pane opens there and takes keys (P0 rules a, b)
…es for the daemon (P0 rules a, b)

cmux-ad's two rules for the P0, both in this PR:
(a) A refusal is never silent. A person's optimistic create (Cmd-D split,
    Ctrl-` New Terminal Tab, New Row, New Column) the daemon refuses rolls its
    stand-in back AND shows the refusal where refusals show (refusal HUD) with a
    log line: ActionRegistry.trackRollingBack, the legacy handlers' form of the
    ActionEffect.applied failure path (remoteFailureObserver). A create the
    person closed first (PaneSplitCancelled) is not a failure.
(b) A stand-in id never leaves for the daemon. Every create that targets a
    pane resolves it at send time with DaemonStore.daemonTarget(_:) (the daemon's
    pane that took the stand-in over; PendingPaneNotCreated when its create was
    never made) and runs in the workspace's create chain (pendingCreate), so it
    goes out after the create that makes its target: split, New Column, New Row
    and now New Terminal Tab. The New Terminal Tab overlay on a stand-in follows
    it to the daemon's pane (ProvisionalSplit.rememberStandInTarget/shownPane).
    DEBUG builds stop at a typed request that names a stand-in pane or surface
    (DaemonRequestExecutor.standInReference).

Tests: RefusedCreateIsNeverSilentTests, NewTerminalOnPendingSplitTests (red at
the commit before), StandInReferenceTests.
…PendingPaneNotCreated, 21 locales); the English text stays for logs

Lead decision: the refusal HUD is this PR's visible error; its text reaches the
person, so PendingPaneNotCreated maps to MiscHandlerStrings.pendingPaneNotCreated
(MiscHandlers.xcstrings: en and ja translated, the other 19 locales
needs_review as the table's other keys) in ActionWorkFailure.create, the one
failure builder of Cmd-D, Ctrl-`, New Row and New Column. Test:
CreateFailureTextTests.
… main-actor strings; every caller is main-actor)
…eation hold ended (a test window is never key, as the journeys' outwait_hold) and says where a lost line went

Green 87ad5a04 at 98b377d: 10 of 11 suites passed; this one failed at
the typed line (wait-for timeout), every earlier check holding (two terminals
in the confirmed pane, the new one selected, focus and responder in the pane).
Feat's creation hold replays held keys with NSApp.sendEvent, which reaches only
the key window. CreationInputCoordinator.isHolding(in:) (as perf has it) lets
the test type after the hold; on a miss it records each terminal's screen,
the responder and the hold.
…Reached | focusLeft | windowClosed) and logs a limit end; NewTerminalOnPendingSplitTests asserts focusLanded

Lead decision (R-L5: a hold's end is a signal): waiting in the test for the
hold to end could hide keys a person feels up to 2 s late. The hold now records
its end per window (CreationInputCoordinator.lastHoldEnd(in:)): unforced means
every creation replied and the focus it asked for holds with a responder that
takes keys (focusLanded); the two bound timers are limitReached and log a
notice (window, held key count, last action) so a person's log shows it; a key
leaving the content is focusLeft. The test asserts the Ctrl-` on a pending
split's pane ended its hold with focusLanded before it types.
…hat resolve their target; other verbs log a fault (review #4 B1); a failed create's hold ends as creationFailed (N1)

Review #4 (FIX at 670131c), B1: DaemonRequestExecutor stopped DEBUG builds
on every request naming a stand-in, but only the terminal creates (new-tab,
split, new-pane, new-pane-right, new-row: TerminalSpawningRequest) resolve
their target with DaemonStore.daemonTarget; zoom, rename, swap and a New Tab
page on a pending pane still send the stand-in, which the daemon refuses
("unknown pane") as before. The assertion now covers those creates only;
other requests log a fault. A seam that resolves every pane verb is a
follow-up; rule (b) in the PR body is scoped to the creates.
N1: a hold whose creation failed ended as focusLanded; it is creationFailed
now. daemonTarget's doc says which stand-ins it covers (N3).
… opening' notice, sends nothing, zooms nothing (P0 rule b, lead decision)
…never key (#28436)

The diagnostic runs found the cause of the missed line. At 9adddb3 (job eae2fd94),
6 of 8 runs typed while the window's creation hold was still open: the hold took all 24
keys, ended with focusLanded, and replayed them through NSApp.sendEvent, which reaches only
the key window. The test window is never key and the test host's app is a plain
NSApplication, so no terminal got a byte (no input attach record, both screens blank).

The product replays into the key window, the window the person types in, so this is the
rig, not a key path race. NewTerminalOnPendingSplitTests already waits for the hold for
the same reason. The test now waits for the hold to end, expects it ended because the focus
landed, and keeps the line-runs-in-its-pane assertion and the miss report.
…Cmd-D runs in the second pane (#28436)

The person's path: the keys are typed at once after the second Cmd-D, while the hold of both
splits waits. The test expects some keys held, the line to run in the second pane's shell,
and the hold to end on focusLanded.

CreationInputCoordinator.replay is the hold's replay, NSApp.sendEvent as before (to the key
window); the test gives the window it types into, because a test window is never key.
isHolding and hasPendingCreation become one-line expressions (same result) to pay for its
lines (SW14).

Correction to a87ac4f's message and comment: 5 of 8 runs (not 6) at 9adddb3 typed
during the hold (job eae2fd94: 3 passed, 5 failed).
@lawrencecchen lawrencecchen added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Oct 31, 2026
…plit-keys

# Conflicts:
#	Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SplitOfPendingSplitTests.swift
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (independent review subagent, Claude Opus 5.5): SHIP at 16a5309

I read the merge-base diff (3 files), the PR body, CreationInputCoordinator, KeyRouter.interceptKeyDown/dispatch, CmuxApplication.sendEvent/route/dispatch, CrossWindowInputGuard, DecidedKeyEvents, the type-ahead, bare-key and new-tab capture paths, InputJournal, PinnedPageTests.withApp, and the run-3 log (eae2fd9426eb367df8bf1803). I did not compile or run anything.

Findings

  1. Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift:26,151,232: no finding. No production code assigns replay (git grep: the only assignment is SplitOfPendingSplitTests.swift:23). The default { NSApp.sendEvent($0) } reads NSApp at call time, the same as the old direct call. The replaying = true / replaying = false bracket, the order, and the re-entrancy checks (buffer.openedCount != opened in capture, buffers[window.windowNumber] in flush) are unchanged.

  2. CreationInputCoordinator.swift:49,70: no finding. The one-line rewrites give the same result. isHolding: nil window gives false, no buffer gives false, a buffer gives true. hasPendingCreation: nil window gives nil == true, which is false; no buffer gives false; a buffer gives buffer.hasPendingCreation == true.

  3. The product key path, for the claim "keys are lost only because the test window is not key": I found no way that a person loses held keys in their key window. The flush runs from a detached task, so it is not nested in sendEvent. An unforced end needs responderTakesKeys to be true, which means the first responder is the new terminal's view. In CmuxApplication.route, crossWindowKeyGuard drops a key only when a different cmux window is key. That drop is intended and logged. After that comes interceptKeyDown:

    • The decided check only sends the key on to AppKit, which still delivers it.
    • newTabInputCoordinator.capture acts only when a New Tab hold is open, and it re-delivers the keys it holds.
    • creationInputCoordinator.capture returns false while replaying is set.
    • routesBareKey, typesAhead and typesIntoPrimaryInput all return early when the first responder is a TerminalSurfaceView.
    • Then super.sendEvent delivers the key to the key window's first responder.

    The run-3 log supports the diagnosis. All 5 failed attempts show hold open true, window key false, app NSApplication. That is 120 of 120 keys intercepted true holding true, and every hold ended focusLanded. The journal has no input attach event, only grid/opened/replay/resize/start/visible events. Plain NSApplication.sendEvent sends a key-down only to the key window, and with ordersWindowsIn = false there is none. One caveat: the restored first terminal attached before journalMark() turned the journal on, so it has no attach observer (DaemonTerminalIO.swift:103). For that pane, the evidence is its screen (sh-3.2$), not the journal.

  4. Tests/CmuxNextAppTests/SplitOfPendingSplitTests.swift:23-25, low severity, a coverage note and not a defect. The seam replays through interceptKeyDown and then window.sendEvent. It skips these parts of the real path: inputObserver, crossWindowKeyGuard, dispatchScope, Sleepy Mode's consumesKeyDown, FindPillView.forwardArrowKeyDown, and AppKit's key-equivalent pass. For the plain printable keys and Return that this test types, in one window, none of these drops a key. So the model is faithful for this test. No test here runs the default NSApp.sendEvent replay in a real key window. The PR body discloses that the GUI typeahead gate was not run.

  5. SplitOfPendingSplitTests.swift:18-70, nit. The new during-hold test does not assert two things that the rewritten test asserts: that the first split is still provisional at the second Cmd-D (old line 87), and that refusals.isEmpty. held > 0 does prove a hold took keys. This is deterministic, because the first key is typed in the same turn as the second perform.

  6. SplitOfPendingSplitTests.swift:23: no leak. The closure captures window and router weakly. withApp builds a fresh AppServices for each test, so the KeyRouter and its lazy coordinator are new each time, and the seam cannot reach another test.

  7. SplitOfPendingSplitTests.swift:117-119: nothing is weakened. The rewritten test now types after the hold ends and expects focusLanded. The line-in-second-pane check stays a failure (Issue.record), and a thrown wait-for is still a failure. Typing during the hold moved to the new test.

  8. SplitOfPendingSplitTests+Diagnostics.swift:13, info. The test turns InputJournal.shared on for the process and does not turn it off. SwitchToColdTabTypeAheadTests:69 does the same. Debug builds turn the journal on by default (InputJournalPolicy.resolve). The ring is bounded at 4096 entries. The only other test that reads the journal reads entries after its own mark, and no test reads stats or isEnabled. I see no effect on other suites.

  9. Repo rules for the changed lines: there is no try!, no main-thread blocking call, no animation and no dialog. The tests use try?, polling with Task.sleep, and NSApp! inside a test string, which is acceptable.

Checked / not checked

Checked: the diff, the call sites, the production key path, the test lifecycle, the journal's global state, and the counts in the run-3 log.

Not checked: I did not compile or run anything. I did not check the fleet job results in the PR's table, the god-file size (SW14), or the behavior in a real key window (GUI). I did not check the pre-existing edge cases that this PR does not change, for example a Chromium child window of the same owner that is key at flush time.

@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Review 435d465: HQ feed card · gallery before/after runs · checks

App of this head (app.zip, kept 1 day)

gh workflow run reload-build.yml -R manaflow-ai/cmux --ref feat-cmux-next -f tag=pr28810 -f ref=fix/28436-split-keys -f platform=macos -f backend_mode=local

red: cmux-next checks (god files, concurrency, crash safety, l10n); NEW: red check: Default cursor (no pointing hand in app UI) is not red on the base f8c1ccb; do not merge until green; see lane-rules

Shipped in:

… passes in, with a red form of the during-hold test (#28436)

cmux-ad item 884. CreationInputCoordinator.replay was a settable property; it is now
HeldKeyReplay, a closure passed at init (PATTERNS P1): AppDelegate passes
NSApp.sendEvent to AppServices, AppServices to KeyRouter, KeyRouter to the coordinator.
The app's behavior is the same: each held key runs through NSApp.sendEvent, to the key
window, inside the same replaying flag and re-entrancy checks.

Tests: the test AppServices init and PinnedPageTests.withApp take the replay (the app's
by default); a KeyRouter(registry:) test init passes the app's. The during-hold body is
one function run twice:
- aLineTypedDuringTheHoldRunsInTheSecondPane: the replay runs each key as the app does
  (key router, then window) into the test's window, which is never key; the line runs.
- aReplayThatDropsTheHeldKeysFailsTheCheck: the red form, a replay that drops the keys
  (the shape of #28436). The check must fail on its own assertion (the line missing from
  the second pane); withKnownIssue matches only that assertion, and fails if it passes.
CreationInputCoordinator.HoldEnd moves to CmuxNextFocusUI as CreationHoldEnd (same cases), so
the App does not grow (SW14). The during-hold body also checks the first split was still pending at the second Cmd-D
and that no split was refused (review nit at 16a5309).
@blacksmith-sh

blacksmith-sh Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Found 9 test failures on Blacksmith runners:

Failures

Test View Logs
/tmp/tmpwbn38bf0/summary-fail.json (live):/summary-fail.json (live): View Logs
/tmp/tmpxh55xlxt/summary-missing.json (live):/summary-missing.json (live): View Logs
[new_workspaces] debug.new_workspace timed 0 of 50 new workspace rows/
[new_workspaces] debug.new_workspace timed 0 of 50 new workspace rows
View Logs
[new_workspaces] the window draws no frames/[new_workspaces] the window draws no frames View Logs
[tabs] debug.tab_switch timed 0 of 40 switches/
[tabs] debug.tab_switch timed 0 of 40 switches
View Logs
[tabs] the window draws no frames/[tabs] the window draws no frames View Logs
convention violations found (ERROR lines above)./
convention violations found (ERROR lines above).
View Logs
test_a_picked_signing_label_has_a_rerun_alternative (main.RunsOnFallbackTests.test_
a_picked_signing_label_has_a_rerun_alternative)/
test_a_picked_signing_label_has_a_rerun_alternative (main.RunsOnFallbackTests.test_
a_picked_signing_label_has_a_rerun_alternative)
View Logs
test_no_runs_on_chain_ends_on_the_reserved_side_label (main.RunsOnFallbackTests.tes
t_no_runs_on_chain_ends_on_the_reserved_side_label)/
test_no_runs_on_chain_ends_on_the_reserved_side_label (main.RunsOnFallbackTests.tes
t_no_runs_on_chain_ends_on_the_reserved_side_label)
View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

….sendEvent); test-target compile fixes

4f78e2c broke the test target: every KeyRouter(registry:) test call needed the new
parameter, and its test convenience init did not import ActionRegistry. KeyRouter's
heldKeyReplay now defaults to the app's replay, so those callers stay as they were; only
the composition root (AppServices, from AppDelegate) and the hold tests pass one. The
convenience init is gone. The red-form test is async throws (withKnownIssue rethrows).
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (independent review subagent, Claude Opus 5.5): SHIP at 435d465

I did a read-only review of git diff origin/feat-cmux-next...HEAD -- Packages/ (10 files). I did not compile or run tests. The earlier SHIP at 16a5309 was not used.

Findings

No blocking findings. Correctness is good: the app path is the same as before. Low items, all non-blocking:

  1. LOW, P1: KeyRouter.swift:55 has a default closure parameter (heldKeyReplay: ... = { NSApp.sendEvent(event) }). This is an ambient fallback to NSApp in production source, and P1 says "no ambient fallback". It is safe today. The only production KeyRouter( is AppServices.swift:387, and that call passes the root's value. The default is the same closure, so no production construction silently takes another behavior. The P1 shape is the one that AppServices already uses: no default in production, plus a test-only convenience init. Follow-up: remove the default and add extension KeyRouter { convenience init(registry:inputDispatch:) } in the test target, next to AppServices+TestKeyWindow.swift. Every production construction must pass the replay, and today the only one does.
  2. LOW: KeyRouter.swift:53 declares let heldKeyReplay as internal. Only the lazy creationInputCoordinator (line 343) reads it, so it can be private.
  3. NIT: some doc text is now stale.
    • AppServices+TestKeyWindow.swift:9 still says the root "must pass both sources". It now must pass three.
    • CreationInputCoordinator.swift:18 still says the keys run again "through NSApplication.sendEvent". They now run through the injected replay, which is NSApp.sendEvent in the app.
  4. NIT: SplitOfPendingSplitTests+Diagnostics.swift:13 (journalMark()) turns on InputJournal.shared for the whole process and never restores it. SwitchToColdTabTypeAheadTests does the same, and recordsCharacters is false. The fleet logs show no effect on the suite. Other suites that run later in a full CmuxNextAppTests process see the journal on.
  5. NIT: in the PR body, the Testing table has a blank line before the three 435d465 rows. GitHub therefore shows those rows as raw text, not as a table.

What I checked

  1. App path.

    • AppDelegate.swift:126-127 passes { event, _, _ in NSApp.sendEvent(event) }, the same call as the removed lines at both replay sites (CreationInputCoordinator.swift:146 in capture, :227 in flush).
    • The window and router arguments are ignored, so the app still delivers to the key window, as before. It does not deliver to the holding window.
    • Nothing else changed:
      • The replaying = true / false bracket, the openedCount re-entrancy check and the prepend order.
      • lastHoldEnd is recorded before the replay.
      • The weak var router handling.
    • git grep finds one production AppServices( (AppDelegate.swift:126) and one production KeyRouter( (AppServices.swift:387, which passes heldKeyReplay). No other package or target constructs either type.
  2. P1. The replay is stored as private let replay in the coordinator and passed through init at every level. There is no settable property, static or global hook. For the KeyRouter default, see finding 1. REVIEW.md section 8 says "no test seams in production source". Here the seam is constructor injection that cmux-ad item 884 asked for, and production passes its real value, so I accept it.

  3. CreationHoldEnd.

    • Every former HoldEnd use now names CreationHoldEnd:
      • the coordinator's dictionary, lastHoldEnd(in:) and the flush default argument;
      • the doc links.
    • No other module defined its own HoldEnd (git grep HoldEnd).
    • The test uses (NewTerminalOnPendingSplitTests.swift:52, SplitOfPendingSplitTests.swift:91,141, Diagnostics) only compare with .focusLanded or print the value. They need no import because the package does not enable MemberImportVisibility.
    • public nonisolated enum ...: Equatable, Sendable is correct in a defaultIsolation(MainActor) target with InferIsolatedConformances: == stays nonisolated and works from @MainActor code.
    • CmuxNextFocusUI is already a dependency of the app and test targets.
    • CmuxNextApp source is +28/-33 lines in total. KeyRouter.swift is 448 → 449 lines, already over its 400-line god-file limit; the CI annotation for it is a warning only.
  4. Red form.

    • withKnownIssue with no isIntermittent records "Known issue was not recorded" when the body records no issue. An issue that does not match the matcher is recorded as usual. This includes #require failures and errors the body throws, because their comments do not contain "did not run in the second pane", so they still fail the test.
    • Issue.record's comment is in issue.comments, so the matcher is correct.
    • The only other repo uses are SidebarFooterAlignmentTests:114 (strict) and ClassicUpgradeSimulator:401 (isIntermittent: true), so this form is the strict one.
    • The suite has .serialized (line 12).
    • Each withApp builds new AppServices, KeyRouter and coordinator objects. It closes its windows and shuts its daemon down, so no replay closure or hold leaks into the next test.
    • In the logs, the test after the red form (aSecondSplitOnAPendingSplitsPaneMakesASecondPane) passes.
  5. The real form's replay (router.interceptKeyDown(event, in: window), then window.sendEvent) is the same path the test types through. It is also the core of CmuxApplication.dispatch: keyDownInterceptor → KeyRouter.interceptKeyDown → super.sendEvent. It skips these:

    • the sleepy-mode check, crossWindowKeyGuard, dispatchScope and inputDispatch;
    • menu key equivalents.

    None of these can drop a printable key or Return to a terminal first responder in its own window. The replaying flag makes capture return false on the re-entrant intercept, as NSApp.sendEvent does in the app.

  6. Test-target compile.

    • AppServicesKeyWindowSourceTests:36-37 passes all three sources, with the labels in order.
    • PinnedPageTests.withApp(heldKeyReplay:_:): the existing trailing-closure callers still bind to body (SE-0286 forward scan skips a defaulted parameter when a later parameter needs an argument).
    • AppServices(environment:heldKeyReplay:) picks the test convenience init.
    • HeldKeyReplay is internal and reached through @testable import.
    • The fleet logs confirm this: the test target compiled at 435d465, with no warnings in the changed files.
  7. Fleet evidence. I read 745851a36dfa78263b76f1fc.log and 8cc154b06ceee4f05c504618.log. Both have suite ... at 435d4652b4730f90c208cb69216430cbc16c74bf and 12 runs each, and each run says "3 tests in 1 suite passed ... with 1 known issue". The counts per log:

    • aLineTypedDuringTheHoldRunsInTheSecondPane passed 12/12.
    • aSecondSplitOnAPendingSplitsPaneMakesASecondPane passed 12/12.
    • The red form reported "recorded a known issue at SplitOfPendingSplitTests.swift:89:29" 12/12, and "passed ... with 1 known issue" 12/12, with 24 keys held.

    This matches the claim (24 + 24 + 24).

  8. CI on this head. cmux-next checks is red on "Default cursor (no pointing hand in app UI)". That is a base finding: the guard landed on base as 3cb1742, and this diff has no cursor code. The compile and test lanes were still pending when I read them.

Not checked

  • I compiled nothing and ran nothing locally.
  • I did not read the light job 0d24d13186268d3cc6e82155.
  • There is no GUI run in a real key window. The product's NSApp.sendEvent replay, and the AppDelegate closure itself, have no test. The PR body says so.

@lawrencecchen lawrencecchen added the ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds label Oct 31, 2026
lawrencecchen pushed a commit that referenced this pull request Oct 31, 2026
… the hold, and cover typing during it (#28810)

merge-batch candidate: the squash merge of #28810 at 435d465
lawrencecchen pushed a commit that referenced this pull request Oct 31, 2026
… the hold, and cover typing during it (#28810)

merge-batch candidate: the squash merge of #28810 at 435d465
lawrencecchen pushed a commit that referenced this pull request Oct 31, 2026
… the hold, and cover typing during it (#28810)

merge-batch candidate: the squash merge of #28810 at 435d465
@lawrencecchen
lawrencecchen merged commit aeb7ab1 into feat-cmux-next Nov 1, 2026
198 of 222 checks passed
@lawrencecchen
lawrencecchen deleted the fix/28436-split-keys branch November 1, 2026 02:19

This branch was previously deployed

1 inactive deployment
ci-routing-agent — 435d4652 Deployed Oct 31, 2026 by lawrencecchen via namespace autofix #8969
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant