Skip to content

Input tools can leave keys and the mouse button held down; the root cause is an unguarded Keyboard.press() in Puppeteer #2353

Description

@Liohtml

#2347 fixed one unpaired-keyDown in press_key, and I claimed at the time that it was the only instance. That was wrong. I went back and audited every input path, and verified each finding below against real Chrome and the vendored puppeteer-core@25.3.0 — no theorising this time.

There is a single root cause, and most of the damage is not in this repo.

Root cause: Keyboard.press() has no try/finally

puppeteer-core/lib/puppeteer/cdp/Input.js:

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

A rejecting up() — a CDP hiccup, a detached session, a response timeout — leaves the key logically held down in the renderer. Every input path below is a consequence of this.

Where it surfaces

1. press_key — main key left held

Verified on main: with a transient failure on the main key's key up, the page sees
["dControl","dShift","dC","uShift","uControl"] — no uC. #2347 released only the modifiers. PR #2351 fixes this one (split down/up so the release can be retried).

2. fill / fill_form — key left held, and the tool reports success

This is the one I'd prioritise. Locator.fill() types short values (< typingThreshold, default 100 chars) through handle.type()Keyboard.type(), which presses each character with the same unguarded press() (locators.js, the typeable-input branch → from(handle.type(textToType))).

Repro — locator('#i').fill('abc') with a transient failure on b's key up:

key events seen by page: [ 'da', 'ua', 'db', 'dc', 'uc' ]
                                      ^^^^ no 'ub' — "b" is still down
fill() did NOT throw.

The Locator's retry re-reads the input value, types the remainder, and resolves successfully. So the tool reports success while leaving a key held down in the renderer. Silent corruption — the agent has no way to know. (This rhymes with #2199.)

selectNativeSelectOption and selectOption go through Locator.fill() too.

3. type_text — same, via Keyboard.type()

4. drag — mouse button left held, and the obvious fix is a trap

ElementHandle.drag() calls page.mouse.down(); only drop() calls page.mouse.up(). Drag interception is never enabled here (setDragInterception has zero hits repo-wide), so this is the live path. A failing drop() leaves the left mouse button held down, breaking every later click.

I wrote the obvious fix — release the mouse on the failure path — and it makes things worse. drop() clears page._isDragging only on success:

await dataOrElement.drag(this);
page._isDragging = false;      // never reached if drag/drop throws
await page.mouse.up();

So releasing the button without clearing that flag leaves _isDragging === true, and the next drag takes the if (!page._isDragging) branch, skips mouse.down() entirely, and dies. Verified against real Chrome:

drop threw: drop failed
logs after failed drag  : [ 'down', 'up' ]     <- button released, fix "worked"
page._isDragging LEAKED?: true
second drag FAILED      : 'left' is not pressed.
second drag logs        : []                   <- no mousedown ever dispatched

A stuck button traded for a permanently broken drag tool. _isDragging isn't in types.d.ts, so clearing it means reaching into a Puppeteer internal — which is why I pulled this out of #2351 rather than guess. This one needs your call.

Not affected

Mouse.click() — 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 it would leak. Worth knowing if a delay option is ever added.)

Suggested direction

The high-leverage fix is upstream. A try/finally in Puppeteer's Keyboard.press() closes press_key, type_text, fill, fill_form and select in one change, and fixes it for every Puppeteer user rather than just this server. Working around it here would mean reimplementing type() on top of the non-public charIsKey(), which I don't think is right.

drag needs handling separately, either upstream (drag()/drop() should release the mouse and clear _isDragging in a finally) or here, if you're comfortable touching the internal.

Happy to do the work — send the Puppeteer PR, and/or a drag fix here once you've said which way you want it. Just didn't want to write more code before checking, having already got ahead of myself once on #2283.

Repro scripts for the fill and drag findings available on request; both are ~30 lines of plain puppeteer-core.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions