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

Merged
LA
2d ago
+7128-5

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 (#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>.

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

FindingTestRed (0515bf7b748)Green (f7edc24147a)
Major 1: a Created reply before the tab showskey_router::lifetimes::a_creation_done_before_its_tab_shows_keeps_the_keys_for_its_terminalFAIL: the key went Deliver { Content("b1") }, expected Deliver { Host("T9"), Direct }PASS
Major 2: an exited terminal, then a workspace switch or a window reopenkey_router::lifetimes::an_exited_terminal_takes_no_key_after_a_switch_or_a_reopen_until_a_restartFAIL: 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_offerFAIL: Cmd-R got Offer, expected Run { browserReload }PASS
R9 R-L1/R-L5: offers bounded per page, with a visible endkey_router::sessions::offers_past_the_bound_close_visiblyFAIL: 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.

FindingTestRed (6d4725ca36d)Green (dce7c8c23fb)
Major: a failed "Restart Shell Here" revived a dead terminal after a workspace switch or reopenkey_router::lifetimes::a_failed_restart_of_an_exited_terminal_keeps_it_ended_wherever_it_showsFAIL: Dropped { SinkGone(CreationFailed) }, expected SinkGone(Exited) (step cc5b682f6dcf)PASS (step ce4553b5b2b1)
Minor: a key session took bare keys under a blocking dialogkey_router::sessions::a_blocking_dialog_takes_bare_keys_before_a_running_sessionFAIL: 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 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 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-tests and the concurrency part of cmux-next checks are base findings; Check the app FFI pin is this PR's own and expected, because cmux-focus-core is 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 to None, 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_FIRST and CMUX_INPUT_FUZZ_CASES).

Changelog

none

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