Skip to content

fix(viewer): keep shared fitz handle valid, cancel renders before saveIncr, debounce search - #138

Merged
nelsonduarte merged 2 commits into
mainfrom
fix/viewer-shared-doc-search
Jul 28, 2026
Merged

fix(viewer): keep shared fitz handle valid, cancel renders before saveIncr, debounce search#138
nelsonduarte merged 2 commits into
mainfrom
fix/viewer-shared-doc-search

Conversation

@nelsonduarte

Copy link
Copy Markdown
Owner

Root-cause fixes for four viewer bugs found by adversarial audit. Scope is limited to app/viewer/canvas.py, app/viewer/panel.py and tests.

M2 (MAJOR) — panel _fitz_doc left pointing at a closed Document

canvas._doc and panel._fitz_doc are the same fitz.Document (self._canvas.load(doc, ...)). In the failed delete-comment path the canvas did self._doc.close() + reopen but only updated its own reference; panel._fitz_doc kept pointing at the closed handle, so _do_search / _print_pdf raised RuntimeError: document closed.

Fix (chosen approach): canvas → panel signal. The canvas already owns the close/reopen, so notifying the shared owner is the least-invasive, most robust option (vs. inverting ownership so the panel reopens). The canvas exposes doc_replaced = Signal(object) and emits the fresh handle right after self._doc = new_doc in the saveIncr except block; the panel connects it and repoints _fitz_doc in _on_doc_replaced. So no path leaves _fitz_doc on a closed doc.

Defense in depth: _do_search and _print_pdf now abort gracefully if the handle is None/closed (getattr(doc, "is_closed", False) guard + RuntimeError/ValueError catch around the scan) instead of crashing.

MINOR — saveIncr ↔ render worker race

Background _PageJob workers fitz.open(self._path) from disk while the main thread does self._doc.saveIncr() (append) to the same file — a worker opening mid-append reads a half-written incremental update (parse failure / Windows sharing violation). Before saveIncr, the delete path now bumps _gen, clears _pending and joins in-flight workers via QThreadPool.waitForDone(), then reschedules visible pages afterwards (both the success and the reopen paths). Follows the existing generation/epoch pattern.

MINOR — search ran on the UI thread per keystroke

_on_search_text_changed_do_search scanned every page synchronously on each keystroke, freezing the UI for seconds on large PDFs. Added a 250 ms single-shot QTimer debounce (same pattern as the thumbnail scroll timer); empty query still resets immediately and cancels the pending scan. Results are unchanged, only responsiveness.

MINOR — inconsistent state on password cancel

Replacing an open document with a new encrypted one and cancelling the password prompt left the previous doc closed but the splitter still visible, _name_lbl stale and nav buttons active. New _reset_to_placeholder() restores a coherent placeholder state (placeholder shown, splitter/sidebar hidden, title + page label reset, nav disabled, recents rebuilt) and is called on the cancel return.

Tests (tests/test_viewer_shared_doc.py, 9 tests, offscreen)

  • M2: doc_replaced.emit repoints panel._fitz_doc and search then works; _do_search/_print_pdf survive a closed handle.
  • Canvas internals (race + emit) are guarded at source level because QMenu.exec spins a native popup loop that PySide won't let us monkeypatch, so the delete context-menu can't be driven headless — documented in the test.
  • Debounce: three rapid setText calls trigger _do_search once (after the timer), with correct results; empty query cancels the pending scan.
  • Password-cancel: after cancel the viewer is in a coherent placeholder state.

Two byte-window guards in tests/test_viewer_safety.py were widened (the added code shifted offsets; the asserted strings are all still present).

Validation

  • pytest -q: 483 passed, 3 skipped, 0 failed.
  • ruff check --select F,E9 on the three scope files + new test: clean.

🤖 Generated with Claude Code

nelsonduarte and others added 2 commits July 28, 2026 14:12
…eIncr, debounce search

Root-cause fixes for four viewer bugs from the adversarial audit:

M2 - shared fitz handle closed underneath the panel. The canvas and the
panel hold the SAME fitz.Document (canvas.load(doc)). In the failed
delete-comment path the canvas closed+reopened self._doc but only
updated its own reference, leaving panel._fitz_doc pinned to the closed
Document so _do_search / _print_pdf raised "document closed". The canvas
now exposes a doc_replaced(object) signal, emits the fresh handle after
reopening, and the panel repoints _fitz_doc via _on_doc_replaced.
Defensive guards added: _do_search and _print_pdf abort gracefully on a
None/closed Document (is_closed check + RuntimeError/ValueError catch).

saveIncr race - background _PageJob workers fitz.open() the same file on
disk that saveIncr() appends to on the main thread, so a worker
mid-open read a half-written incremental update. The delete path now
bumps _gen, clears _pending and joins in-flight workers (waitForDone)
before saveIncr, then reschedules renders afterwards.

Search debounce - _do_search scanned every page synchronously on the UI
thread on each keystroke, freezing large PDFs. Keystrokes are now
coalesced through a 250 ms single-shot QTimer.

Password-cancel state - cancelling the password prompt for a new
encrypted PDF (after the previous doc was already closed) left an empty
splitter with a stale title and live nav buttons. A new
_reset_to_placeholder restores a coherent placeholder state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ebounce teardown

MINOR 1: the pre-saveIncr join used QThreadPool.globalInstance().waitForDone()
with no timeout on the UI thread, and the global pool is shared with the
editor canvas, so a pathological/unrelated render could pin the UI
indefinitely. Give the viewer its own dedicated QThreadPool for _PageJob
workers and extract the pre-save preparation into a testable
_prepare_for_save() that bumps _gen (epoch guard invalidates late results),
clears _pending and joins with a bounded 5s wait, returning False on timeout
(safe to proceed past).

MINOR 2: a successful load() of a new document did not stop the search
debounce nor clear _pending_search_query, so a search scheduled before the
swap could fire against the new doc. Add a shared _reset_search_state()
helper (reused by _reset_to_placeholder) and call it in the load() teardown.

MINOR 3: if the reopen after a failed saveIncr raised, self._doc was left
pointing at the already-closed Document (latent use-after-close) and
doc_replaced was never emitted. Extract _reopen_document(): publish
self._doc = None before closing the old handle, only swap in the fresh
Document after fitz.open succeeds, and emit doc_replaced(new_doc) — or
doc_replaced(None) on a double failure so the panel drops the shared ref.

Tests: replace the two weak source-substring tests (race guard, doc_replaced)
with behavioural tests of _prepare_for_save (gen bump, pending clear, bounded
timeout, dedicated pool) and _reopen_document (live-handle swap + old handle
closed; double-failure leaves _doc None, not closed). Add a behavioural test
that load() cancels a pending debounced search. Widen the saveIncr window in
test_viewer_safety after the reopen refactor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying pdfapps with  Cloudflare Pages  Cloudflare Pages

Latest commit: ee52fbe
Status: ✅  Deploy successful!
Preview URL: https://5afa6f48.pdfapps.pages.dev
Branch Preview URL: https://fix-viewer-shared-doc-search.pdfapps.pages.dev

View logs

@nelsonduarte
nelsonduarte merged commit 68c537c into main Jul 28, 2026
4 checks passed
@nelsonduarte
nelsonduarte deleted the fix/viewer-shared-doc-search branch July 28, 2026 14:15
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