Skip to content

Commit d650c97

Browse files
committed
fix(sync[indicator]) Atomic spinner→permanent transition; close render race
why: Reporter saw a flicker (and occasionally a doubled ``✓ Synced clap`` line) when a repo finished. Two interacting causes: 1. ``_render_tty`` snapshotted state under ``_lock`` then released the lock before the actual ``stream.write``. A concurrent ``stop_repo`` could clear the screen between the snapshot and the write, letting the spinner thread paint a stale frame on top of the new state. Rich avoids this by holding ``self._lock`` across every refresh tick *and* re-checking ``done.is_set()`` inside the lock (``rich/live.py:_RefreshThread.run``); the same pattern applies here. 2. ``stop_repo`` cleared the spinner, then the caller printed the permanent line in a *separate* stream call. Even on a fast terminal the spinner row blinks to blank between the two writes, which reads as flicker. The atomic transition pattern (single ``\x1b[?2026h ... \x1b[?2026l`` block that erases the panel and replaces the spinner row with the permanent line) eliminates the flash. what: - ``_render_tty``: hold ``_lock`` from snapshot through the stream write. Re-check ``self._active_repo`` under the lock at the top -- if it changed (``stop_repo`` ran), skip the tick entirely so a stale frame never lands on screen. - ``stop_repo`` gains an optional ``final_line: str | None`` argument and now returns ``bool``. When the indicator is actively rendering on a TTY *and* a ``final_line`` is given, build one atomic ANSI block under the lock that walks up the panel rows, erases each going down, and replaces the spinner row with ``final_line + \n``. Returns ``True`` to tell the caller "I owned the print; skip your ``formatter.emit_text``" -- which is what kills the doubled ``✓ Synced clap`` artefact. - Headless / disabled-indicator paths still return ``False`` so the caller's ``formatter.emit_text`` keeps running unchanged. - ``cli/sync.py``: replace the ``with indicator.repo(...)`` context manager (which fired ``stop_repo()`` with no args before the outcome was known) with manual ``start_repo`` + post-outcome ``stop_repo(final_line=permanent if is_human else None)``. Skip the separate ``formatter.emit_text(permanent)`` when ``stop_repo`` reported it owned the print. Applies uniformly to the ``synced``, ``failed``, and ``timed_out`` branches. - ``cli/sync.py`` exception path: any ``BaseException`` (incl. the KeyboardInterrupt the SIGINT handler relies on) tears the indicator down with ``stop_repo()`` (no replacement line) and re-raises so the surrounding ``except KeyboardInterrupt`` in ``_sync_impl`` still owns the partial-summary print. - 4 new tests in ``tests/cli/test_sync_progress.py``: ``stop_repo(final_line=...)`` writes inside one synchronized-output bracket and returns ``True``; the no-arg path returns ``False``; non-TTY stays caller-owned even with a ``final_line``; ``_render_tty`` skips writes when the active repo was cleared (the lock-protected stale-tick guard).
1 parent 0eb04f2 commit d650c97

3 files changed

Lines changed: 278 additions & 60 deletions

File tree

src/vcspull/cli/_progress.py

Lines changed: 114 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -173,22 +173,79 @@ def start_repo(self, name: str) -> None:
173173
# ``Synced``/``Timed out`` leading-cap pattern.
174174
self._emit_line(f"Syncing {name}")
175175

176-
def stop_repo(self) -> None:
177-
"""Stop showing any active-repo indicator and collapse the panel."""
176+
def stop_repo(self, final_line: str | None = None) -> bool:
177+
"""Stop the active-repo indicator and collapse the panel.
178+
179+
When ``final_line`` is provided AND the indicator is actively
180+
rendering on a TTY, write ``final_line`` *as the spinner-erase*
181+
in one atomic ANSI write (under the same lock the render loop
182+
holds). The spinner row morphs in place into the permanent
183+
line; panel rows above are erased; cursor advances to the row
184+
below. Returns ``True`` when the line was written by the
185+
indicator, so the caller can skip its own ``formatter.emit_text``
186+
and avoid the double-print artefact reporters have seen.
187+
188+
Returns ``False`` when:
189+
- the indicator is disabled (``--color=never``, JSON output);
190+
- we're running headless (non-TTY pipe / CI / capsys);
191+
- no ``final_line`` was provided (caller will emit_text itself).
192+
193+
In every False case the caller is responsible for printing its
194+
own permanent line through the formatter as before.
195+
"""
178196
if not self._enabled:
179-
return
197+
return False
180198

181199
with self._lock:
182200
self._active_repo = None
183201
self._repo_started_at = None
184202
self._last_heartbeat_at = None
203+
panel_visible = self._panel_visible_lines
204+
had_render = self._last_line_len > 0 or panel_visible > 0
185205
self._panel_buffer.clear()
186206

187-
if self._tty:
188-
# Erase the panel + spinner row entirely; the main sync loop
189-
# then prints the permanent ``✓ Synced ...`` line on a clean
190-
# row, giving the "trail collapses" effect.
191-
self._clear_block()
207+
if not self._tty:
208+
# Headless: nothing was drawn to erase, and the caller
209+
# still needs to surface ``final_line`` themselves.
210+
self._panel_visible_lines = 0
211+
self._last_line_len = 0
212+
return False
213+
214+
# Build the atomic clear (and optional replacement) under the
215+
# lock so a concurrent spinner tick can't squeeze a stale
216+
# frame in. Layout:
217+
#
218+
# <walk up panel_visible rows from spinner>
219+
# <CR>
220+
# <erase + \n> x panel_visible # erase panel rows
221+
# <erase + final_line + \n> # spinner row replaced
222+
#
223+
# When ``final_line`` is None we still walk + erase so the
224+
# caller's ``formatter.emit_text`` lands on a fresh row, but
225+
# we do NOT advance the cursor with a trailing ``\n`` -- the
226+
# caller's own ``\n`` (via ``print``) does that.
227+
parts: list[str] = [_SYNC_START]
228+
if had_render:
229+
if panel_visible > 0:
230+
parts.append(f"\x1b[{panel_visible}A")
231+
parts.append(_CURSOR_TO_COL0)
232+
# Erase panel rows row-by-row, descending.
233+
parts.extend(_ERASE_LINE + "\n" for _ in range(panel_visible))
234+
# We're now at the original spinner row.
235+
if final_line is not None:
236+
parts.append(_ERASE_LINE + final_line + "\n")
237+
else:
238+
parts.append(_ERASE_LINE)
239+
parts.append(_SYNC_END)
240+
try:
241+
self._stream.write("".join(parts))
242+
self._stream.flush()
243+
self._last_line_len = 0
244+
self._panel_visible_lines = 0
245+
except (OSError, ValueError):
246+
return False
247+
248+
return final_line is not None and had_render
192249

193250
def add_output_line(self, text: str) -> None:
194251
"""Push streamed subprocess output into the live trail panel.
@@ -341,49 +398,57 @@ def _render_tty(self, frame: str, name: str, elapsed: float) -> None:
341398
coloured_name = self._colors.info(name)
342399
visible = f"{frame} Syncing {name} ... {elapsed:4.1f}s"
343400
line = f"{coloured_frame} Syncing {coloured_name} ... {elapsed:4.1f}s"
344-
pad = max(self._last_line_len - len(visible), 0)
345-
# Holding the lock around the actual write ensures a concurrent
346-
# ``write()`` / ``add_output_line()`` (called by the stdout
347-
# diverter on the main thread) can't begin mid-frame and end up
348-
# fighting with the ``\r`` redraw. Also: snapshot the panel buffer
349-
# under the lock so the deque can't mutate mid-render.
401+
402+
# Hold the lock for the entire render -- snapshot through write --
403+
# so a concurrent ``stop_repo`` can't tear the frame mid-flight.
404+
# Rich does the same (see ``rich/live.py`` ``_RefreshThread.run``
405+
# which acquires ``self.live._lock`` around every refresh and
406+
# re-checks ``done.is_set()`` inside the lock). The race we're
407+
# closing: spinner thread captures ``name`` outside the lock, then
408+
# main thread enters ``stop_repo`` and clears the screen, then the
409+
# spinner thread (still mid-build) writes a stale frame on top of
410+
# the new state -- producing the duplicate ``✓ Synced <repo>``
411+
# artefact reporters have seen.
350412
with self._lock:
413+
# Re-check the active repo under the lock. If ``stop_repo``
414+
# already cleared it, this tick is stale; skip the write so
415+
# the next tick starts from a clean state.
416+
if self._active_repo != name:
417+
return
351418
panel_lines = list(self._panel_buffer)
352-
# Cap the rendered panel at its declared height so a concurrent
353-
# ``add_output_line`` racing the deque can't make us write more
354-
# rows than ``stop_repo`` will later erase.
355-
cap = self._panel_buffer.maxlen
356-
if cap and cap > 0 and len(panel_lines) > cap:
357-
panel_lines = panel_lines[-cap:]
358-
new_panel_height = len(panel_lines)
359-
360-
# Build the frame as a single string so the synchronized-output
361-
# bracket wraps the whole region atomically. Layout:
362-
#
363-
# <move cursor up to top-of-previous-frame>
364-
# <erase + panel row 1>\n
365-
# <erase + panel row 2>\n
366-
# ...
367-
# <erase + spinner line> (no trailing newline; cursor stays here)
368-
parts: list[str] = [_SYNC_START]
369-
if self._panel_visible_lines:
370-
# Cursor sits at the spinner row; walk up to the first panel
371-
# row of the previous frame.
372-
parts.append(f"\x1b[{self._panel_visible_lines}A")
373-
parts.append(_CURSOR_TO_COL0)
374-
parts.extend(_ERASE_LINE + panel_line + "\n" for panel_line in panel_lines)
375-
parts.append(_ERASE_LINE + line + (" " * pad))
376-
parts.append(_SYNC_END)
377-
try:
378-
self._stream.write("".join(parts))
379-
self._stream.flush()
380-
# Track the *visible* column count (not the string length with
381-
# ANSI codes), so the next frame's padding calculation clears
382-
# exactly the on-screen cells the previous frame occupied.
383-
self._last_line_len = len(visible)
384-
self._panel_visible_lines = new_panel_height
385-
except (OSError, ValueError):
386-
pass
419+
cap = self._panel_buffer.maxlen
420+
if cap and cap > 0 and len(panel_lines) > cap:
421+
panel_lines = panel_lines[-cap:]
422+
new_panel_height = len(panel_lines)
423+
pad = max(self._last_line_len - len(visible), 0)
424+
425+
# Build the frame as a single string so the synchronized-output
426+
# bracket wraps the whole region atomically. Layout:
427+
#
428+
# <move cursor up to top-of-previous-frame>
429+
# <erase + panel row 1>\n
430+
# <erase + panel row 2>\n
431+
# ...
432+
# <erase + spinner line> (no trailing newline; cursor stays)
433+
parts: list[str] = [_SYNC_START]
434+
if self._panel_visible_lines:
435+
# Cursor sits at the spinner row; walk up to the first
436+
# panel row of the previous frame.
437+
parts.append(f"\x1b[{self._panel_visible_lines}A")
438+
parts.append(_CURSOR_TO_COL0)
439+
parts.extend(_ERASE_LINE + panel_line + "\n" for panel_line in panel_lines)
440+
parts.append(_ERASE_LINE + line + (" " * pad))
441+
parts.append(_SYNC_END)
442+
try:
443+
self._stream.write("".join(parts))
444+
self._stream.flush()
445+
# Track the *visible* column count (not the string length
446+
# with ANSI codes), so the next frame's padding clears
447+
# exactly the on-screen cells the previous frame occupied.
448+
self._last_line_len = len(visible)
449+
self._panel_visible_lines = new_panel_height
450+
except (OSError, ValueError):
451+
pass
387452

388453
def _emit_line(self, line: str) -> None:
389454
try:

src/vcspull/cli/sync.py

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1468,13 +1468,30 @@ def _run_sync_loop(
14681468
"workspace_root": str(workspace_label),
14691469
}
14701470

1471-
with indicator.repo(repo_name):
1471+
# Manual ``start_repo`` / ``stop_repo`` instead of the
1472+
# ``with indicator.repo(...)`` context manager: we want
1473+
# ``stop_repo`` to receive the permanent line so the spinner
1474+
# collapse + completion print happen as ONE atomic ANSI write
1475+
# under the lock. The ``with`` form fires ``stop_repo()`` (no
1476+
# args) on exit, before we know the outcome -- which means the
1477+
# spinner clears, then the formatter writes the permanent line
1478+
# in a separate stream call. That two-step is the source of the
1479+
# flicker reporters have called out.
1480+
indicator.start_repo(repo_name)
1481+
try:
14721482
outcome = _sync_repo_with_watchdog(
14731483
repo,
14741484
progress_callback=progress_callback,
14751485
timeout=repo_timeout,
14761486
is_human=is_human,
14771487
)
1488+
except BaseException:
1489+
# Any exception (KeyboardInterrupt, runtime crash) tears the
1490+
# indicator down with no replacement line; the surrounding
1491+
# ``except KeyboardInterrupt`` in ``_sync_impl`` still owns
1492+
# the partial-summary print.
1493+
indicator.stop_repo()
1494+
raise
14781495

14791496
if outcome.status == "timed_out":
14801497
summary["timed_out"] += 1
@@ -1491,12 +1508,17 @@ def _run_sync_loop(
14911508
event["duration_ms"] = int(outcome.duration * 1000)
14921509
if outcome.captured_output:
14931510
event["details"] = outcome.captured_output.strip()
1494-
formatter.emit(event)
1495-
formatter.emit_text(
1511+
permanent = (
14961512
f"{colors.warning('-')} Timed out {colors.info(repo_name)} "
14971513
f"after {colors.warning(f'{outcome.duration:.1f}s')} "
1498-
f"{colors.muted('->')} {display_repo_path}",
1514+
f"{colors.muted('->')} {display_repo_path}"
1515+
)
1516+
wrote_final = indicator.stop_repo(
1517+
final_line=permanent if is_human else None,
14991518
)
1519+
formatter.emit(event)
1520+
if not wrote_final:
1521+
formatter.emit_text(permanent)
15001522
if exit_on_error:
15011523
_emit_rerun_recipe(
15021524
formatter,
@@ -1524,17 +1546,22 @@ def _run_sync_loop(
15241546
event["error"] = err_msg
15251547
if outcome.captured_output:
15261548
event["details"] = outcome.captured_output.strip()
1549+
permanent = (
1550+
f"{colors.error('✗')} Failed syncing {colors.info(repo_name)}: "
1551+
f"{colors.error(err_msg)}"
1552+
)
1553+
wrote_final = indicator.stop_repo(
1554+
final_line=permanent if is_human else None,
1555+
)
15271556
formatter.emit(event)
15281557
if is_human:
15291558
log.debug("Failed syncing %s", repo_name)
15301559
if log.isEnabledFor(logging.DEBUG) and err is not None:
15311560
import traceback
15321561

15331562
traceback.print_exception(type(err), err, err.__traceback__)
1534-
formatter.emit_text(
1535-
f"{colors.error('✗')} Failed syncing {colors.info(repo_name)}: "
1536-
f"{colors.error(err_msg)}",
1537-
)
1563+
if not wrote_final:
1564+
formatter.emit_text(permanent)
15381565
if exit_on_error:
15391566
_emit_summary(formatter, colors, summary)
15401567
formatter.finalize()
@@ -1545,11 +1572,16 @@ def _run_sync_loop(
15451572

15461573
summary["synced"] += 1
15471574
event["status"] = "synced"
1548-
formatter.emit(event)
1549-
formatter.emit_text(
1575+
permanent = (
15501576
f"{colors.success('✓')} Synced {colors.info(repo_name)} "
1551-
f"{colors.muted('→')} {display_repo_path}",
1577+
f"{colors.muted('→')} {display_repo_path}"
1578+
)
1579+
wrote_final = indicator.stop_repo(
1580+
final_line=permanent if is_human else None,
15521581
)
1582+
formatter.emit(event)
1583+
if not wrote_final:
1584+
formatter.emit_text(permanent)
15531585

15541586
# Sync worktrees if enabled and configured
15551587
worktrees_config = repo.get("worktrees")

0 commit comments

Comments
 (0)