Skip to content

R-INPUT S1: the one input router in cmux-focus-core (core, lanes, sessions, dialogs, page-first chords), Rust only - #34052

Merged
lawrencecchen merged 17 commits into
feat-cmux-nextfrom
fix/r-input-s1a-v2
Nov 4, 2026
Merged

lawrencecchen merged 17 commits into
feat-cmux-nextfrom
fix/r-input-s1a-v2

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Where a key goes is decided today in about 40 places in the app, mostly after the key: at a hold's flush, at a replay through NSApplication.sendEvent, at a page's answer or at a 2 s timer. A later decision reads a later state, so a key can reach a target the person never saw focused (P0 1193, REOPEN-F4/F5, close-then-type, INVERSE-KEY, #32633, the #32537 review rounds). R-INPUT replaces those decisions with one reducer in Rust (DESIGN.md).

This is stage S1 (S1a and S1b in one PR): the router in cmux-focus-core::input, Rust only, with no caller yet.

  • InputRouter owns every window's focus (today's focus reducer state) and decides each key's route in the key's own call: the tab the focus is on its way to (a creation's stand-in), else the focused target. AppKit's dispatch only chooses how the key reaches that sink (its responder, else the sink's own path), never which sink. The binding rules (tier rule, Ghostty rule) read the same target.
  • A key whose sink cannot take it yet (a page before its first paint, a field that has not got the keyboard, a terminal while an input method composes) waits in that sink's lane. A lane is the one held-key mechanism: typing order, bounded by a limit from physical memory (R9 R-L1: 4096 keys on 16 GiB), no timer, and its entries leave only to that sink, or are dropped with a reason. An input action (Paste) waits there like a key.
  • Sink lifetimes: a tab's sink that ended takes no key while its tab shows. A closed one takes keys again only as a new sink (a window shows it again after none did, as Reopen Closed does with the old id, or a creation names it again). An exited one (GoneReason::Exited: a terminal whose process ended, its tab stays) stays ended wherever its tab shows until a restart names it again or its tab closes. A creation stays known until its reply, and a done one until a window shows its tab, so a failure after the stand-in showed still ends its terminal and a reply before the show keeps the new terminal's keys. The SinkEnded and SinkNew effects say so.
  • S1b: key sessions (link hints the person started, window hints, the shortcut recorder; at most one runs, and a key it takes reaches no sink), blocking dialogs (a key with no Command or Control typed under a dialog is the dialog's; a key typed before it waits for the answer with its own target), page-first chords (offered to the focused page only when it reports the keys it leaves unhandled, as a Chromium page does; the action runs once, by the key's own id, only when the page reports it unhandled; every offer ends once, with its action or an OfferClosed reason; a page's answer closes only its own older offers; the offers per page are bounded like a lane, visibly), and Escape in a field the person moved into from content gives the keyboard back to that content (cmux-next: Escape in the right-sidebar text field does not give the keyboard back to the Chromium page #33049).
  • ledger::Ledger checks the invariants over the effect stream (I1-I8: no lost key, no double key, order kept, a session's key reaches no sink, one session, the sink fixed at key time, no delivery to an ended sink, bounded lanes). It keeps only what can still change.
  • The topology's Tab gets the host terminal id (terminal). The C topology does not carry it yet: the FFI decode sets None (the app sends it from stage S3). The JSON shape is unchanged when it is absent.

Tests (cmux-tui/crates/cmux-focus-core/tests/input_router, filter key_router::):

  • 23 S1a scenarios, 27 S1b scenarios and 14 lifetime scenarios, one per defect class, each with its STATUS items or review finding.
  • A seeded model-based fuzz: 20,000 cases of 200 events on every core, through the router and a plain reference model together. After every event the ledger must hold, and every key's state (with the action and tab of each run) and every sink's deliveries must equal the model's. The checker adds a finale (no input method, no session, dialogs answered, pages ready, windows closed), and then nothing may wait. A failure prints the lowest failing seed and a shrunk event list (CMUX_INPUT_FUZZ_SEED replays it).
  • 13 mutants the fuzz must catch, at least one for each invariant; each case must pass on the real router first.
  • A counting allocator: 0 allocations for a key, its release and a bound chord.

Red/green, the same command: cmux-ci run --class isolated --script scripts/ci/cmux-tui-rust-check.sh --os linux --arg test|all --arg key_router:: --ref <sha>.

  • S1a red ff367cc (today's two key rules kept), step 16411615e4296124666e63c5: 25 run, 11 failed on their assertions, 14 passed.
  • S1a green at c499a3b, step 759965538c9001efaac5aac4: fmt and 25 of 25 tests passed; clippy failed on one method name (fixed in d039085). Step c896db46ef6b5a59b317ae1c at d039085 was cancelled by infra (60 min in the script-version check) and is replaced by the green below.
  • S1b red fb2ea1e (no session, dialog, offer or Escape rule), step 0817b7711eaff343164173f4: 45 run, 14 failed on their assertions (13 S1b scenarios and the fuzz), 31 passed.
  • S1b green e3c5be0, step 1803a1b30c2e0e27010e4125: fmt and clippy (all targets) passed, 44 of 45 tests passed; the fuzz failed at seed 39 on I7 (a sink that came back; the fix is below).
  • First-review red 94527cb (the lifetime scenarios on the S1b router), step 757b6f409bd8310487c2f44b: 55 run, 10 failed on their assertions (9 lifetime scenarios and the fuzz at seed 39), 45 passed.
  • First-review green 5ddd5d8, step 05d9b051358a4c766bb8c84a: fmt passed, 56 of 56 tests passed, clippy failed on one collapsible_match in the test target (fixed in 55ac76c).
  • Green 55ac76c, step 1a43b14343bea2b210bf8af2: passed. Fmt, clippy -D warnings on all targets, 56 of 56 key_router:: tests (the 20,000 x 200 fuzz in 3.3 s, the 0-allocation test, the 13 mutants).
  • Second-review red 0515bf7 (the new exit, creation, offer and dialog scenarios on the first-review router), step a98b855ab62cb4c8d20ea307: 68 run, 14 failed on their assertions, 54 passed.
  • Second-review green f7edc24, step 2dfb26f0dc2cca99f6d3c4dc: fmt and clippy passed, 67 of 68 (one stale assertion, above).
  • Green 679b1d1, step 543452f980475a65a96bb360: passed, 68 of 68.
  • Third-review red 6d4725c, step cc5b682f6dcf0ad9921db3cc: 70 run, 2 failed on their assertions (the two scenarios above), 68 passed.
  • Third-review green dce7c8c, step ce4553b5b2b1b18c60955010: passed, 70 of 70, fmt and clippy.
  • Ledger red 1525c55, step 3b52c986936b10ae232eadc6: 71 run, 1 failed on its assertion (the new ledger test), 70 passed.
  • Ledger green 75ed18a, step fd523cb817e1ffac5e1b5791: fmt passed, 71 of 71 tests passed, clippy failed on one test line (fixed in 121ee31).
  • Green 121ee31 (the head), step 16e6bc1970409922d420cd45: passed. Fmt, clippy -D warnings on all targets, 71 of 71 key_router:: tests (the 20,000 x 200 fuzz, the 13 mutants, the 0-allocation test, every regression test above).
  • The FFI crate (its own Cargo workspace): step 0ddeeaaf2b855f557b45bda0 passed at the never-merge proof ref 8d74ac2: cmux-layout-reducer-ffi 7 of 7, cmux-focus-core 55 of 55 (ported::). The head changes no FFI code.

Regression tests for the second review (each red on the 5ddd5d8 router, green on the head; the fuzz is not the proof):

  • The red commit 0515bf7 is the 5ddd5d8 router: 55ac76c changes one test line (the clippy fix), and 0515bf7 adds only the API these tests name (GoneReason::Exited, InputEvent::PageHandled, InputEffect::OfferClosed, KeyRecord.page_reports_unhandled), with no behavior change. The tests cannot compile on 5ddd5d8 itself, because they use that API.
  • Same command for both: scripts/ci/cmux-tui-rust-check.sh --os linux --arg test|all --arg key_router::. Red step a98b855ab62cb4c8d20ea307 at 0515bf7; green step 2dfb26f0dc2cca99f6d3c4dc at f7edc24.
Finding Test Red (0515bf7) Green (f7edc24)
Major 1: a Created reply before the tab shows key_router::lifetimes::a_creation_done_before_its_tab_shows_keeps_the_keys_for_its_terminal FAIL: the key went Deliver { Content("b1") }, expected Deliver { Host("T9"), Direct } PASS
Major 2: an exited terminal, then a workspace switch or a window reopen key_router::lifetimes::an_exited_terminal_takes_no_key_after_a_switch_or_a_reopen_until_a_restart FAIL: after the switch the key went Deliver { Host("T2") }, expected Dropped { SinkGone(Exited) } PASS
Major 3: a page that does not report unhandled keys (WebKit) key_router::sessions::a_page_that_does_not_report_unhandled_keys_gets_no_offer FAIL: Cmd-R got Offer, expected Run { browserReload } PASS
R9 R-L1/R-L5: offers bounded per page, with a visible end key_router::sessions::offers_past_the_bound_close_visibly FAIL: no OfferClosed, expected [(1, Overflow)] PASS

Red step a98b855ab62c: 68 run, 14 failed on their assertions (the 4 above, the other 9 new scenarios, and the fuzz at seed 5), 54 passed. Green step 2dfb26f0dc2c at f7edc24: fmt and clippy passed; 67 of 68 passed, with the 4 above passing. Green step 543452f980475a65a96bb360 at 679b1d1: passed, 68 of 68.

One test expectation changed to match the new code (679b1d1), so here is the design rule that makes it right. The S1b scenario key_router::sessions::a_report_of_a_newer_key_means_the_older_offers_were_handled asserted that a newer key's report makes the older offer vanish with no effect. It now asserts OfferClosed { id: older, reason: Handled } before the newer key's Run. Both say "handled": the older offer's action never runs in either version. The only change is that the end is now visible. The rule is DESIGN.md 4.7 (v1.3), "Page-first chords":

Every offer ends exactly once: its action runs, or OfferClosed { Handled | PageGone | Overflow }. A page's answer (PageUnhandled or PageHandled) closes only that page's older offers.

R9 R-L5 asks for the same: "a limit that drops user data spills to disk or shows the loss". The ledger enforces it from 75ed18a on (before, it only accepted the close and did not require it): it keeps each open offer's page, and an offer's end while an older offer to the same page is still open breaks I3. key_router::sessions::the_ledger_requires_the_offers_of_a_page_to_end_in_key_order feeds the ledger both streams with no router and no model: the old silent stream must break I3, and the router's stream (the older offer closed as handled, then the newer key's run) must pass. Red 1525c55 (step 3b52c986936b): this one test failed on its assertion (the old ledger reported no violation for the silent stream), 70 of 71 passed. Green 75ed18a (step fd523cb817e1): fmt and 71 of 71 tests passed; clippy failed on one line of this new test (cloned_ref_to_slice_refs), fixed in 121ee31 with std::slice::from_ref.

Third review (at 679b1d1, receipt): FIX, one major, one minor; red 6d4725c and green dce7c8c fix both.

Finding Test Red (6d4725c) Green (dce7c8c)
Major: a failed "Restart Shell Here" revived a dead terminal after a workspace switch or reopen key_router::lifetimes::a_failed_restart_of_an_exited_terminal_keeps_it_ended_wherever_it_shows FAIL: Dropped { SinkGone(CreationFailed) }, expected SinkGone(Exited) (step cc5b682f6dcf) PASS (step ce4553b5b2b1)
Minor: a key session took bare keys under a blocking dialog key_router::sessions::a_blocking_dialog_takes_bare_keys_before_a_running_session FAIL: the dialog took no key, expected [1] PASS

Red step cc5b682f6dcf: 70 run, the 2 above failed on their assertions, 68 passed. Green step ce4553b5b2b1 at dce7c8c: fmt, clippy -D warnings on all targets, and 70 of 70 key_router:: tests passed.

The fuzz reaches the failed restart too: World::restart marks its reply, and a failed restart keeps its tab.

The fuzz event alphabet has the three cases, so later seeds can reach them (fuzz.rs):

  • World::reply: half the replies are a Created for a creation whose stand-in has not shown yet.
  • World::end_shown sends Gone { Exited } while the tab shows; World::switch_away shows another workspace, action 105 shows the window's own workspace again, action 98 closes and opens window w2, and World::restart names a shown terminal again.
  • World::key sets page_reports_unhandled: false on 30 % of keys.

Review: the first independent review (at d039085) found 4 major and 7 minor defects (receipt); 94527cb and 5ddd5d8 fix them. The second (at 5ddd5d8) found 3 major and 4 minor defects (receipt); 0515bf7, f7edc24 and 679b1d1 fix them. The third (at 679b1d1) found 1 major and 1 minor defect; 6d4725c and dce7c8c fix them, and DESIGN.md v1.4 takes its three nits (the reload of a crashed page, and the S3/S4 caller checklist). The fourth review stopped on a connection error before its verdict; its last note found that the ledger did not require an offer's close, which 1525c55 and 75ed18a fix. The fourth (at 75ed18a, receipt) found only one clippy line in the new ledger test, fixed in 121ee31, and confirmed that the changed offer expectation is the design. The review of that delta: SHIP at 121ee31 (receipt).

Observed / Not run

Changelog

none

No capture: Rust-only library code with no caller; nothing in the app changes.

…h the typed C ABI

The module comment still said the app asks over the JSON operation. Since
#33313 the Swift resolver calls cmux_agent_cursor_visibility; the JSON
operation stays and its FFI test replays the vectors.
…on today's two key rules (R-INPUT S1a)

The one input router (handoff/rewrites/R-INPUT/DESIGN.md, stage S1a) in
cmux-focus-core::input, with its tests: a scenario for each key defect class
R-INPUT ends (P0 1193, REOPEN-F4/F5, #32633, close-then-type, INVERSE-KEY, a
click before the first paint, the cross-window key, keys for Home after a new
terminal), and a seeded model-based fuzz against a plain reference model,
with seven mutants it must catch.

This commit is the red: its router keeps TODAY's two rules of the app's key
paths, so the tests fail here:
- AppKit's dispatch decides where a key that runs no binding goes (the first
  responder of the key window), as the key window's first responder does now;
- a held key runs again through the router when it leaves its hold, and goes
  where the focus is then (the CreationInputCoordinator / NewTabInputCoordinator
  replay through NSApplication.sendEvent).

The next commit removes both: a key's sink is decided in the key's own call
from the window's focus, and a waiting key leaves its lane only to that sink.

The topology's Tab gets the host terminal id (`terminal`), so a creation's
stand-in tab and the daemon's tab are one terminal sink. The C topology
carries none yet (the FFI decode sets None; the app sends it from S3).

Run: cmux-ci run --class isolated --script scripts/ci/cmux-tui-rust-check.sh
  --os linux --arg test --arg key_router:: --ref <this sha>
…ting key leaves its lane only to that sink (R-INPUT S1a)

Removes today's two rules from the router (see the red commit):
- the sink of a key that runs no binding comes from the window's focus at key
  time: the tab the focus is on its way to (a creation's), else the focused
  target. AppKit's dispatch only chooses HOW the key reaches that sink (its
  responder when it reaches it, else the sink's own path), never which sink;
- a key that waits stays in its sink's lane until that sink can take it, and
  leaves only to it, in typing order; when the sink ends the key is dropped
  with the reason. No re-aim, no replay through the router, no timer.

Rust only, no caller yet: the app links it from stage S3 (shadow), and the
behavior stages flip one runtime switch each (DESIGN.md section 5).
…o_sink (clippy wrong_self_convention)

A to_* method that takes &mut self breaks clippy's naming rule (green step
759965538c90: fmt and the 25 tests passed, clippy -D warnings failed on this
name only). A pure rename; no behavior change.
@lawrencecchen lawrencecchen added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Nov 3, 2026
@blacksmith-sh

blacksmith-sh Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Found 43 test failures on Blacksmith runners:

Failures

Test View Logs
/tmp/tmp96ix1n65/summary-fail.json (live):/summary-fail.json (live): View Logs
/tmp/tmpk4681o1x/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
main.DeveloperIdAppTests.test_download_prints_the_active_profile_only/
test_download_prints_the_active_profile_only
View Logs
convention violations found (ERROR lines above)./
convention violations found (ERROR lines above).
View Logs
stubs: /System/Library/Frameworks/Security.framework/Versions/A/Security has no stub/
Security has no stub
View Logs
stubs: CoreFoundation.tbd: install-name /System/Library/Frameworks/
CoreFoundation.framework/Versions/A/CoreFoundation, missing 1: ['_CFRetain']/
CoreFoundation, missing 1: ['_CFRetain']
View Logs

...and 33 more test failures. View all on Blacksmith

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

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

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

Review 121ee31: 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=pr34052 -f ref=fix/r-input-s1a-v2 -f platform=macos -f backend_mode=local

red: cmux-next checks (god files, concurrency, crash safety, l10n); no finished base run on ee78bc6 to compare; do not merge until green; see lane-rules

Shipped in:

…Escape rule, red on today's rules (R-INPUT S1b)

Stage S1b of the one input router (handoff/rewrites/R-INPUT/DESIGN.md
sections 4.4, 4.7 and 4.8), Rust only, no caller yet:
- key sessions (link hints the person started, window hints, the shortcut
  recorder): at most one runs (I5), and a key it takes reaches no sink (I4);
- blocking dialogs (a window's or one tab's): a key typed under a dialog is the
  dialog's, a key typed before it waits for the answer with its own target;
- page-first chords: a chord a page may own goes to the page first, and its
  action runs once, by the key's own id, only when the page leaves it
  unhandled;
- Escape in a field the person moved into from content gives the keyboard
  back to that content (#33049).

The tests: 20 scenarios (#32537 review rounds, #31849, #32291/#32409, #32849,
#33049, with their STATUS items), the reference model and the fuzz extended
with sessions, dialogs and page reports, and four more mutants the fuzz must
catch (a hint letter to the page, two sessions, an action run twice, a key
under a dialog to its target).

This commit is the red: the router keeps TODAY's rules (no key session in the
router, a dialog gets keys only through AppKit, a bound chord runs at once,
Escape stays in the field), so the tests fail here. The next commit adds the
rules.
…ets, page-first chords wait for the page's report, Escape returns from a field (R-INPUT S1b)

Adds the four rules to the router's one decision per key (see the red commit):
- a running key session takes its window's hint keys (letters, Backspace,
  Escape; the recorder: every key) in the key's own call; any other key, a
  key of another window, a focus move off its target, its window closing or a
  newer session ends it, and that key then routes as usual. At most one runs;
  link hints start only on the page the window's keys go to;
- a dialog that blocks a key's target takes the key (a bound chord still
  runs), and a target a dialog blocks takes no key: keys typed before the
  dialog wait for the answer and go on in order, or are dropped when the
  answer closes their tab;
- a chord a page may own (Find, a page-editing chord, a content action) goes
  to the focused page first when the page takes keys now; its action runs once,
  aimed at the key's tab, when the page reports that key unhandled. A report
  of a newer key forgets the older offers (a page reports in key order);
- a bare Escape in a sidebar or text field the person moved into from content
  gives the keyboard back to that pane, unless the field uses Escape itself.

Rust only, no caller yet: the app links it from stage S3 (shadow), and S6/S7
flip input.router.pageKeys and input.router.overlays (DESIGN.md section 5).
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (independent subagent, correctness first): FIX at d039085
perf claim: none
R9: R-L1 (router.rs:17 LANE_LIMIT = 4096 is a bare count on a queue of user keys, with no resource behind it; finding 7)

Paths are relative to the repo root at d039085. The design is /Users/lawrence/fun/wt/handoff/rewrites/R-INPUT/DESIGN.md. I removed the review worktree.

Findings

1. major: an ended sink stays "gone" forever, so a sink that comes back cannot take keys. The set also grows without bound.

  • Where: cmux-tui/crates/cmux-focus-core/src/input/router.rs:287-295. end() puts the sink in facts.gone and never removes it. Its comment says "a sink never comes back: terminal and tab ids are not reused".
    • Readiness for a gone sink is ignored (:242-246).
    • A creation for a gone sink is refused (:251-255).
    • Every key and release for it is dropped (:416-420, :436-440).
    • The ledger (ledger.rs:59, :100-102) and the model (model.rs:157-171, :201-210) copy the same rule.
  • Why the assumption is false:
    • Reopen Closed brings a tab back under its retired id (cmux-tui-core/src/state/closed_history.rs:691-695). A tab whose terminal still runs also keeps that terminal (mux/resource_content.rs:44-46).
    • The topology tab id is that same id: TabModel.identity gives tab_resource_id, which is tab_id (conversation_tabs_wire.rs:381).
    • Field and Overlay sinks are keyed by (window, kind), so they repeat by construction. DESIGN.md:304 says a Field is gone "when it lost the responder".
  • Scenario:
    1. w1 shows browser tab tab_A, and the page is ready.
    2. Cmd-W. The app sends Gone{Content(tab_A), Closed}.
    3. Cmd-Shift-T. The daemon reopens the tab as tab_A, and the focus lands on it.
    4. The page paints. Ready{Content(tab_A)} is ignored.
    5. The person types "abc". Every key is Dropped{SinkGone(Closed)}, for the life of the process.
    • Reopened terminal tabs fail the same way. Today every terminal sink is TerminalKey::Tab(tab id), because the FFI decode sets terminal: None.
  • Fix:
    • Treat a re-creation as a new incarnation. Either accept Creation for a gone sink and clear its entry (and the ledger's ended entry), or put an incarnation in the sink id.
    • Keep the drop for a terminal whose tab still shows.
    • Remove gone entries that no topology, expectation, creation, lane or press names.
    • Do not record Gone for window-scoped sinks.
    • Add a scenario (close, reopen with the same id, type) and a fuzz action that reopens a closed id.

2. major: CreationFailed does nothing after the stand-in shows, which is the normal order.

  • Where: router.rs:271-274 and :297-306 forget a creation when any topology shows its tab. After that, :256-260 finds no entry. The API doc (mod.rs:168) says "its sink ends". DESIGN.md:368 shows the stand-in in the key's own reduction, so the reply always comes after the show. DESIGN.md:520 requires a visible drop.
  • Scenario:
    1. Terminal t1 has the focus. Cmd-D runs newSplit. The app sends Creation{Tab(s1), Terminal(Host(T2))} and Expect{Tab(s1)}.
    2. The topology shows s1. The router forgets the creation.
    3. The person types "ls" and Return. All three keys get Deliver{Host(T2), Direct}.
    4. The spawn fails. CreationFailed{Tab(s1)} has no effect, and T2 does not end.
    • Result: the keys go to a terminal that never existed, and the daemon drops them silently. With an input method on, they wait in T2's lane and go to T2 later. Nothing reports them as dropped, and the ledger cannot see it.
  • Why the tests pass:
    • The model forgets creations the same way (model.rs:129-134).
    • The fuzz fails only creations it has not shown yet (fuzz.rs:443-448).
    • The only scenario fails the creation before the show (scenarios.rs:243-258).
  • Fix:
    • Keep the key-to-sink map until the creation gets its reply (the design's replied(ticket, outcome)).
    • Add a "fail after show" scenario and a matching fuzz action.

3. major: a Paste for a sink that cannot take input yet runs at once.

  • Where: router.rs:399-412 queues an edits_input action only when the lane already holds keys. Also, drain() treats a waiting action at the front of a lane as always ready (:612). DESIGN.md:349 and I3 say an input action joins the sink's lane like keys.
  • Scenario:
    1. Browser tab b1 is reopened and has not painted yet.
    2. The first key is Cmd-V. The router runs Run{paste, b1} at once.
    3. Chromium drops input before the first paint (item 8061), so the paste is lost.
    4. A later "x" correctly waits for the paint.
    • Expected: Queued{paste, NotReady}, and the paste runs at the first paint.
    • The model has the same rule (model.rs:331-341), so the fuzz cannot catch this.
  • Fix: Queue the action when its sink cannot take input now (deliverable(...) returns Err). In drain(), make a waiting action wait for the same readiness as a key.

4. major: the S1a acceptance in the design is not met, or there is no evidence for it.

  • The fuzz default is 400 runs of 160 events (fuzz.rs:774, :778). DESIGN.md:437 asks for 20,000 × 200 unless a debug-build run takes more than 60 s. The PR's own green log shows 0.303 s for 400 × 160 (job 759965538c9001efaac5aac4), so 20,000 × 200 takes about 19 s. DESIGN.md:485 also asks for one run of 1,000,000 sequences per stage. There is no evidence of either run.
  • There is no mutant for I7 or I8. DESIGN.md:433 asks for one mutant per invariant.
  • There is no router bench. The S1a row (DESIGN.md:468) asks for p99 ≤ 2 µs and 0 allocations per key.
  • Clippy -D warnings has never checked the test targets. The green job stopped at the lib error. At review time, the head job c896db46 was still running, at "Require the reviewed script version" (Actions run 41676933161).
  • Fix: set the defaults to 20,000 × 200, post the 1,000,000 run, add the two mutants, add the bench or move it to a follow-up in the PR text, and get a green clippy on the head.

5. minor: the reference model copies the router's rules. It is independent only for lane mechanics.

  • Where:
    • model.rs:396-471 copies router.rs:473-561 line for line.
    • model.rs:500-516 copies allows().
    • model.rs:243-255 copies deliverable().
    • The creation and gone handling are also copies.
  • What it can and cannot catch: the fuzz catches lost, doubled and reordered keys, keys re-aimed when they leave a lane, and timers. Findings 1, 2, 3 and 9 pass both the router and the model by construction.
  • What the generator never produces:
    • a reused id
    • a failure after the show
    • an expectation with a chrome target or a surface key (every expectation is Target::Content with a tab key: fuzz.rs:290-303, support.rs:138-147)
    • a Gone while the tab still shows
  • Fix: get the expected sink from the focus reducer's own landed focus, not from a copy of expected(). Add generator actions for the cases above.

6. minor: the ledger cannot see some ended sinks, and its state grows without bound.

  • Where: the ledger learns that a sink ended only from Gone events and from waiting keys dropped with SinkGone (ledger.rs:100-102, :157-159). A sink that ends by CreationFailed with an empty lane is never marked, so a later delivery to it passes I7. Its keys, ended and newest maps (:53-59) keep entries forever. The ledger's doc says the app's live monitor and journal replay can use it.
  • Fix:
    • Have the router report each ended sink as an effect.
    • Keep only the waiting keys plus the newest id. An end for an id that is neither waiting nor new is then a double end.

7. minor (R9 R-L1): LANE_LIMIT = 4096 is a bare cap on a queue of user keys.

  • Where: router.rs:17.
  • How a person can reach it: a held key auto-repeats at about 30 keys a second, so a page that never paints fills the lane in about 2.5 minutes.
  • Why it fails R-L1: no resource stands behind the number. The design pairs it with the daemon's 64 KiB limit (cmux-tui-core/src/mux/creating_input.rs:21), which the router cannot count. The loss itself is typed (Dropped{LaneFull}).
  • Fix: derive the number from a stated resource, and document the reason on the constant.

8. minor: the binding rule and the sink use two different focus states.

  • Where: router.rs:372-386. The Ghostty rule and allows() read the focus now. The sink comes from the expectation's tab.
  • Scenario:
    1. Terminal t1 has the focus.
    2. Cmd-T creates browser tab n1, which has not shown yet.
    3. The person presses Ctrl-Cmd-Left (the Ghostty keybind for goToSplit). The focus now is a terminal, so the chord goes to the view. But the aimed sink is Content(n1), so the chord waits for n1's first paint and then reaches the page as a raw key.
    • Result: goToSplit never runs.
  • Fix: decide the binding against the aimed target, or record this for S1b with a scenario.

9. minor: no test checks which tab an action runs on.

  • Where:
    • fuzz.rs:638 records every Run only as Ran.
    • model.rs:21-27 keeps no action and no tab.
    • scenarios.rs:41 checks only Run { .. }.
  • Effect: a change that splits t1 instead of s1 on the second Cmd-D (the action half of P0 1193) passes every test.
  • Fix: record the action and tab, and assert tab: Some("s1") in the 1193 scenario.

10. minor: the release of a dropped key-down still reaches the sink.

  • Where: decide() records Press::Sink whatever route_to did (router.rs:387-391). The doc at :105 says a dropped press is recorded as Consumed, but the code does not do that.
  • Scenario: a key-down is refused (LaneFull), or its field lane is dropped (TargetLeft). Its release still waits in the lane, or goes to the field through the responder. The sink gets a key-up with no key-down.
  • Fix: record Press::Consumed when the press itself was dropped.

11. minor: expected() disagrees with the focus reducer on chrome targets, and loses the tab for a surface key.

  • Chrome targets: router.rs:511-520 sends address-bar, find-bar and DevTools targets to fields for every Sink::Content tab. The reducer keeps a chrome target only on Kind::Browser (reducer.rs:107-110, :609-615). For an agent, page or conversation tab, keys typed before the show wait in a field lane. At the show, drop_left_lanes drops them as TargetLeft.
  • Surface keys: with ExpectationKey::Surface, the tab is None (router.rs:503-506). An address-bar target then sends keys to the page, although the creation's Content(tab) names the tab. The app uses address-bar expectations through expectFocus(on: surface) (PaneBrowserTabOpener.swift:148, :273; BrowserHandlers.swift:192, :215), which use a surface key when the store does not have the tab yet.
  • No test reaches either branch.

Checked and sound

  • Router:
    • Each key gets exactly one result in its own call.
    • A key that waits leaves its lane only to its own sink, in typing order. The drain always takes the oldest key that can go.
    • There is no timer, no clock read, and no re-aim when a key leaves its lane.
  • Tier rule: allows() matches the app's KeyRouter.allows(_:id:focus:) (KeyRouter.swift:75-81, KeyRouter+BrowserChrome.swift, KeyRouter+FocusModeEscape.swift:13) and the Ghostty rule (KeyRouter.swift:137-142).
  • Red commit ff367cc is fair: it puts back the two old rules. In job 16411615, 11 of 25 tests failed on their assertions, not on compile. every_mutant_is_caught passed there only because the red router already fails seed 1.
  • Tab.terminal:
    • Every construction site is updated. The Tab in sidebar_face.rs is a different type.
    • The JSON shape does not change when the field is absent.
    • Swift's FocusTopology.Tab ignores unknown keys.
    • The FFI decode only sets None.
    • The FFI proof job 0ddeeaaf passed 62 of 62.
  • Panic freedom: src/input has no unwrap, expect, panic! or indexing. Its counters use wrapping or saturating arithmetic.
  • Repo rules:
    • The PR adds no timer and no CI job, gate or workflow.
    • File caps hold: router.rs has 680 lines and 33 functions, fuzz.rs has 1085 lines and 63 functions (counted with check-no-godfiles.sh's own pattern).

Nits

  • Some public items have no doc comment: InputRouter::new, Ledger::new, Ledger::violations, the fields of Violation and Modifiers, KeyPhase::Down, KeyRecord.role/phase/modifiers, and Binding.tier.
  • every_mutant_is_caught should first check that the real router passes a seed, so a failure of the real router cannot count as a caught mutant.
  • In the ledger, a key result outside its own call is reported as NoDoubleKey, and the id-order check is reported as OrderKept. Both names are misleading.
  • A repeat of a key-down that went nowhere (NoTarget) decides again (router.rs:336-339). It can then start keys at a new sink with no key-down.
  • Two text fields of one window share Field{window, TextField}, so keys that wait for field A can reach field B.
  • The FFI decode sets terminal: None, so a stand-in terminal shows as Tab(s1). Until S3, a Creation sent with Host(T2) therefore splits one terminal's keys across two sinks.

Not verified

  • I did not compile or run anything; the rules do not allow it on this machine. Clippy on the test targets is still unknown.
  • I followed the reopen-id path (finding 1) through the code only, not in a live run.
  • The R9 result is my reading of R9 in principled-audit-rules.md, which lists 4096 as an example.

…it, the key's own target for its binding (R-INPUT S1 review), red

Scenarios for the findings of the first independent review of PR #34052 and
for the fuzz failure of the S1b green step (fleet step 1803a1b30c2e, seed 39,
I7: a key reached Host("T2") after the ledger saw T2 end):
- a terminal that ended takes no key while its tab still shows, and takes
  keys again only when a window shows it after none did (seed 39);
- Reopen Closed brings a tab back under its old id: the new page takes keys
  after its own first paint; a creation that names an ended tab again makes
  it a new sink (finding 1);
- a creation that fails after its stand-in showed still ends its terminal:
  waiting and later keys are dropped with the reason (finding 2);
- a Paste before a page's first paint waits for it and keeps its place; a
  Paste under a dialog runs at once (finding 3);
- a Ghostty keybind typed for a new tab runs its action (finding 8);
- the 1193 scenario checks the tab each Cmd-D runs on (finding 9);
- a key-down that reached nothing has no repeat or release anywhere
  (finding 10);
- an address bar expected by surface takes the keys typed for it
  (finding 11);
- seed 39's shrunk case: a key for the open palette waits for it and always
  ends (delivered, or dropped when the palette closes).

This commit is the red: the router is unchanged, so these tests fail here
(the palette and 1193 checks pass: they pin behavior that is right today).
…ions stay known until their reply, input actions wait like keys, the binding rules read the key's own target (R-INPUT S1 review)

Fixes the findings of the first independent review of PR #34052 and the
fuzz failure of the S1b green step (fleet step 1803a1b30c2e, seed 39):
- Sink lifetimes (finding 1, seed 39): a tab's sink that ended takes no key
  while its tab still shows, and takes keys again only as a new sink: a
  window shows it again after none did (Reopen Closed reuses the tab id), or
  a creation names it again. An ended sink that no window shows and no held
  key names is forgotten. The router says so with the new SinkEnded and
  SinkNew effects, and the ledger follows them, so its I7 check holds across
  a reused id. A window's field, overlay or dialog never ends that way.
- Creation replies (finding 2): a creation stays known until its reply
  (new event Created, or CreationFailed), so a failure after the stand-in
  showed still ends its terminal. Creation carries the tab's kind.
- Input actions (finding 3): an action that edits an input (Paste) waits in
  that input's lane while the input cannot take input, like a key, and runs
  when a key could go; under a dialog it edits the dialog's input at once.
- Lane limit (finding 7, R9 R-L1): LaneLimit::for_memory derives the bound
  from physical memory (1/4096 of it, at 1 KiB a waiting key: 4096 keys on
  16 GiB); the platform reads hw.memsize at start.
- Binding rules (finding 8): the tier and Ghostty rules read the key's own
  target (the tab the focus is on its way to), the same state as its sink; a
  page gets a chord first only when the key is aimed at that focused page.
- A press that reached nothing (finding 10): no target, dropped, or its sink
  became a new one: its repeats and release are consumed.
- Expected targets (finding 11): a chrome target only on a browser tab, as
  the focus reducer keeps it; a creation known by its surface keeps its tab.
- The ledger keeps only what can still change (finding 6), and names its two
  protocol checks OwnCall and IdOrder.
- Tests: the fuzz runs 20,000 cases of 200 events on every core, with a lane
  limit of 8, new actions (creation replies after the show, Reopen Closed
  with the same id, an end while the tab shows, chrome targets, surface
  keys), ids that never repeat by chance, and a finale the checker adds
  itself (the shrinker can no longer cut it off, which made seed 39's shrunk
  case look like a waiting key). It compares the action and tab of every run
  (finding 9). Two more mutants (I7: a key to an ended sink; I8: an unbounded
  lane), and the mutant test checks that the real router passes each case
  first. A counting allocator checks 0 allocations per key, release and bound
  chord. The router splits into lane, lifetime and binding modules.
@lawrencecchen lawrencecchen changed the title input router core: each key's target fixed in its own call, one held-key lane per target (R-INPUT S1a, Rust only) R-INPUT S1: the one input router in cmux-focus-core (core, lanes, sessions, dialogs, page-first chords), Rust only Nov 3, 2026
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (independent subagent, correctness first): FIX at 5ddd5d8
perf claim: none
R9: R-L1, R-L5 (offer.rs:113 OFFER_LIMIT = 16 is a bare cap on pending page-first offers; an evicted offer's unhandled report runs nothing and nothing shows the loss; finding 4. LaneLimit::for_memory passes.)

Re-review of the full PR at the head: S1b (fb2ea1e red, e3c5be0 green) and the fixes for the first review (94527cb red, 5ddd5d8 green). Paths are relative to the repo root at 5ddd5d8. The design is /Users/lawrence/fun/wt/handoff/rewrites/R-INPUT/DESIGN.md v1.2. I compiled and ran nothing.

Findings

1. major: a creation's Created reply that comes before its tab shows sends the next keys to the old target (the 1193 / INVERSE-KEY class, added by the finding-2 fix).

  • Where: router.rs:239-241 removes the creation at Created. After that, expected() finds neither a shown tab nor a creation (router.rs:568-573, the ? at :571), so aim_in falls back to the focused target (router.rs:549-555).
  • Contract: mod.rs:210-215 and lifetime.rs:4-7 say the reply comes "usually" after the stand-in showed, so the router must also handle a reply that comes first. Today's daemon reply and its store delta are separate messages, so a key can come between them.
  • Scenario:
    1. w1: the page b1 has the keyboard.
    2. Ctrl-runs newTerminalTab. The app sendsCreation{Tab(s9), Host(T9)}andExpect{Tab(s9)}`.
    3. The daemon replies first: Created{Tab(s9)}.
    4. The person types "l". It gets Deliver{Content(b1), Responder}, because the topology does not show s9 yet.
    • Expected: Deliver{Host(T9), Direct}.
  • Why the tests pass:
    • The fuzz makes Created only for a creation that already shows (show fills awaiting at fuzz.rs:368, and reply reads it at fuzz.rs:589-596).
    • The model has the same rule (model.rs:202).
    • No scenario sends Created before the show.
  • Fix:
    • At Created, mark the creation as replied. Forget it only when a topology shows its tab or no expectation names its key.
    • CreationFailed still ends the sink at any time.
    • Add a "reply before the show" scenario and a matching fuzz action.

2. major: an ended terminal becomes a "new" sink after any topology that does not show it, so after a workspace switch, a tab move or a window close and reopen, keys go to a terminal that does not run.

  • Where: lifetime.rs:105-119. When no window shows an ended sink and no press holds it, track_gone forgets it and renew emits SinkNew. After that, route_to (router.rs:451-467) delivers to a terminal at once (router.rs:129-145).
  • Contradiction: lifetime.rs:9-11 and DESIGN.md 4.3 say an ended tab sink "takes no key while its tab still shows (a terminal that exited)". The same tab shows the same dead terminal after the round trip, but now it takes keys.
  • Scenario:
    1. w1 shows workspace A, and terminal tab t2 (T2) has the keyboard.
    2. T2's shell exits. The tab stays and shows its end banner. The app sends Gone{Host(T2)}.
    3. "x" gets Dropped{SinkGone}, as designed.
    4. The person switches w1 to workspace B. Topology B does not show t2, so T2 is forgotten and the router emits SinkNew.
    5. The person switches back to workspace A.
    6. "x" gets Deliver{Host(T2), Direct}, and the writer sends it to a dead terminal.
    • Dragging the tab to another window, or closing and reopening a window, gives the same result. lifetimes.rs:50-71 (seed 39) asserts the close-and-reopen case as correct.
  • Why the tests pass:
    • The model has the same rule (model.rs:353-377).
    • The generator never switches workspace (one fixed workspace per window: fuzz.rs:158-169).
    • The generator models an exit as Gone{Closed} (fuzz.rs:670-677).
  • Root cause: GoneReason (mod.rs:96-103) has no difference between "exited, the tab stays" and "the tab closed". The router uses "a window shows it again after none did" as proof of a new incarnation, but this proof is not valid.
  • Fix:
    • Make a sink new only from an explicit fact: a Creation (Reopen Closed and a relaunch send one), or an incarnation in the sink id (the app has TabModel.terminalIncarnation).
    • Keep an exited sink ended until the platform reports that its tab closed for good. Forget it only then; this also bounds the set.

3. major: page-first offers do not check that the page reports unhandled keys, so on a WebKit page every content-tier chord and Find is lost.

  • Where: offer.rs:128-161. takes_now (offer.rs:157-159) checks only that AppKit's dispatch reaches the page.
  • Today's rule: the app offers only when facts.pageReportsUnhandledKeys is true (KeyRouter+PageFirstKeys.swift:23-25). That fact is chromiumPageHasKeys (KeyRouter+Context.swift:154, :161-166, KeyFacts.swift:64-67): the tab is a CEFTab and its page window has the keys.
  • Scenario:
    1. A WebKit browser tab b1 (openBrowser.webkit) is ready, and its web view is the first responder, so reaches is Content(b1).
    2. Cmd-R (browserReload, content tier) gets Offer{b1, browserReload}.
    3. WebKit has no OnKeyEvent report, so no PageUnhandled comes, and Cmd-R does nothing.
    • Cmd-F and every content-tier chord fail the same way. Today the app runs them at once on a WebKit page.
  • Rule: REVIEW-STANDARD says "trace the real code path (every entrypoint, both engines ...)".
  • Fix:
    • Add a fact, on the key record or as a sink event, that says the page reports unhandled keys. Offer only when it is true.
    • Add a scenario with a page that does not report.

4. minor (R9 R-L1, R-L5): OFFER_LIMIT = 16 forgets offers with no sign.

  • Where:
    • offer.rs:113 sets the cap.
    • offer.rs:171-174 evicts the oldest offer with no effect.
    • At offer.rs:187, the unhandled report for an evicted key finds nothing and runs nothing.
  • Scenario:
    1. The person holds Cmd-G (findNext, which a page may own) on a busy Chromium page. Each auto-repeat is decided again (router.rs:327-330) and offered.
    2. The renderer lags, so the 17th offer evicts the first.
    3. The first key's unhandled report then runs nothing.
    • Result: Find moves fewer times than the person pressed, and nothing shows it.
  • Fix:
    • Have the platform report the outcome of every offered key, handled ones too (the fork's key acknowledgement), so no count bound is needed.
    • Until then, bound the offers per page, for a stated reason, and emit a visible drop when an offer is evicted.

5. minor: one page's report removes the older offers of every page.

  • Where: page_unhandled (offer.rs:186-192) drains every offer older than the reported one. The reason it gives ("a page reports in key order") is true only within one renderer. The model does the same (model.rs:242-251).
  • Scenario:
    1. In w1, the busy page b1 gets Cmd-F (offer 1).
    2. The person moves to w2, and page c2 gets Cmd-F (offer 2).
    3. c2 reports offer 2 unhandled first, so offer 1 is drained.
    4. b1's later report for offer 1 runs nothing, and Find never opens in w1.
  • Fix: keep the page in Offer, and drain only the older offers of the same page.

6. minor: a bare key with a binding runs its action under a blocking dialog instead of going to the dialog.

  • Where: router.rs:365-386 runs an allowed binding before the dialog check at :387-390.
  • Today's rule: the app gives every key without Command or Control to the blocking dialog before any binding (KeyRouter.swift:186, CmuxDialogCenter.swift:142-147). DESIGN.md 4.4 step 4 says the same.
  • Scenario: a diff viewer page binds bare j (KeyRouter+BareKeys.swift), and a tab-scoped question shows over it. j runs its action on the blocked tab. Expected: Deliver{Dialog}.
  • Fix: for a key without Command or Control, check the dialog before the binding.

7. minor: an input action for an ended sink runs, but a key for the same sink is dropped.

  • Where: in router.rs:422-436, the ended(&sink).is_none() guard makes an ended sink skip the lane and run the action at once. The model does the same (model.rs:694-698).
  • Scenario: T2 exited, and its tab shows. "x" gets Dropped{SinkGone(Closed)}, but Cmd-V gets Run{paste, t2} into the dead terminal.
  • Fix: drop the action with the sink's reason, the same as a key.

On the reference model (finding 5 of the first review, which the author asked me to judge): a restatement of the rules is enough for lane mechanics: order, losses, doubles, the fixed target and the 13 mutants. It is not enough for lifetime, creation and offer rules. Findings 1, 2, 5 and 7 pass both the router and the model by construction. Add scenarios and generator actions for each of them:

  • a reply before the show
  • a workspace switch while a tab's sink is ended
  • offers on two pages
  • an input action for an ended sink

Fixes from the first review, checked

  • F1: part fixed. A creation that names an ended sink renews it correctly, but the visibility rule is wrong (finding 2).
  • F2: part fixed. A failure after the show now ends the sink, but a success before the show is wrong (finding 1).
  • F3: fixed. An input action waits in its lane and runs when a key could go. Under a dialog it runs at once.
  • F4:
    • Fixed: the fuzz runs 20,000 × 200 by default on all cores; there are mutants for I7 and I8; the real router must pass each case before a mutant counts; there is a test for 0 allocations.
    • Deferred, with reasons in DESIGN.md 4.11 and the PR body: the p99 bench (to S3) and the 1,000,000-case run.
  • F6: fixed. The ledger keeps only what can still change, and OwnCall and IdOrder are separate.
  • F7: fixed (LaneLimit::for_memory).
  • F8: fixed. The binding rules read the class of the aimed target.
  • F9: fixed. A run records its action and tab, and the 1193 scenario asserts t1 and then s1.
  • F10: fixed (Press::Nothing).
  • F11: fixed. Chrome targets apply only to Browser kinds, and a creation with a surface key keeps its tab.

S1b, checked and sound

  • Sessions: at most one runs. A key that a session takes reaches no sink. Escape, another key, another window, a focus move, a window close and a newer session each end it, in the same order as today's interceptKeyDown (recorder, link hints, window hints, before bindings).
  • Dialogs: a key typed before a dialog waits with its own target and keeps its order. A key typed under the dialog goes to the dialog. A bound chord still runs.
  • Offers: an offered action runs at most once, by the key's id. DevTools actions and open-browser actions are never offered.
  • Escape: Escape gives the keyboard back only after the person's own move from content, and never when the field uses Escape itself.
  • Panic freedom: src/input has no unwrap, expect, panic! or indexing, and every public item has a doc comment.
  • File caps: router.rs has 656 lines and 29 functions, fuzz.rs has 1072 and 42, model.rs has 984 and 41.
  • Red commits:
    • fb2ea1e (S1b) compiled and failed 14 of 45 tests on assertions (step 0817b7711eaff343164173f4, log read).
    • 94527cb adds only tests against the S1b API. The fix commit only adds kind: and the lane limit to them.

Evidence

  • S1b green, step 1803a1b30c2e0e27010e4125 (log read): fmt passed, and clippy -D warnings passed on all targets. The tests failed only at fuzz seed 39.
  • At the head, red step 757b6f409bd8310487c2f44b and green step 05d9b051358a4c766bb8c84a were both still running when I wrote this. So these are not verified at the head:
    • clippy on the new test code (allocations.rs, lifetimes.rs, the new mutants)
    • the 20,000-case fuzz
    • the 0-allocation test

Nits

  • A repeat of an offered press is decided again. If it then runs (no offer), the press becomes Consumed, so the page got the key-down but never gets its key-up (router.rs:312-314, :327-330).
  • Under a dialog, Run names the blocked tab (lifetimes.rs:184-199 asserts tab: Some("b1")). The effect cannot say that the paste goes to the dialog, so Swift must work that out.
  • A Ready for a sink that is still in gone is ignored, even when it is the new incarnation's first paint (router.rs:229-233). Say in the event's doc that the platform reports readiness again after SinkNew.
  • terminal_copy_mode belongs to the focused terminal, but when the key is aimed at a new terminal, the Ghostty rule applies it to that terminal (binding.rs:89-91).
  • The ledger's I4 check (ledger.rs:10-11) catches only a later delivery of a session's key. A key delivered instead of SessionKey is caught only by the model.
  • SessionKey::Backspace and the fields of SessionKey::Letter have no doc comments.

…clippy collapsible_match)

Green step 05d9b051358a at 5ddd5d8 passed fmt and all 56 key_router::
tests (the 20,000-case fuzz, the 0-allocation test, the 13 mutants) and
failed clippy -D warnings on one collapsible_match in the test target
(fuzz.rs:814). No behavior change.
…r tab shows, offers per page with a visible end, bare keys under a dialog (R-INPUT S1 second review), red

Scenarios for the findings of the second independent review of PR #34052
(receipt #34052 (comment)):
- an exited terminal takes no key after a workspace switch, a tab move or a
  window reopen, until a restart names it again; an exited terminal whose
  tab closes is a new tab when reopened (finding 2);
- a creation done before its tab shows keeps the keys typed for its terminal
  (finding 1);
- a page that does not report unhandled keys (WebKit) gets no offer: its
  chords run at once (finding 3);
- the offers waiting for one page are bounded by the lane limit and the
  oldest past the bound closes visibly; a page that closes closes its
  offers; a handled report closes the offer and the page's older ones
  (finding 4, R9 R-L1/R-L5);
- one page's report keeps the offers of another page (finding 5);
- a bare bound key under a dialog goes to the dialog (finding 6);
- a Paste for an exited terminal is dropped like a key (finding 7);
- copy mode is the focused terminal's, not a new terminal's; the release of
  an offered key reaches its page; a Paste under a dialog names no tab (nits).
The API gets what the tests name: GoneReason::Exited, the PageHandled event,
the OfferClosed effect with OfferEnd, and KeyRecord.page_reports_unhandled.
The ledger checks I4 on key-downs (a key-down typed while a session runs is
the session's unless the session ended first in the call) and accepts an
offer's close. The reference model and the fuzz state the new rules; the
generator adds replies before the show, exits, restarts, workspace switches
and handled reports.

This commit is the red: the router is unchanged, so these tests fail here.
…n key order (R-INPUT S1), red

At dce7c8c the ledger accepted an offer's OfferClosed but did not require
it: an older offer that vanished with no end (the expectation that
679b1d1 replaced) passed the ledger. DESIGN.md 4.7 says a page answers
its keys in key order and every offer ends exactly once, so the offers to one
page must end in key order (I3). This test feeds the ledger both streams
directly, with no router and no model: the silent one must break I3 at the
newer key's run, and the router's order (the older offer closed as handled,
then the newer key's run) must pass.

This commit is the red: the ledger is unchanged.
… key order (R-INPUT S1)

The ledger keeps each open offer's page, and an offer's end (its action's
run, or its OfferClosed) while an older offer to the same page is still
open breaks I3. A page answers its keys in key order (DESIGN.md 4.7), so an
older offer that vanished with no end can no longer pass: the check that
the PR body claimed now exists. Every end the router emits is in that order
(an answer closes the page's older offers before it runs or closes its own
key; a page's end and an overflow close the oldest first).
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (independent subagent, correctness first): FIX at 75ed18a
perf claim: none
R9: pass (no new limit in this delta; the per-page offer bound and LaneLimit::for_memory are unchanged)

Re-review of the delta since my receipt at 679b1d1:

Paths are relative to the repo root at 75ed18a. I compiled and ran nothing. git ls-remote gave this head at the start and again before I posted.

Findings

1. blocker: clippy -D warnings fails at this head, in the new test.

  • Evidence: green step fd523cb817e1ffac5e1b5791 at 75ed18a (log read). All 71 tests passed, but clippy failed: error: this call to clone can be replaced with std::slice::from_ref (clippy::cloned_ref_to_slice_refs) at tests/input_router/key_router/sessions.rs:634 (silent.call(&answer, &[run.clone()]);). The step exits red, so the PR has no green at its head.
  • Fix: silent.call(&answer, std::slice::from_ref(&run));. This is the only &[x.clone()] in the file. The ordered call moves closed and run into an array, which is fine.
  • Delta check: after the fix, only that one line needs checking.

The question asked: is "the older offer closes before the newer key's Run" the design, or a convenience?

It is the design.

  • "Handled" is the right reason.
    • DESIGN.md 4.7 (v1.3) says: "Every offer ends exactly once: its action runs, or OfferClosed { Handled | PageGone | Overflow }. A page's answer (PageUnhandled or PageHandled) closes only that page's older offers."
    • The reasoning is today's app's: PageFirstKeyOffer.swift:7-10 says "Chromium reports keys in the order it got them, so a report releases its key and every older one (a page that handled a key reports nothing for it)".
    • When a newer key's report comes, the page has answered every older key. A page reports only unhandled keys, so an older key with no report was handled. Handled is the correct reason, not an approximation.
    • The action never runs, before or after this change. Today's take also removes the older entries (entries.removeFirst(index + 1)) and runs nothing for them.
    • The only change is that the end is visible. R9 R-L5 ("shows the loss") and the exactly-once rule of 4.7 require that.
  • The order is right, and it is not cosmetic.
    • A page answers in key order, so the effects in one call must also keep key order: the older offer's end comes first, then the newer key's Run.
    • The platform applies effects in order (mod.rs, the InputEffect doc). So a journal or monitor sees every end in typing order.
    • Within one page, older offers have lower ids. page_answered (offer.rs:110-131) closes them in queue order (oldest first), then emits the answered key's Run or OfferClosed.
  • The ledger now requires it.
    • end_offered (ledger.rs:229-234) fails I3 when an offer ends while an older offer to the same page is still open.
    • So a silent removal of an older offer (the expectation before 679b1d1) is caught.
    • So is an older OfferClosed placed after the newer Run. The ledger processes effects in order, so at the Run the older offer is still open.
    • Offers to different pages never constrain each other, as 4.7 says.
    • sessions::the_ledger_requires_the_offers_of_a_page_to_end_in_key_order feeds both streams straight to the ledger, with no router or model. The red step 3b52c986936b fails it on its assertion (left: [], right: [(OrderKept, Some(InputId(2)))]).
    • Every remaining place where the router changes offers (offer.rs:98, :112, :119, :138) emits an effect for the removed offer.
  • Doc gap (minor): the I3 row of DESIGN.md 4.8 (line 455) still says only "Each sink receives its keys in typing order". The ledger doc (ledger.rs:9-10) and 4.7 hold the offer rule. Add it to the I3 row too, so the design table and the check agree.

Third-review fixes, checked

  • A failed restart:
    • creation() (lifetime.rs:57-62) records restarts_exited when a creation renews an Exited sink.
    • creation_failed (lifetime.rs:87-96) ends such a sink as Exited again, so track_gone keeps it ended wherever its tab shows.
    • The model has the same rule. The fuzz now keeps the tab when a restart fails (fuzz.rs, Awaiting.restart).
    • lifetimes::a_failed_restart_of_an_exited_terminal_keeps_it_ended_wherever_it_shows covers a restart that fails, the time after it, a workspace switch and back, a window reopen, and a second restart that works.
    • The app's tab.restart only restarts a dead tab (TabRestart.swift:37), so every restart through it renews an Exited record.
  • Dialogs before sessions:
    • dialog_first (dialog.rs:33) takes a key with no Command or Control, in a main cmux window and with no input method composing, before any session or binding. This matches today's interceptKeyDown: the dialog at KeyRouter.swift:186, then the recorder at :197, link hints at :207 and bare keys at :226.
    • The session keeps running, and a key of another window still ends it first (end_session_of_other_window).
    • In the ledger, past_session (ledger.rs:281) accepts a dialog's key while a session runs, and still fails a key that goes to any other sink, an action or AppKit. I traced every path; no false red or false green.
    • DESIGN.md 4.4 (v1.4) puts dialogs at step 3, and section 8 lists the change.
  • Nits from round 3:
    • DESIGN.md 4.3 now says a reloaded crashed page is new only through a Creation, and has the S3/S4 caller checklist.
    • Your reason for the model's forget_shown_creations holds: a done creation kept for good would still hold an expectation's sink after its tab moved to another window and then left every window. Both the router and the model now forget it once a window shows it.

Evidence (logs read)

  • Third-review red cc5b682f6dcf (6d4725c): 2 of 70 tests failed, exactly the two new scenarios, on their assertions:
    • Dropped{CreationFailed} was expected to be Dropped{Exited}.
    • The dialog got [] where [InputId(1)] was expected.
  • Third-review green ce4553b5b2b1 (dce7c8c): fmt passed, clippy passed on all targets, and 70 of 70 tests passed.
  • Ledger red 3b52c986936b (1525c55): 1 of 71 tests failed, the new ledger test, on its assertion. It adds only a test, so it is a fair red.
  • Ledger green fd523cb817e1 (75ed18a): fmt passed and 71 of 71 tests passed, among them the 20,000 × 200 fuzz, 13 mutants and the 0-allocation test. Clippy failed (finding 1).
  • Code rules: file caps hold (ledger.rs has 289 lines and 11 functions, sessions.rs has 644 and 35). src/input still has no unwrap, expect, panic! or indexing.

Nits

  • In the router, a second Creation for a key that already has one replaces the earlier one with no effect. That is right if the caller sends exactly one reply per creation, which is the new checklist rule. A creation that restarts an Exited sink keeps restarts_exited only on its own record, so a repeated Creation for the same restart loses it: the first one already removed the gone record. That path is open only if the caller breaks the checklist.

…with slice::from_ref (clippy cloned_ref_to_slice_refs)

Green step fd523cb817e1 at 75ed18a passed fmt and 71 of 71 key_router::
tests, and failed clippy -D warnings on this one line of the new ledger
test (sessions.rs:634). No behavior change.
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Non-green checks at 121ee31, classified before any merge decision. None of them comes from a defect in this change.

The fleet evidence of this change is in the PR body: fmt, clippy -D warnings on all targets, and all 71 key_router:: tests, run with the same command at every head.

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (independent subagent, correctness first): SHIP at 121ee31
perf claim: none
R9: pass (this delta adds no limit; the per-page offer bound and LaneLimit::for_memory are unchanged)

Delta review only, as asked: 75ed18a..121ee31. That is one commit, and its parent is 75ed18a (checked). git ls-remote and the PR both give 121ee31 as the head. I compiled and ran nothing on this machine.

Findings

None.

Checked

  • The diff: one line, tests/input_router/key_router/sessions.rs:634, from silent.call(&answer, &[run.clone()]); to silent.call(&answer, std::slice::from_ref(&run));. This is the fix for the blocker of my receipt at 75ed18a. The slice has the same one effect, so the test means the same thing.
  • It compiles as written:
    • Ledger::call takes effects: &[InputEffect] (ledger.rs:111).
    • The borrow of run ends with the call, and run is moved later, into ordered.call(&answer, &[closed, run]).
    • The file has no use std::slice, so the workspace's unused_qualifications lint (a warning, an error under -D warnings) does not fire on the full path.
  • No other instance: no other &[x.clone()] pattern in src/input or tests/input_router at this head (git grep).
  • Fleet green step 16e6bc1970409922d420cd45 at this head (log read):
    • fmt passed, and clippy -D warnings passed on all targets.
    • 71 of 71 key_router:: tests passed, 0 flaky, and the step ended "cmux-tui-rust-check: green".
    • The tests include the 20,000 × 200 fuzz, every_mutant_is_caught, the 0-allocation test and the new ledger order test.
  • Design: the I3 row of DESIGN.md 4.8 now states the offer order rule, so the doc gap from my last receipt is closed.

Carried over from my receipt at 75ed18a

  • Every finding of the four earlier reviews is fixed.
  • One nit remains: a repeated Creation for the same restart loses restarts_exited. It can happen only if the caller breaks the S3/S4 checklist rule of one reply per creation.

Not verified

  • Anything in the app: the router has no caller yet. S3 links it in shadow mode.

@lawrencecchen
lawrencecchen marked this pull request as ready for review November 4, 2026 01:08
@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 Nov 4, 2026
@cursor

cursor Bot commented Nov 4, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot uses usage-based billing for this team and requires on-demand usage to be enabled.

A team admin can enable on-demand usage in the Cursor dashboard.

This branch was previously deployed

3 inactive deployments
artifacts — 121ee319 Deployed Nov 4, 2026 by lawrencecchen via publish tree-addressed cmux-tui #108242
sentry-dsyms — 121ee319 Deployed Nov 4, 2026 by lawrencecchen via upload cmux-tui dSYMs to Sentry #108242
ci-routing-agent — 121ee319 Deployed Nov 4, 2026 by lawrencecchen via wait for same-key tree publisher #108241
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