Skip to content

Commit 66dfd22

Browse files
committed
Stage 2b review round 2: three more gaps between a frame and the terminal
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.
1 parent 668294a commit 66dfd22

2 files changed

Lines changed: 102 additions & 21 deletions

File tree

‎cmd2/prompt_toolkit_bridge.py‎

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -216,11 +216,13 @@ def note_managed_write(self, prompt_anchor: int | None = None) -> None:
216216
against a screen the terminal no longer shows, from an origin it no longer has.
217217
218218
:param prompt_anchor: the physical row the prompt now starts on, where the layer that
219-
emitted the output knows it; recovery asks the terminal otherwise
219+
emitted the output knows it. Passing nothing *forgets* the origin rather than
220+
keeping the old one: the write moved the cursor and may have scrolled the screen,
221+
so the remembered row is exactly what is no longer true, and recovery asks the
222+
terminal instead.
220223
"""
221224
self._terminal_generation += 1
222-
if prompt_anchor is not None:
223-
self._prompt_anchor = prompt_anchor
225+
self._prompt_anchor = prompt_anchor
224226
self.require_resynchronization("managed output reached the terminal")
225227
self._request_redraw()
226228

@@ -323,10 +325,12 @@ def prepare(self, app: "Application[Any]") -> PreparedRender | None:
323325
self._renderer.output = original
324326
self._preparing = False
325327

326-
if self.needs_resynchronization:
328+
if self.needs_resynchronization or self.reserved_emission_stopped:
327329
# Something invalidated the terminal while the frame was being prepared -- a
328-
# managed write from inside a layout callback, say. The operations are already
329-
# recorded against a terminal that has moved on.
330+
# managed write from inside a layout callback, say, or a failure that abandoned
331+
# the reservation outright. Either way the operations are recorded against a
332+
# terminal that has moved on, and publishing them would hand the caller a frame
333+
# that is already retired.
330334
self._retire()
331335
return None
332336

@@ -341,14 +345,17 @@ def commit(self, prepared: PreparedRender) -> bool:
341345
:return: whether the frame was emitted in full
342346
"""
343347
assert_no_terminal_transaction("committing a prepared frame")
344-
if prepared is not self._in_flight:
345-
# Already retired, or from a previous attempt. Replaying it would emit a frame
346-
# nothing has validated, and possibly emit it twice. Everything that invalidates
347-
# the terminal retires the frame in flight, so this one test covers an owed
348-
# recovery and an abandoned reservation as well as a superseded batch.
349-
return False
350-
351348
with self._lock.transaction("commit", generation=prepared.generations.geometry):
349+
if prepared is not self._in_flight:
350+
# Already retired, or from a previous attempt. Replaying it would emit a frame
351+
# nothing has validated, and possibly emit it twice. Everything that
352+
# invalidates the terminal retires the frame in flight, so this one test
353+
# covers an owed recovery and an abandoned reservation as well as a superseded
354+
# batch -- but only when it is read here, after the terminal has been
355+
# acquired. Read before the wait, it answers a question about a terminal
356+
# somebody else still held: a writer can retire the batch while this call
357+
# queues for the lock, changing neither the generations nor the size.
358+
return False
352359
if self.generations() != prepared.generations:
353360
self.require_resynchronization("the terminal changed between preparing and committing")
354361
return False

‎tests/test_prompt_toolkit_bridge.py‎

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"""
1111

1212
import io
13+
import threading
1314
from concurrent.futures import Future
1415
from typing import Any
1516

@@ -41,13 +42,20 @@ def isatty(self) -> bool:
4142
class Harness:
4243
"""A real application over a reserved terminal, with the stream it writes to."""
4344

44-
def __init__(self, rows: int = 24, columns: int = 40, reserved_rows: int = 1, content: Any = "hello") -> None:
45+
def __init__(
46+
self,
47+
rows: int = 24,
48+
columns: int = 40,
49+
reserved_rows: int = 1,
50+
content: Any = "hello",
51+
lock: TerminalLock | None = None,
52+
) -> None:
4553
self.stream = TtyStringIO()
4654
self.size = Size(rows=rows, columns=columns)
4755
self.backend = Vt100_Output(self.stream, lambda: self.size)
4856
self.display = TerminalDisplay(self.backend, reserved_rows=reserved_rows)
4957
assert self.display.acquire() is True
50-
self.lock = TerminalLock()
58+
self.lock = lock or TerminalLock()
5159
self.app: Application[Any] = Application(
5260
layout=Layout(Window(FormattedTextControl(content))),
5361
output=self.display.output,
@@ -208,7 +216,7 @@ def test_uncommitted_frame_metadata_is_not_dispatched(self) -> None:
208216
harness = Harness()
209217
prepared = harness.prepare()
210218
assert prepared is not None
211-
harness.bridge.note_managed_write()
219+
harness.bridge.note_managed_write(prompt_anchor=1)
212220
assert harness.bridge.can_dispatch_input is False
213221
assert harness.bridge.commit(prepared) is False
214222
assert harness.bridge.can_dispatch_input is False
@@ -274,7 +282,7 @@ def test_discarded_frame_resynchronizes_terminal_modes(self) -> None:
274282
assert prepared is not None
275283
# Preparation advanced the flag even though the terminal saw nothing.
276284
assert harness.renderer._bracketed_paste_enabled is True
277-
harness.bridge.note_managed_write()
285+
harness.bridge.note_managed_write(prompt_anchor=1)
278286
harness.bridge.commit(prepared)
279287
harness.clear()
280288

@@ -291,7 +299,7 @@ def test_recovery_restores_the_baseline_only_after_a_full_frame(self) -> None:
291299
harness = Harness()
292300
prepared = harness.prepare()
293301
assert prepared is not None
294-
harness.bridge.note_managed_write()
302+
harness.bridge.note_managed_write(prompt_anchor=1)
295303
harness.bridge.commit(prepared)
296304
harness.resynchronize()
297305
harness.clear()
@@ -374,7 +382,7 @@ def test_repeated_invalidation_yields_to_managed_output(self) -> None:
374382
harness.bridge.set_redraw_scheduler(lambda: scheduled.append(1))
375383

376384
for _ in range(5):
377-
harness.bridge.note_managed_write()
385+
harness.bridge.note_managed_write(prompt_anchor=1)
378386

379387
assert len(scheduled) == 1
380388
assert harness.bridge.redraw_pending is True
@@ -387,10 +395,10 @@ def test_a_redraw_is_requested_again_after_it_is_served(self) -> None:
387395
harness = Harness()
388396
scheduled: list[int] = []
389397
harness.bridge.set_redraw_scheduler(lambda: scheduled.append(1))
390-
harness.bridge.note_managed_write()
398+
harness.bridge.note_managed_write(prompt_anchor=1)
391399
harness.resynchronize()
392400
harness.render()
393-
harness.bridge.note_managed_write()
401+
harness.bridge.note_managed_write(prompt_anchor=1)
394402
assert len(scheduled) == 2
395403

396404

@@ -660,3 +668,69 @@ def test_a_cursor_report_is_validated_against_the_screen_when_released(self) ->
660668
harness.bridge.request_cursor_position()
661669
assert harness.bridge.report_cursor_row(24) is True
662670
assert harness.renderer._min_available_height == 24 - 24 + 1
671+
672+
673+
class RetiringLock:
674+
"""A lock that runs a callback at the moment it is handed over.
675+
676+
This stands in for another writer retiring the batch while a commit waits for the
677+
terminal. Driving that with two real threads cannot say *where* the second thread got to
678+
before the lock was released -- the interleaving the test is about is the one where the
679+
commit is already past its own checks -- so the handover itself is the seam to inject at.
680+
"""
681+
682+
def __init__(self) -> None:
683+
self._lock = threading.RLock()
684+
self.on_acquire: Any = None
685+
686+
def acquire(self, *args: Any, **kwargs: Any) -> bool:
687+
acquired = self._lock.acquire(*args, **kwargs)
688+
if self.on_acquire is not None:
689+
callback, self.on_acquire = self.on_acquire, None
690+
callback()
691+
return acquired
692+
693+
def release(self) -> None:
694+
self._lock.release()
695+
696+
697+
class TestReviewRegressionsRoundTwo:
698+
def test_a_managed_write_without_an_origin_forgets_the_old_one(self) -> None:
699+
"""Review finding 1: the output moved the cursor, so the remembered row is stale."""
700+
harness = Harness()
701+
harness.render()
702+
assert harness.bridge.prompt_anchor == 1
703+
harness.bridge.note_managed_write()
704+
assert harness.bridge.prompt_anchor is None
705+
706+
harness.clear()
707+
harness.resynchronize()
708+
written = harness.written()
709+
assert "\x1b[1;1H" not in written
710+
assert "\x1b[6n" in written
711+
assert harness.bridge.needs_resynchronization is True
712+
713+
def test_a_frame_prepared_after_emission_stopped_is_not_published(self) -> None:
714+
"""Review finding 2: stopping is not the same state as owing a recovery."""
715+
716+
def content() -> str:
717+
harness.bridge.stop_reserved_emission(OSError("terminal went away"))
718+
return "hello"
719+
720+
harness = Harness(content=content)
721+
assert harness.prepare() is None
722+
assert harness.bridge.in_flight is None
723+
assert harness.bridge.reserved_emission_stopped is True
724+
725+
def test_a_frame_retired_while_the_commit_waits_is_not_emitted(self) -> None:
726+
"""Review finding 3: retirement only replaces an explicit guard if read under the lock."""
727+
handover = RetiringLock()
728+
harness = Harness(lock=TerminalLock(lock=handover))
729+
prepared = harness.prepare()
730+
assert prepared is not None
731+
harness.clear()
732+
733+
handover.on_acquire = lambda: harness.bridge.require_resynchronization("another writer")
734+
assert harness.bridge.commit(prepared) is False
735+
assert harness.written() == ""
736+
assert harness.bridge.needs_resynchronization is True

0 commit comments

Comments
 (0)