Skip to content

Fix preview progress bar stranded on early-cancelled cold mount - #73

Merged
ben-dev-au merged 4 commits into
mainfrom
fix/preview-mount-cancel-strand
Jun 15, 2026
Merged

Fix preview progress bar stranded on early-cancelled cold mount#73
ben-dev-au merged 4 commits into
mainfrom
fix/preview-mount-cancel-strand

Conversation

@ben-dev-au

Copy link
Copy Markdown
Owner

Problem

The preview "loading" bar could stick forever — until the user navigated to a different file and back. Reported as "the amount gets stuck while loading."

It was a stuck mount, not a paint/count issue (the count was always correct). In _mount_chunks_async, the early awaits (container mount, prefetch cancel_task_on) ran before the try block and before the detached finalize task was spawned. That finalize task is the only thing that hides the bar and clears the inflight_target latch on success; cancel_mount_task hides nothing. So a mount cancelled in that early window skipped the finally entirely — the bar stayed up and the latch stayed set. A same-file re-load then deduped out (line 152), and only a different-file nav cleared the latch and dispatched a completing load (the "navigate away and back" workaround).

The window widens for slow cold mounts (large PDFs) and under load, so it surfaced intermittently and didn't reproduce in headless/tmux harnesses — found by static analysis of every cancel_mount_task caller and the mount lifecycle.

Fix

  • Extend the try to cover the early awaits.
  • In the finally, hide the bar + release the latch when the task ends before a finalize task existed and no successor mount took over.

Verification

New regression test tests/test_preview_mount_cancel_strand.py parks a mount on a blocked early await, cancels it, and asserts the bar is hidden and the latch cleared — fails on pre-fix code, passes after. All preview + scope suites green; ruff + pyright-strict clean.

The preview "loading" bar could stick forever until the user navigated to
a different file and back. Root cause: in _mount_chunks_async the early
awaits (container mount, prefetch cancel_task_on) ran BEFORE the try block
and BEFORE the detached finalize task was spawned. The finalize task is the
only thing that hides the bar and clears the inflight_target latch on
success; cancel_mount_task hides nothing. So a mount cancelled in that
early window skipped the finally entirely, leaving the bar up and the latch
set — a same-file re-load then deduped out, and only a different-file nav
cleared the latch and dispatched a completing load. The window widens for
slow cold mounts (large PDFs) and under load, so it surfaced intermittently.

Extend the try to cover the early awaits, and in the finally hide the bar +
release the latch when the task ends before a finalize task existed and no
successor mount took over. Regression test parks a mount on a blocked
early await, cancels it, and asserts the bar is hidden and the latch clear.
@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7f5c5cc8-9bad-45b8-95e0-d412c93f36e2

📥 Commits

Reviewing files that changed from the base of the PR and between 6d4445e and 511ed33.

📒 Files selected for processing (2)
  • fnd/tui/preview/presenter.py
  • tests/test_preview_mount_cancel_strand.py

📝 Walkthrough

Walkthrough

_mount_chunks_async in presenter.py is hardened against a cancellation race: the try block is extended to cover early async steps and the finally clause gains a guard that hides the progress bar and clears self.inflight_target when cancellation or exception occurs before a detached finalize task is spawned. Three regression tests are added to verify this path in cancellation, exception, and successor-ownership scenarios.

Changes

Early-cancel strand fix in _mount_chunks_async

Layer / File(s) Summary
Expanded try/finally and early-cancel cleanup
fnd/tui/preview/presenter.py
hidden_widgets is declared before the try block and the try scope is extended to cover placeholder removal, prefetch cancellation, container activation, scrollbar refresh, and focused-window index computation. The finally block gains a branch that hides the progress bar and resets self.inflight_target when no _finalize_task is present on the container and no successor mount_task is active on the presenter.
Regression tests for early-cancel, early-exception, and successor-contention scenarios
tests/test_preview_mount_cancel_strand.py
Adds a pytest fixture built_index that builds a temporary FND index directory. Introduces three async regression tests: test_cancel_during_early_mount_does_not_strand_progress_bar gates the mount in the vulnerable early-await phase via a blocked cancel_task_on hook, cancels whilst parked, and asserts the progress bar and preview.inflight_target are cleared; test_exception_during_early_mount_does_not_strand_progress_bar similarly blocks early mounting but raises an exception instead, verifying finally cleanup clears the stranded state even without a finalize task; test_early_cancel_does_not_clobber_successor_decode_bar verifies that an early cancellation does not hide a successor decode session's progress bar or clear its inflight_target even though the cancelled mount task is nulled.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • ben-dev-au/fnd#42: Addresses the same "preview stuck mid-mount on cancellation" class of bug by clearing _inflight_preview_target in app.py::_schedule_preview_load during navigation-away cancellation, which is the complementary call site to the presenter.py fix here.

Poem

🐇 A task was cancelled too soon, oh dear,
The progress bar spun with nothing to clear.
I widened the try, expanded the gate,
Now finally catches both early and late.
No latch left behind, no strand left aglow —
Hop along cleanly, let the UI flow! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main fix: addressing a preview progress bar that was stranded on early-cancelled cold mount operations.
Description check ✅ Passed The description thoroughly explains the problem (progress bar stuck), root cause (early awaits before try block), fix applied, and verification approach, all directly related to the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/preview-mount-cancel-strand

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request fixes a bug where cancelling a cold preview mount during its early-await phase (before the detached finalize task is spawned) would strand the progress bar and the inflight latch. This is addressed by wrapping the early awaits in the try block and adding cleanup logic in the finally block of _mount_chunks_async, along with a regression test. The reviewer suggests a more robust check in the finally block to ensure that the cleanup logic is not skipped if the task is cancelled or fails through paths other than cancel_mount_task().

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread fnd/tui/preview/presenter.py Outdated
old.remove()
if container.is_complete:
self.hide_progress_bar()
elif getattr(container, "_finalize_task", None) is None and self.mount_task is None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Checking self.mount_task is None to determine if a successor mount has taken over can be fragile. If the task is cancelled or fails through a path other than cancel_mount_task() (which explicitly sets self.mount_task = None), self.mount_task will still point to the current (now cancelled/failed) task, causing this elif block to be skipped and stranding the progress bar.

A more robust approach is to check if self.mount_task is either None or is the current task itself (using asyncio.current_task()). If it is the current task, it means no successor task has overwritten it yet, and we should safely clean up.

Suggested change
elif getattr(container, "_finalize_task", None) is None and self.mount_task is None:
elif getattr(container, "_finalize_task", None) is None and (self.mount_task is None or self.mount_task is asyncio.current_task()):

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6d4445e: self.mount_task is None or self.mount_task is asyncio.current_task(). The added clause also covers a mount that ends via an exception (or a non-cancel_mount_task cancellation), where mount_task still points at the dead task. Added an exception-path regression test that fails on the is-None-only guard.

The finally guard checked only `self.mount_task is None`, which catches
cancel_mount_task (it nulls mount_task) but not a mount that ends via an
exception or a cancellation from another path — there mount_task still
points at the now-dead task, so the guard was skipped and the bar stranded.
Also check `mount_task is current_task()`: true when this task still owns
mount_task (no successor overwrote it), so failed/early-cancelled mounts
clean up too while a real successor still keeps its own bar. Adds an
exception-path regression test.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@fnd/tui/preview/presenter.py`:
- Around line 1501-1516: The cleanup logic in the finally block can incorrectly
hide a successor decode progress bar because it only checks whether
self.mount_task is None, which doesn't account for the uncached decode path that
cancels the old mount and starts a new worker without immediately assigning
self.mount_task. To fix this, introduce a generation token mechanism for
tracking the current progress owner: create a generation counter that increments
each time a new load (mount or decode) begins, store the generation token with
each mount task or at the class level, and modify the cleanup condition to only
hide_progress_bar() and clear inflight_target if the stored generation token
matches the current generation (indicating this task still owns the progress
state). This ensures that a cancelled old mount task cannot interfere with a
successor's progress session that started after the cancellation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 33e170ec-e92b-4f16-b0b2-9d341ec8ed2f

📥 Commits

Reviewing files that changed from the base of the PR and between adacb9d and 6d4445e.

📒 Files selected for processing (2)
  • fnd/tui/preview/presenter.py
  • tests/test_preview_mount_cancel_strand.py

Comment thread fnd/tui/preview/presenter.py Outdated
The finally's hide/release fired whenever mount_task was None — but the
uncached decode path cancels the prior mount (nulling mount_task) and opens
a NEW 'decoding...' session WITHOUT reassigning mount_task. So a previous
mount cancelled in its early-await window, navigating onto an uncached file,
could reach this finally and hide the successor decode's bar + clear its
inflight latch. Add a target-ownership check: only clean up when the latch
still points at THIS mount's target (or is already clear). The cancelled
mount's own target still matches (strand fix preserved); a successor's does
not. Adds a cross-target regression test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant