Conversation
Closes the small and medium items of the v0.8 milestone; the two design questions (#146, #147) are addressed in their issues. - DEC Special Graphics (ESC ( 0, SO/SI) is translated so ncurses borders read as box drawing, and an OSC 8 label records the glyphs (#179) - Terminal::scroll_with with Scroll::{ctrl,alt,shift} and ScrollChord (#174) - Screen implements PartialEq/Eq as the same observation (#183) - wait_until/wait_frame errors name scrolled-off rows and full_text (#203) - resize is refused once the child released the terminal (#202) - Bitmap::colours is linear with a documented tie order (#201) - resize documents that history keeps its captured width (#148) - doc links name Error::Size, not Error::Input (#198) - README mirror compile-checks insta::assert_snapshot! (#200) - CONTRIBUTING lists every CI gate and the real full-suite command (#199) - SECURITY.md names the two unsafe FFI calls instead of claiming none - cells_between uses i64 so it holds its own bound Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com>
The stress workflow failed once at 8 threads on ubuntu, in mouse_events_outside_the_grid_are_refused: a resize followed at once by Esc hung the fixture and wait_exit timed out. Reproduced locally at about one run in forty. crossterm's event reader returns the Resize event as soon as its poll reports the SIGWINCH, abandoning the tty readiness delivered in the same poll; mio registers edge-triggered (EPOLLET, EV_CLEAR), so the Esc already in the queue is never offered again until more input arrives. A real terminal has the same race. The fixture now acknowledges a resize on its last: line, the test waits for that frame before sending Esc, and Terminal::resize documents the trap so a user's test does not walk into it. 40 further iterations of the input suite and 10 of the whole workspace at 8 threads: no failure. Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com>
The second stress run failed at 16 threads on iteration 21, at the wait for the resize acknowledgement, with the live screen showing the acknowledging repaint: resize sent the SIGWINCH first and took the frame cursor after, so a fast application's repaint could complete and be counted in that gap, sit behind the cursor, and never be offered to wait_frame. Such a frame was also rendered into the old grid and clipped afterwards. The grid is now resized and the cursor taken before the ioctl, under one state lock, so no byte is processed between the two steps; if the ioctl fails the grid is restored. A new test resizes three times and requires each acknowledging frame to be offered and to carry the new size. 40 iterations each of the input and frames suites and 12 of the workspace at 16 threads: no failure. Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com>
This was referenced Aug 29, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes ten of the twelve open items of the v0.8 milestone. The two that
are not code changes here — #146 (styled history) and #147 (addressing
history) — are design questions, and each gets a written decision in its
issue once this lands rather than a half-built feature in a release.
DEC Special Graphics is translated (#179)
ESC ( 0 l q q q kis┌───┐, and the grid used to saylqqqk. That is howevery ncurses application draws its borders (
smacs/rmacson an xtermterminfo are exactly
ESC ( 0/ESC ( B), so the crate's own promise —assert on what a user would see — was wrong for a large class of programs,
and wrong in the worse direction too: a snapshot that had blessed
lqqqkkeptpassing after the border broke.
vt100drops the designation entirely (ESC ( 0reachesunhandled_escape),so the sequence tracker now holds the G0/G1 designations and the
SO/SIlocking shift, and the emulator asks it, byte by byte and before stepping,
whether a byte draws as a glyph. If so the glyph is staged in place of the byte
and both parsers receive the staged stream — rewriting before either parser is
what keeps the primary and the attribute shadow the same shape, exactly as the
SGR rewrite does, and the shadow's correspondence check runs on every snapshot.
A stream that never designates a set takes the old slice-through path and pays
nothing.
Scope is stated rather than implied, in the README,
DESIGN.mdand theCHANGELOG: G0/G1 and the locking shifts only; one set translated; other
designations read as ASCII; G2/G3 and single shifts not modelled;
RISreturnsboth sets to ASCII;
DECSC/DECRCdo not save charset state. AnOSC 8labelwritten in the graphics set records the glyphs, since the label is what a
reader sees.
Terminal::scroll_with(#174)Scroll::{ctrl, alt, shift}and aScrollChordtype, mirroringMouseButton/MouseChord;scrollis now the unmodified case of it, asclickis ofclick_with. The wheel gets a chord type of its own rather than awider
MouseChordso a wheel direction cannot be handed toclick_with— anotch has no release, and the type keeps that from being a runtime surprise.
The
form-echofixture now names mouse modifiers, soCtrl-wheel is provedthrough crossterm's parser and not only through our own table.
Screen: PartialEq + Eq(#183)Equality is the same observation, counters included, and the doc says so —
along with the trap it replaces: comparing
to_string()is style-blind.Waits name scrolled-off history (#203, the diagnostic half of #147)
wait_untilandwait_frameerrors, on both the timeout and EOF paths, sayhow many rows have scrolled off the top and point at
Screen::full_text. Thenote is conditional on purpose: the predicate is an arbitrary closure, so the
wait cannot know what it was looking for — only what is true. Silent when
nothing has scrolled.
containsandfindsay where they stop.resizeafter exit is refused (#202) — behaviour changeThe same
Error::Writeassend, on the same evidence (EOF). It used tosucceed and the snapshot then reported a size no application ever rendered at.
Listed under Changed. No existing test resized after
wait_exit.The rest
Bitmap::coloursis quadratic, so a real screenshot takes seconds #201Bitmap::coloursis linear (HashMap), ties ordered by firstappearance on purpose and documented. The timing is not asserted — the issue
is right that it would flake — but the 512x512 all-distinct case is in the
suite, and it ran in about 8 s quadratic against milliseconds now.
resizeandscrollback_text, with thealternative (discard history on resize) rejected in writing; the two-width
case is pinned in a test.
Error::Inputfor what is nowError::Size#198 three doc links, not two:spawn's# Errorshad the same wrongvariant.
insta::assert_snapshot!line #200 the README mirror snapshots withinsta::assert_snapshot!; theblessed file was read against the
hello-tuisnapshot before acceptance(identical grid). It needs no
cfg(feature = "insta"):instais adev-dependency and is present in every feature configuration, which the
--no-default-featuresjob now proves.gate is listed inline with
ci.ymlnamed as the source of truth.SECURITY.mdclaimed the crate has nounsafe. It has two blocks —dup(2)andkill(2)— both audited FFI. The policy now says that.cells_betweenusesi64: bounded by its callers today, butd * stepsatu16::MAXoverflows ani32, and a helper should hold its own bound.Verification
fmt · clippy
-D warningsin all three feature configurations · 360 testsall-features, 340 no-default-features, 358 decode-only (337 on
main)· docs
-D warnings, default and all-features ·cargo deny· MSRV 1.85 with--locked --all-features·cargo-semver-checksagainst the published 0.7.0:no semver update required (every API change is additive).
Every new guard was mutation-checked — removed in turn, its test went red,
file restored from the commit: charset translation off,
SOignored,RISkeeping the graphics set, the history note silenced,
resizeaccepting adeparted child, wheel modifiers dropped, and the colours tie order reversed.
What the stress workflow found
The first stress run on this branch — 100 iterations, ten shards — went 9 of
10 green. The ubuntu/8-thread shard failed on its first iteration in
mouse_events_outside_the_grid_are_refused: aresizefollowed at once byEsc, and the fixture never exited. That test landed in #177 after the laststress run, so it had never been stressed. It reproduced locally at about one
run in forty.
The cause is in crossterm's event reader, and a real terminal has the same
race. When its poll reports the tty and the
SIGWINCHfd in the same call,the signal arm
returns theResizeevent at once, abandoning the ttyreadiness it was handed — and mio registers edge-triggered (
EPOLLET,EV_CLEAR), so theEscalready in the queue is never offered again untilmore input arrives. The application blocks in
read()with the keystrokebehind it.
Fixed in the second commit:
form-echoacknowledges a resize on itslast:line, the test waits for that frame before sending
Esc, andTerminal::resizedocuments the trap so a user's own test does not walk intoit.
…and then a real bug in
resizeThe second stress run went 9 of 10 again, now failing at 16 threads on
iteration 21 — at the new
wait_framefor the acknowledgement. The error wastelling: the live screen showed
last: resize:18x4, so the fixture hadrepainted, yet the wait reported no repaint since the cursor.
resizesent theSIGWINCHfirst and took the frame cursor afterwards; a fast application'sacknowledging repaint could complete, and be counted, in that gap — so the one
frame the wait had been promised sat behind the cursor. The doc's guarantee
("only a frame completed after the resize can satisfy the wait") was inverted
by the ordering. Worse, such a frame was rendered into the old grid and then
clipped.
Fixed in the third commit: the grid is resized and the cursor taken before
the ioctl, all under one state lock, so no byte is processed between the two
steps; if the ioctl fails the grid is restored so kernel and grid never
disagree. A new test in
frames.rsresizes three times and requires eachacknowledging frame to be offered and to carry the new size. Listed under
Fixed in the CHANGELOG — this one affected users, not just our suite.
Verified with 40 iterations each of the input and frames suites and 12 of the
whole workspace, all at 16 threads, no failure — and the stress workflow is
running again on the final commit; the release PR will cite that run.