Summary
KeyEventManager.handleRawKeyMessage drains its pending-event queue without exception safety. A single assertion thrown while dispatching leaves the offending event at the head of the queue forever, so every subsequent key event replays it, throws again, and is never dispatched. All keyboard input in the app dies for the rest of the process.
This is not a report of a new desync trigger — there are plenty of those already (#152391, #101285, #94441, #173951, #92589). It is a report of the amplification: it explains why so many of those issues describe "keyboard input stops working entirely" rather than "one exception was logged". A transient, self-healing state discrepancy is turned into a permanent, unrecoverable one.
The defect
packages/flutter/lib/src/services/hardware_keyboard.dart, in handleRawKeyMessage:
bool handled = true;
if (shouldDispatch) {
handled = _rawKeyboard.handleRawKeyEvent(rawEvent);
for (final KeyEvent event in _keyEventsSinceLastMessage) {
handled = _hardwareKeyboard.handleKeyEvent(event) || handled; // (1) can throw
}
if (_transitMode == KeyDataTransitMode.rawKeyData) {
assert(setEquals(...)); // (2) can throw
}
handled = _dispatchKeyMessage(_keyEventsSinceLastMessage, rawEvent) || handled;
_keyEventsSinceLastMessage.clear(); // never reached
}
return <String, dynamic>{'handled': handled}; // never reached
HardwareKeyboard.handleKeyEvent calls _assertEventIsRegular as its first act, before recording the event and before _dispatchKeyEvent. So when the pressed-key record has drifted from the platform's — which ui.KeyData.synthesized documents as expected when "some key downs or ups might be lost when the window loses focus" — the throw at (1) skips the clear().
From that point:
- _keyEventsSinceLastMessage still holds the poisoned event at index 0.
- Every later key message appends to that list and then re-runs the loop, hitting the same event first and throwing again.
- _dispatchKeyMessage is never reached, so Focus, Shortcuts and EditableText receive nothing.
- {'handled': ...} is never returned to the embedder.
The embedder's own repair also cannot land. handleKeyData dispatches a synthesized event immediately only while the queue is empty:
if (data.synthesized && _keyEventsSinceLastMessage.isEmpty) {
_hardwareKeyboard.handleKeyEvent(event);
_dispatchKeyMessage(<KeyEvent>[event], null);
} else {
_keyEventsSinceLastMessage.add(event);
}
A non-empty queue is exactly what the wedge guarantees, so the synthesized key-up that would have resynchronised the state is queued behind the event it was meant to fix. Nothing ever clears _pressedKeys outside clearState(), which is @visibleForTesting, so there is no recovery short of restarting the app.
Steps to reproduce
Any state desync will do; the amplification is independent of how the desync arose. On macOS the reliable path is the one from #89748 — type while a hot restart is still processing.
- Run any app with a TextField on macOS (debug).
- Hot restart and type into the field before the restart finishes.
- Keep typing after it settles.
Expected
The desync is reported once. Typing continues, and the embedder's synthesized event resynchronises the state.
Actual
The same KeyDownEvent — identical hashCode, identical timeStamp — is re-reported on every subsequent key message, and no keystroke reaches any field again. From a production log, one event replaying across 16 seconds of typing:
A KeyDownEvent is dispatched, but the state shows that the physical key is already pressed. ...
This was the event: KeyDownEvent#386e3(physicalKey: PhysicalKeyboardKey#91fcd(usbHidUsage: "0x0007000c",
debugName: "Key I"), logicalKey: ..., character: "i", timeStamp: 376:31:44.172972)
'package:flutter/src/services/hardware_keyboard.dart':
Failed assertion: line 516 pos 11: '!_pressedKeys.containsKey(event.physicalKey)'
#2 HardwareKeyboard._assertEventIsRegular (hardware_keyboard.dart:516:11)
#3 HardwareKeyboard._assertEventIsRegular (hardware_keyboard.dart:536:6)
#4 HardwareKeyboard.handleKeyEvent (hardware_keyboard.dart:660:5)
#5 KeyEventManager.handleRawKeyMessage (hardware_keyboard.dart:1185:37)
The frozen timeStamp across many seconds is the tell: it is one event being replayed, not many keystrokes failing.
Proposed fix
Drain in a finally, so one bad event costs one dropped keystroke instead of the session:
try {
for (final KeyEvent event in _keyEventsSinceLastMessage) {
handled = _hardwareKeyboard.handleKeyEvent(event) || handled;
}
...
handled = _dispatchKeyMessage(_keyEventsSinceLastMessage, rawEvent) || handled;
} finally {
_keyEventsSinceLastMessage.clear();
}
With the queue drained, the next synthesized event from the embedder meets the isEmpty condition in handleKeyData and resynchronises _pressedKeys on its own, which is what the existing design already intends.
This is debug-only in effect, since _assertEventIsRegular is inside an assert — but the debug loop is where it hurts, and it makes every desync report in the tracker far worse than the underlying discrepancy.
Version
Flutter 3.38.9 • channel stable
Framework • revision 67323de285 • 2026-01-28
Engine • hash 5eb06b7ad5bb8cbc22c5230264c7a00ceac7674b (revision 587c18f873)
Tools • Dart 3.10.8 • DevTools 2.51.1
Verified identical in 3.27.3, so this is long-standing rather than a recent regression.
Summary
KeyEventManager.handleRawKeyMessage drains its pending-event queue without exception safety. A single assertion thrown while dispatching leaves the offending event at the head of the queue forever, so every subsequent key event replays it, throws again, and is never dispatched. All keyboard input in the app dies for the rest of the process.
This is not a report of a new desync trigger — there are plenty of those already (#152391, #101285, #94441, #173951, #92589). It is a report of the amplification: it explains why so many of those issues describe "keyboard input stops working entirely" rather than "one exception was logged". A transient, self-healing state discrepancy is turned into a permanent, unrecoverable one.
The defect
packages/flutter/lib/src/services/hardware_keyboard.dart, in handleRawKeyMessage:
HardwareKeyboard.handleKeyEvent calls _assertEventIsRegular as its first act, before recording the event and before _dispatchKeyEvent. So when the pressed-key record has drifted from the platform's — which ui.KeyData.synthesized documents as expected when "some key downs or ups might be lost when the window loses focus" — the throw at (1) skips the clear().
From that point:
The embedder's own repair also cannot land. handleKeyData dispatches a synthesized event immediately only while the queue is empty:
A non-empty queue is exactly what the wedge guarantees, so the synthesized key-up that would have resynchronised the state is queued behind the event it was meant to fix. Nothing ever clears _pressedKeys outside clearState(), which is @visibleForTesting, so there is no recovery short of restarting the app.
Steps to reproduce
Any state desync will do; the amplification is independent of how the desync arose. On macOS the reliable path is the one from #89748 — type while a hot restart is still processing.
Expected
The desync is reported once. Typing continues, and the embedder's synthesized event resynchronises the state.
Actual
The same KeyDownEvent — identical hashCode, identical timeStamp — is re-reported on every subsequent key message, and no keystroke reaches any field again. From a production log, one event replaying across 16 seconds of typing:
The frozen timeStamp across many seconds is the tell: it is one event being replayed, not many keystrokes failing.
Proposed fix
Drain in a finally, so one bad event costs one dropped keystroke instead of the session:
With the queue drained, the next synthesized event from the embedder meets the isEmpty condition in handleKeyData and resynchronises _pressedKeys on its own, which is what the existing design already intends.
This is debug-only in effect, since _assertEventIsRegular is inside an assert — but the debug loop is where it hurts, and it makes every desync report in the tracker far worse than the underlying discrepancy.
Version
Verified identical in 3.27.3, so this is long-standing rather than a recent regression.