Stage 2b: serialization, painter and renderer recovery - #1757
Conversation
L_terminal is the last lock in the output path, so the two rules that make 'last' true are enforced rather than documented: no higher-level lock may be held while taking it, and nothing that can wait may run while holding it. Violations raise synchronously before the blocking primitive is entered. A guard that fires after a wait has begun protects nothing, and the tests assert the sentinel was never reached rather than only that an error was raised -- which also means a missing guard fails the suite instead of hanging it.
…ation Renderer.render() evaluates layout, filters and styles while it emits, so holding the terminal lock around it would run application callbacks inside the transaction. Preparation now runs against a recorder and produces an immutable batch the bridge replays later, under one transaction, after revalidating it. The recorder holds no backend object at all -- reads are answered from a preflight snapshot. Using the real output as a recording sink would advance its buffering, attribute and cursor caches and console modes, leaving a discarded frame's beliefs behind on a terminal that never received it.
A frame is a grid of what the terminal will show, one cell per display column, so comparing two frames answers whether the user would see a difference rather than whether a Python string changed. That comparison is what decides whether anything is emitted at all. Wide characters own two cells and are never split at the right edge, combining marks join the cell before them, and zero-width escape fragments, mouse handlers, carriage returns and tabs are resolved during layout -- each of them is cursor motion or raw control in a band where the painter owns the cursor.
The painter is independent of the renderer -- it writes physical rows the application's geometry excludes -- so its output is never part of a renderer diff and never erased by one. That independence is also why it restores everything it touches: cursor and attributes through DECSC/DECRC, wrap mode explicitly, all inside one terminal transaction. Nothing is erased before writing. An erase followed by a write is two visible states, and that pair is the flicker this design exists to remove; a changed run is overwritten in place and a shortened frame's tail is padded instead. Content is evaluated off-lock, once per refresh. A callback that raises keeps the last good frame on screen and is not called again until its error has been reported -- a blanked toolbar is a worse failure than a stale one, and the command that was running is not the callback's to interrupt.
Upstream's renderer emits while it thinks, so the frame is recorded off-lock and replayed under one transaction only after its geometry, output, owner and terminal generations still match. A managed write, resize, owner change or handoff in between retires the batch without emitting a byte of it. Discarding the output is only half of discarding the frame. The renderer has already advanced: _last_screen became a baseline for a frame the terminal never received, and the mode flags latch beside their emission, so a full repaint never re-emits them. Recovery drops the baseline and re-establishes a small enumerated state contract physically, then tells the renderer what is true -- not a snapshot of upstream fields, and not upstream's reset(), which emits operations of its own and rewrites available-height bookkeeping. Cursor position replies are correlated by order, since the wire carries no generation, and a row inside the reserved band is rejected before it can set a height that upstream would have computed as zero or negative. The contract tests record why each of those choices is necessary against prompt_toolkit 3.0.53, so an upgrade that changes them fails loudly.
A sleep inside the transaction only makes an overlap likely to be observed. A barrier both threads must reach while inside can be satisfied only if they are genuinely there together, so the assertion means what it says -- and removing the lock makes it fail, which was checked.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## reserved_row_toolbar #1757 +/- ##
========================================================
+ Coverage 99.68% 99.73% +0.04%
========================================================
Files 28 32 +4
Lines 6771 7428 +657
========================================================
+ Hits 6750 7408 +658
+ Misses 21 20 -1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. |
…as for Eight findings, each now with a test that fails without its fix. The frame's facts and the generations it is validated against are captured in one transaction, and commit checks the frame's own recorded size as well: the generations can agree while the batch was laid out for a terminal that has since resized, and the facts are what the renderer actually branched on. Preparation is marked active before the renderer is invoked rather than after it returns. Layout, filter and style callbacks run inside that call, and from the first of them the renderer's state is provisional -- a recursive render or an input dispatch started from one would consume a screen that does not exist. A managed write between two frames now invalidates the committed baseline instead of only bumping a generation. Generation comparison catches a write during a preparation; it cannot catch one before the next, which leaves the renderer diffing against a screen the terminal no longer shows, from an origin the output just moved. Callers that know where their output ended can supply the new anchor. Recovery no longer trusts a remembered anchor a resize has invalidated -- row 20 is inside the band once the terminal is twelve rows tall -- and it no longer zeroes the available height it just established: the cursor was placed on a known row, so the height below it is known by the same arithmetic a cursor report would give. The painter takes the display rather than an output and a geometry snapshot, so a frame cannot be painted against a snapshot the terminal has moved past; the geometry is read from its owner inside the transaction and checked against the one the frame was laid out for, and a refused frame publishes no baseline. Cells are compared by resolved attributes rather than style strings, so a theme change under an unchanged class name repaints. A combining mark after a wide character attaches to the half that carries the text instead of taking a column of its own and shifting every later cell. Also removed a commit-time guard that could not fail: everything that invalidates the terminal retires the frame in flight, and that retirement is now what the tests assert.
|
All eight findings were real and are fixed in 668294a. Each has a test that fails without its fix; I applied the removal of each fix and confirmed the failure, then reverted. 1 — facts and generations captured apart. Both are now taken inside one transaction, and commit additionally checks the batch's own recorded size against the terminal's current one. The generations can agree while the frame was laid out for a different size, and the facts are what the renderer actually branched on, so they are the last word on whether the frame still fits. On the second half of that finding: I removed the 2 — recursive preparation and dispatch. 3 — managed writes between frames. 4 — painting against a stale geometry. The painter now takes the 5 — stale anchors. Recovery validates the anchor against the current usable region, forgets it if it no longer fits, and takes the unknown-origin path (a cursor report, or an explicit 6 — available height. Recovery sets 7 — resolved styles. Cells are compared by resolved attributes, not style strings, so a rule change under an unchanged class name repaints. The old 8 — combining marks after wide characters. The mark attaches to the half that carries the text instead of taking a column of its own. Re-verified: |
…inal A managed write with no supplied origin now forgets the remembered one instead of keeping it. The write moved the cursor and may have scrolled the screen, so the remembered row is precisely what is no longer true; recovery asks the terminal rather than jumping to a row the prompt has left. Preparation refuses to publish a frame when reserved emission was abandoned during the render, not only when a recovery is owed. A layout callback that stops emission left the caller holding a frame that was already retired. The retirement check moved inside the commit transaction. Reading it before the lock answers a question about a terminal somebody else still held: another writer can retire the batch while the commit queues, changing neither the generations nor the size. That check standing in for an explicit recovery guard was the argument for removing the guard, and the argument only holds here.
|
All three fixed in 66dfd22, each with a test that fails without its fix (mutation applied, failure observed, reverted). 1 — a managed write with no origin now forgets the remembered one. Five existing tests drove 2 — preparation refuses to publish after emission stops. The post-render check tests 3 — the retirement check moved inside the commit transaction. You are right that this is what the earlier removal rested on: retirement can only stand in for an explicit guard if it is read after the terminal has been acquired. Read before the wait it answers a question about a terminal another writer still held, and that writer can retire the batch while the commit queues, changing neither the generations nor the size. The reproduction injects at the lock handover rather than with two threads: a Re-verified: (The |
… under the lock Recovery read its origin before acquiring the terminal. It can queue behind another writer for as long as that writer holds it, and what that writer does meanwhile -- emitting output, moving the prompt, resizing -- is exactly what changes where the prompt starts. The origin is now read inside the transaction, and recovery is published there too, so whoever takes the terminal next cannot find a recovery still owed against work already done. Pending cursor-position requests carried only the geometry generation. The terminal samples the cursor when it processes the request, so managed output written afterwards moves the very thing the reply describes; a resize is not the only way a reply goes stale. Requests now carry the whole generation tuple. The queue is still popped whatever the outcome, or dropping one reply would answer every later request with its predecessor.
|
Both fixed in 7407dd7, each mutation-checked. 1 — recovery's origin is read under the lock. Only the mode policy is still resolved before the lock, because resolving it runs application filters and the wait contract keeps those off the terminal. 2 — pending cursor requests carry the whole generation tuple. The terminal samples the cursor when it processes the request, so managed output written afterwards moves the very thing the reply describes; a resize was never the only way a reply goes stale. Re-verified: Twenty-three mutations killed on the branch now, seven of them from this and the previous round. |
…overy's last check Validating a cursor-position reply and publishing the origin it establishes are now one transaction. Split, they are two steps a managed write can land between: the reply passes as current, the write moves the prompt, and the row just recorded is no longer where the prompt is. Managed output reaches the terminal under this same lock, so a reply validated there cannot be overtaken. The request stamps itself with the generations after acquiring the terminal rather than before. A write landing while the request queued made the reply to a request issued after it look stale. Recovery rechecks abandoned emission inside its transaction. Rendering can be given up while recovery queues, and recovery would otherwise write cursor and mode sequences into a terminal nothing may emit to any more. Settling the renderer's own pending report stays inside the transaction: completing an asyncio future schedules its callbacks on the loop, which is neither a wait nor a dispatch of application code.
|
Both fixed in c8a8b32. First, a correction: my previous summary said the three sites were "all read after acquisition". That was wrong about 1 — cursor reports are validated and published under the lock. The request side moved too: the generations are stamped after acquiring the terminal, so a write landing while the request queued no longer makes the reply to a request issued after it look stale. One deliberate inclusion: settling the renderer's own pending report stays inside the transaction. 2 — recovery rechecks abandoned emission inside its transaction. Rendering can be given up while recovery queues for the terminal, and recovery would otherwise write cursor and mode sequences into a terminal nothing may emit to any more. The pre-lock check was removed rather than kept alongside: with the inner one in place it could not fail, and an unkillable guard is not worth the line. Re-verified: Four rounds have now found the same shape four times: state read before the lock that the lock exists to protect. The remaining places the bridge reads shared state outside a transaction are |
…st too Recovery without a known origin gives the terminal back and asks for it again to send the cursor request, so emission can be abandoned in between. The request now rechecks that inside its own transaction, for the same reason recovery does: before the wait, the answer describes a terminal somebody else still held. The handover test double now schedules a callback per acquisition, so an operation that takes the terminal more than once can be interrupted at the handover that matters rather than only at its first.
|
Fixed in 320b440. The handover double now schedules one callback per acquisition ( Audit of the remaining transaction entry points, so this is checkable rather than assumed. There are six:
Re-verified: Agreed on the Stage 3 invalidation contract — it is recorded in the Stage 2b completion note as an explicit integration requirement, not an inherited assumption: the invalidation notes ( |
…ing retirement Checking whether the terminal is still ours and publishing the prepared frame were two steps. A writer abandoning the reservation between them had its retirement overwritten by the publication that followed, leaving a frame in flight that nothing had invalidated. They are one transaction now. Commit asks about the terminal's state again rather than inferring it from retirement. Removing that guard in the first review round was wrong: a publication racing a retirement produces a frame that matches on identity and on every generation, so identity carries the argument only while nothing else can publish. It can.
|
Fixed in 0dc711e, and you are right that this invalidates the audit claim — more than that, it overturns my reasoning from the first review round. I removed the commit-side stopped/recovery guard back then because no test could kill it, and argued retirement was the mechanism it duplicated. That argument holds only while nothing else can publish a frame. A preparation completing concurrently can, and the frame it publishes matches on identity and on every generation, so identity carries no information about whether the terminal is still ours. The guard is back, and now it is killable: Two changes rather than one:
The two regressions for commit build the inconsistent state directly. That is deliberate: the guard's contract is that a frame in flight while emission is abandoned is uncommittable whatever sequence produced that pair, and a test that could only reach it through today's sequence would go quiet the moment a new one appeared — which is exactly what happened here. Corrected audit. The claim "preflight and commit are covered by the post-render check and retirement" was wrong. What is true now: Re-verified: |
Stage 2b of the reserved-row toolbar plan: the terminal transaction lock, the operation
recorder, the differential painter, and the prepare/commit/recovery bridge. Nothing is wired
into toolbar rendering yet — there is still no user-visible change and no public API. Stage 3
is the lifecycle integration.
What this adds
cmd2/terminal_transaction.pyL_terminal, the lock-order and wait contract, and the guard that enforces bothcmd2/output_recorder.pycmd2/toolbar_painter.pycmd2/prompt_toolkit_bridge.pyWhy each piece is shaped this way
The lock is last, and the rules that make it last are enforced. Nothing higher-level may
be held while taking it, and nothing that can wait may run while holding it. Violations raise
synchronously before the blocking primitive is entered — a guard that fires after a wait has
begun protects nothing, and the tests assert the sentinel was never reached, which also means a
missing guard fails the suite instead of hanging it.
Emission is separated from preparation.
Renderer.render()evaluates layout, filters andstyles while it emits, so holding the lock around it would run application callbacks inside the
transaction. The render now runs against a recorder that holds no backend object at all, and
the resulting batch is replayed under one transaction only after its geometry, output, owner
and terminal generations still match.
Discarding the output is only half of discarding the frame. Upstream has already advanced:
_last_screenbecame a baseline for a frame the terminal never received, and mode flags latchbeside their emission, so a full repaint never re-emits them. Recovery drops the baseline and
re-establishes a small enumerated state contract physically, then tells the renderer what is
true — not a snapshot of upstream fields, and not upstream's
reset(), which emits operationsof its own and rewrites available-height bookkeeping.
The painter never erases before it writes. An erase followed by a write is two visible
states, and that pair is the flicker this design exists to remove. A changed run is overwritten
in place; a shortened frame's tail is padded.
Evidence
Stage 2a deferred to the bridge.
stale-commit keeping the diff baseline, baseline-only recovery, an unchecked cursor row, a
lock that takes nothing, a full-band repaint, and an erase before each paint.
asserts emitted byte order and that the blocked writer held no higher-level lock.
pinned_dynamic.py,dynamic_gate.py, kept outside the repowith the rest of the harness) passes at 12, 24 and 40 rows: zero disappearances of the stable
half, the changing half advanced in every window, every paint a partial run, zero erases
addressed to the band, zero cursor reports from inside it. A frozen toolbar fails that gate,
which is the control for it.
32/32 complete, exactly once, in order, no toolbar leakage.
make checkand
make docs-testclean; new tests also pass under GIL-enabled CPython 3.13 as well as thedefault free-threaded 3.14t build.