Selkie--App--Internal--TerminalReport

NAME

Selkie::App::Internal::TerminalReport - recognise fragmented terminal reports

DESCRIPTION

Implementation detail for Selkie::App's input dispatch. Application code never calls this; see the Fragmented terminal reports section of Selkie::App for the user-facing description of what it defends against.

The problem

A terminal answers capability queries (DA1, DA2, CPR, XTWINOPS, XTGETTCAP, XTVERSION, XTSMGRAPHICS, DECRPM, the kitty keyboard and graphics protocols, OSC colour queries) by writing an escape sequence back on the tty as if the user had typed it. notcurses recognises those replies and swallows them — but only when the whole reply lands in a single read(2). Its own rule, from src/lib/automaton.c, is that "an escape must arrive as a single unit to be interpreted as an escape"; a reply that straddles two reads is treated as an aborted sequence and replayed to the application verbatim, byte by byte, as ordinary keypresses.

The gap needed to trigger that is sub-millisecond. Measured against notcurses 3.0.17 in a pty: writing \e[?64;1;2;6;9;15;18;21;22c in one write(2) is absorbed silently, while splitting it in two with a 0.5ms pause delivers 26 key events — Escape, then [, ?, 6, 4, ;c. Into a focused text field, that is 25 characters of garbage the user never typed; into a masked field, 25 bullets nobody can read back. The regime that produces the fragmentation is exactly a saturated CPU (Cantina's post-login module warm-up child, say) delaying the reading thread between the terminal's two writes.

The defence

terminal-report-prefix is a strict recogniser for complete control sequences that only a terminal can have produced. Selkie::App's input dispatch runs it over the text of an Escape-led input burst and drops what it matches, so a fragmented reply is discarded instead of being typed into whatever has focus.

Two independent properties keep it away from real keystrokes:

  • Shape. A match must be Escape, an introducer, a body built only from the character class that introducer allows, and a report final byte. Escape followed by ordinary text, by an arrow key, by a bare [, or by anything with a character outside the parameter class fails to match and is dispatched untouched.

  • Timing. The caller only ever offers characters that were already sitting in notcurses's queue at one instant — pulled with the non-blocking notcurses_get_nblock, never waited for. Filling that queue with a six-plus character control sequence between two consecutive polls of a loop that runs at 60Hz is not something a human hand does; a terminal answering a query does it every time.

Neither property is load-bearing alone, which is the point: matching requires both a byte sequence nobody types and a delivery rate nobody can type at.

Grammar

report  := ESC ( csi | string )

csi     := '[' private? params intermediates final
private := one of  ? > = <
params  := ( digit | ';' | ':' )+          # at least one
intermediates := ( '$' | ' ' | '!' | '"' )*
final   := one of  c R t u S y n           # any private introducer

mouse   := '<' button ';' coordinate ';' coordinate ( 'M' | 'm' )
button  := digit+
coordinate := '-'? digit+

string  := ( 'P' | ']' | '_' | '^' ) body terminator   # DCS / OSC / APC / PM
body    := any run of characters containing no ESC
terminator := ESC '\'                      # ST
            | "\a"                         # BEL, OSC only

params being mandatory and non-empty is what excludes the cursor keys: \e[A has no parameters and A is not a report final, so an arrow key that somehow arrived in pieces is passed through rather than eaten.

The M/m finals are admitted only for the exact three-field SGR mouse shape. Coordinates may be negative: terminals can report a pointer outside the drawable plane with sequences such as \e[E<lt>0;3510;-10M. The sign is deliberately part of the mouse grammar, not the generic CSI parameter class. Bare \e[…m is an SGR colour sequence — output, never input — and \e[…M is the X10 mouse encoding, whose payload bytes are not restricted to the parameter class and so cannot match anyway.

Deliberate losses

u and R finals are shared between terminal reports and the kitty keyboard protocol's encoding of real keypresses (\e[97;2u is Shift+A; \e[1;5R is Ctrl+F3). A fragmented kitty keypress therefore gets dropped rather than delivered. That is the right trade: without the filter the same fragment does not arrive as Shift+A either — it arrives as the six literal characters [97;2u inserted into the focused widget. Losing one keystroke beats typing six.

Bounds

MAX-REPORT-CHARS caps how far the recogniser will scan. A string sequence (DCS/OSC/APC/PM) whose terminator never arrives cannot make the scan run away over a large paste, and an unterminated fragment is passed through as input rather than being held back.

SEE ALSO

Selkie::App, Selkie::App::Internal::Dispatch

sub terminal-report-prefix

sub terminal-report-prefix(
    Str:D $text
) returns Int

Longest control sequence the recogniser will consider. Real replies are far shorter — the longest in the wild is an XTGETTCAP answer carrying a hex-encoded terminfo string, comfortably under 128 characters. The cap exists so an unterminated \eP… fragment at the head of a large paste cannot turn the scan into an O(paste) walk on every burst. Private-parameter introducers permitted directly after ESC [. Characters a CSI parameter list may be built from. Decimal digits used by the stricter SGR mouse grammar. Intermediate bytes permitted between the parameters and the final. Final bytes that mark a CSI as a report: c device attributes, R cursor position, t window manipulation, u kitty keyboard flags, S XTSMGRAPHICS, y DECRPM, n device status. Extra finals allowed only behind the C<E> (SGR mouse) introducer. Introducers for the ST-terminated string sequences. Length in characters of the complete terminal report at the head of $text, or 0 when $text does not begin with one. The scan is strictly prefix-anchored and never looks past MAX-REPORT-CHARS: an incomplete sequence returns 0 exactly like a non-matching one, so a caller that keeps the unmatched text is keeping everything it was given. =begin code :lang terminal-report-prefix("\e[?64;1;2;6;9;15;18;21;22c"); # 26 terminal-report-prefix("\e[?1u" ~ 'hunter2'); # 5 terminal-report-prefix("\eP1+r5463=78\e\"); # 13 terminal-report-prefix("\e[A"); # 0 — cursor up terminal-report-prefix("\e[?64;1;2"); # 0 — incomplete terminal-report-prefix('hunter2'); # 0 =end code

sub csi-report-prefix

sub csi-report-prefix(
    Str:D $text
) returns Int

CSI branch of terminal-report-prefix. Walks private introducer, parameters, intermediates and final in that order, bailing out to 0 the moment a character falls outside what the position allows.

sub sgr-mouse-report-prefix

sub sgr-mouse-report-prefix(
    Str:D $text
) returns Int

SGR mouse branch of terminal-report-prefix. The button code is unsigned; each coordinate may carry a leading minus sign. Exactly three non-empty decimal fields separated by semicolons are required.

sub terminal-report-strip-length

sub terminal-report-strip-length(
    Str:D $text
) returns Int

Total length of the run of consecutive complete terminal reports at the head of $text0 when it does not start with one. Replies do not arrive one at a time. A terminal answering the start-up probe burst sends DA1, DA2, the cursor position and the window geometry back to back, and if the whole run is replayed as input then the whole run has to come off the front, not just its first member. =begin code :lang terminal-report-strip-length("\e[?62;22c\e[>1;10;0c"); # 20 terminal-report-strip-length("\e[?1u" ~ 'hunter2'); # 5 terminal-report-strip-length('hunter2'); # 0 =end code

sub terminal-report-viable

sub terminal-report-viable(
    Str:D $text
) returns Bool

True when $text is not yet a complete terminal report but could still become one if more characters arrive — every character so far is legal for its position and no terminating byte has been seen. This is what lets the caller wait rather than guess. The reply to a capability query does not just arrive late; under load it arrives in pieces, and notcurses replays each piece separately, so the fragments reach Selkie in different input bursts. Recognising a half-arrived report is what turns "dispatch [?64;1;2 into the password field, then ;6;9;15;18;21;22c a frame later" into "hold briefly, reassemble, drop". =begin code :lang terminal-report-viable("\e[?64;1;2"); # True — mid-parameters terminal-report-viable("\e["); # True — mid-CSI terminal-report-viable("\eP1+r5463"); # True — string, no ST yet terminal-report-viable("\e[?64;1;2c"); # False — already complete terminal-report-viable("\e[?64;1;2h"); # False — terminated, not a report terminal-report-viable("\e[A"); # False — terminated, not a report terminal-report-viable("\ehunter2"); # False — never was one =end code

sub csi-viable

sub csi-viable(
    Str:D $text
) returns Bool

CSI branch of terminal-report-viable: viable while every character past the introducer is a private marker, a parameter character or an intermediate — i.e. while no final byte has been reached. Reaching a final byte means the sequence is over, and terminal-report-prefix has already ruled it out as a report.

sub sgr-mouse-viable

sub sgr-mouse-viable(
    Str:D $text
) returns Bool

True while an exact SGR mouse report is incomplete but every byte received so far is legal for its position.

sub string-report-prefix

sub string-report-prefix(
    Str:D $text
) returns Int

DCS / OSC / APC / PM branch: everything up to the first ST (ESC \), or — for OSC only, which is the one sequence terminals still terminate the old way — a BEL. An embedded ESC that is not the start of an ST aborts the match: that is a new sequence beginning, not payload.

Selkie v0.16.0

High-level TUI framework built on Notcurses

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

Notcurses::Native:ver<0.6.5+>:auth<zef:apogee>

Test Dependencies

Provides

  • Selkie
  • Selkie::Align
  • Selkie::Alpha
  • Selkie::App
  • Selkie::App::Internal::Animation
  • Selkie::App::Internal::Dispatch
  • Selkie::App::Internal::ErrorLog
  • Selkie::App::Internal::ErrorLogPlatform
  • Selkie::App::Internal::FocusTree
  • Selkie::App::Internal::HitTest
  • Selkie::App::Internal::IdleBudget
  • Selkie::App::Internal::OverlayTree
  • Selkie::App::Internal::PosixFD
  • Selkie::App::Internal::RenderLoop
  • Selkie::App::Internal::ScreenModalLifecycle
  • Selkie::App::Internal::Terminal
  • Selkie::App::Internal::TerminalPlatform
  • Selkie::App::Internal::TerminalReport
  • Selkie::App::Internal::TerminalSequences
  • Selkie::BorderStyle
  • Selkie::Container
  • Selkie::EffectiveBounds
  • Selkie::Event
  • Selkie::Gradient
  • Selkie::Layout::Allocate
  • Selkie::Layout::HBox
  • Selkie::Layout::Split
  • Selkie::Layout::VBox
  • Selkie::Plot::Palette
  • Selkie::Plot::Scaler
  • Selkie::Plot::Ticks
  • Selkie::ScreenManager
  • Selkie::Sizing
  • Selkie::Store
  • Selkie::Store::Schema
  • Selkie::Store::Schema::UI
  • Selkie::Style
  • Selkie::Test::Focus
  • Selkie::Test::Keys
  • Selkie::Test::Snapshot
  • Selkie::Test::Snapshot::Harness
  • Selkie::Test::SnapshotPlatform
  • Selkie::Test::Store
  • Selkie::Test::Supply
  • Selkie::Test::Tree
  • Selkie::Theme
  • Selkie::Trace
  • Selkie::Tree
  • Selkie::Tween
  • Selkie::Widget
  • Selkie::Widget::Axis
  • Selkie::Widget::BarChart
  • Selkie::Widget::Border
  • Selkie::Widget::Button
  • Selkie::Widget::CardList
  • Selkie::Widget::Checkbox
  • Selkie::Widget::CommandPalette
  • Selkie::Widget::ConfirmModal
  • Selkie::Widget::FileBrowser
  • Selkie::Widget::FocusableByDefault
  • Selkie::Widget::GradientFill
  • Selkie::Widget::Heatmap
  • Selkie::Widget::HelpOverlay
  • Selkie::Widget::Histogram
  • Selkie::Widget::Image
  • Selkie::Widget::Legend
  • Selkie::Widget::LineChart
  • Selkie::Widget::ListView
  • Selkie::Widget::Modal
  • Selkie::Widget::MultiLineInput
  • Selkie::Widget::PasswordStrength
  • Selkie::Widget::Plot
  • Selkie::Widget::ProgressBar
  • Selkie::Widget::RadioGroup
  • Selkie::Widget::RichText
  • Selkie::Widget::RichText::Span
  • Selkie::Widget::ScatterPlot
  • Selkie::Widget::ScrollView
  • Selkie::Widget::Select
  • Selkie::Widget::Sparkline
  • Selkie::Widget::Spinner
  • Selkie::Widget::TabBar
  • Selkie::Widget::Table
  • Selkie::Widget::Text
  • Selkie::Widget::TextInput
  • Selkie::Widget::TextInput::HighlightSpan
  • Selkie::Widget::TextStream
  • Selkie::Widget::Toast
  • Selkie::Widget::ViewportedCardList
  • X::Selkie::WidgetCycle

Documentation

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.