Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 47 additions & 20 deletions fnd/tui/preview/presenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1296,32 +1296,36 @@ async def _mount_chunks_async(
pane = self._app.query_one("#preview_pane", VerticalScroll)

needs_pre_reveal = container.parent is None or container.has_class("-hidden")
if container.parent is None:
await pane.remove_children("#placeholder")
await pane.mount(container)
else:
await pane.remove_children("#placeholder")
await self._app._prefetch.cancel_task_on(container)
self.activate_container(
container, pre_reveal=needs_pre_reveal, keep_outgoing=needs_pre_reveal
)
cold_mount = needs_pre_reveal
self.refresh_match_scrollbar(chunks)

# Establish the focused window indices (clamped to chunks).
focus_idx = next(
(i for i, c in enumerate(chunks) if c.chunk_seq == focus_chunk_seq),
0,
)
win_start = max(0, focus_idx - tuning.VISIBLE_FIRST_ABOVE)
win_end = min(len(chunks), focus_idx + tuning.VISIBLE_FIRST_BELOW + 1)

# Newly-mounted "above-window" widgets get hidden until phase 2b
# finishes; the finally block makes sure every entry in this
# list ends up displayed even on cancellation.
hidden_widgets: list[Widget] = []

# The try MUST cover the early awaits below (container mount,
# cancel_task_on): they run BEFORE the detached finalize task is
# spawned, and a cancellation here would otherwise skip the finally
# entirely — stranding the progress bar with no task left to hide it.
try:
if container.parent is None:
await pane.remove_children("#placeholder")
await pane.mount(container)
else:
await pane.remove_children("#placeholder")
await self._app._prefetch.cancel_task_on(container)
self.activate_container(
container, pre_reveal=needs_pre_reveal, keep_outgoing=needs_pre_reveal
)
cold_mount = needs_pre_reveal
self.refresh_match_scrollbar(chunks)

# Establish the focused window indices (clamped to chunks).
focus_idx = next(
(i for i, c in enumerate(chunks) if c.chunk_seq == focus_chunk_seq),
0,
)
win_start = max(0, focus_idx - tuning.VISIBLE_FIRST_ABOVE)
win_end = min(len(chunks), focus_idx + tuning.VISIBLE_FIRST_BELOW + 1)

# Phase 1a: mount the focused chunk first and yield so it
# paints before the surrounding context mounts. On large
# files the rest of the visible window can take several
Expand Down Expand Up @@ -1494,6 +1498,29 @@ async def _mount_chunks_async(
old.remove()
if container.is_complete:
self.hide_progress_bar()
elif (
getattr(container, "_finalize_task", None) is None
and (self.mount_task is None or self.mount_task is asyncio.current_task())
and self.inflight_target in (None, (parent_id, focus_chunk_seq))
):
# Ended in the early-await phase — before the detached finalize
# task (the ONLY thing that hides the bar + releases the in-flight
# latch on success) was spawned — and no successor took over the
# loading state. Three ownership checks, all required:
# * ``_finalize_task is None``: no detached finalize will clear it.
# * ``mount_task is None`` (cancel_mount_task nulled it) OR
# ``is current_task()`` (exception / other-path end, where it
# still points at this now-dead task) — a successor MOUNT would
# have overwritten it.
# * ``inflight_target`` is still THIS target (or already clear):
# the uncached decode path cancels us and opens a NEW
# "decoding…" session WITHOUT assigning mount_task, so the
# latch already points at the successor — don't hide its bar.
# Otherwise the bar stays "loading" until an unrelated navigation
# dispatches a fresh load. Hide + release so a cancelled (or
# failed) cold mount can't strand the preview.
self.hide_progress_bar()
self.inflight_target = None
# Re-anchor only needed for cancellation case: a successful
# Phase 2b reveal+anchor inline already scrolled to the
# focused chunk. The inline anchor sees the post-reveal
Expand Down
202 changes: 202 additions & 0 deletions tests/test_preview_mount_cancel_strand.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"""Regression: a cold preview mount cancelled in its early-await phase —
before the detached finalize task (the only thing that hides the progress
bar + releases the in-flight latch) is spawned — must not strand the bar.

The symptom was "the loading bar gets stuck until I navigate to a different
file and back": cancel_mount_task cancels the mount but hides nothing, the
finally only hid on is_complete, and no finalize task existed yet — so the
bar stayed up forever and the inflight latch kept a same-file re-load from
re-dispatching.
"""

from __future__ import annotations

import asyncio
from pathlib import Path

import pytest

from fnd.index import build_index
from fnd.tui import FNDApp
from tests._pilot_wait import safe_pause


@pytest.fixture
def built_index(fixtures_dir: Path, tmp_index_dir: Path) -> Path:
build_index(roots=[fixtures_dir], index_dir=tmp_index_dir, collection="default")
return tmp_index_dir


@pytest.mark.asyncio
async def test_cancel_during_early_mount_does_not_strand_progress_bar(
built_index: Path,
) -> None:
app = FNDApp(index_dir=built_index, initial_query="blue penguin sandwich")
async with app.run_test() as pilot:
await pilot.pause()
assert app._search.groups, "setup — query produced no results"

preview = app._preview
from fnd.tui.widgets.preview_container import PreviewContainer

# Park a cold structural mount in its EARLY-await window: cancel_task_on
# is awaited (presenter ~line 1304) BEFORE the finalize task is spawned
# (~1352). Block it on an event we never set so the mount task sits
# exactly in the vulnerable window.
gate = asyncio.Event()

async def _blocking_cancel_task_on(_container: object) -> None:
await gate.wait()

app._prefetch.cancel_task_on = _blocking_cancel_task_on # type: ignore[assignment]

g = app._search.groups[0]
seq = g.hits[0].chunk_seq if g.hits else 0
chunks = app._search.searcher.get_file_chunks(g.parent_id) # type: ignore[union-attr]
container = PreviewContainer(
parent_doc_id=g.parent_id,
query_signature=app._search.query_signature(),
total_chunks=len(chunks),
)

# Mirror the real pre-mount state: bar shown, latch set, mount task
# running — then drive the structural cold mount directly so routing
# (flat vs structural, warm-cache) can't change the path under test.
preview.show_progress_bar(total=len(chunks), phase="mounting…")
preview.inflight_target = (g.parent_id, seq)
task = asyncio.create_task(preview._mount_chunks_async(g.parent_id, seq, chunks, container))
preview.mount_task = task

# Let the task reach and park on the blocked early await.
await safe_pause(pilot)
assert app._progress.active is not None, (
"setup — the cold mount should have the progress bar open"
)
assert getattr(container, "_finalize_task", None) is None, (
"setup — mount must be parked BEFORE the finalize task is spawned"
)

# Cancel the mount while parked pre-finalize — the strand condition.
preview.cancel_mount_task()
await safe_pause(pilot)
await safe_pause(pilot)

assert app._progress.active is None, (
"BUG: progress bar stranded after a cold mount was cancelled before "
"its finalize task spawned (the 'stuck loading until I switch files' bug)"
)
assert preview.inflight_target is None, (
"inflight latch not released on cancel — a same-file re-load would "
"dedup out and never re-mount"
)

gate.set() # release the blocked coroutine so the loop can drain


@pytest.mark.asyncio
async def test_exception_during_early_mount_does_not_strand_progress_bar(
built_index: Path,
) -> None:
"""A mount that FAILS (not cancelled) in its early-await phase must also
clean up. cancel_mount_task nulls mount_task; an exception does not — so
the finally checks `mount_task is current_task()` too, else the bar would
strand on any non-cancellation failure (e.g. a MountError)."""
import contextlib

app = FNDApp(index_dir=built_index, initial_query="blue penguin sandwich")
async with app.run_test() as pilot:
await pilot.pause()
assert app._search.groups, "setup — query produced no results"

preview = app._preview
from fnd.tui.widgets.preview_container import PreviewContainer

async def _raising_cancel_task_on(_container: object) -> None:
raise RuntimeError("simulated early-mount failure")

app._prefetch.cancel_task_on = _raising_cancel_task_on # type: ignore[assignment]

g = app._search.groups[0]
seq = g.hits[0].chunk_seq if g.hits else 0
chunks = app._search.searcher.get_file_chunks(g.parent_id) # type: ignore[union-attr]
container = PreviewContainer(
parent_doc_id=g.parent_id,
query_signature=app._search.query_signature(),
total_chunks=len(chunks),
)

preview.show_progress_bar(total=len(chunks), phase="mounting…")
preview.inflight_target = (g.parent_id, seq)
task = asyncio.create_task(preview._mount_chunks_async(g.parent_id, seq, chunks, container))
preview.mount_task = task # NOT nulled — the mount fails, it isn't cancelled

# Drain the task (it raises); the finally must still run.
with contextlib.suppress(RuntimeError):
await task
await safe_pause(pilot)

assert app._progress.active is None, (
"BUG: progress bar stranded after a cold mount FAILED before its "
"finalize task spawned (mount_task is current_task path)"
)
assert preview.inflight_target is None, "inflight latch not released on mount failure"


@pytest.mark.asyncio
async def test_early_cancel_does_not_clobber_successor_decode_bar(
built_index: Path,
) -> None:
"""A mount cancelled in its early-await window must NOT hide a SUCCESSOR's
progress bar or clear its latch. The uncached decode path cancels the old
mount (nulling mount_task) and opens a new "decoding…" session without
reassigning mount_task — so mount_task being None is NOT enough to prove
ownership; the cleanup also checks the inflight latch still points at this
target."""
app = FNDApp(index_dir=built_index, initial_query="blue penguin sandwich")
async with app.run_test() as pilot:
await pilot.pause()
assert app._search.groups, "setup — query produced no results"

preview = app._preview
from fnd.tui.widgets.preview_container import PreviewContainer

gate = asyncio.Event()

async def _blocking_cancel_task_on(_container: object) -> None:
await gate.wait()

app._prefetch.cancel_task_on = _blocking_cancel_task_on # type: ignore[assignment]

g = app._search.groups[0]
seq = g.hits[0].chunk_seq if g.hits else 0
chunks = app._search.searcher.get_file_chunks(g.parent_id) # type: ignore[union-attr]
container = PreviewContainer(
parent_doc_id=g.parent_id,
query_signature=app._search.query_signature(),
total_chunks=len(chunks),
)
preview.show_progress_bar(total=len(chunks), phase="mounting…")
preview.inflight_target = (g.parent_id, seq)
task = asyncio.create_task(preview._mount_chunks_async(g.parent_id, seq, chunks, container))
preview.mount_task = task
await safe_pause(pilot)
assert getattr(container, "_finalize_task", None) is None, "setup — must be pre-finalize"

# A successor (uncached) decode now owns the loading state: a DIFFERENT
# inflight target, and mount_task nulled — exactly what the decode path
# leaves behind (cancel_mount_task + show_progress_bar, no mount_task).
successor_target = ("successor-parent-id", 7)
preview.inflight_target = successor_target

preview.cancel_mount_task() # cancels M1, nulls mount_task
await safe_pause(pilot)
await safe_pause(pilot)

assert app._progress.active is not None, (
"BUG: a cancelled early mount hid the SUCCESSOR's progress bar"
)
assert preview.inflight_target == successor_target, (
"BUG: a cancelled early mount cleared the SUCCESSOR's inflight latch"
)

gate.set()
Loading