Skip to content

Stop terminal reports from typing themselves into the input box - #844

Merged
yogthos merged 1 commit into
mainfrom
fix/terminal-report-input-flood
Sep 2, 2026
Merged

Stop terminal reports from typing themselves into the input box#844
yogthos merged 1 commit into
mainfrom
fix/terminal-report-input-flood

Conversation

@yogthos

@yogthos yogthos commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

The report

i asked it a question and then suddenly there was just like nonstop input in my text field — like it wouldn't stop, as if i had been holding something down

Root cause

crossterm 0.29 has no parser for OSC / DCS / APC sequences. In
src/event/sys/unix/parse.rs, parse_event handles only ESC O, ESC [ and
ESC ESC; every other byte after ESC falls through to

_ => parse_event(&buffer[1..], input_available).map(|e| e.map(|e| ..ALT..))

so ESC ] yields Key(Alt+']') — and Parser::advance
(src/event/source/unix/tty.rs:245) then clears its buffer. The rest of
the report is fed to the parser byte-by-byte with no ESC context, so every
payload byte parses as a standalone plain Char. BEL becomes Ctrl+G; ST
becomes Alt+\.

A background-colour report:

\x1b]11;rgb:2e2e/3434/3636\x07

therefore reached dirge as Alt+] followed by the literal characters
11;rgb:2e2e/3434/3636, and ui::input inserts every unmodified Char into
the compose buffer — so the user watches text type itself into the input box.

Terminals emit these unprompted: colour reports on a theme change or an
alt-screen transition (kitty, ghostty, foot, iTerm2), an OSC 52 clipboard
reply (kilobytes of base64 — the shape that looks like a held-down key), DCS
XTGETTCAP / DECRQSS replies, kitty's OSC 99 notification reports; and in a
multiplexer one pane can be handed the reply to another pane's query. dirge
already knew about this chatter — sync_and_drain_via_sentinel and
drain_stdin_nonblocking exist to swallow "alt-screen-exit chatter from
iTerm2 / kitty / foot — OSC 11 bg-color, primary DA, spontaneous CPR"
— but
those drains only run at startup, at teardown, and around a suspended
subprocess. Once the reader is live, nothing stood between a report and the
editor.

What changed

The input reader filters reports (dirge-v4xf). It recognises an OSC / DCS
/ APC / SOS / PM introducer and swallows the run through its terminator (BEL
or ST, including an ST split across two parses). Deliberately conservative in
the other direction: a run it cannot close — no terminator, a human-scale gap
(25ms), or more than 4096 events — is released as ordinary keystrokes rather
than dropped, so a real Alt+] is delayed and never lost. An AltGr-composed
] (Ctrl+Alt on the Italian/German/Spanish layouts, GH #659) is still typing.

Readers carry a generation (dirge-xxo9). suspend_tui_for_subprocess
gives the reader 150ms and then proceeds anyway (it says so on stderr);
resume_tui_after_subprocess clears the shutdown flag and starts a
replacement. The loop never latched the flag, so a reader that hadn't exited
woke to a false flag and kept reading fd 0 next to its replacement — and
next to the stdin drains, and EVENT_READER_EXITED was then set by whichever
exited first, so the next suspend's barrier could pass with a reader still
consuming. Two consumers on one descriptor split escape sequences, and the
tail of a split sequence parses as plain text: the same symptom by a different
route. Claiming a generation now retires every older reader at its next tick,
and only the live generation may report the reader gone.

Poll/read errors are retried (dirge-sp1x). Both mapped Err to break,
which ended the thread for the session with nothing outside suspend/resume to
restart it — a painted UI that accepts no keys, indistinguishable from a hang,
with the dead-tty watchdog quiet because the terminal was alive. Errors are
logged and retried a few times; the dead-tty probe still owns the case that
cannot recover.

?1003h is no longer enabled (dirge-hn6e). Any-event mouse tracking made
the terminal report every cell of pointer motion with no button held, and
nothing consumed it: the reader maps the wheel and left button down/drag/up
and drops the rest, and MouseEventKind::Moved appears nowhere in the tree.
?1002h already covers the wheel and the drag-selection. All the mode bought
was the only continuous input byte stream in the program — parsed and thrown
away whenever the pointer crossed the window, and the fuel that turns a
one-off desync on fd 0 into a sustained flood rather than a single burst.
?1015h (urxvt encoding, which crossterm cannot parse) goes with it. The
teardown and panic-reset strings still clear both, since another program — or
an older dirge — may have set them.

Tests

  • ui::input_reader unit tests for the filter: report swallowed whole, both
    terminators, ST split across two parses, the cap releasing rather than
    swallowing forever, AltGr ] untouched, ordinary typing untouched, the idle
    tick releasing a lone introducer.
  • ui::relay_tests::terminal_reports pushes the real byte sequences through a
    PTY on fd 0 and the production reader — this is what pins the crossterm
    behaviour the filter assumes. Before the filter the first of them collected
    22 events; it now expects only the trailing keystroke. One more test asserts
    a retired reader stops consuming input.
  • renderer_tests now assert ?1003h is absent from both re-assert payloads
    and ?1002h present.

Verified locally: cargo fmt --all --check, cargo clippy --all-targets --no-default-features --features no-plugin,sandbox-microvm -- -D warnings,
and the tests above. The plugin feature needs libclang, which this box
doesn't have, so the default-feature and all-features builds are CI's to
confirm.

A user watched text pour into the compose field on its own — "nonstop
input, as if I had been holding something down".

crossterm 0.29 has no parser for OSC / DCS / APC sequences. `parse_event`
handles `ESC O`, `ESC [` and `ESC ESC`; every other byte after ESC falls
through to a recursive call that reports the introducer as an
Alt-modified character, and `Parser::advance` then clears its buffer — so
the payload is parsed byte-by-byte as ordinary text. A background-colour
report, `ESC ] 11 ; rgb:2e2e/3434/3636 BEL`, therefore arrived as Alt+`]`
plus twenty-one characters, every one of which the compose editor
inserted. Terminals emit these unprompted (colour reports on a theme
change or an alt-screen transition, an OSC 52 clipboard reply, DCS
XTGETTCAP replies, kitty's OSC 99 notification reports), and in a
multiplexer one pane can receive the reply to another pane's query. dirge
already drained that chatter at startup, at teardown and around a
suspended subprocess; nothing stood between it and the editor
mid-session.

Four changes, all in the input path:

- The input reader recognises an OSC / DCS / APC / SOS / PM introducer and
  swallows the run through its terminator, including an ST split across
  two parses. A run it cannot close — no terminator, a human-scale gap, or
  more than 4096 events — is released as ordinary keystrokes rather than
  dropped, so a deliberate Alt+`]` is delayed by 25ms and never lost, and
  an AltGr-composed `]` (Ctrl+Alt on the Windows layouts) is still typing.
  (dirge-v4xf)

- Readers carry a generation. The suspend path proceeds after 150ms even
  when the reader has not exited and the resume path then clears the
  shutdown flag, so a stale reader woke to a `false` flag and kept reading
  fd 0 next to its replacement — and next to the stdin drains, which
  splits escape sequences and lands the same junk in the compose box by a
  different route. Claiming a generation retires every older reader at its
  next tick, and only the live generation may report the reader gone.
  (dirge-xxo9)

- Poll/read errors are logged and retried a few times instead of ending
  the thread for the session, which left a healthy-looking UI that
  accepted no keys. The dead-tty probe still owns the unrecoverable case.
  (dirge-sp1x)

- `?1003h` (any-event mouse tracking) is no longer enabled. Nothing
  consumed it — the reader maps the wheel and left button down/drag/up and
  drops the rest, `MouseEventKind::Moved` appears nowhere in the tree, and
  `?1002h` already covers the wheel and the drag-selection. All it bought
  was the only continuous input byte stream in the program, which is what
  turns a one-off desync on fd 0 into a sustained flood rather than a
  single burst. `?1015h` (urxvt encoding, which crossterm cannot parse)
  goes with it; the teardown strings still clear both. (dirge-hn6e)

Tests: unit tests for the filter (report swallowed whole, both
terminators, split ST, cap released not dropped, AltGr `]` untouched,
ordinary typing untouched) and PTY-level tests that push the real bytes
through fd 0 and the production reader — which is what pins the crossterm
behaviour the filter assumes — plus one that a retired reader stops
consuming input.
@yogthos
yogthos merged commit 68d1800 into main Sep 2, 2026
15 checks passed
@yogthos
yogthos deleted the fix/terminal-report-input-flood branch September 2, 2026 18:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant