v0.5.0
What the harness could not observe, could not reach, and quietly got wrong.
Seventeen issues, every one verified against the published 0.4.2 before a
line was written — four by reproductions that contradicted the report, and
one of those by a reproduction that contradicted me. Three themes:
behaviour a test could not see at all (repaints, bells, images, focus),
applications that could not be driven down a path they probe for first, and
accessors that answered confidently where they had nothing to say.
Two API changes are breaking, both in the direction of honesty:
send/send_str/paste return Result, and ExitStatus::code returns
Option.
Changed
-
send,send_strandpastereturnResult<()>and no longer
panic. Every input call in the crate is now fallible, so a write that
cannot be delivered is something a test can see, handle, or propagate
with?— previously the only route from a failed write to the test was
aborting it. Call sites grow a?; that is the whole migration. -
Typed input to a closed terminal is refused identically on Linux and
macOS. It was not: a write to a master whose slave descriptors are all
closed fails withEIOon macOS and succeeds on Linux, queueing the
bytes for a reader that no longer exists. The same keystroke was
therefore an error on one CI runner and silently discarded on the other.
Every sender now checks for a closed terminal before writing, so the
answer is the same everywhere and no keystroke is lost quietly. -
A batch of startup probes is answered in full, and the reply queue is
now bounded by memory rather than by queue slots — which took three
attempts to get right, each one teaching what the invariant actually is.
200 queries asked back to back returned 173 answers; 400 returned 235; 1000
returned 285. The stated cause — the application had stopped reading — was
wrong: the same 200 queries a millisecond apart were all answered, so
nothing was blocked anywhere. The reader was enqueueing one entry per
reply while the writer issued onewrite(2)per entry, so it outran the
writer and the 64-slot queue overflowed. Batching per read fixed that on a
fast machine — but on a slow one an application's writes dribble out, the
same 400 queries arrive in hundreds of small reads, and 64 slots ran out
again at 235 of 400. Slots were never the thing worth bounding: the queue is
now unbounded with a 1 MiB ceiling on undelivered reply bytes, so the
reader can never block, a real application is never shorted, and a hostile
one still cannot grow memory without limit. The writer coalesces whatever is
queued into a single write. -
Undelivered replies are counted whether dropped or blocked mid-write, so
a non-reading application is named in the wait error rather than producing a
plain timeout.
One diagnosis got weaker on Linux, and that is the price of the fix
above. The note used to appear there because replies overflowed our queue
— the same overflow that was losing a well-behaved application's answers.
With that fixed, the replies reach the kernel, and the platforms diverge: a
write into a full terminal input queue blocks on macOS, where the backlog
stays visible and the count is exact, while Linux'sn_ttydiscards input
once its 4 KB buffer is full — the write succeeds, the bytes are gone, and
nothing distinguishes that from delivery. We cannot report what we were never
told.docs/DESIGN.md§1 states the split; the trade is a diagnosis for a
pathological application in exchange for a well-behaved one actually
receiving its answers. -
dragreports one motion per cell crossed, on a straight interpolated
path, instead of a single report at the destination. Seven cells crossed
used to produce one motion event. Invisible to an application that only
asks "where did it start, where is it now" — which is why it went unnoticed
— and wrong for every application that does something along the path: a
drawing surface painting each crossed cell, a selection highlighting
incrementally, a drag that must cross a pane edge to register. The
mode-aware refusals are unchanged:?1000still hears no motion at all,
and X10 is still a typed error. -
A mouse action at a departed child names the child.
click,drag
andscrollcheck liveness before the mouse-tracking mode, because a
child that has exited necessarily never enabled tracking either — so the
old order reported a missingCSI ?1000 hfor a terminal whose
application was simply gone. The tracking-mode error is unchanged for a
live application that really has not enabled it. -
ExitStatus::codereturnsOption<u32>,Nonewhen a signal killed
the child. A signalled process has no exit status — POSIX gives one or
the other — and the OS placeholder (1) that filled the slot made
assert_eq!(status.code(), 1)pass on aSIGTERMpath, which would keep
passing if the application later started exiting 1 for a real reason.
Displayno longer prints the invented(code 1)tail either. Mirrors
std::process::ExitStatus::code. -
Screen::rect_textpanics on a backwards range instead of returning
""or a bare"\n", and both axes now behave identically — they did
not. It reads as "this pane is empty", a plausible assertion outcome, so
a call with its arguments swapped passed for the wrong reason and kept
passing. A panic rather than an error for the same reason&slice[3..0]
panics: a backwards literal range is a mistake in the calling source, not
a fact about the terminal. Out-of-range bounds are a different thing and
stay clamped. -
An implausible terminal size is refused, at most 1000 per axis, with
the limit named.5000x5000used to spawn happily and then spend 16
seconds inside the first wait before timing out with a message about the
predicate — a transposed.size()turned a sub-second test into a wedged
one with no hint of why.resizeis held to the same limit. -
Screen::containsandScreen::findfold both sides to NFC, so a
needle finds text the application normalized the other way. A terminal
drawscaf\u{e9}andcafe\u{301}identically — and so do the failure
output and the diff, which is what made the mismatch a trap rather than a
limitation: an author types NFC (what editors produce) while text from a
filesystem path, a git author name or macOS input is frequently NFD.
Unconditional, and no escape hatch is needed because the raw form is never
taken away:text,row_text,rect_text,cellandtitleall still
return exactly the codepoints the application sent. One consequence worth
knowing: matching is grapheme-shaped, so on a screen showingcaf\u{e9},
contains("cafe")is now false — the screen does not showcafe.
Added
Screen::repaints— how many synchronized updates the application has
completed, as of this observation, on every snapshot including the frames
wait_framereturns. It counts repaints, not changes, so a
Begin/End pair that drew nothing still counts, which is exactly the
property an amplification test needs: "one wheel notch produced four
repaints" is invisible to every content predicate, because each
intermediate frame shows correct content.Terminal::frame_timingsandFrameTiming— per-repaint wall-clock cost
and printable-character count, so a suite can hold a performance line as well
as a correctness one. A TUI's most common regression is not wrong output; it
is a repaint that got slower or larger, and no content predicate sees either.
Both ends of the span are stamped at the byte carrying the marker, not when
the read arrived, so a burst delivered in one read is still timed per frame.
The docs state what the span includes rather than leaving it to be assumed:
it is measured through a PTY and covers the application's write pacing, so it
is a trend to watch and not a render benchmark. Bounded at 512 repaints,
independently of the eight frameswait_frameretains, since a timing is
three words where a frame is a whole grid.Screen::bells— how many times the application rangBEL. The bell
is often the only feedback a rejected input produces, so "an invalid key
does nothing" and "an invalid key is refused with a bell" used to be the
same screen. A count, not a flag, so twice differs from once; and only a
BELin ground state counts, since the one terminating anOSCstring is
punctuation and one inside a DCS-class string is payload.Screen::graphicsandGraphicsSeen— kitty (APC G … ST) and
sixel (DCS q … ST) payloads transmitted, by protocol, with total bytes.
The assertion this exists for is as often the negative one —
assert!(s.graphics().is_empty()), "this must render as text in every
terminal and never go out as an image" — sois_emptyis a method rather
than something to spell out. Observing is not rendering and claims
nothing: DA1 still declines both protocols.- The kitty graphics query is diagnosed.
APC _G…a=q…STwas swallowed
whole — no answer and no mention in the timeout note, alone among the
startup probes, becausestring_finalinspected only+q/$qand an APC
matches neither. An application blocked on it now gets the same one-line
diagnosis^[[?uand^[P+q…already got. Only an explicita=qcounts
as a question: a transmission is an instruction, and treating one as a
query would put "the application queried the terminal" into the next
timeout of every application that draws. XTGETTCAPis answered — the last of the common startup probes with no
reply. A capability termlens genuinely implements gets a truthful
DCS 1 + r <name>=<value> ST; anything else gets an explicit
DCS 0 + r <name> ST, which is the half that turns a hang into a decision:
the application learns the answer is no instead of waiting for one. The set
isTN/name(whateverTERMthe child was actually given, so the two
cannot disagree),Co/colors, and the cursor, home/end, delete, page and
backspace keys — each the exact bytesKey::encodeemits, checked against
the code that emits them rather than copied from a terminfo file. One reply
per requested capability, because the status flag is per-reply and a mixed
request cannot be answered in one frame without lying about half of it.TerminalBuilder::cell_size— pixels per character cell, which is the
one number every layout decision in an image-drawing application rests on.
CSI 16 tthen answersCSI 6 ; h ; w t,CSI 14 tanswers the window
size in pixels, andTIOCGWINSZcarries the same geometry instead of
contradicting it; aresizerecomputes all three. Opt-in: unset, the two
reports stay unanswered and the ioctl reports zero pixels — which is what a
real terminal reports when it has none, so the default is not a lie and no
existing suite moves onto a pixel branch.TerminalBuilder::graphicsandGraphics— declare the inline-graphics
support of the terminal being simulated.Graphics::Sixeladds4to the
DA1 reply;Graphics::Kittyanswers thea=qcapability probe with
APC _G i=<id> ; OK ST, echoing the id the probe named. Default unchanged:
nothing claimed. This is not the harness lying — it is the test author
stating which terminal is simulated, the waybackground_rgbstates a
background — and it matters because for an application that probes first
the pixel path is not merely unasserted, it is unreachable: the code never
runs, so nothing about it is testable.Terminal::send_after— wait, then send, so this write and the previous
one land in separate reads. The remedy for theEscwire ambiguity when the
Eschas no observable effect to wait for: a vim-style TUI whereEsc
leaves insert mode silently andjthen moves down could not be driven at
all, because sending them together is byte-identical toAlt+j. The delay
is a named argument, not a hidden constant, andsend(Key::Esc)carries no
default separation — most suites sendEscwith nothing behind it, and a
hidden sleep would slow all of them for a hazard they do not have while
making the tests that need it work for a reason invisible at the call site.Terminal::focus_in/Terminal::focus_outand
Screen::focus_events— focus reporting (mode 1004). The unfocused
branch of a UI was not merely unasserted, it was unreachable: no input
existed that could enter it, so the code never ran. Mode-aware like every
other input — refused with a typed error when the application never enabled
1004, exactly asclickis refused without mouse tracking.DECRQMnow
answers for 1004 as well, since termlens tracks it exactly, which is the
honesty rule's precondition; it previously reported "not recognized" even
immediately after the application enabled it.Error::Write, carrying the screen at the moment of the failed
write, the wayError::TimeoutandError::Eofalready do.
Error::screen()returns it.Screenis 40 bytes instead of 80, with all out-of-band state behind
oneArc. AScreenis embedded in everyError, so this shrinks every
Resultin the crate, and a clone — taken on each wait evaluation — is
now one refcount bump rather than a field-by-field copy.
Documented
- The README, crate docs and design notes describe 0.5. The README's
headline example now propagates theResultthatsendreturns — it is the
first code anyone copies — its limitations section dropsXTGETTCAP(0.5
answers it) and gains the two bounds this release introduced: graphics are
observed and offered but never rendered, and a reply the terminal's own
input queue cannot hold may not arrive, undetectably so on Linux. The
docs.rs landing page anddocs/DESIGN.md§6 gained the observability
counters, focus events, per-cell drag motion andsend_after. SECURITY.md's resource bounds match the code again. The reply queue is
a 1 MiB byte cap rather than "a fixed depth", and the note now says why a
depth bounds the wrong thing: two earlier versions counted slots and both
shorted a well-behaved application, while a byte bound leaves the queue
unbounded so the drain can never block on it.- What a large grid costs, on
TerminalBuilder::size: a snapshot holds
one entry per cell and is rebuilt on every state change, so the cost is
O(cells) and shape-independent, while repeat reads of an unchanged screen
are cached and free. The table gives release and debug figures, because
cargo testbuilds unoptimized by default and the two differ by 16-29x —
the debug column is the one most suites actually see.