Skip to content

Commit 7407dd7

Browse files
committed
Stage 2b review round 3: read recovery's origin and correlate replies 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.
1 parent 66dfd22 commit 7407dd7

2 files changed

Lines changed: 112 additions & 30 deletions

File tree

cmd2/prompt_toolkit_bridge.py

Lines changed: 60 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ def __init__(self, renderer: "Renderer", display: "TerminalDisplay", lock: Termi
123123
self._pending_error: BaseException | None = None
124124
self._prompt_anchor: int | None = None
125125
self._resynchronization_reason: str | None = None
126-
self._pending_cpr: deque[int] = deque()
126+
self._pending_cpr: deque[Generations] = deque()
127127

128128
# -- what is known ---------------------------------------------------------------------
129129

@@ -415,38 +415,59 @@ def resynchronize(self) -> None:
415415
if self._reserved_emission_stopped:
416416
raise ReservedModeFailureError("reserved emission has stopped; release before rendering again")
417417

418+
# Resolved before the terminal is taken: this runs application filters, which the
419+
# wait contract keeps off the lock.
418420
policy = self._desired_policy()
419-
origin = self._usable_prompt_anchor()
420-
if origin is None:
421-
if not self._display.output.responds_to_cpr:
422-
raise ReservedModeFailureError("the prompt's origin is unknown and the terminal does not report its cursor")
423-
# The reply establishes the origin. Recovery stays owed until it arrives; guessing
424-
# would repaint the prompt over committed output.
425-
self.request_cursor_position()
426-
return
427421

428422
with self._lock.transaction("resynchronize"):
429-
output = self._display.output
430-
output.write_raw(f"\x1b[{origin};1H")
431-
# Upstream enables bracketed paste on every render and latches a flag beside the
432-
# emission, so the policy here is not conditional: it is on, and the flag is made
433-
# to agree with an enable that actually reached the terminal.
434-
output.enable_bracketed_paste()
435-
if policy.mouse_support:
436-
output.enable_mouse_support()
423+
# The origin is read *here*, not before the wait. Recovery can queue behind
424+
# another writer for as long as that writer holds the terminal, and what it does
425+
# in the meantime -- emitting output, moving the prompt, resizing -- is exactly
426+
# what changes where the prompt now starts. An origin read beforehand describes a
427+
# terminal somebody else still owned.
428+
origin = self._usable_prompt_anchor()
429+
if origin is None:
430+
can_report = self._display.output.responds_to_cpr
437431
else:
438-
output.disable_mouse_support()
439-
output.reset_cursor_key_mode()
440-
output.reset_attributes()
441-
output.enable_autowrap()
442-
output.reset_cursor_shape()
443-
output.show_cursor()
444-
output.flush()
445-
self._initialize_renderer(policy, origin)
432+
self._establish(policy, origin)
433+
return
434+
435+
if not can_report:
436+
raise ReservedModeFailureError("the prompt's origin is unknown and the terminal does not report its cursor")
437+
# The reply establishes the origin. Recovery stays owed until it arrives; guessing
438+
# would repaint the prompt over committed output.
439+
self.request_cursor_position()
446440

441+
def _establish(self, policy: TerminalModePolicy, origin: int) -> None:
442+
"""Put the terminal into the known state, from inside the transaction.
443+
444+
Recovery is marked complete here rather than after the lock is given back: whoever
445+
takes the terminal next must not find a recovery still owed against work that has
446+
already been done.
447+
448+
:param policy: the mode policy to establish
449+
:param origin: the physical row to place the cursor on
450+
"""
451+
output = self._display.output
452+
output.write_raw(f"\x1b[{origin};1H")
453+
# Upstream enables bracketed paste on every render and latches a flag beside the
454+
# emission, so the policy here is not conditional: it is on, and the flag is made
455+
# to agree with an enable that actually reached the terminal.
456+
output.enable_bracketed_paste()
457+
if policy.mouse_support:
458+
output.enable_mouse_support()
459+
else:
460+
output.disable_mouse_support()
461+
output.reset_cursor_key_mode()
462+
output.reset_attributes()
463+
output.enable_autowrap()
464+
output.reset_cursor_shape()
465+
output.show_cursor()
466+
output.flush()
447467
self._needs_resynchronization = False
448468
self._resynchronization_reason = None
449469
self._in_flight = None
470+
self._initialize_renderer(policy, origin)
450471

451472
def _usable_rows(self) -> int:
452473
"""How many rows the application may use right now.
@@ -528,11 +549,14 @@ def request_cursor_position(self) -> bool:
528549
output = self._display.output
529550
if not output.responds_to_cpr:
530551
return False
531-
generation = self.generations().geometry
532-
with self._lock.transaction("cursor position request", generation=generation):
552+
generations = self.generations()
553+
with self._lock.transaction("cursor position request", generation=generations.geometry):
533554
output.ask_for_cpr()
534555
output.flush()
535-
self._pending_cpr.append(generation)
556+
# The whole generation tuple, not just the geometry. The terminal samples the cursor
557+
# when it processes the request, so managed output written afterwards moves the very
558+
# thing the reply describes -- a resize is not the only way a reply goes stale.
559+
self._pending_cpr.append(generations)
536560
return True
537561

538562
def report_cursor_row(self, row: int) -> bool:
@@ -542,6 +566,10 @@ def report_cursor_row(self, row: int) -> bool:
542566
requests this bridge made. A reply from before a geometry change describes a screen
543567
that no longer exists and must not satisfy the request made after it.
544568
569+
A reply is stale when anything about the terminal has changed since the request went
570+
out -- a resize, an owner change, or managed output that moved the cursor the terminal
571+
was about to sample.
572+
545573
A row inside the reserved band is the failure named in the design: upstream would
546574
compute ``U - r + 1``, which is zero at the first reserved row and negative below it,
547575
and would leave the prompt's height silently invalid rather than raising.
@@ -554,8 +582,10 @@ def report_cursor_row(self, row: int) -> bool:
554582
# must not be allowed to answer a request that was never made.
555583
self._settle_renderer_cpr()
556584
return False
557-
generation = self._pending_cpr.popleft()
558-
if generation != self.generations().geometry:
585+
# Popped whatever the outcome: replies correlate by order, so dropping one without
586+
# taking it off the queue would answer every later request with its predecessor.
587+
generations = self._pending_cpr.popleft()
588+
if generations != self.generations():
559589
self._settle_renderer_cpr()
560590
return False
561591

tests/test_prompt_toolkit_bridge.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,3 +734,55 @@ def test_a_frame_retired_while_the_commit_waits_is_not_emitted(self) -> None:
734734
assert harness.bridge.commit(prepared) is False
735735
assert harness.written() == ""
736736
assert harness.bridge.needs_resynchronization is True
737+
738+
def test_recovery_uses_the_origin_it_finds_after_taking_the_terminal(self) -> None:
739+
"""Review finding: an origin read before the wait describes a terminal someone held."""
740+
handover = RetiringLock()
741+
harness = Harness(lock=TerminalLock(lock=handover))
742+
harness.bridge.set_prompt_anchor(1)
743+
harness.clear()
744+
745+
handover.on_acquire = lambda: harness.bridge.note_managed_write(prompt_anchor=7)
746+
harness.resynchronize()
747+
748+
written = harness.written()
749+
assert "\x1b[7;1H" in written
750+
assert "\x1b[1;1H" not in written
751+
assert harness.renderer._min_available_height == 23 - 7 + 1
752+
assert harness.bridge.needs_resynchronization is False
753+
754+
def test_recovery_completes_before_the_terminal_is_released(self) -> None:
755+
"""Whoever takes the terminal next must not find a recovery still owed."""
756+
seen: list[bool] = []
757+
harness = Harness()
758+
original = harness.bridge._initialize_renderer
759+
760+
def watched(policy: Any, origin: int) -> None:
761+
original(policy, origin)
762+
seen.append(harness.bridge.needs_resynchronization)
763+
764+
harness.bridge._initialize_renderer = watched # type: ignore[method-assign]
765+
harness.bridge.require_resynchronization("test")
766+
harness.resynchronize()
767+
assert seen == [False]
768+
769+
def test_a_cursor_report_invalidated_by_managed_output_is_rejected(self) -> None:
770+
"""Review finding: the write moved the cursor the terminal was sampling."""
771+
harness = Harness()
772+
harness.bridge.request_cursor_position()
773+
harness.bridge.note_managed_write(prompt_anchor=7)
774+
assert harness.bridge.report_cursor_row(4) is False
775+
assert harness.bridge.prompt_anchor == 7
776+
assert harness.renderer._min_available_height == 0
777+
778+
def test_replies_still_correlate_by_order_after_one_is_invalidated(self) -> None:
779+
"""Rejecting a reply must not desynchronize the queue behind it."""
780+
harness = Harness()
781+
harness.bridge.request_cursor_position()
782+
harness.bridge.note_managed_write(prompt_anchor=7)
783+
harness.bridge.request_cursor_position()
784+
785+
assert harness.bridge.report_cursor_row(4) is False
786+
assert harness.bridge.report_cursor_row(5) is True
787+
assert harness.bridge.prompt_anchor == 5
788+
assert harness.renderer._min_available_height == 23 - 5 + 1

0 commit comments

Comments
 (0)