fix: release held modifiers when press_key key event fails (#2347)

## What

`press_key` currently presses each modifier down, presses the main key,
then releases the modifiers — in three sequential steps with no
`try/finally`:

```ts
for (const modifier of modifiers) {
  await page.pptrPage.keyboard.down(modifier);
}
await page.pptrPage.keyboard.press(key);      // if this rejects…
for (const modifier of modifiers.toReversed()) {
  await page.pptrPage.keyboard.up(modifier);  // …this never runs
}
```

If `keyboard.press(key)` rejects — a CDP hiccup, a target crash, or a
dropped connection — the release loop is skipped and the modifier keys
are left **logically held down in the browser**.
`waitForEventsAfterAction` re-throws the action error, so nothing
downstream releases them either.

This is the unpaired-`keyDown` class of defect asked about in #2309
(*"whether any code path sends a keyDown without a guaranteed matching
keyUp on an error/timeout branch"*). This PR fixes the one concrete
instance of it in this repo.

## Fix

Wrap the down/press sequence in `try/finally` and track which modifiers
were actually pressed, releasing each held modifier even when the main
press throws. Only modifiers whose `keyboard.down()` succeeded are
released, so a failure *while* pressing a modifier doesn't emit a
spurious `keyUp`.

## Test

Adds a regression test (real browser, keydown/keyup logging) that
injects a `press()` failure mid-sequence and asserts both modifiers are
still released. It fails on `main` (`['dControl','dShift']` — no keyups)
and passes with the fix (`['dControl','dShift','uShift','uControl']`).

Verified locally: full `tests/tools/input.test.ts` suite passes, `npm
run typecheck`, eslint, and prettier all clean.

## Scope note re: #2309

I want to be precise about what this does and does not address. This
closes a **browser-level** stuck-key path: leaked keys here live in
Chromium's input state (CDP `Input.dispatchKeyEvent` is injected into
the renderer), so the observable effect is a modifier stuck **within the
driven page**. The report in #2309 is a bare `Space` that repeats
**system-wide** and survives physically unplugging the keyboard — that
symptom is at the OS input layer, which CDP-injected input doesn't route
through, so I don't claim this fully explains that case (details and a
non-reboot workaround are in a comment on the issue). Still, an unpaired
keyDown on an error branch is a real defect worth closing on its own,
and it's exactly the code path the issue asked to audit.

Prepared with AI assistance (Claude Code) and verified against a local
build before submission.
This commit is contained in:
Liohtml
2026-07-12 11:56:35 +02:00
committed by GitHub
parent 58ba174273
commit 78ccb193e0
2 changed files with 65 additions and 6 deletions
+14 -6
View File
@@ -526,12 +526,20 @@ export const pressKey = definePageTool({
const [key, ...modifiers] = tokens;
const result = await page.waitForEventsAfterAction(async () => {
for (const modifier of modifiers) {
await page.pptrPage.keyboard.down(modifier);
}
await page.pptrPage.keyboard.press(key);
for (const modifier of modifiers.toReversed()) {
await page.pptrPage.keyboard.up(modifier);
const heldModifiers: KeyInput[] = [];
try {
for (const modifier of modifiers) {
await page.pptrPage.keyboard.down(modifier);
heldModifiers.push(modifier);
}
await page.pptrPage.keyboard.press(key);
} finally {
// Release every modifier that was successfully pressed, even if a
// later key event throws. Otherwise a failed press leaves modifiers
// logically held down in the browser (see #2309).
for (const modifier of heldModifiers.toReversed()) {
await page.pptrPage.keyboard.up(modifier);
}
}
});
+51
View File
@@ -9,6 +9,8 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import {describe, it} from 'node:test';
import sinon from 'sinon';
import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js';
import {McpResponse} from '../../src/McpResponse.js';
import {TextSnapshot} from '../../src/TextSnapshot.js';
@@ -1359,5 +1361,54 @@ describe('input', () => {
]);
});
});
it('releases held modifiers when the main key press fails', async () => {
await withMcpContext(async (response, context) => {
const page = context.getSelectedMcpPage().pptrPage;
await page.setContent(
html`<script>
logs = [];
document.addEventListener('keydown', e => logs.push('d' + e.key));
document.addEventListener('keyup', e => logs.push('u' + e.key));
</script>`,
);
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
context.getSelectedMcpPage(),
);
// Simulate the main key press failing mid-sequence (e.g. a CDP
// hiccup) after the modifiers have already been pressed down.
sinon
.stub(page.keyboard, 'press')
.throws(new Error('injected press failure'));
try {
await assert.rejects(
pressKey.handler(
{
params: {
key: 'Control+Shift+C',
},
page: context.getSelectedMcpPage(),
},
response,
context,
),
);
} finally {
sinon.restore();
}
// The modifiers were pressed down; both must be released even though
// the main key press threw, otherwise the browser is left with the
// modifiers logically stuck down.
assert.deepStrictEqual(await page.evaluate('logs'), [
'dControl',
'dShift',
'uShift',
'uControl',
]);
});
});
});
});