Hotkeys fire twice per keypress: xterm customKeyEventHandler re-pushes keyup events into HotkeysService as keydowns
Summary
A single keypress of a hotkey chord can dispatch the hotkey twice. Most visible with split-bottom / split-right: one Ctrl+Shift+S press creates two panes. It affects any hotkey whose modifiers are still held when the final key is released. Intermittent for human typing (depends on key release order and event-loop timing), but reliably reproducible with synthesized input.
(I searched the tracker for existing reports — hotkey twice, split twice, duplicate hotkey, pushKeyEvent, customKeyEventHandler — and found nothing covering this, so filing.)
Environment
- Tabby 1.0.235, Windows 11, local (PowerShell) terminal — reproduced
- Verified the same code paths exist unchanged on current
master(89c9447)
Reproduction
- Focus any terminal pane
- Press
Ctrl+Shift+S(split-bottom), hold the final key ~100–150 ms, and releaseSbeforeCtrl/Shift - Two panes are created from one press
Instrumented HotkeysService (prototype probes from a plugin) for a single press:
pushKeyEvent('keydown', type=keydown, key=S, ts=43834.70) <- real keydown (xterm custom handler route)
pushKeyEvent('keydown', type=keydown, key=S, ts=43834.70) <- same event again (document listener route)
Matched hotkey split-bottom <- 1st split
Matched hotkey split-bottom <- 2nd split, two panes created
pushKeyEvent('keyup', type=keyup, key=S, ts=43955.90) <- real keyup
pushKeyEvent('keydown', type=keyup, key=S, ts=43955.90) <- GHOST: the keyup re-pushed as a keydown
Root cause
tabby-terminal/src/frontends/xtermFrontend.ts (master):
// line ~247
this.xterm.attachCustomKeyEventHandler((event: KeyboardEvent) => {
...
const handled = keyboardEventHandler('keydown', event) // hardcodes 'keydown', ignores event.type
...
})
// line ~355
const oldKeyUp = this.xtermCore._keyUp.bind(this.xtermCore)
this.xtermCore._keyUp = (e: KeyboardEvent) => {
this.xtermCore.updateCursorStyle(e)
if (keyboardEventHandler('keyup', e)) { // pushes a correct 'keyup'
oldKeyUp(e) // xterm's original _keyUp then invokes
} // customKeyEventHandler again -> the wrapper pushes
} // the SAME keyup event labeled 'keydown'
xterm invokes customKeyEventHandler from both _keyDown and _keyUp, so every keyup of a chord gets re-pushed as a keydown.
In tabby-core/src/services/hotkeys.service.ts this slips past the dedup:
pushKeyEvent(line ~130) only drops pushes whosetimeStamp === lastEventTimestamp— the ghost carries the keyup's fresh timestamp, so it passes- the keydown branch (lines ~169/201) unconditionally sets
recognitionPhase = trueand re-adds the key topressedKeys; with the modifiers still held,pressedKeystrokereassembles the full chord (Ctrl+Shift+S) matchActiveHotkey()matches again →emitHotkeyOnfires a second time
Why it's intermittent for humans: the ghost only re-matches when the final key is released before the modifiers (chord still complete at keyup), and the duplicate document-listener push is only deduped when event-loop ordering lets lastEventTimestamp catch it (the zone.run emission defers, which widens the race).
Suggested fix
Two independent layers, either helps, both cheap:
- Label events truthfully in the wrapper:
const handled = keyboardEventHandler(event.type, event)
- Harden
HotkeysService.pushKeyEvent: ignore a'keydown'push whosenativeEvent.type === 'keyup'— a genuine keydown can never carrytype: 'keyup'. As defense in depth,emitHotkeyOncould also suppress the same hotkey id emitted twice within a few ms (intentional re-presses and key auto-repeat are both far outside that window).
Source: Eugeny/tabby