Skip to content

Stage 3: ordinary lifecycle and mode selection - #1758

Open
tleonhardt wants to merge 27 commits into
reserved_row_toolbarfrom
stage3-lifecycle-integration
Open

Stage 3: ordinary lifecycle and mode selection#1758
tleonhardt wants to merge 27 commits into
reserved_row_toolbarfrom
stage3-lifecycle-integration

Conversation

@tleonhardt

Copy link
Copy Markdown
Member

Stage 3 wires the Stage 2a/2b machinery into cmd2's command loop. The reserved toolbar is now
something a user can turn on — bottom_toolbar_mode="reserved" — rather than something only
the tests can drive. The default stays legacy and nothing changes for existing applications.

What this adds

Module Role
cmd2/toolbar_mode.py Mode validation and prerequisite qualification
cmd2/reserved_toolbar.py Owns the reservation, bindings, painter and bridge for one loop
cmd2/managed_output.py Writes command output inside the terminal transaction

plus the integration in cmd2.py, command_toolbar.py and the bridge.

The decisions worth reviewing

auto falls back silently; reserved refuses to start. A backend that looks close enough
is still a guess, and a wrong guess corrupts the screen the user is working in. But a caller
who asked for reserved rendering and silently got legacy has been given the behaviour they
ruled out, and would learn it from a flickering toolbar rather than an error.

Two kinds of pause, and each site says which it means. Stopping the renderer and the input
reader is one thing; handing the physical terminal to something else is another. Command
finalization needs only the first — the toolbar has to still be there at the next prompt — and
so does the main prompt, which is what the reservation exists for. Shell commands, editors,
embedded interpreters and external pagers get the rows back for the duration.

Command output goes straight to the terminal, inside the transaction. The stdout proxy
exists to put output above a toolbar that scrolls; a reserved toolbar does not, and proxying
anyway would put back exactly the flicker being removed. The bridge is told inside the same
transaction — the contract Stage 2b could not enforce from where it sat.

Renders go through the bridge. Prompt-toolkit rendered whenever its event loop decided to,
so a frame could interleave with command output or a paint. Renders, erases, clears and cursor
reports are intercepted on the renderer instance and restored on teardown.

Gate evidence

The observer gate runs against a real cmd2 application now, not a harness that drives the
adapter directly.

Rows Commands Disappearances Bar present after Committed history
12 22 0 22/22 20/20
24 34 0 34/34 32/32
40 50 0 50/50 48/48

The same application in legacy mode is the control: 34 disappearances across 34 commands, 33
of them erase-caused
, with the toolbar still present after every window and history complete.
Legacy remains functional and flickers; reserved does not. Same observer, same workload, only
the mode differs.

Mode selection verified from the raw captures: reserved and auto install ESC[1;23r and
reset exactly once; legacy installs no margins at all. The dynamic gate passes at all three
geometries, with 0 of 35 cursor reports coming from inside the band — the first live evidence
that the band-row validation is exercised by a real terminal.

Repeated loops, finalization failure and shutdown are covered at the cmdloop() level.

Verification

Full suite 2514 passed / 6 skipped; make check and make docs-test clean. 100% line coverage
on the five new/rewritten modules, 99% on command_toolbar.py and cmd2.py. Ten review rounds
and twenty-two findings were resolved on this branch, each with a failing test first and each
fix mutation-checked.

Documentation, the changelog, the real-terminal matrix and any change to the default belong to
Stage 5.

Reserved rendering depends on things cmd2 does not control -- which backend
prompt-toolkit selected, which version of it is installed, whether there is a
terminal at all -- so one module decides those prerequisites before anything
binds a bridge or writes a margin sequence.

The two non-default modes answer the same question differently on purpose.
'auto' falls back to legacy rendering for anything it has not qualified,
because a backend that looks close enough is still a guess and a wrong guess
corrupts the screen the user is working in. 'reserved' refuses to start rather
than fall back: a caller who asked for it and silently got legacy rendering has
been given the behaviour they ruled out, and would learn that from a flickering
toolbar rather than an error.

Qualification is by exact prompt-toolkit version. The package requirement is
what cmd2 installs against; the qualified set is what the mechanism has been
tested against, and it grows only through the qualification gates.

The default stays legacy and nothing selects a mode yet, so no behaviour
changes. Documentation and the changelog wait for Stage 5, as the plan
sequences them.
Binding is two assignments, not one. Application.output is what the
application and its session report, but the renderer keeps its own reference to
the output it was constructed with, so binding only the application leaves the
renderer drawing through the unwrapped backend -- straight over the reserved
row.

Restoration is just as exact: the originals go back only where the adapter is
still installed, since something else may have rebound them in between and a
stale object is worse than a newer one. A backend left wrapped would report a
terminal one row shorter than it is to whatever runs next.

The toolbar's content is read through a callable rather than captured, so a
caller assigning a new bottom_toolbar to the session still reaches the band.

Nothing constructs this yet; the default mode is still legacy.
Reserved mode suppresses the window prompt-toolkit would draw the toolbar in,
and paints the reserved rows itself. Both layouts share one container object, so
one suppression covers the main prompt and the command display alike.

The window is hidden by asking whether the reservation is live rather than by
latching a False into its filter. A restoration that never runs -- a teardown
that raised, a caller that dropped the object -- then leaves a filter that heals
itself instead of a toolbar that is gone for the rest of the session.

The content provider is left alone. Callers read bottom_toolbar to mean "a
toolbar is configured", so suppressing by setting it to None would answer a
different question than the one being asked.

The band is painted when the reservation starts, so the toolbar is there from
the first prompt rather than from the first refresh.
The mode is chosen when the loop starts rather than at construction, because
the answer depends on the session in use then: a caller may have replaced
main_session since, and the terminal it renders to is what decides whether a
reservation is possible at all. The layout is one of those prerequisites, so an
unrecognized one falls back under auto and is an error under forced reserved --
the same policy as every other prerequisite, rather than a second mechanism.

The reservation is established after the intro has been printed. Installing it
around output that belongs to the terminal's ordinary scrollback would put a
scroll region over text the user expects to keep.

A refused reservation is not a refused loop: below the two-row floor there is
nothing to reserve and the toolbar renders natively, so the object is kept even
while inactive because the terminal can grow back.
…still ours

A caller using this as a context manager never reaches __exit__ when __enter__
raises, so a failure after the acquisition -- the first paint, say -- left the
margins installed, both outputs wrapped and the native toolbar suppressed: a
terminal nobody owned and nobody would release. Startup now unwinds itself,
best-effort, and lets the original failure propagate.

The application's output and the renderer's are saved separately. They are
usually the same object, but nothing guarantees it, and restoring one over the
other handed the renderer a terminal it never had.

The toolbar filter is restored only while it is still the one this object
installed, which is the rule the outputs already followed. A filter a caller
installed while reserved rendering was live is theirs, not ours to discard.
The backend buffers a paint and flushes it as one write, so a failure part-way
through that write leaves the terminal holding a prefix: cursor saved and moved
into the band, autowrap off, some cells replaced and some not. Unwinding the
Python call undoes none of it -- those sequences are already on the wire.

The painter now restores wrap mode and cursor on that path, before anything
else touches the terminal. Releasing the margins first would save a cursor
still sitting in the band and put it back there afterwards, which is what the
rollback was doing. It also discards its baseline: the band is showing
something no frame describes, so the next paint has to be a full one rather
than a diff against a frame that was never finished.

Fixing this in the painter rather than in startup covers every caller. An
ordinary refresh mid-session fails the same way and left the same state behind.

The regression drives a real partial emission -- a stream that writes a prefix
of one batch and then raises. The earlier tests replaced paint() outright, so
nothing was ever emitted and they could not have seen this.
The paint's opening flush drains whatever another writer left buffered, so a
failure there belongs to that output rather than to this paint. Nothing of the
band has been emitted, and the terminal's saved position is still the one some
earlier operation put there -- the margin change's, most likely. Restoring it
would move the cursor backwards over output written since, and the next write
would overwrite it.

An old saved position is not a recovery origin, so cleanup now asks whether
this paint got as far as saving one. Everything else about the path is
unchanged: the baseline is still discarded either way, because a paint that
raised anywhere leaves a band no frame describes.
…ack if it repeats

The backend clears its buffer before writing it, so a failed flush cannot say
whether the terminal received a prefix of the batch or none of it. Buffering
the cursor save does not prove the terminal got it. Either way the cursor is
somewhere this process no longer knows, which makes it the renderer's problem
as much as the painter's -- the next frame would be drawn from a believed
position that may not be where the cursor is. A failed refresh therefore leaves
recovery owed before anything renders again.

A failure never reaches the command that was running. The toolbar is cosmetic
and the command is not its to interrupt, so the error is kept for the caller to
report once, and an error the painter is still holding is taken before the
painter is dropped -- one the user never sees is the same as no error handling.

One failure is a bad moment; two in a row is a terminal that has gone away. The
second gives the rows back and lets the native toolbar render again, because
compatibility rendering starts only after the reservation is released, never
alongside it.

Establishing and refreshing differ deliberately: a band that cannot be painted
at all is a reservation that cannot be established, so startup still rolls back
and reports rather than running with rows nothing can draw in.
In legacy rendering, output written while a command runs goes through
prompt-toolkit's stdout proxy, which erases the toolbar, prints, and draws it
again. That erase-and-redraw is the flicker the reserved row exists to remove:
with rows withheld from scrolling, output can go straight to the terminal and
the toolbar stays where it is.

Straight to the terminal, but not at any moment. The write and its flush happen
inside the terminal transaction, so a command's output and a toolbar paint
reach the terminal one after the other rather than interleaved.

The bridge is told inside that same transaction. This is the contract Stage 2b
could not enforce from where it sat: output moves the cursor and may scroll the
screen, and telling the bridge after the lock is released would tell it about a
terminal that may have changed again. Inside, "the terminal changed" and "the
change was recorded" are one event.

No prompt origin is claimed. Where the cursor ends up after arbitrary output --
wrapped lines, embedded control sequences, a resize mid-write -- is not
something this layer knows, and a guess would put the next prompt over
committed output.

ToolbarStream picks its destination under the routing lock and then releases it
before writing. Carrying a routing lock into the terminal transaction is the
deadlock the ordering rule exists to prevent; the legacy proxy path keeps
holding it, because there the write is what must not race a proxy being closed.
The stdout proxy exists to put output above a toolbar that scrolls with the
screen: it erases the toolbar, prints, and draws it again. A reserved toolbar
does not scroll, so none of that is needed, and doing it anyway would put back
exactly the flicker the reservation removes.

The command display therefore installs serialized writers instead of a proxy
when a reservation is holding the toolbar, and takes them off again when it
stops. Nothing is drained on the way out: a serialized write has reached the
terminal before it returns, so there is no queued work to lose.

Legacy rendering is untouched, including the routing lock held across a proxy
write, which is what stops a write landing on a proxy being closed.
…nation once

A write that raised may have emitted part of its text and may have scrolled the
screen doing it, and a stream cannot say which. The bridge is therefore told
whether or not the write succeeded, still inside the transaction: invalidating
after output that never arrived costs a repaint, while not invalidating after
output that did costs a prompt drawn over it.

ToolbarStream now chooses its destination and acts in one routing-lock
acquisition, or acts after a single one. Deciding under the lock, releasing it,
and taking it again to act let the destination change in between -- a
serializer installed in that gap was skipped, and its write reached the
terminal outside any transaction. The serializer still runs after the release,
because it takes the terminal lock and carrying a routing lock into that is the
deadlock the ordering rule exists to prevent.
The bridge existed and was wired to the renderer, but nothing called it:
prompt-toolkit still rendered directly whenever its event loop decided to, so a
frame could interleave with a command's output or a toolbar paint. Bounded
erases and virtual geometry meant it could not destroy the band, but the
ordering the design asks for was not there.

Renders, erases and clears now go through the bridge. Preparation runs off the
lock, as before, and the recorded batch is replayed inside one transaction --
the same transaction command output and paints take, which is what puts all
three writers in one queue.

The interception is installed on the renderer instance and removed on the way
out. Patching the class would change every renderer in the process, including
ones cmd2 does not own. Preparation calls the saved original rather than the
attribute, or the wrapper would call itself forever; a mutation that removes
that indirection hangs, which is how it was checked.

A render that finds recovery owed performs it first. It runs on the UI thread,
which is where recovery's callbacks belong anyway, and a frame prepared before
the terminal is resynchronized would be diffed against a screen nobody has
seen. When recovery cannot finish -- no known origin, waiting on the terminal
to say where the cursor is -- the frame is skipped rather than guessed at.

An erase or a clear leaves recovery owed: both move the cursor and clear what
was below it, so nothing may be diffed against what was there.
…iling paths

Abandoning reserved emission did not release the rows, so rendering upstream
directly from that branch wrote outside the transaction into a terminal that
was still reserved. It now renders nothing and tells the owner, which is the
only thing that can give the rows back and unbind the renderer. Compatibility
rendering then follows that release rather than racing it: with the bridge
unbound, upstream's own render is back on the renderer and nothing routes
through here at all.

An erase or clear that raised part-way has still moved the cursor and cleared
some of what was below it, and a stream cannot say how much, so invalidation
moved inside the transaction and onto both paths -- the rule the managed writer
already follows.

A clear also moves the prompt. Whatever row it started on it is not that row
now, so the remembered origin is forgotten rather than carried across, and the
cursor reports already in flight -- which describe the screen before the clear
-- are discarded with it.

Unbinding restores only the methods still holding this bridge's replacements.
Another caller may have wrapped the renderer since, and putting the original
back over theirs would silently undo it. That is the rule the outputs and the
toolbar filter already followed.
…k stale requests

Cleanup failure -- the one path that actually abandons reserved emission --
stopped emission by setting the flag itself, so the owner was never told: the
rows stayed withheld and the renderer stayed bound. It goes through the same
door as every other abandonment now. The later call that would have notified
could not help, since it finds emission already stopped and returns.

Emptying the pending cursor-report queue could not discard the replies: they
are already in the terminal's hands. The next one to arrive was then matched
against whatever request came after the clear -- the oldest reply answering the
newest question, publishing an origin from a screen that no longer exists. The
entries stay in the queue now, marked, so each reply is still consumed in order
and each one is refused.
The reserved row makes a distinction that did not exist before. Stopping the
renderer and the input reader is one thing; handing the physical terminal to
something else is another, and until now one decorator meant both.

Command finalization needs only the first. It runs at the end of every command
to restore terminal input settings, and the toolbar has to still be there when
the next prompt appears -- so it now quiesces the display and keeps the rows.

Everything that hands the terminal to a program which knows nothing about a
scroll region -- shell commands, editors, embedded interpreters, external
pagers, and the public suspend_bottom_toolbar() callers do the same -- gives
the rows back for the duration and takes them again after. The release happens
inside the pause, not around it: the renderer has to be quiet before the
margins go, or a frame could land in rows that are no longer reserved.

The lease is kept across the loan, and the geometry is measured afresh on the
way back, because the guest may have resized the window. What it left on the
screen is unknown, so the band's contents and the renderer's beliefs are both
discarded rather than trusted, and the band is repainted.
…changed hands

Suspension asked whether a stdout proxy was installed, which in reserved mode
is never -- serialized writers take its place. Both kinds of pause therefore
left the command display running: its renderer drawing and its input reader
reading, alongside a guest that had been given the terminal. What has to stop
is the display, whichever way its output is routed.

The prompt origin is forgotten as the terminal changes hands rather than after
the guest has finished with it. From the moment of the handoff the remembered
row describes a screen someone else is writing on, and anything rendering
against it would paint over their output.

Owning the display is not the same as holding a region, and one property was
answering for both. A terminal below the two-row floor left the toolbar neither
painted nor natively rendered: no band to paint in, and the native window still
suppressed by an owner that called itself active. Activity now means rows are
actually reserved, so the native toolbar renders whenever they are not.

A handoff with no region installed still runs the protocol. There is nothing to
give back, but the guest may resize the window, and the return path is where
that is noticed and the rows are taken again.
Suspensions nest -- cmd2 suspends around its own external commands and callers
suspend around theirs -- and running the handoff protocol for each one meant an
inner block ending reinstalled the margins and repainted the band while the
guest the outer block had handed the terminal to was still using it. Painting a
band over someone else's screen is the same mistake as never releasing at all.

This was a regression from making region-less handoffs run the protocol: before
that, a nested suspension found no region installed and did nothing, so the
nesting was safe by accident rather than by design. Depth is now tracked
explicitly.

The existing nested test asserted inside both contexts and inside neither of
the intervals that matter; it now checks the one between the inner and outer
exits, which is where the terminal is still the guest's.
…ted frames

Upstream both asks for cursor reports and receives them: its own key binding
calls report_absolute_cursor_row when the reply arrives, and its renderer asks
whenever it needs the height. Neither went through the bridge, so in practice
the band-row validation never ran on a real reply, and a request upstream made
had its reply arrive uncorrelated and discarded -- leaving the prompt's height
unknown, which is the failure the design names as the reason for the rule.

Both are intercepted now. A request is recorded only if one actually went out:
in full-screen mode, and on backends that answer natively, upstream fills in
the height and returns, and recording those would leave entries in the queue
that no reply will ever consume.

The band is repainted after each frame the terminal actually received. That
ties it to the refresh cadence the session already has -- its interval, its
invalidations, its key presses -- rather than inventing a second timer, and it
paints after the prompt rather than into the middle of it. Upstream's own
after-render event cannot serve: it fires during preparation, when the frame
exists only as a recording.
… after_render

The main prompt went through the same physical suspension as an external
program, so every prompt gave the rows back, showed the native toolbar and
reset the margins -- which is the stable-toolbar requirement inverted. The
prompt the reservation exists for now keeps it; any other session is one cmd2
has not bound to the reservation and still gets the terminal to itself.

Upstream fires after_render once render() returns, whatever the wrapper
decided, so a skipped frame told everything downstream that a frame was on the
screen. It is gated on the emission actually reaching the terminal now.

Gating it exposed that the command display used that same event to mean "the
display has started" -- two different questions sharing one signal. Waiting for
a committed frame made starting the display depend on a cursor-position round
trip, and with a terminal that never answers it never started at all. Readiness
now hangs on a render attempt, which is what it was really asking about.

The readiness wait is also bounded. It had no timeout, so anything that stopped
the signal arriving held the command thread forever -- which is how this was
found, as a hung suite rather than a failing test. A display that cannot start
now says so, and the command runs without it.
The readiness timeout only bounded the waiting. Cleanup then joined the display
thread with no timeout, so a render callback blocked inside it held the command
thread anyway -- the hang moved rather than went away.

The join is bounded now. A thread that will not finish is reported and kept: it
is a daemon and still owns the application, so clearing the reference would let
a second display start over it, and one terminal cannot have two input readers.
Resuming refuses for the same reason, and a suspension that cannot restore its
display says so once and carries on without a toolbar -- the command that
suspended it is not this failure's to end.

The earlier test suppressed the readiness signal while leaving the worker
responsive, so the join it performed always returned promptly and the case
could not appear. The new tests block a real render callback.
…says so

Bounding the join stopped the hang but left the caller believing the display
had stopped. It had not: the thread was still inside the application, still
rendering and still reading input. Suspension went on to release the margins
and run the guest, so two programs shared one terminal; the borrowed layout and
key bindings went back to the main prompt underneath a thread still using them;
and the display object was dropped at the end of the command, taking the only
record of the problem with it.

A timed-out pause now raises. Nothing that follows a successful pause happens:
no handoff, no restoration, no typeahead transfer. The caller is the only one
who knows what it was about to do with the terminal, so the failure goes to it
rather than being reported and swallowed.

The refusal is recorded on the application rather than on the display object,
which the next command replaces. The thread outlives it, and one terminal
cannot have two input readers.

Startup keeps propagating the failure that brought it there rather than a
secondary cleanup failure -- the session is disabled either way, and "did not
start" is the useful half. A stop that fails during ordinary teardown still
propagates, as it did before.

Guarding _resume() against a live thread became unreachable once _pause()
refuses instead of returning, so it is gone rather than left as a branch no
test can reach.
…op still holds it

Disabling future command displays only stopped another one from starting. The
thread that would not stop was still inside the application -- rendering, and
reading the same input -- and everything else went on as though the terminal
were free: startup reported its timeout, the command ran, and the next
suspension released the margins and handed a guest a terminal cmd2 did not own.

The application now keeps a reference to the display that did not let go, and
refuses to pause, prompt or hand over until it does. That covers the paths that
matter through the two context managers every caller already goes through: the
main prompt, nested prompts, external commands, pagers and finalization.

The refusal heals itself. A render callback that finally returns, a subprocess
that finally exits, and the thread ends; the next check finds it gone and stops
refusing, rather than leaving the session broken for a condition that has
passed.
… refusal

A pause that timed out left the application as the running thread expected to
find it: its layout, its key bindings, its erase-on-done setting. That was the
right thing to do while the thread was alive. Once the thread ended, that state
belonged to nobody, and simply dropping the refusal handed the next prompt an
application still dressed as the command display.

The teardown the pause could not do is finished at the moment the refusal
lifts, and the refusal lifts only if it could be. Both halves of the pause --
returning the borrowed application, and handing over the keys typed meanwhile
-- are now one method, so the deferred path cannot drift from the ordinary one.

The regression asserts the layout, bindings and erase setting are back, not
just that the refusal is gone. Checking only the refusal is what let this
through.
The reservation seen from outside: a real cmdloop, start to finish. It takes
the rows, gives them back, and leaves the application rendering through its own
backend again -- and a second loop in the same process does it all over.

A finalization hook that raises does not cost the toolbar. Finalization runs at
the end of every command, which is exactly when the toolbar has to still be
there, and it is the site the design singles out for keeping the reservation
while the display is quiesced.
@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.74%. Comparing base (0dc711e) to head (1a6a556).
⚠️ Report is 1 commits behind head on reserved_row_toolbar.

Additional details and impacted files
@@                   Coverage Diff                    @@
##           reserved_row_toolbar    #1758      +/-   ##
========================================================
+ Coverage                 99.73%   99.74%   +0.01%     
========================================================
  Files                        32       35       +3     
  Lines                      7428     7915     +487     
========================================================
+ Hits                       7408     7895     +487     
  Misses                       20       20              
Flag Coverage Δ
unittests 99.74% <100.00%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

Wedging the display inside a render callback only asked for a redraw, which
schedules one rather than performing it. The pause that followed could reach
the event loop first, in which case the display exited cleanly and there was no
wedged thread to test against -- so the test asserted a refusal that never
happened, or hit the resume path and waited out the startup timeout. It now
waits until the callback has actually been entered.

The reserved-lifecycle harness never bound an ambient application session, so
prompt-toolkit built one on demand -- patch_stdout() in _read_raw_input() does
-- and on Windows that means asking for a console the CI runner does not have.
The existing toolbar fixture already binds one for this reason; the new harness
now does the same.

Both are test defects. Neither reproduced here: this machine runs the
free-threaded build, which was among the configurations that passed.
…aking them

Unbinding leaves a wrapper somebody else installed over ours in place, because
it is not ours to remove -- and that wrapper goes on calling in here afterwards.
Clearing the saved originals at the same time left those calls with nothing to
delegate to: a render that emitted nothing, and a fired after-render event that
raised on a None.

The originals are kept now, and every intercepted method checks whether the
bridge is still bound. Unbound it is not the terminal's owner, so it passes the
call straight through to upstream rather than taking the lock, preparing a
frame, or withholding a notification it has no business withholding.

The earlier restoration tests used standalone replacements, which never called
back in, so they could not see this. The new ones delegate.
The pass-through guard reached every intercepted method except this one. A
retained delegating wrapper therefore had a retired bridge take its lock and
invalidate its state on someone else's behalf -- and taking the lock is not
harmless: the caller may hold a higher-level lock, and the ordering rule
forbids that nesting, so acting as owner turned someone else's clear into an
exception.

The test could not have caught it. It asserted the delegated call returned,
which it did whenever nothing else held a lock. It now asserts what the guard
is actually for: no terminal transaction is taken, under a higher-level lock,
and the retired bridge's own state is left alone.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant