mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-09-14 19:45:30 +08:00
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:
+14
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user