fix(tui): drain stdin on exit to avoid Kitty release leak

Drain stdin for up to 1s after disabling Kitty protocol so in-flight key
release events are consumed before the shell regains control.

Fixes #1204
This commit is contained in:
Mario Zechner 2026-02-03 00:07:35 +01:00
parent 9a4d043b28
commit 8c38de0495
5 changed files with 42 additions and 22 deletions

View file

@ -4,7 +4,7 @@
### Fixed ### Fixed
- Fixed Kitty key release events leaking to parent shell over slow SSH connections ([#1204](https://github.com/badlogic/pi-mono/issues/1204)) - Fixed Kitty key release events leaking to parent shell over slow SSH connections by draining stdin for up to 1s on exit ([#1204](https://github.com/badlogic/pi-mono/issues/1204))
## [0.51.1] - 2026-02-02 ## [0.51.1] - 2026-02-02

View file

@ -2554,7 +2554,7 @@ export class InteractiveMode {
// Drain any in-flight Kitty key release events before stopping. // Drain any in-flight Kitty key release events before stopping.
// This prevents escape sequences from leaking to the parent shell over slow SSH. // This prevents escape sequences from leaking to the parent shell over slow SSH.
await this.ui.terminal.prepareForExit(); await this.ui.terminal.drainInput(1000);
this.stop(); this.stop();
process.exit(0); process.exit(0);

View file

@ -4,11 +4,11 @@
### Added ### Added
- Added `Terminal.prepareForExit()` method to drain Kitty key release events before exit - Added `Terminal.drainInput()` to drain stdin before exit (prevents Kitty key release events leaking over slow SSH)
### Fixed ### Fixed
- Fixed Kitty key release events leaking to parent shell over slow SSH connections ([#1204](https://github.com/badlogic/pi-mono/issues/1204)) - Fixed Kitty key release events leaking to parent shell over slow SSH connections by draining stdin for up to 1s ([#1204](https://github.com/badlogic/pi-mono/issues/1204))
## [0.51.1] - 2026-02-02 ## [0.51.1] - 2026-02-02

View file

@ -13,12 +13,12 @@ export interface Terminal {
stop(): void; stop(): void;
/** /**
* Prepare for process exit by disabling Kitty protocol and draining stdin. * Drain stdin before exiting to prevent Kitty key release events from
* Call this before stop() when exiting to prevent Kitty key release events * leaking to the parent shell over slow SSH connections.
* from leaking to the parent shell over slow SSH connections. * @param maxMs - Maximum time to drain (default: 1000ms)
* @param drainMs - How long to drain stdin (default: 50ms) * @param idleMs - Exit early if no input arrives within this time (default: 50ms)
*/ */
prepareForExit(drainMs?: number): Promise<void>; drainInput(maxMs?: number, idleMs?: number): Promise<void>;
// Write output to terminal // Write output to terminal
write(data: string): void; write(data: string): void;
@ -158,25 +158,45 @@ export class ProcessTerminal implements Terminal {
process.stdout.write("\x1b[?u"); process.stdout.write("\x1b[?u");
} }
async prepareForExit(drainMs = 50): Promise<void> { async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
if (!this._kittyProtocolActive) return; if (this._kittyProtocolActive) {
// Disable Kitty keyboard protocol first so any late key releases
// do not generate new Kitty escape sequences.
process.stdout.write("\x1b[<u");
this._kittyProtocolActive = false;
setKittyProtocolActive(false);
}
// Disable Kitty keyboard protocol first const previousHandler = this.inputHandler;
process.stdout.write("\x1b[<u"); this.inputHandler = undefined;
this._kittyProtocolActive = false;
setKittyProtocolActive(false);
// Wait briefly to let any in-flight key release events arrive and be let lastDataTime = Date.now();
// consumed by our still-active stdin handler. This prevents Kitty const onData = () => {
// escape sequences from leaking to the parent shell over slow SSH. lastDataTime = Date.now();
await new Promise((resolve) => setTimeout(resolve, drainMs)); };
process.stdin.on("data", onData);
const endTime = Date.now() + maxMs;
try {
while (true) {
const now = Date.now();
const timeLeft = endTime - now;
if (timeLeft <= 0) break;
if (now - lastDataTime >= idleMs) break;
await new Promise((resolve) => setTimeout(resolve, Math.min(idleMs, timeLeft)));
}
} finally {
process.stdin.removeListener("data", onData);
this.inputHandler = previousHandler;
}
} }
stop(): void { stop(): void {
// Disable bracketed paste mode // Disable bracketed paste mode
process.stdout.write("\x1b[?2004l"); process.stdout.write("\x1b[?2004l");
// Disable Kitty keyboard protocol if not already done by prepareForExit() // Disable Kitty keyboard protocol if not already done by drainInput()
if (this._kittyProtocolActive) { if (this._kittyProtocolActive) {
process.stdout.write("\x1b[<u"); process.stdout.write("\x1b[<u");
this._kittyProtocolActive = false; this._kittyProtocolActive = false;

View file

@ -36,8 +36,8 @@ export class VirtualTerminal implements Terminal {
this.xterm.write("\x1b[?2004h"); this.xterm.write("\x1b[?2004h");
} }
async prepareForExit(_drainMs?: number): Promise<void> { async drainInput(_maxMs?: number, _idleMs?: number): Promise<void> {
// No-op for virtual terminal - no Kitty protocol to drain // No-op for virtual terminal - no stdin to drain
} }
stop(): void { stop(): void {