Skip to content

R-INPUT S2a: the input router reduces each window's focus as the app's focus handle does (WindowFocus), Rust only - #34929

Merged
lawrencecchen merged 2 commits into
feat-cmux-nextfrom
fix/r-input-s2a-window-focus
Nov 4, 2026
Merged

lawrencecchen merged 2 commits into
feat-cmux-nextfrom
fix/r-input-s2a-window-focus

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

R-INPUT stage S2a (DESIGN.md, stage S2). Stage S2 makes the input router (cmux-focus-core input::InputRouter, #34052) own every window's focus, so that the app's per-window focus handle can become the router's in S2b and S2c. Today the router and the app's handle reduce the same focus event in different ways:

  • A topology report: the router runs reducer::reduce again. The app's handle takes reducer::set_topology's fast path: an equal topology changes nothing. The daemon sends the topology again after every delta, so the router reported ran for events that the app's handle drops.
  • The router had no address bar memory (Chrome's per-tab omnibox focus). The app's handle keeps it beside the state.

This PR makes the router reduce focus as the app's handle does. It changes no caller and no app behavior:

  • New cmux_focus_core::window::WindowFocus: one window's focus (the state, the address bar memory, the Close Tab aim), with the operations that the app's focus handle performs (send, set_topology, pane_moved, frame_presented, pane_emptied, aims_at_emptied_pane, set_close_focus, import, rederive).
  • The FFI focus handle (cmux-layout-reducer-ffi src/focus.rs) now holds a WindowFocus and delegates to it. The C ABI and the reply bytes do not change.
  • The router holds a WindowFocus for each window, and a focus event goes through WindowFocus::send. After the focus effects, the router emits the new InputEffect::FocusReduced { window, reduction }: whether the reducer ran and what the address bar memory asks for. The app's reply carries the same data.
  • The fuzz's reference model also holds a WindowFocus for each window, so the fuzz compares the routing over the same focus rules.

This is step A of the FFI two-step rule (STATUS 5388): cmux-focus-core and cmux-layout-reducer-ffi are app FFI inputs. After this lands, the bot publishes the FFI release and opens the re-pin. There is no Swift half: the ABI does not change.

Test (cmux-tui/crates/cmux-focus-core/tests/input_router/key_router/window_focus.rs):

  • key_router::window_focus::the_router_reduces_focus_events_as_the_apps_focus_handle_does: the same focus events go through the router and through a WindowFocus (the app handle's type). The events are a topology, the equal topology again, a mouse focus on a page pane, the address bar by keyboard, a mouse focus on the terminal pane, a keyboard focus back to the page pane, and the return event. The router's focus effects, its FocusReduced reduction and the window's state must be equal to the handle's after each event.

Red/green, the same filter key_router:: (scripts/ci/cmux-tui-rust-check.sh --os linux):

  • Red 0c9fe16 (the test and WindowFocus, with the router as on feat), mode test: step 3d17eb26c48f34c41d3ce7b1. 72 tests ran and 1 failed: key_router::window_focus::the_router_reduces_focus_events_as_the_apps_focus_handle_does, on its assertion at window_focus.rs:68, on the equal topology report (left Some(Reduction { ran: true, .. }), right Some(Reduction { ran: false, .. })). It failed again when it ran alone. The first red step (bf51f012572b93d9e4bd7045) also ended in failure, but its log is lost: GitHub answers BlobNotFound and the controller has no full log. So I ran it again.
  • Green d5cb5d4, mode all: step 28af93a87fd6752c31c2dbd8. fmt passed. Clippy (--workspace --all-targets -D warnings) passed. 72 of 72 key_router:: tests passed: the new test, the router fuzz (20,000 cases against the reference model), key_router::mutants::every_mutant_is_caught and key_router::allocations::a_key_its_release_and_a_bound_chord_allocate_nothing (a key still allocates nothing).
  • FFI crate: cmux-layout-reducer-ffi is its own Cargo workspace, so the fleet's Rust check does not build it. The never-merge ref fix/r-input-s2a-ffi-proof-never-merge (439a4c8, listed in stopped.txt) adds the crate to the cmux-tui workspace and runs every workspace test, including the FFI crate's Swift-recorded focus corpus (the_rust_focus_rules_reproduce_every_recorded_swift_step) and the handle tests in close_focus_rules.rs. Step 6203ede8b4514ffe3266189d (mode test, no filter): cmux-layout-reducer-ffi 74 of 74 passed, cmux-focus-core 133 of 133 passed. The step as a whole failed on 10 tests in crates that this PR does not touch (acpmux 3, cmux-agent-pane-policy 2, cmux-tui 4 and their reruns; for example acpmux::hermetic_daemon_guard every_test_binary_runs_in_the_hermetic_environment, which the proof ref's extra workspace member can cause, and cmux-agent-pane-policy::typescript the_page_sends_only_allowed_acpmux_meta). The GitHub check cmux-tui/crates/cmux-layout-reducer-ffi of this PR also builds the FFI crate in its own workspace, runs clippy -D warnings on all targets and runs every FFI test: it passed.

Independent review: SHIP at d5cb5d4 (#34929 (comment)). Its three small points go to a follow-up (S2b touches the same files): an FFI handle test of the reply to an equal topology; a reference model that keeps reducer::reduce for topology events, so that the fuzz also proves the fast path changes no routing; and a doc line that the FFI sends a topology only through set_topology.

Observed / Not run

  • Observed: the fleet steps above.
  • Not run: anything in the app. This is Rust only, and no caller changes until S2b. The S3 gate (KeyRouterBench p99 ≤ 2 µs and 0 allocations per key before any route turns on) does not apply here: this stage turns on no route.

Changelog

none

No capture: Rust-only library code; the app's behavior does not change.

…focus handle does (R-INPUT S2a), red

Stage S2 makes the router own every window's focus, so the app's per-window
focus handle can become the router's. Today the two reduce differently:
- the router reduces a topology report again (reducer::reduce), while the
  app's handle takes reducer::set_topology's fast path (an equal topology,
  re-reported after every daemon delta, changes nothing);
- the router has no address bar memory (Chrome's per-tab omnibox focus),
  which the app's handle keeps beside the state.

This commit adds:
- cmux_focus_core::window::WindowFocus: one window's focus (the state, the
  address bar memory, the Close Tab aim) with the operations the app's
  handle performs. The FFI handle (cmux-layout-reducer-ffi focus.rs) now
  wraps it and delegates, with no ABI or reply change.
- InputEffect::FocusReduced: after a focus event's effects, whether the
  reducer ran and what the address bar memory asks for (the app's reply
  carries this).
- key_router::window_focus: the same focus events through the router and a
  WindowFocus; effects, reductions and state must be equal.

The red: the router still reduces as today (it reports ran from
reducer::reduce and no address bar memory), so the test fails here.
…uces focus events as the app's focus handle does (R-INPUT S2a)

The router's per-window entry holds a cmux_focus_core::window::WindowFocus
(the state, the address bar memory, the Close Tab aim) instead of a bare
FocusState, and a focus event goes through WindowFocus::send:
- a topology report takes reducer::set_topology's fast path (an equal
  topology changes nothing), as in the app's handle;
- the address bar memory notes every reduction, and the router reports what
  it asks for in InputEffect::FocusReduced.
The reference model holds a WindowFocus per window too, so the fuzz compares
routing over the same focus semantics. No caller yet: stage S2b gives the app
one cmux_input_* handle over the router, with the focus handle's replies.
@lawrencecchen lawrencecchen added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Nov 4, 2026
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (independent subagent, correctness first): SHIP at d5cb5d4
perf claim: none
R9: pass (no new limit; the router now holds one OmnibarFocusMemory per window, bounded by its existing LIMIT = 64, which this PR does not change)

Reviewed at d5cb5d4: git ls-remote and the PR agree, and the PR is open. There are two commits on ab7e724, which is on feat-cmux-next: red 0c9fe16 and green d5cb5d4. I compiled and ran nothing.

Findings

None that block.

1. WindowFocus behaves as the old FFI Focus handle did

  • Operation by operation, the new delegation runs the same code: new, set_topology (the equal-topology fast path stays reducer::set_topology), pane_moved, frame_presented, pane_emptied, aims_at_emptied_pane, set_close_focus, import (the omnibar memory and the close aim are not reset, as before), rederive as used by recover, state, recent_panes and remembered.
  • One change in order: omnibar.note now runs before the FFI finish, not after it. finish only reads the state and the effects, and the two omnibar reply fields are written after finish in both versions, so no reply byte changes.
  • Panics: a panic inside send still ends in recover, which rederives the state and rewrites the reply with ok = 0, as before.
  • The topology redirect is unreachable from C: WindowFocus::send sends a FocusEvent::Topology to set_topology. The old Focus::send would have used reducer::reduce, omnibar.note and take_return for one. But Focus::send has one caller, cmux_focus_send, and its decode_event (kinds 0 to 15) never makes a Topology. Topology reaches the handle only through cmux_focus_set_topology, whose path is unchanged. So the redirect cannot change any reply.
  • The C ABI is unchanged: no extern "C" signature, no #[repr(C)] struct and no header changes. The diff of focus.rs touches only the Focus impl and its imports, and every remaining import is still used.

2. The router

  • Focus events: focus_event reduces through WindowFocus::send, drains the focus effects, then pushes InputEffect::FocusReduced. It still reindexes and runs track_gone on every topology event, whatever ran is.
  • The only change of state is the topology fast path, where the old router reduced an equal topology again. is_settled_on takes the fast path only when the topology is equal, no expectation waits, and the pane, its memory, the chrome target, the history and the focus-mode tabs already agree. In that case reduce would also change nothing that routing reads: no effects, the same pane, target, overlays, expectation and derived values. The one possible difference is an empty history entry for the shown workspace in an imported checkpoint, which reduce deletes and the fast path keeps. Routing never reads it, and the router never imports.
  • The omnibar memory only computes the FocusReduced flags; no rule of the router reads it.
  • dialog.rs and the .focus() reads return the same FocusState as before.
  • So no routing decision changes.
  • Ledger and Tracker: FocusReduced has no key id, so the ledger skips it. The fuzz Tracker ignores it with the other non-key effects.

3. The model and the fuzz

  • Before this PR, the model and the router both called reducer::reduce. Now both call WindowFocus::send.
  • Their routing logic is still written separately on each side: aim, dialogs, sessions, lanes, offers and lifetimes. So shared code cannot hide a routing bug.
  • What they share is the focus state, which is an input to routing; the focus reducer's own tests cover it.
  • One cross-check is lost (nit 2).

4. Evidence (logs read)

  • Red step 3d17eb26c48f34c41d3ce7b1 at 0c9fe16: 1 of 72 tests failed, window_focus::the_router_reduces_focus_events_as_the_apps_focus_handle_does, at the second (equal) topology: ran: true against ran: false. It failed again alone, so it is a fair red. The red's router keeps today's rule (reduce on every event, no omnibar memory).
  • Green step 28af93a87fd6752c31c2dbd8 at this head: fmt passed, clippy -D warnings on all targets passed, and 72 of 72 key_router:: tests passed, 0 flaky. These include the 20,000-case fuzz, all mutants and the 0-allocation test.
  • The FFI crate is outside the cmux-tui workspace, so that step does not build it. The GitHub check cmux-tui/crates/cmux-layout-reducer-ffi covers it: it ran on the merge ref e236547, whose parents are 4a1d91d and this head.
    • It ran cargo clippy --workspace --all-targets -- -D warnings, clean.
    • It ran cargo test --workspace: every test binary passed, including close_focus_rules (7, among them the handle's omnibar-return, typing-tab and close-aim tests through the handle and the C functions) and focus_rules (10).
  • Not verified: the never-merge proof step 6203ede8b4514ffe3266189d (439a4c8) was still running when I posted. The first red step bf51f012572b has no log, and 3d17eb26c48f replaces it.

5. Risk tier and the FFI rule

  • risk:normal is right. The change ships through the app FFI with no behavior change, and it is not in any high-tier class.
  • This is the FFI rule's step A. The C ABI does not change, so there is no Swift step B. The change reaches the app at the next automatic re-pin, because cmux-focus-core is an app FFI input.

Non-green GitHub checks

Nits

  1. window_focus.rs checks the router against WindowFocus, the code the router now calls. It guards the wiring: that send is called, the order of the effects, and FocusReduced. It cannot show that WindowFocus equals the old FFI handle; code reading and the FFI handle tests show that. No test covers the handle's reply for an equal topology (ok 1, changed 0, effect_count 0), import or recover. Their code did not change, but a handle test for the equal-topology reply would pin the one path that depends on the fast path.
  2. The model's switch to WindowFocus drops a free check. Had the model kept reducer::reduce (no fast path), the fuzz would show on every topology event that the router's new fast path changes no routing. My reading says it does not (point 2), but the fuzz would then prove it for free.
  3. WindowFocus::send handles a Topology event differently from the old Focus::send. No C path reaches it today. Say in its doc that the FFI sends topologies only through set_topology, so a later C kind for topology does not silently change the omnibar flags.

@lawrencecchen
lawrencecchen marked this pull request as ready for review November 4, 2026 06:42
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

REWRITE R-INPUT: the non-green check on head d5cb5d4 is a base finding. This PR changes only cmux-tui/crates/cmux-focus-core and cmux-tui/crates/cmux-layout-reducer-ffi/src/focus.rs.

Independent review: SHIP at d5cb5d4 (#34929 (comment)).

Sequencing: I add ready-to-land after the bot's re-pin for #34641 (cmux-app-ffi-2e0b6acc3593) merges. This PR is also an app FFI input, so its merge starts a new FFI release; landing it first would move that re-pin, which #34685 waits for.

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

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

Review d5cb5d4: 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=pr34929 -f ref=fix/r-input-s2a-window-focus -f platform=macos -f backend_mode=local

Shipped in:

This branch was previously deployed

3 inactive deployments
artifacts — d5cb5d44 Deployed Nov 4, 2026 by lawrencecchen via publish tree-addressed cmux-tui #109703
sentry-dsyms — d5cb5d44 Deployed Nov 4, 2026 by lawrencecchen via upload cmux-tui dSYMs to Sentry #109703
ci-routing-agent — d5cb5d44 Deployed Nov 4, 2026 by lawrencecchen via cmux-tui tree preflight #109703
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