Skip to content

v0.3: fix OCR continuation crash + rework indexer progress UX - #3

Merged
GordonBeeming merged 1 commit into
mainfrom
gb/v0.3-ocr-crash-and-progress
Apr 22, 2026
Merged

GordonBeeming merged 1 commit into
mainfrom
gb/v0.3-ocr-crash-and-progress

Conversation

@GordonBeeming

Copy link
Copy Markdown
Owner

Summary

  • Crash fix: OCRRecognizer.recognize(cgImage:) was double-resuming its CheckedContinuation under rare Vision timing — both the VNRecognizeTextRequest completion handler AND handler.perform()'s catch block could signal a result, tripping the runtime assertion. Reproduced at ~2.7k files into a 4.7k-file scan. Wrapped both paths in a ContinuationGate (NSLock-protected one-shot) so the second resume is dropped.
  • Progress UX rework: relaunches showed Indexing N / 4762 even when the DB was already populated, because fast fingerprint-checks and slow OCR passes shared the same counter. Split initialScan into four named phases (enumerate, reconcile, fingerprint, OCR) and Indexer.Progress into .enumerating / .indexing / .watching, with total on .indexing now meaning "new work only". A resumed launch with a fully-populated DB shows Up to date almost instantly instead of rolling a counter through the whole library.

Test plan

  • swift test --parallel — 35 tests green
  • Fresh install, add a large folder → OCR'ing N / total new images counts up
  • Quit + relaunch → Up to date immediately (no counter through the library)
  • Kill the brew v0.2 mid-scan (which was crashing) → v0.3 completes all 4,750 files without crashing

Two ship-blockers for v0.3:

1. EXC_BREAKPOINT crash in OCRRecognizer.recognize(cgImage:)
   Both the VNRecognizeTextRequest completion handler and the
   VNImageRequestHandler.perform() catch block can signal a result
   to the same checked continuation. Under rare Vision timing — seen
   reliably at ~2.7k files into a scan of a ~4.7k-file folder — both
   fire, double-resuming the continuation and crashing the process
   with "CheckedContinuation.resume(throwing:) called twice".

   Routed both paths through a tiny ContinuationGate wrapper: NSLock-
   protected one-shot flag, forwards the first resume and drops
   subsequent ones. Can't actually log the second resume from inside
   the callback without re-introducing the same race, so we eat it.

2. "Looks like it's starting from scratch on each relaunch"
   initialScan was reporting progress as "Indexing N / TOTAL" across
   ALL discovered files — including the tens of thousands that just
   needed a mtime+size fingerprint check (microseconds each). Mixing
   fast fingerprint skips with slow OCR passes made the counter look
   like a fresh re-index even though the DB was persistent.

   Split initialScan into four clearly-labelled phases:
     1. enumerate  — walk the disk once, collect image candidates
     2. reconcile  — drop rows for files no longer on disk
     3. fingerprint — filter to genuinely new/changed files using the
        (mtime, size) fingerprint already stored per row
     4. OCR        — Vision pass on that shorter list

   Progress enum reflects the phases:
     .idle
     .enumerating(folders: Int)     — brief walk, no usable progress
     .indexing(done: Int, total: Int) — OCR queue, total == new files
                                         only (total=0 = fully resumed)
     .watching(indexed: Int)         — steady state

   Menu bar status strings match:
     Scanning folder…
     OCR'ing 0 / 2003 new images     — only new work
     Up to date                       — when total is 0 after resume
     4,762 screenshots indexed

   Net effect: second launch after a full scan shows "Up to date"
   almost instantly rather than rolling a progress counter through
   the entire library.

Tests: 35 still green — the fingerprint check in ScreenshotStore
was already tested; the Progress enum change has no test coverage
(it's UI state), and the OCR gate's threadsafety is in the same
category as the thread-safety guarantees the Vision callbacks
already carry.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: GitButler <gitbutler@gitbutler.com>
Copilot AI review requested due to automatic review settings April 22, 2026 13:04
@GordonBeeming
GordonBeeming merged commit 55ca7d9 into main Apr 22, 2026
4 checks passed
@GordonBeeming
GordonBeeming deleted the gb/v0.3-ocr-crash-and-progress branch April 22, 2026 13:05

Copilot AI 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.

Pull request overview

This PR addresses a rare OCR crash caused by double-resuming a CheckedContinuation in the Vision pipeline, and refactors Indexer progress reporting so relaunches with an already-populated DB don’t misleadingly count through the entire library.

Changes:

  • Add a thread-safe one-shot ContinuationGate to prevent double-resume crashes in OCRRecognizer.recognize(cgImage:).
  • Split initial scan work into clearer phases and update Indexer.Progress to distinguish enumeration vs OCR indexing vs steady-state watching.
  • Update menu bar and app state UI mapping to reflect the new progress model.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
Sources/VistaCore/OCRRecognizer.swift Prevents rare Vision timing from resuming continuations twice by gating resumes.
Sources/VistaCore/Indexer.swift Reworks initial scan phases and progress reporting so “total” reflects only new OCR work.
Sources/Vista/MenuBarContentView.swift Updates status line messaging for new progress states (enumerating/indexing/watching).
Sources/Vista/AppState.swift Adjusts derived indexed-count logic to align with new progress cases.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +26 to +27
/// DB populated typically shows total=0 → we skip straight to
/// `.watching`.

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

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

The Progress case docs say that when the DB is populated you’ll have total=0 and “skip straight to .watching”, but initialScan() still yields .indexing(done: 0, total: 0) before emitting .watching. Either adjust the emitted progress (skip .indexing when total == 0) or update this comment so it matches the actual state transitions.

Suggested change
/// DB populated typically shows total=0 → we skip straight to
/// `.watching`.
/// DB populated may briefly emit `.indexing(done: 0, total: 0)`
/// before transitioning to `.watching`.

Copilot uses AI. Check for mistakes.
Comment on lines +213 to +215
let attrs = try? fm.attributesOfItem(atPath: url.path)
let mtime = (attrs?[.modificationDate] as? Date) ?? Date()
let size = (attrs?[.size] as? NSNumber)?.int64Value ?? 0

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

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

In Phase 3 the fingerprint filter uses try? fm.attributesOfItem(...) and then defaults mtime to Date() when attributes can’t be read. That masks attribute-read failures and can incorrectly force files into the OCR queue (and then fail again in indexFile). Consider either (a) using the URL resource values you already requested during enumeration (mtime/size) so you don’t need a second filesystem stat, or (b) handling the attribute lookup failure explicitly (log + skip) instead of defaulting to Date().

Suggested change
let attrs = try? fm.attributesOfItem(atPath: url.path)
let mtime = (attrs?[.modificationDate] as? Date) ?? Date()
let size = (attrs?[.size] as? NSNumber)?.int64Value ?? 0
let attrs: [FileAttributeKey: Any]
do {
attrs = try fm.attributesOfItem(atPath: url.path)
} catch {
VistaLog.log("Skipping fingerprint check for \(url.path): failed to read file attributes (\(error))")
continue
}
let mtime = (attrs[.modificationDate] as? Date) ?? .distantPast
let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0

Copilot uses AI. Check for mistakes.
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.

2 participants