Skip to content

Commit 0dc711e

Browse files
committed
Stage 2b review round 6: close the publication window, and stop trusting 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.
1 parent 320b440 commit 0dc711e

2 files changed

Lines changed: 67 additions & 19 deletions

File tree

‎cmd2/prompt_toolkit_bridge.py‎

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -325,18 +325,22 @@ def prepare(self, app: "Application[Any]") -> PreparedRender | None:
325325
self._renderer.output = original
326326
self._preparing = False
327327

328-
if self.needs_resynchronization or self.reserved_emission_stopped:
329-
# Something invalidated the terminal while the frame was being prepared -- a
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.
334-
self._retire()
335-
return None
336-
337-
prepared = PreparedRender(batch=recorder.batch(), generations=generations)
338-
self._in_flight = prepared
339-
return prepared
328+
with self._lock.transaction("publish"):
329+
# Checking and publishing are one step. Apart, they are two, and a writer that
330+
# abandons the reservation between them has its retirement overwritten by the
331+
# publication that follows -- leaving a frame in flight that nothing invalidated
332+
# and everything downstream believes is current.
333+
if self.needs_resynchronization or self.reserved_emission_stopped:
334+
# Something invalidated the terminal while the frame was being prepared -- a
335+
# managed write from inside a layout callback, say, or a failure that
336+
# abandoned the reservation outright. Either way the operations are recorded
337+
# against a terminal that has moved on.
338+
self._retire()
339+
return None
340+
341+
prepared = PreparedRender(batch=recorder.batch(), generations=generations)
342+
self._in_flight = prepared
343+
return prepared
340344

341345
def commit(self, prepared: PreparedRender) -> bool:
342346
"""Revalidate a prepared frame and, if it is still current, emit it.
@@ -348,13 +352,19 @@ def commit(self, prepared: PreparedRender) -> bool:
348352
with self._lock.transaction("commit", generation=prepared.generations.geometry):
349353
if prepared is not self._in_flight:
350354
# 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.
355+
# nothing has validated, and possibly emit it twice.
356+
#
357+
# Read here, after the terminal has been acquired: read before the wait, it
358+
# answers a question about a terminal somebody else still held, since a writer
359+
# can retire the batch while this call queues, changing neither the
360+
# generations nor the size.
361+
return False
362+
if self._needs_resynchronization or self._reserved_emission_stopped:
363+
# Not covered by the identity test above. Retirement clears the frame in
364+
# flight, but a preparation completing concurrently can publish a new one over
365+
# that retirement, and the frame it publishes matches on identity and on every
366+
# generation. What makes it uncommittable is the state of the terminal, so
367+
# that is what is asked.
358368
return False
359369
if self.generations() != prepared.generations:
360370
self.require_resynchronization("the terminal changed between preparing and committing")

‎tests/test_prompt_toolkit_bridge.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -851,3 +851,41 @@ def test_an_unknown_origin_request_that_finds_emission_stopped_writes_nothing(se
851851
assert harness.written() == ""
852852
# Nothing was queued either: a reply now would be answering a request never made.
853853
assert harness.bridge.report_cursor_row(4) is False
854+
855+
def test_a_frame_is_not_published_over_an_abandonment(self) -> None:
856+
"""Review finding: checking and publishing must be one step, or one overwrites the other."""
857+
handover = RetiringLock()
858+
harness = Harness(lock=TerminalLock(lock=handover))
859+
# Skip the preflight acquisition; abandon emission as the publication takes the lock.
860+
handover.schedule(None, lambda: harness.bridge.stop_reserved_emission(OSError("terminal went away")))
861+
862+
assert harness.prepare() is None
863+
assert harness.bridge.in_flight is None
864+
865+
def test_a_frame_in_flight_while_emission_is_abandoned_is_not_committed(self) -> None:
866+
"""Retirement alone cannot carry this: a publication can overwrite a retirement.
867+
868+
The state is built directly because that is the point -- commit must reject a frame
869+
that is in flight while emission is abandoned, whatever sequence produced that pair,
870+
rather than trusting that nothing can produce it.
871+
"""
872+
harness = Harness()
873+
prepared = harness.prepare()
874+
assert prepared is not None
875+
harness.bridge.stop_reserved_emission(OSError("terminal went away"))
876+
harness.bridge._in_flight = prepared
877+
harness.clear()
878+
879+
assert harness.bridge.commit(prepared) is False
880+
assert harness.written() == ""
881+
882+
def test_a_frame_in_flight_while_recovery_is_owed_is_not_committed(self) -> None:
883+
harness = Harness()
884+
prepared = harness.prepare()
885+
assert prepared is not None
886+
harness.bridge.require_resynchronization("another writer")
887+
harness.bridge._in_flight = prepared
888+
harness.clear()
889+
890+
assert harness.bridge.commit(prepared) is False
891+
assert harness.written() == ""

0 commit comments

Comments
 (0)