R-INPUT S2b (FFI step A): the app's one input handle cmux_input_* over the input router, with no copy per focus event

Merged
LA
2d ago
+1982-596

REWRITE R-INPUT stage S2, step A of the FFI two-step (DESIGN.md section 5, /Users/lawrence/fun/wt/handoff/rewrites/R-INPUT/DESIGN.md): one input handle for the app, cmux_input_*, over cmux-focus-core's input router, which owns every window's focus (WindowFocus, S2a #34929). Rust only: no Swift caller yet. Step B (after the app-ffi re-pin) moves the Swift FocusMachine onto it, one window id per call; the per-window cmux_focus_* handle is deleted in that stage's cleanup.

What changes:

  • One reply writer for both handles. The window focus handle's reply buffers move to focus/reply.rs (FocusReply) and its wire decoding to focus/codec.rs. focus.rs keeps the cmux_focus_* ABI unchanged. So a second handle can answer with the same bytes.
  • input.rs: the app's input handle. Input holds the router and one boxed FocusReply per open window (the windows' map moves its entries when it grows; a reply the app holds for a window stays where it is until the next call for that window: one allocation per window open, none per event). cmux_input_window_open/close, and cmux_input_focus_* = each cmux_focus_* call with a window id (send, set_topology, current, set_close_focus, recent_panes, remembered, export/import, the Close Tab aim calls). The C header declares them; their press arguments are the same two bytes as cmux_focus_*'s.
  • No copy on the way through the router (second commit). InputRouter::send_focus takes a focus event by reference; set_focus_topology takes a topology by value and calls WindowFocus::set_topology (no clone). An equal topology does not run the reducer, so the router rebuilds no tab index for it. A person's move out of a content pane keeps the pane in a buffer the window owns, not a copy of the resolved target per event. The handle writes each reply from the router's own effects slice (InputRouter::effects, FocusReply::finish takes an iterator), not a copy of the effects. A topology report has no event, so session_after_focus takes an Option. Every topology report still judges the sinks that ended (track_gone), equal or not; only the index rebuild is skipped for an equal one (the router fuzz caught my first version, which skipped both: seed 217, a closed terminal that no window showed stayed gone after an equal report, so a later key for a new terminal with its id was dropped).

Tests

cmux-layout-reducer-ffi/tests/input_handle.rs (new):

  • the_input_handle_replies_to_every_corpus_step_as_the_window_handle: the recorded focus corpus (tests/focus/corpus.json, the one the focus rules are checked against) replays through the window handle and the input handle. Every step's reply must match: all fields, the decoded strings, the effects, the overlays, and the raw string bytes. Then the focus history, the window record, the journal export and the Close Tab aim must match. It also counts this thread's allocations: over the corpus the input handle makes no more allocations per focus event than the window handle (a small warm-up per sequence), and per topology report at most the router's tab index on top.
  • an_equal_topology_report_adds_no_allocation_in_the_router: an equal report of a corpus layout with at least 4 tabs; the input handle makes no more allocations than the window handle (it fails if the router rebuilds its tab index for an equal report). Both handles clone the resolved target once per topology report in WindowFocus::set_topology: a shared cost since S2a, not this PR's.
  • a_windows_reply_does_not_move_while_other_windows_open_and_close (review of this PR): window A's reply and string-list pointers stay the same while 40 other windows open and 8 close.
  • the_c_abi_refuses_bad_input_with_its_documented_values (review of this PR): every cmux_input_* edge through the C ABI: a null handle, null pointers, bad UTF-8, a window that is not open (and no call opens it), an event that does not decode (ok 0), a checkpoint that does not decode (ERR_INVALID, the state kept).
  • a_window_that_is_not_open_has_no_reply_and_another_windows_focus_is_its_own.

The FFI crate is its own Cargo workspace, so the fleet's cmux-tui-rust-check.sh does not build it. The proof refs add it to the cmux-tui workspace (a never-merge commit, as for S2a):

  • Red, fix/r-input-s2b-proof-red2-nomerge 100f98253bde (the first commit c667ee47683, which still copies, plus the proof commits), cmux-tui-rust-check.sh test input_handle on Linux, step 965caba8b86cb2e9ea6567a5: every corpus step's reply matched (2,468 focus events and the topology reports of every sequence), then the allocation check failed: "the input handle made 24441 allocations over 2468 focus events, the window handle 19009" (about 2.2 more per event: the copy of the event, of each effect and of the resolved target).
  • Green, fix/r-input-s2b-proof-green4-nomerge 923bc717e508 (this head a200b26051d plus the proof commits), cmux-tui-rust-check.sh all on Linux, step 23318abadb2afcdd4249134d: fmt, clippy -D warnings and doc tests pass; every test of cmux-focus-core (with the router fuzz) and of cmux-layout-reducer-ffi (with input_handle) passes. The workspace tests step is red only in other crates: 29 failures are exactly the base list of dd03fbf83bb4, and 3 are not in it (below).

Tests outside the two changed crates: no crate in cmux-tui/Cargo.lock depends on cmux-focus-core (the FFI crate is its own workspace), so this PR cannot change them.

  • 29 failures of the green step are exactly the base list of the base-red owner's whole-suite run dd03fbf83bb4 at b657cec72db (acpmux, cmux-agent-pane-policy, cmux-tui).
  • Failed in a loaded full-suite run, not reachable from this change (each passed in the base run dd03fbf83bb4; the lead gives them to the base-red owner). Each passes alone on this head:

Review round 1 (FIX at a200b26051d: a window's reply was kept inline in the windows' HashMap, so opening more windows moved it and the app's pointer for another window read freed memory, against the header). Red test 1dd26d04384; fix 79eeff1c877 (out: Box<FocusReply>; the header also names ERR_INVALID for the import call and ERR_NULL for a null handle). Red step 0bc5ba4214b37456737801a1 on fix/r-input-s2b-proof-red5-nomerge 683bd0dc9a6c: the reply test fails on its assertion ("the window's reply moved": 0x7f2ae4002548 at the open, 0x7f2ae4007e28 after the other windows). Green step 8bd5e053ead82e13329ff2b4 on fix/r-input-s2b-proof-green5-nomerge 8eecefe9e1fc (this head + the proof commits), Linux all: fmt, clippy -D warnings and doc tests pass; every cmux-focus-core test (with the router fuzz) and every cmux-layout-reducer-ffi test (with the five input_handle tests) passes. Workspace tests are red only in other crates: 28 of the dd03fbf83bb4 base list, wait_for_off_dispatch_refuses_over_capacity_without_queueing again, and three that passed in dd03fbf83bb4 and fail in this loaded run: acpmux::daemon_idle acpmux_daemon_idle_has_no_periodic_wakeups, acpmux::send_attach_cli send_attach_sends_files_of_any_size_and_number_by_reference, cmux-tui-core server::tests::identify_and_protocol::title_changed_event_includes_authoritative_surface_title (none reachable from this change; alone on this head: idle step 2d2b65f90927b3d43a080483 passed, attach step 7e4fb028b99da10bf756949b passed, title step 5fcd4fb3eba36b019081d15e passed). For S3: import_focus does not clear the router's effects first (no reader of them follows an import today).

Sanitizer (lead): no ASan, miri or valgrind path exists for a Rust FFI crate on the fleet. cmux-tui-rust-check.sh has no sanitizer mode, and the controller's step environment allowlist has no RUSTFLAGS (hq build-fleet/README.md: the only sanitizer toggle is CMUX_SWIFT_SANITIZE for Swift steps). The repo's one valgrind job (cmux-tui.yml valgrind-leak-check-shard) runs only in CI full mode, on the cmux-tui and terminal startup tests. So no sanitizer ran here. The reply test does not depend on luck: it compares addresses and never reads freed memory. On the unboxed head, the map's growth must move window A's entry, because the new table is allocated while the old one is still live (the red step shows it). The ABI edge test reads each reply before the next call only. The lead accepted this plan (no new sanitizer mode now): the app's own reads of a reply get a sanitizer in step B: the Swift FocusMachine on cmux_input_* (after the re-pin) runs its focus and creation-hold suites with CMUX_SWIFT_SANITIZE=address, whose malloc and free interceptors also cover the Rust library's heap.

Review: SHIP at 79eeff1c877 (https://github.com/manaflow-ai/cmux/pull/35641#issuecomment-6638261510). Its two optional points (the C ABI test does not call every export, and the bad-checkpoint case does not compare export_json before and after) go into step B's PR, which calls these exports from Swift.

CI: the one red check is "Check the app FFI pin" in cmux-next checks: the FFI two-step's step A changes the FFI sources, so the pin's source sha no longer matches until the release bot publishes cmux-app-ffi-<merge sha> and its re-pin PR lands (the same red as S2a #34929).

Observed / Not run

  • Observed: the fleet steps above.
  • Not run: any sanitizer on this crate (none exists on the fleet for it, above).
  • Not run: a Swift caller (step B) and its KeyRouterBench A/B; the router fuzz at 1,000,000 cases runs on the merged commit as the stage's test phase (DESIGN.md section 6).

Changelog

none

No capture: no visible change (Rust only, no caller).