Skip to content

fix: double-resume of checked continuation in ScreenTextExtractor.extractText(from:) crashes app on tiny OCR screenshots#698

Open
mvanhorn wants to merge 1 commit into
FuJacob:mainfrom
mvanhorn:fix/502-ocr-tiny-screenshot-continuation-crash
Open

fix: double-resume of checked continuation in ScreenTextExtractor.extractText(from:) crashes app on tiny OCR screenshots#698
mvanhorn wants to merge 1 commit into
FuJacob:mainfrom
mvanhorn:fix/502-ocr-tiny-screenshot-continuation-crash

Conversation

@mvanhorn

@mvanhorn mvanhorn commented Jun 12, 2026

Copy link
Copy Markdown

Summary

Fixes the SWIFT TASK CONTINUATION MISUSE crash in ScreenTextExtractor.extractText(from:). Vision can report a failing request through the VNRecognizeTextRequest completion handler and then rethrow the same failure from handler.perform(_:), so the checked continuation could resume twice. Resumption is now single-shot through a lock-guarded resumer shared by both paths, and near-zero-sized screenshots (under 4px per side, the known trigger from #502) short-circuit to the existing .noRecognizedText path before a Vision request is built.

Validation

  • Added CotabbyTests/ScreenTextExtractorTests.swift with deterministic synthetic CGImage cases: 1x1 and 2x2 inputs must throw a ScreenTextExtractionError without a continuation misuse, an empty normal-size image resumes exactly once with .noRecognizedText, and the downsampling path is unchanged.
  • Ran standalone Swift probes against Vision with synthetic images from 1x1 up to 128x128: tiny inputs drive perform(_:) into the dual error-reporting path the crash log in [Bug] Crashes when OCR handles tiny screenshot during visual context generation #502 shows, and the guarded flow resumes exactly once (callbackCount=1, no crash).
  • A full xcodebuild test run did not complete in this environment (SPM package resolution stalled); the new test file is registered in project.pbxproj (regenerated via XcodeGen) so CI picks it up.

Linked issues

Fixes #502

Risk / rollout notes

  • Behavior change: screenshots smaller than 4px per side now throw .noRecognizedText immediately instead of reaching Vision. These inputs previously either crashed or produced no text, so the observable change is crash removal.
  • project.pbxproj was regenerated to register the new test file; no app-target settings changed.

Greptile Summary

Fixes the SWIFT TASK CONTINUATION MISUSE crash (#502) where Vision's dual error-reporting path (completion handler + handler.perform rethrow) could resume a CheckedContinuation twice. A lock-guarded OCRContinuationResumer gate ensures only the first resume call executes, and a 4px minimum-dimension guard short-circuits degenerate screenshots before a Vision request is even built.

  • OCRContinuationResumer: new final class using NSLock to make the "resume once" contract explicit and safe across the callback/async boundary; all three continuation.resume call sites are routed through it.
  • Minimum dimension guard: images with either side below 4px throw .noRecognizedText immediately after downsampling, keeping them on the same unavailable-context path as blank screenshots.
  • New tests in ScreenTextExtractorTests.swift cover the 1px/2px short-circuit paths and the blank normal-size image path; project.pbxproj is updated to register the new test file.

Confidence Score: 4/5

Safe to merge — the crash fix is correctly scoped and the minimum-dimension guard preserves existing behavior for all images that previously reached Vision.

The lock-and-gate pattern in OCRContinuationResumer is correct: the check-and-set is atomic and the action is executed outside the lock to avoid re-entrancy issues. The one gap is that OCRContinuationResumer is not declared @unchecked Sendable despite crossing a concurrency boundary, which may produce compiler warnings under stricter settings. The 3px boundary case is also untested, but this has no runtime impact.

ScreenTextExtractor.swift — the OCRContinuationResumer class should declare @unchecked Sendable; everything else in the file is straightforward.

Important Files Changed

Filename Overview
Cotabby/Services/Visual/ScreenTextExtractor.swift Adds OCRContinuationResumer gate to prevent double-resume of the checked continuation (the root cause of the crash), and a 4px minimum-dimension guard to short-circuit degenerate images. Logic is sound; class is missing @unchecked Sendable.
CotabbyTests/ScreenTextExtractorTests.swift New test file covering tiny-image short-circuit (1px, 2px) and blank normal-size image paths. Missing a 3px case; does not directly exercise OCRContinuationResumer's double-resume guard (but that path requires live Vision and is hard to unit-test).
Cotabby.xcodeproj/project.pbxproj Adds PBXBuildFile and PBXFileReference entries for the new ScreenTextExtractorTests.swift; no app-target settings changed.

Sequence Diagram

sequenceDiagram
    participant C as Caller
    participant E as extractText(from:)
    participant R as OCRContinuationResumer
    participant V as VNImageRequestHandler
    participant H as Completion Handler

    C->>E: extractText(from: image)
    E->>E: downsampledImageIfNeeded()
    alt "preparedImage < 4px side"
        E-->>C: throw .noRecognizedText (short-circuit)
    else image is large enough
        E->>E: withCheckedThrowingContinuation
        E->>R: create resumer
        E->>V: DispatchQueue.async → handler.perform([request])
        V->>H: completion(request, error?)
        alt error in callback
            H->>R: "resumer.resume { continuation.resume(throwing: .ocrFailed) }"
            R-->>H: "hasResumed = true, action fired"
        else success in callback
            H->>R: "resumer.resume { continuation.resume(returning: text) }"
            R-->>H: "hasResumed = true, action fired"
        end
        alt handler.perform also throws (double-error path)
            V->>R: "resumer.resume { continuation.resume(throwing: .ocrFailed) }"
            R-->>V: hasResumed already true, no-op (crash prevented)
        end
        E-->>C: ExtractedScreenText or throws
    end
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix: double-resume of checked continuati..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

…ractText(from:) crashes app on tiny OCR screenshots

Fixes FuJacob#502
Comment on lines +57 to +72
private final class OCRContinuationResumer {
private let lock = NSLock()
private var hasResumed = false

func resume(_ action: () -> Void) {
lock.lock()
let shouldResume = !hasResumed
if shouldResume {
hasResumed = true
}
lock.unlock()

guard shouldResume else { return }
action()
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 OCRContinuationResumer missing @unchecked Sendable

OCRContinuationResumer is captured by reference inside a DispatchQueue.global(qos:).async closure, which requires its captured values to be Sendable in Swift 5.7+ with -strict-concurrency. The class uses NSLock to protect all mutable state, so it is safe for concurrent access, but without declaring @unchecked Sendable it may produce a warning (or error under stricter project settings) like "capture of 'resumer' with non-sendable type 'OCRContinuationResumer' in an isolated closure". Adding the conformance makes the intent explicit and future-proofs the code.

Fix in Codex Fix in Claude Code

Comment on lines +14 to +17
for dimension in [1, 2] {
let image = try makeSolidImage(width: dimension, height: dimension)
await assertNoRecognizedText(from: image, extractor: extractor)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Tiny-image test misses the 3px boundary case

The guard threshold is >= 4, which means 1, 2, and 3 px all short-circuit to .noRecognizedText, but the loop only iterates over [1, 2]. A 3px input is the edge case closest to the threshold and is currently untested. Adding 3 to the array would cover all values immediately below the minimum without meaningfully increasing test runtime.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

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.

[Bug] Crashes when OCR handles tiny screenshot during visual context generation

1 participant