fix(focus): a browser page that has focus but not the keyboard takes the keys from its cmux window
Summary
Keys typed into a web page right after it loads from the address bar were lost (P0 browser-click-then-type; journey kit cp/reopen-video-kit@4ff1d5e5ce6). Typing "fieldkeysok" into a focused text field left "fok", and the page logged a click no one sent. Two causes, both fixed here:
- Link hints took the letters (the root cause, STATUS 7518 / 7543). After an address-bar load, Chromium reports a letter typed into the focused field as one no editable field took: CEF's
focus_on_editable_fieldis stale (CEFShim/src/shim_client.mm:660; filed as #32573, owner PL-cef-tab).KeyRouter.routePageKeythen runs the single-key page binding:fstarts link hints. The session took the next letters as hint keys, picked the only target (the field) and clicked it; theno,kreached the field.
Fix, for the two cases off(lead review):- The page answers first. A link-hint session first asks the page whether a text field has the keyboard. It reads only the focused element (
LinkHintSession.focusScript). The check looks through shadow roots and same-origin frames. It counts text inputs, a textarea, a select, contenteditable and designMode. A focused cross-origin frame counts as editing. The 500 ms bound covers this question only. While the page answers, every key typed is held (LinkHintPendingKeys), and a key-up waits behind its key-down. - A text field has the keyboard (case 1): every held key goes to the page once, in the order typed. The field shows the whole word.
- No text field has the keyboard (case 2, link hints start): the held keys run through the hint session at once, and later keys are handled live. Only after that does the session collect the hint targets (
collectScript, about 45 ms on Wikipedia). A hint letter never reaches the page. Escape ends the session. A key that is no hint key ends it, and it and every key after it go to the page. A click or a lost focus ends the session; the hint letters typed before it are dropped, as before this PR. This part is unchanged from before this PR: the dispatcher gives a plainfto the page first (KeyRouter.isPageKey), and cmux starts hints only from CEF'sOnKeyEvent(CEFShim/src/shim_client.mm:644), which runs for a key the renderer did NOT consume. So a page shortcut onfthat handles its key runs alone, and hints do not start. - No key lost, none doubled. If the page gives no answer within 500 ms, or the call fails, or the person clicks before the answer, every held key goes to the page once, in order. Chromium sends a key the page left unhandled back through
NSApp.sendEvent. Such a key, which the page already had, is never a session's (LinkHintSeenKeys: a fixed ring of event time and key code; a synthetic key by its object).noteKeyDownlooks up theCefNSWindowclass on each key (objc_lookUpClass, no allocation), so the check works when Chromium loads after the first key. It returns at once for a key outside a child window; a key in a child window does one class lookup, and only a Chromium page window's key is noted. A key sent back is marked (LinkHintReplays), so no session takes it again. LinkHintControllermoved from CmuxNextApp to CmuxNextBrowser (app_lines -132). Its words stay in the app (LinkHintStrings). Test seams (focusAnswer,targetsAnswer,send) letLinkHintControllerTestsrun the controller itself.
Window hints keep the plaincollectScript.
- The page answers first. A link-hint session first asks the page whether a text field has the keyboard. It reads only the focused element (
- The page window did not get the keyboard. cmux gives a Chromium page focus through CEF
SetFocus(true)and relied on it to make the page's child window key. After an address-bar load it did not: the cmux window kept the keys with itself as first responder. NowCEFTab.grantFocus, the grant every path uses (direct, at browser attach, when the page shows), hands the keys from the cmux window that shows the page to the page window when CEF moved none. Still one key change per request (#24913); never from another window or an inactive app; a hidden page is never granted.
Why this layer: Chrome has one key window per browser window that forwards keys to the focused renderer. With in-process CEF the page is its own child window that must itself be key, and SetFocus is cmux's one call to make it so; cmux now checks that call's result where it makes it. With the Chromium Host (pages as views) there is no page window and nothing changes.
Cost on the typing path: none per key. The hand-over runs once per focus grant (one scan of the cmux window's child windows). The page script runs only when f/F starts link hints, never per key.
Testing
Fleet, package-test-lane.sh suite, 5 runs each, proof-step.py. A step counts only with "Test run with N tests", N > 0. Each red is the test before its fix:
- Hand-over red 8e0150b: step a026a51a0efd31928d9e1461.
- Link hints reds:
- case 1/2, e455265: bb02d371e1aa33aae3e334ac;
- loop, c586b45: 71154bb94c5b684df10e7a9e;
- doubles, 9ba4cc9: 3eccf64ebaf1ea4edd96a880;
- queued key, 296e7bf: 213e2ff4783fb7f36612c8ac;
- every key held, ce15a59: ea1a6b84168e12921c4b8e4c;
- focus check, 450fa5b: 157deb9f534f4b22dfc220bc;
- controller (slow collect, late Chromium), 4cf419a: d1509c93bf7f7b9f0afbfac5;
- click or Escape after "no field", 3a30e9c: 6a09fdd2e299e55470babc0b;
- a held key that starts link hints again keeps its new session, 40c81ae: c17c3c4adac018c12fabc283;
- model-based fuzz test (
LinkHintControllerFuzzTests, 3000 seeded sequences of 25 events against a reference model, checked after every event: no key lost or doubled, no hint letter to the page, order kept, key-up after key-down, one session) on 50fa0da plus the test seams and the two fixes below, branch fix/p0-bct-fuzz50 6ecd9bb: 4ab4e12fdb3abe22a4b012d7 (", , answer:no-field: the session is gone": the fourth bug, fixed in 1ae9b86); - the fuzz test on 1ae9b86, bb0d137: 29b6a2a3e9a94913f0ab4c8d (a label typed in full before the labels came kept the session live while they drew: the next key was taken and lost; fixed in e98112e);
- the fuzz test on e98112e: a277d579f1bdd3aa655705f4 (seed 4642252087, sequence 10: a send-back that starts link hints twice; the empty send of the first new session unmarked the rest, and the key after the second chord was lost; fixed in 466e904).
Each failed 5/5. Replay a fuzz failure withCMUX_FUZZ_SEED=<seed>; widen withCMUX_FUZZ_ITERATIONS=<n>.
- Green 466e904 (this head): step 931b9cbb78171964ec4b6df4, 26 test runs passed:
LinkHintControllerFuzzTests,LinkHintControllerTests,LinkHintTypingTestsandCEFDeferredFocusTests5x each, plus the 6 focus key suites. Compile-check app and CmuxNext passed at 466e904. - Earlier green 50fa0da: step ac8de658e2430f2e1bd891dd, 21 test runs passed:
LinkHintControllerTests,LinkHintTypingTestsandCEFDeferredFocusTests5x each, plusChildPageFocusKeyChangeTests,ColdBrowserFocusKeysTests,PageFirstKeysTests,BrowserFocusModeKeysTests,OmnibarFocusMemoryTests,WindowHintOffscreenTargetTests. - KeyRouterBench Release, base and this head on the same worker: see the PR comment. A plain key makes 0 allocations, and the p50 is the same.
- Journeys (REOPEN VIDEO, real keys on minis, kit 4ff1d5e5ce6): browser-click-then-type 3/3 on build 1c6390004fb94fd0fdb813af (4e1d2f7). The page got exactly "fieldkeysok" and "secondwordok" each round. The link-hint page check on Wikipedia: focus check p95 2.3 ms, target collect p95 47 ms (STATUS 8055).
Observed / Not run
- Observed so far: the diagnostic round (STATUS 7518): the app's own key path (no CuaDriver) and CuaDriver lose the same keys on a fresh page; a restored page gets all of them; the page window is key and Chromium's view is its first responder in all cases. Base journey red on dogfood 44dc8431.
- History of this PR: 9d7ac0a and 323d248 did not compile their test target (a mutating call inside
#expect), and d17dbcb did not compile (a string literal); fixed in c62a2e4 and 5706562. v5 looped without end (keys sent back were held again); v6 doubled the first letters (a key the page already had came back through the intercept); v7 could drop a key typed before the session but dispatched after it. The first version (ChildPageFocus hand-over) covered only the direct grant (review FIX); 637d3fc did not compile (missingimport AppKit); 1972dd8 did not compile (WindowHintController switch); a version that took no key before the labels showed sent hint letters to the page (lead review: a link-hints regression; reverted to holding them). - Known, not fixed here (review 6603845634, follow-up PR): a pick decided when the labels come is lost, and its labels stay, if a click or a new
flands during the labels' draw (a few ms); the bound can end a newer session in a very small window; a nestedstartfrom a delivered key duringstart's own cancel. The fuzz test does not cover the seen-key ring, key rollover across the answer, or clicks after the labels show. - Not run: a click into the page (CuaDriver cannot deliver a verified click into a page window on the minis without a screen-recording grant). The journey covers typing after focus only.
- Cycle B of
reopen-browser-plain-keys(keys typed before the restored page exists) may be a separate defect; it is reported per round.
Changelog
Fixed: Keys you type into a web page right after it loads from the address bar reach the page, also when the word starts with "f"
Proof
No capture: key routing; the proof is the tests and the journeys.