#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.
#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 vendoredpuppeteer-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 notry/finallypuppeteer-core/lib/puppeteer/cdp/Input.js: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 heldVerified on
main: with a transient failure on the main key's key up, the page sees["dControl","dShift","dC","uShift","uControl"]— nouC. #2347 released only the modifiers. PR #2351 fixes this one (splitdown/upso the release can be retried).2.
fill/fill_form— key left held, and the tool reports successThis is the one I'd prioritise.
Locator.fill()types short values (<typingThreshold, default 100 chars) throughhandle.type()→Keyboard.type(), which presses each character with the same unguardedpress()(locators.js, thetypeable-inputbranch →from(handle.type(textToType))).Repro —
locator('#i').fill('abc')with a transient failure onb's key up: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.)
selectNativeSelectOptionandselectOptiongo throughLocator.fill()too.3.
type_text— same, viaKeyboard.type()4.
drag— mouse button left held, and the obvious fix is a trapElementHandle.drag()callspage.mouse.down(); onlydrop()callspage.mouse.up(). Drag interception is never enabled here (setDragInterceptionhas zero hits repo-wide), so this is the live path. A failingdrop()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()clearspage._isDraggingonly on success:So releasing the button without clearing that flag leaves
_isDragging === true, and the next drag takes theif (!page._isDragging)branch, skipsmouse.down()entirely, and dies. Verified against real Chrome:A stuck button traded for a permanently broken
dragtool._isDraggingisn't intypes.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 thedown()andup()promises beforePromise.all, andclick/click_atnever pass adelay. (It is not a general property: on thedelaypathup()is created after the await, so it would leak. Worth knowing if adelayoption is ever added.)Suggested direction
The high-leverage fix is upstream. A
try/finallyin Puppeteer'sKeyboard.press()closespress_key,type_text,fill,fill_formandselectin one change, and fixes it for every Puppeteer user rather than just this server. Working around it here would mean reimplementingtype()on top of the non-publiccharIsKey(), which I don't think is right.dragneeds handling separately, either upstream (drag()/drop()should release the mouse and clear_isDraggingin afinally) or here, if you're comfortable touching the internal.Happy to do the work — send the Puppeteer PR, and/or a
dragfix 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
fillanddragfindings available on request; both are ~30 lines of plainpuppeteer-core.