Skip to content

fix: release keys left held when a press_key key event fails#2351

Draft
Liohtml wants to merge 3 commits into
ChromeDevTools:mainfrom
Liohtml:fix/input-release-all-held-keys
Draft

fix: release keys left held when a press_key key event fails#2351
Liohtml wants to merge 3 commits into
ChromeDevTools:mainfrom
Liohtml:fix/input-release-all-held-keys

Conversation

@Liohtml

@Liohtml Liohtml commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #2347, which fixed one instance of an unpaired-input defect and claimed to have fixed the class. It hadn't. I audited the rest of the input paths, and then audited my own first draft of this PR — which was wrong in two ways and would have made things worse. This is the narrowed version: only what I can prove.

What this fixes

press_key still left the main key held. Puppeteer's Keyboard.press() is a bare down() then up() with no guard of its own:

async press(key, options = {}) {
  await this.down(key, options);   // keyDown dispatched
  await this.up(key);              // if this rejects, the key stays down
}

#2347 released only the modifiers. Verified on main: ["dControl","dShift","dC","uShift","uControl"] — no uC.

The press is now split into down()/up() so a failed release can be retried. down(key, {}) keeps the CDP payload byte-identical to press() (which passes {}, so commands goes out as undefined rather than []). A key whose down() never landed is not released — there is no key up to send.

Each release is guarded so a failing up() can neither abort the remaining releases nor replace the error that triggered them (a flaw in the finally #2347 added). type_text's submitKey uses the same helper.

What I removed from this PR after review, and why

The drag fix — it was worse than the bug. ElementHandle.drag() presses the mouse button and only drop() releases it (drag interception is never enabled), so a failed drop really does leave the button down. But drop() clears page._isDragging only on success. Releasing the mouse without clearing that flag makes the next drag take the if (!page._isDragging) branch, skip mouse.down() entirely, and die with 'left' is not pressed. — I'd have traded a stuck button for a permanently broken drag tool. Clearing the flag means reaching into a Puppeteer internal that isn't in types.d.ts, so it deserves its own change and your opinion first. The drag leak is real and still open.

I also removed a comment justifying a behaviour with a false claim. I had written that a key whose down() rejected must still be released because down() latches the modifier bit before it sends. That's true for modifiers — and false for the main key: #modifierBit() returns 0 for anything that isn't Alt/Control/Meta/Shift. I was asserting a spurious uC keyup as correct behaviour on a fabricated premise. Modifiers are still released on a failed down() (that part is real, and _modifiers is stamped onto every later keyboard, mouse and touch event); the main key is not.

Still open, disclosed rather than claimed fixed

  • Keyboard.type() presses each character through the same unguarded press(), so a mid-string failure leaves a character key held. Locator.fill() types short values through Keyboard.type() too — so fill and fill_form are exposed the same way. Guarding it here would mean reimplementing type() on top of the non-public charIsKey(). The real fix belongs in Puppeteer: press() should release in a finally. That single change would close press_key, type_text, fill and fill_form at once. Happy to send it upstream if you agree.
  • Mouse.click() is not affected for this repo's callers — it eagerly creates both the down() and up() promises before Promise.all, and click/click_at never pass a delay. It is not a general property: on the delay path up() is created after the await, so a rejecting down() would leak. Left alone deliberately.

Tests

One regression test, failing on main and passing here: a transient key-up failure no longer leaves the main key held. The #2347 test is kept and updated to stub down (the code no longer routes through press). Full input.test.ts green (47), typecheck + eslint + prettier clean.

Liohtml added 2 commits July 12, 2026 14:35
ChromeDevTools#2347 fixed one instance of the unpaired-input defect in this file. I
audited the rest of the input tools afterwards and found more, all with
the same shape: a key or button is pressed down, and the release is only
reachable on the success path.

- press_key left the *main* key held. Puppeteer's Keyboard.press() is a
  bare down() followed by up() with no guard of its own, so a rejection
  between the two leaves the key logically held down. ChromeDevTools#2347 only released
  the modifiers.
- drag left the *mouse button* held. ElementHandle.drag() presses the
  button down and only drop() releases it (drag interception is not
  enabled), so a failed drop leaves the button down for the rest of the
  session, breaking every later click.
- type_text's submitKey went through the same unguarded press().
- The release loop added in ChromeDevTools#2347 was itself unguarded: a failing up()
  aborted the remaining releases and replaced the original error.

Adds pressKeyReleasingHeldKeys(), which releases every key it pressed
even if a key event rejects, guarding each release so one failure can
neither abort the others nor mask the original error. Keys whose down()
rejected are released too: Keyboard.down() latches the key on the client
*before* it sends the CDP command and only up() clears it, so a latched
modifier would otherwise be stamped onto every later keyboard and mouse
event.

Tests: a transient key up failure now leaves no key held, and a failed
drop no longer leaves the mouse button down. Both fail on main.
Calling keyboard.down(key) directly changed the CDP payload: press()
passes {} to down(), which sends commands: undefined, while calling
down(key) with no options uses Puppeteer's default and sends
commands: []. Keep using press() so the wire payload is unchanged; the
release guarantee is unaffected because the key is recorded as held
before press() runs and only cleared once it resolves.

Also documents why the drag path releases the mouse unconditionally.
@Liohtml

Liohtml commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Self-review pass before you spend time on this — two things I tightened, and one claim I want to be explicit about because I got a mechanism wrong on #2283 and would rather not repeat it.

Fixed: I was changing the CDP payload. My first version replaced keyboard.press(key) with down(key) + up(key). Those aren't equivalent on the wire: press() passes {} into down(), so commands goes out as undefined, whereas calling down(key) with no options takes Puppeteer's default {commands: []} and sends an empty array. Harmless in all likelihood, but it was an unnecessary behaviour change smuggled into a bug fix. Now back to press() — the release guarantee is unaffected, since the key is recorded as held before press() runs and only cleared once it resolves.

Verified, not assumed: the justification for releasing a key whose down() rejected is that Keyboard.down() does _modifiers |= bit before it sends the CDP command, and _modifiers is read on every mouse and touch event too (Input.jsmodifiers: this.#keyboard._modifiers at the mouse move/down/up/wheel/drag call sites). So a latched modifier would ctrl-click everything afterwards. That's why a stray key up is the cheaper trade.

Checked and deliberately not touched: Mouse.click() looks like the same shape but isn't — it eagerly creates both the down() and up() promises before Promise.all, so the release is dispatched even when the press rejects. No leak there, so click is untouched.

Both regression tests still fail against current main and pass here (I re-ran them against main's input.ts to confirm they're actually guarding something, not just passing). Full input.test.ts green, typecheck/eslint/prettier clean.

@Liohtml
Liohtml marked this pull request as draft July 13, 2026 06:32
@Liohtml

Liohtml commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Moving this to draft — I want to finish auditing the remaining input paths myself rather than have you find them. Will mark it ready once I have. Sorry for the churn.

Narrows this change to what is provably correct after a second audit.

press_key still left the *main* key held: Puppeteer's Keyboard.press() is
a bare down() followed by up() with no guard, so a rejecting up() leaves
the key down in the browser. ChromeDevTools#2347 released only the modifiers. The press
is now split into down()/up() so a failed release can be retried, without
dispatching a key up for a key whose down() never landed. down(key, {})
keeps the CDP payload identical to press().

Each release is also guarded so a failing up() can neither abort the
remaining releases nor replace the error that triggered them.

type_text's submitKey goes through the same helper.

Dropped from this change after review:
- The drag fix. ElementHandle.drag() presses the mouse button and only
  drop() releases it, so a failed drop does leave the button down. But
  drop() clears page._isDragging only on success, so releasing the mouse
  without also clearing that flag makes the *next* drag skip mouse.down()
  and fail with "'left' is not pressed." — worse than the bug. Clearing it
  means reaching into a Puppeteer internal, so it needs its own change.
- Keyboard.type() presses each character through the same unguarded
  press(). Locator.fill() types short values that way too, so fill and
  fill_form are exposed as well. Guarding it here would mean
  reimplementing type() on top of the non-public charIsKey(); the fix
  belongs in Puppeteer's press().
@Liohtml Liohtml changed the title fix: release every held key and the mouse button on input failures fix: release keys left held when a press_key key event fails Jul 13, 2026
@Liohtml

Liohtml commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Filed #2353 with the full audit — the root cause is an unguarded Keyboard.press() in Puppeteer, which also silently affects fill/fill_form (the tool reports success while leaving a key held), plus a drag trap where the obvious fix breaks the next drag. This PR stays scoped to the one thing it can prove: press_key leaving the main key held. Happy to keep it in draft until you have a view on the direction in #2353 — if you would rather the whole thing be solved upstream in Puppeteer, close this and I will send that PR instead.

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.

1 participant