R-INPUT S1: the one input router in cmux-focus-core (core, lanes, sessions, dialogs, page-first chords), Rust only
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.
InputRouterowns 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. TheSinkEndedandSinkNeweffects 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
OfferClosedreason; 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 (#33049). ledger::Ledgerchecks 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
Tabgets the host terminal id (terminal). The C topology does not carry it yet: the FFI decode setsNone(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_SEEDreplays 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 ff367cc22ec9 (today's two key rules kept), step 16411615e4296124666e63c5: 25 run, 11 failed on their assertions, 14 passed.
- S1a green at c499a3bbae48, step 759965538c9001efaac5aac4: fmt and 25 of 25 tests passed; clippy failed on one method name (fixed in d039085356ac). Step c896db46ef6b5a59b317ae1c at d039085356ac was cancelled by infra (60 min in the script-version check) and is replaced by the green below.
- S1b red fb2ea1e4adad (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 e3c5be0410e8, 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 94527cbb4c5c (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 5ddd5d80091c, step 05d9b051358a4c766bb8c84a: fmt passed, 56 of 56 tests passed, clippy failed on one
collapsible_matchin the test target (fixed in 55ac76cc0fa). - Green 55ac76cc0fa4, step 1a43b14343bea2b210bf8af2: passed. Fmt, clippy
-D warningson all targets, 56 of 56key_router::tests (the 20,000 x 200 fuzz in 3.3 s, the 0-allocation test, the 13 mutants). - Second-review red 0515bf7b7483 (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 f7edc24147ae, step 2dfb26f0dc2cca99f6d3c4dc: fmt and clippy passed, 67 of 68 (one stale assertion, above).
- Green 679b1d1e2759, step 543452f980475a65a96bb360: passed, 68 of 68.
- Third-review red 6d4725ca36d5, step cc5b682f6dcf0ad9921db3cc: 70 run, 2 failed on their assertions (the two scenarios above), 68 passed.
- Third-review green dce7c8c23fb4, step ce4553b5b2b1b18c60955010: passed, 70 of 70, fmt and clippy.
- Ledger red 1525c55212b6, step 3b52c986936b10ae232eadc6: 71 run, 1 failed on its assertion (the new ledger test), 70 passed.
- Ledger green 75ed18ac6c04, step fd523cb817e1ffac5e1b5791: fmt passed, 71 of 71 tests passed, clippy failed on one test line (fixed in 121ee319103).
- Green 121ee3191037 (the head), step 16e6bc1970409922d420cd45: passed. Fmt, clippy
-D warningson all targets, 71 of 71key_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 8d74ac2ee947: 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 5ddd5d80091 router, green on the head; the fuzz is not the proof):
- The red commit 0515bf7b748 is the 5ddd5d80091 router: 55ac76cc0fa changes one test line (the clippy fix), and 0515bf7b748 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 5ddd5d80091 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 0515bf7b748; green step 2dfb26f0dc2cca99f6d3c4dc at f7edc24147a.
| Finding | Test | Red (0515bf7b748) | Green (f7edc24147a) |
|---|---|---|---|
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 f7edc24147a: fmt and clippy passed; 67 of 68 passed, with the 4 above passing. Green step 543452f980475a65a96bb360 at 679b1d1e275: passed, 68 of 68.
One test expectation changed to match the new code (679b1d1e275), 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 75ed18ac6c0 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 1525c55212b (step 3b52c986936b): this one test failed on its assertion (the old ledger reported no violation for the silent stream), 70 of 71 passed. Green 75ed18ac6c0 (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 121ee319103 with std::slice::from_ref.
Third review (at 679b1d1e275, receipt): FIX, one major, one minor; red 6d4725ca36d and green dce7c8c23fb fix both.
| Finding | Test | Red (6d4725ca36d) | Green (dce7c8c23fb) |
|---|---|---|---|
| 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 dce7c8c23fb: 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 aCreatedfor a creation whose stand-in has not shown yet.World::end_shownsendsGone { Exited }while the tab shows;World::switch_awayshows another workspace, action 105 shows the window's own workspace again, action 98 closes and opens window w2, andWorld::restartnames a shown terminal again.World::keysetspage_reports_unhandled: falseon 30 % of keys.
Review: the first independent review (at d039085356ac) found 4 major and 7 minor defects (receipt); 94527cbb4c5 and 5ddd5d80091 fix them. The second (at 5ddd5d80091) found 3 major and 4 minor defects (receipt); 0515bf7b748, f7edc24147a and 679b1d1e275 fix them. The third (at 679b1d1e275) found 1 major and 1 minor defect; 6d4725ca36d and dce7c8c23fb 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 1525c55212b and 75ed18ac6c0 fix. The fourth (at 75ed18ac6c0, receipt) found only one clippy line in the new ledger test, fixed in 121ee319103, and confirmed that the changed offer expectation is the design. The review of that delta: SHIP at 121ee3191037a1b66eb34bf76a55ddcc8c6aa2b8 (receipt).
Observed / Not run
- Observed: the fleet steps above.
- Non-green PR checks, classified in https://github.com/manaflow-ai/cmux/pull/34052#issuecomment-6619584016: the Windows cargo check,
tooling-testsand the concurrency part ofcmux-next checksare base findings;Check the app FFI pinis this PR's own and expected, becausecmux-focus-coreis an app FFI input and the release is published only after the feat-cmux-next push (the bot then opens the app-ffi-repin PR; #32564 and #32055 landed the same way). - Not run: anything in the app. Nothing calls the router yet; S3 links it in shadow mode behind
input.router.shadow. No Swift change here. The app FFI decode change sets one new field toNone, so the app's behavior is unchanged; the FFI release workflow will publish a new pin. - Not in this PR, with the reason: the router p99 timing bench (no reviewed runner for a new Rust bench; S3's KeyRouterBench A/B measures the router in the real key path), and the 1,000,000-case fuzz run (after this green, in 5 steps of 200,000 with
CMUX_INPUT_FUZZ_FIRSTandCMUX_INPUT_FUZZ_CASES).
Changelog
none
No capture: Rust-only library code with no caller; nothing in the app changes.