Skip to content

v0.5.0

Choose a tag to compare

@github-actions github-actions released this 20 Aug 00:31
· 50 commits to main since this release
aa47633

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_str and paste return Result<()> 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 with EIO on 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 one write(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's n_tty discards 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.

  • drag reports 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: ?1000 still hears no motion at all,
    and X10 is still a typed error.

  • A mouse action at a departed child names the child. click, drag
    and scroll check liveness before the mouse-tracking mode, because a
    child that has exited necessarily never enabled tracking either — so the
    old order reported a missing CSI ?1000 h for a terminal whose
    application was simply gone. The tracking-mode error is unchanged for a
    live application that really has not enabled it.

  • ExitStatus::code returns Option<u32>, None when 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 a SIGTERM path, which would keep
    passing if the application later started exiting 1 for a real reason.
    Display no longer prints the invented (code 1) tail either. Mirrors
    std::process::ExitStatus::code.

  • Screen::rect_text panics 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. 5000x5000 used 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. resize is held to the same limit.

  • Screen::contains and Screen::find fold both sides to NFC, so a
    needle finds text the application normalized the other way. A terminal
    draws caf\u{e9} and cafe\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, cell and title all still
    return exactly the codepoints the application sent. One consequence worth
    knowing: matching is grapheme-shaped, so on a screen showing caf\u{e9},
    contains("cafe") is now false — the screen does not show cafe.

Added

  • Screen::repaints — how many synchronized updates the application has
    completed, as of this observation, on every snapshot including the frames
    wait_frame returns
    . 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_timings and FrameTiming — 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 frames wait_frame retains, since a timing is
    three words where a frame is a whole grid.
  • Screen::bells — how many times the application rang BEL. 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
    BEL in ground state counts, since the one terminating an OSC string is
    punctuation and one inside a DCS-class string is payload.
  • Screen::graphics and GraphicsSeen — 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" — so is_empty is 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…ST was swallowed
    whole — no answer and no mention in the timeout note, alone among the
    startup probes, because string_final inspected only +q/$q and an APC
    matches neither. An application blocked on it now gets the same one-line
    diagnosis ^[[?u and ^[P+q… already got. Only an explicit a=q counts
    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.
  • XTGETTCAP is 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
    is TN/name (whatever TERM the child was actually given, so the two
    cannot disagree), Co/colors, and the cursor, home/end, delete, page and
    backspace keys — each the exact bytes Key::encode emits, 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 t then answers CSI 6 ; h ; w t, CSI 14 t answers the window
    size in pixels, and TIOCGWINSZ carries the same geometry instead of
    contradicting it; a resize recomputes 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::graphics and Graphics — declare the inline-graphics
    support of the terminal being simulated. Graphics::Sixel adds 4 to the
    DA1 reply; Graphics::Kitty answers the a=q capability 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 way background_rgb states 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 the Esc wire ambiguity when the
    Esc has no observable effect to wait for: a vim-style TUI where Esc
    leaves insert mode silently and j then moves down could not be driven at
    all, because sending them together is byte-identical to Alt+j. The delay
    is a named argument, not a hidden constant, and send(Key::Esc) carries no
    default separation — most suites send Esc with 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_out and
    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 as click is refused without mouse tracking. DECRQM now
    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 way Error::Timeout and Error::Eof already do.
    Error::screen() returns it.
  • Screen is 40 bytes instead of 80, with all out-of-band state behind
    one Arc. A Screen is embedded in every Error, so this shrinks every
    Result in 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 the Result that send returns — it is the
    first code anyone copies — its limitations section drops XTGETTCAP (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 and docs/DESIGN.md §6 gained the observability
    counters, focus events, per-cell drag motion and send_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 test builds unoptimized by default and the two differ by 16-29x —
    the debug column is the one most suites actually see.