Skip to content

Hourly straggler cleanup deletes screenshots written seconds earlier, permanently killing timeline batches #338

Description

@alexeylive

Summary

cleanupRecordingStragglers() deletes freshly captured JPEGs before their screenshots row is inserted. The result is a row with is_deleted = 0 whose file no longer exists, which makes the batch containing that frame fail with A selected screenshot does not exist at …. Retry can never succeed, because the JPEG is physically gone. On my machine this destroys 1–2 fifteen-minute timeline slices every day.

Environment: Dayflow 2.0.3 (118), macOS 15.5 (24F74), Apple Silicon, provider chatgpt_claude (Claude CLI), recordings limit 20 GB, ~46k screenshots / ~19 GB in recordings/.

The race

  1. Capture writes the file, and only then inserts the row — ScreenRecorder.swift#L456-L470:
let fileURL = StorageManager.shared.nextScreenshotURL()
try jpegData.write(to: fileURL)          // file on disk

_ = StorageManager.shared.saveScreenshot(  // row in DB, later
  url: fileURL, capturedAt: capturedAt, idleSecondsAtCapture: idleSecondsAtCapture
)
  1. The sweep takes one DB snapshot of active paths, then walks the whole recordings/ directory and deletes everything absent from that snapshot — StorageManager+Maintenance.swift#L469-L505.

A frame written after the snapshot read but reached by the enumerator looks exactly like garbage and is removed. Its row is inserted a moment later, leaving an orphan.

Why it reproduces like clockwork

The purge timer is repeating: 3600 (#L263-L272) and capture runs on a 10 s grid. Since 3600 % 10 == 0, the purge fires at a fixed phase of the capture grid: if the phases collide once at launch, they collide every single hour. Orphan capture timestamps from one day on my machine:

10:53:16  11:53:18  12:53:17  13:53:17  14:53:17

Note also that cleanupRecordingStragglers() is called before the size check in performPurgeIfNeeded() (#L375-L389), so it runs hourly even when the folder is far below the cap. Only the Unlimited setting avoids the code path entirely.

Evidence that the file existed and was deleted

screenshots.file_size is read via attributesOfItem after the write, and it is non-null on every orphan row (200–660 KB here). So these are not failed captures — the JPEG was written, then removed.

Detection:

cd ~/Library/Application\ Support/Dayflow
sqlite3 chunks.sqlite "select file_path from screenshots where is_deleted=0;" \
  | while IFS= read -r p; do [ -f "$p" ] || echo "$p"; done

13 orphans over 4 days on my machine. The number is capped only because a normal size-driven purge later marks older ones is_deleted = 1 and silently swallows the already-missing files.

Blast radius: one lost frame destroys a whole batch

ClaudeTranscriptionInputBuilder.swift#L328-L332 throws on the first missing file, so 90 perfectly good frames out of 91 are thrown away. Because the file is gone permanently, Retry fails deterministically (Failed - retry stopped) and every batch queued behind it reports Stopped - earlier batch failed.

Workaround for affected users: UPDATE screenshots SET is_deleted = 1 for rows whose file_path is missing on disk, then Retry — the batch query filters on is_deleted = 0, so it reprocesses on the surviving frames.

Suggested fixes

Any one of these breaks the chain:

  1. Skip recently modified files in the sweep — smallest possible change:
let cutoff = Date().addingTimeInterval(-300)
if let mtime = try fileURL.resourceValues(forKeys: [.contentModificationDateKey])
     .contentModificationDate, mtime > cutoff { continue }
  1. Make capture atomic — write to a temporary name (outside recordings/, or with a non-.jpg suffix the sweep ignores) and moveItem into place only after the row is committed.
  2. Re-read the active-path set inside the loop, or take the snapshot after the enumeration rather than before.
  3. Degrade gracefully in the transcription builder — skip missing frames and fail only if too few remain.

1 + 4 looks like the minimal pair: 1 stops the loss, 4 stops a single bad frame from destroying a 15-minute slice.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions