Skip to content

fix(input): restore a dropped background lease before hidden input - #374

Merged
iuyo5678 merged 2 commits into
Tencent:mainfrom
ArcueidMP:fix/355-restore-leased-input
Sep 30, 2026
Merged

iuyo5678 merged 2 commits into
Tencent:mainfrom
ArcueidMP:fix/355-restore-leased-input

Conversation

@ArcueidMP

Copy link
Copy Markdown
Contributor

Closes #355. Follow-up to #311 and #242.

Problem

BackgroundExecution caches the focus override it applied per debugger attachment. When Chrome loses that override outside the cache (e.g. Emulation.setFocusEmulationEnabled is turned off from the extension service worker), synchronize() keeps skipping it because the cache still matches. Since #311, every input tool on that leased tab then fails closed with input_not_ready — safely, but until the session ends.

Change

  • BackgroundExecution.reapply(tabId) resends an owned override even when the applied cache reports it active. It never forces a disable, and it keeps the applied record while resending, so if the resend fails a later release still sends the disable (a returned page is never left emulated).
  • ChromiumCdp.restoreBackgroundExecution(sessionId, tabId) checks that the session owns the lease, then reapplies. Exposed as an optional CdpRunner method.
  • withInputReady: when an owned lease samples hidden, it restores through the lease owner, re-samples visibility, then waits for two animation frames before dispatching. It does not toggle focus emulation itself, does not activate any window, and leaves the override enabled for the next tool. If the page is still not visible (or restore fails), it returns cdp_failed / input_not_ready / effect_state: none with no input sent. Runners without restoreBackgroundExecution keep the fix(input): harden readiness around persistent background leases #311 behaviour.
  • The frame wait reuses the double-rAF helper that waitForInputPaint already used (now shared as waitForRendererFrames).
  • CHANGELOG entry under Unreleased → Fixed.

Why animation frames instead of a surface read

The first version flushed with Page.captureScreenshot({ fromSurface: true }), like the unowned fallback. The real-browser test caught it timing out intermittently. A probe on Windows 11 (3 parallel headless Chrome 153 instances at DPR 1.5, override dropped and restored each cycle):

Wait after restoring the override Completed Hung (>3 s)
Surface read immediately 48 / 75 27 / 75
Double rAF (surface read afterwards) rAF 75 / 75; surface read 31 / 75 surface read 44 / 75

A second probe (180 cycles) delivered every click both with and without the double-rAF wait, and visibilityState was visible right after the restore acknowledgement in every cycle; the rAF wait took up to ~1.9 s under that load. Visibility is checked before waiting, since frames stay suspended while hidden.

This PR does not change the unowned fallback (a page hidden since creation), which still uses the surface read.

Validation

  • New tests, each confirmed to fail without the source change (all 8 new unit tests fail against main's sources):

    • chromium-cdp.test.ts: reapply resends despite the applied cache, rejects non-owners and released leases; a failed resend is still disabled on release while a passive reader keeps the attachment.
    • interaction.test.ts: restore → visibility → frames → pointer ordering with no focus command from withInputReady, no surface read and no Page.bringToFront; restore that stays hidden, throws, or produces no frame reports input_not_ready before dispatch; cancellation during restore.
    • dispatcher.test.ts: real ChromiumCdp with a fake debugger modelling Chrome's override. The override is dropped outside the cache; the hidden-page click recovers through the lease owner and reaches the page (mouse events are rejected while hidden), the override stays enabled, and the next click needs no restore.
    • click.browser.test.ts (real Chrome): drop the override on the same debugger session, then the leased click succeeds, the page records the trusted click, it is visible afterwards, the lease remains owned, and no surface read is used.
  • Real-browser suites (click, background-execution, background-screenshot, background-full-page, observation-layers) run in parallel: 8/8 passes after switching to animation frames (the surface-read version failed 1 of 4 runs under the same load).

  • Full-stack fault injection on Windows 11, Microsoft Edge 154 at DPR 1.5, released bsk 0.3.1 CLI/daemon with an isolated BSK_HOME, unpacked extension: bsk session start --no-focus, click once, minimize the Agent Window, disable focus emulation from the extension service worker (page reports hidden), then bsk click:

    Extension build Click after fault Page clicks Follow-up click Window after
    main input_not_ready, exit 3 1 → 1 still input_not_ready minimized
    This PR success 1 → 2 success (3) minimized

    The temporary probe was not committed.

  • biome check, extension tsc --noEmit, and the affected unit suites pass. On Windows, pnpm ext:test hit one timeout in human-loop.test.ts (child-process regex check) under full-suite load; it passes in isolation both with and without this change. The two kill-grace tests in packages/dsh-plugin-browserskill/tests/runner.test.ts also fail on unmodified main on Windows and are unrelated.

When Chrome dropped a leased tab's focus override outside
BackgroundExecution's applied-state cache, every input tool on that tab
failed with input_not_ready until the session ended (Tencent#355).

- BackgroundExecution.reapply() resends an owned override even when the
  cache reports it applied. It never forces a disable and keeps the
  applied record, so a failed resend is still disabled on release.
- ChromiumCdp.restoreBackgroundExecution() checks ownership, then reapplies.
- withInputReady restores through the lease owner, re-samples visibility
  and waits for two animation frames before dispatching. It does not
  toggle focus emulation or activate a window itself, and the override
  stays with the lease. If the page is still not visible, it returns
  input_not_ready before dispatch.
- The frame wait uses requestAnimationFrame rather than a surface read:
  after a re-show, Chrome can leave Page.captureScreenshot({ fromSurface })
  pending while frames already run.

Closes Tencent#355
@ArcueidMP
ArcueidMP marked this pull request as ready for review September 29, 2026 16:58
@ArcueidMP

Copy link
Copy Markdown
Contributor Author

CI note: the only failing check, Rust fmt, clippy, tests, fails in remote_server::browser_capacity_does_not_block_renewal_or_replacement — HTTP 503 at crates/bsk-cli/tests/remote_server.rs:185, where the stalled same-device replacement socket is rejected for capacity. This PR changes no Rust code, and that job passed on the last 8 main runs, so it looks like an intermittent timing issue in that test. Could a maintainer re-run the failed job? I can open a separate issue for the flake if that helps.

Merge the 0.3.2 release and keep the background-input fix under Unreleased.

Isolate idle-exit tests from release checks and automatic daemon replacement. Wait for the first remote connection to complete its native handshake before testing replacement at capacity; the replacement connection still exercises the pre-handshake case.
@iuyo5678
iuyo5678 merged commit 3f10983 into Tencent:main Sep 30, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Restore hidden input delivery when an owned background lease is out of sync

3 participants