Skip to content

Commit c8a8b32

Browse files
committed
Stage 2b review round 4: take the terminal for cursor reports and recovery'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.
1 parent 7407dd7 commit c8a8b32

2 files changed

Lines changed: 79 additions & 28 deletions

File tree

‎cmd2/prompt_toolkit_bridge.py‎

Lines changed: 45 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -412,14 +412,16 @@ def resynchronize(self) -> None:
412412
origin can be established at all
413413
"""
414414
assert_no_terminal_transaction("resynchronizing the terminal")
415-
if self._reserved_emission_stopped:
416-
raise ReservedModeFailureError("reserved emission has stopped; release before rendering again")
417-
418415
# Resolved before the terminal is taken: this runs application filters, which the
419416
# wait contract keeps off the lock.
420417
policy = self._desired_policy()
421418

422419
with self._lock.transaction("resynchronize"):
420+
if self._reserved_emission_stopped:
421+
# Checked here rather than before the wait. Rendering can be abandoned while
422+
# this call queues for the terminal, and recovery would then write cursor and
423+
# mode sequences into a terminal nothing is allowed to emit to any more.
424+
raise ReservedModeFailureError("reserved emission has stopped; release before rendering again")
423425
# The origin is read *here*, not before the wait. Recovery can queue behind
424426
# another writer for as long as that writer holds the terminal, and what it does
425427
# in the meantime -- emitting output, moving the prompt, resizing -- is exactly
@@ -549,14 +551,18 @@ def request_cursor_position(self) -> bool:
549551
output = self._display.output
550552
if not output.responds_to_cpr:
551553
return False
552-
generations = self.generations()
553-
with self._lock.transaction("cursor position request", generation=generations.geometry):
554+
with self._lock.transaction("cursor position request"):
555+
# Recorded here, not before the wait. A managed write can land while this call
556+
# queues for the terminal, and a request stamped with the generations from before
557+
# that write would have its own reply rejected as stale.
558+
#
559+
# The whole generation tuple, not just the geometry: the terminal samples the
560+
# cursor when it processes the request, so output written afterwards moves the
561+
# very thing the reply describes.
562+
generations = self.generations()
554563
output.ask_for_cpr()
555564
output.flush()
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)
565+
self._pending_cpr.append(generations)
560566
return True
561567

562568
def report_cursor_row(self, row: int) -> bool:
@@ -574,30 +580,41 @@ def report_cursor_row(self, row: int) -> bool:
574580
compute ``U - r + 1``, which is zero at the first reserved row and negative below it,
575581
and would leave the prompt's height silently invalid rather than raising.
576582
583+
Validating the reply and publishing the origin it establishes happen in one terminal
584+
transaction. Split, they are two steps a managed write can land between: the reply
585+
passes as current, the write moves the prompt, and the anchor it just recorded is then
586+
overwritten by a row that is no longer where the prompt is. Managed output reaches the
587+
terminal inside this same lock, so a reply validated here cannot be overtaken by one.
588+
589+
Completing the renderer's own pending report only schedules its callbacks on the event
590+
loop, which is not a wait and dispatches no application code, so it belongs inside the
591+
transaction with the decision it settles.
592+
577593
:param row: the one-based physical row the terminal reported
578594
:return: whether the reply was accepted and used
579595
"""
580-
if not self._pending_cpr:
581-
# Nothing outstanding: a late reply from a stream that was already drained. It
582-
# must not be allowed to answer a request that was never made.
583-
self._settle_renderer_cpr()
584-
return False
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():
589-
self._settle_renderer_cpr()
590-
return False
596+
with self._lock.transaction("cursor position report"):
597+
if not self._pending_cpr:
598+
# Nothing outstanding: a late reply from a stream that was already drained. It
599+
# must not be allowed to answer a request that was never made.
600+
self._settle_renderer_cpr()
601+
return False
602+
# Popped whatever the outcome: replies correlate by order, so dropping one without
603+
# taking it off the queue would answer every later request with its predecessor.
604+
generations = self._pending_cpr.popleft()
605+
if generations != self.generations():
606+
self._settle_renderer_cpr()
607+
return False
591608

592-
usable = self._usable_rows()
593-
if not 1 <= row <= usable:
594-
self._settle_renderer_cpr()
595-
self.require_resynchronization(f"cursor position report row {row} is inside the reserved band")
596-
return False
609+
usable = self._usable_rows()
610+
if not 1 <= row <= usable:
611+
self._settle_renderer_cpr()
612+
self.require_resynchronization(f"cursor position report row {row} is inside the reserved band")
613+
return False
597614

598-
self._prompt_anchor = row
599-
self._renderer.report_absolute_cursor_row(row)
600-
return True
615+
self._prompt_anchor = row
616+
self._renderer.report_absolute_cursor_row(row)
617+
return True
601618

602619
def _settle_renderer_cpr(self) -> None:
603620
"""Resolve one of the renderer's own pending reports, if it has any.

‎tests/test_prompt_toolkit_bridge.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,3 +786,37 @@ def test_replies_still_correlate_by_order_after_one_is_invalidated(self) -> None
786786
assert harness.bridge.report_cursor_row(5) is True
787787
assert harness.bridge.prompt_anchor == 5
788788
assert harness.renderer._min_available_height == 23 - 5 + 1
789+
790+
def test_a_reply_cannot_overtake_a_write_that_lands_while_it_waits(self) -> None:
791+
"""Review finding: validating a reply and publishing its origin must be one step."""
792+
handover = RetiringLock()
793+
harness = Harness(lock=TerminalLock(lock=handover))
794+
harness.bridge.request_cursor_position()
795+
796+
handover.on_acquire = lambda: harness.bridge.note_managed_write(prompt_anchor=7)
797+
assert harness.bridge.report_cursor_row(4) is False
798+
assert harness.bridge.prompt_anchor == 7
799+
800+
def test_a_request_records_the_terminal_it_was_actually_sent_to(self) -> None:
801+
"""A write during acquisition must not make the reply to a later request look stale."""
802+
handover = RetiringLock()
803+
harness = Harness(lock=TerminalLock(lock=handover))
804+
805+
handover.on_acquire = lambda: harness.bridge.note_managed_write(prompt_anchor=7)
806+
assert harness.bridge.request_cursor_position() is True
807+
# The request went out after that write, so its reply describes the current terminal.
808+
assert harness.bridge.report_cursor_row(4) is True
809+
assert harness.bridge.prompt_anchor == 4
810+
811+
def test_recovery_that_finds_emission_stopped_writes_nothing(self) -> None:
812+
"""Review finding: rendering can be abandoned while recovery queues for the terminal."""
813+
handover = RetiringLock()
814+
harness = Harness(lock=TerminalLock(lock=handover))
815+
harness.bridge.require_resynchronization("test")
816+
harness.clear()
817+
818+
handover.on_acquire = lambda: harness.bridge.stop_reserved_emission(OSError("terminal went away"))
819+
with pytest.raises(ReservedModeFailureError):
820+
harness.resynchronize()
821+
assert harness.written() == ""
822+
assert harness.bridge.needs_resynchronization is True

0 commit comments

Comments
 (0)