Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .greptile/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"autoReview": ["open", "push"]
}
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ State what the old code did before describing the new behavior. Link the reviewe

The `size:*` label describes the effective diff. It is a review signal, not a merge gate. Do not set it by hand.

Greptile reviews new commits through [`.greptile/config.json`](.greptile/config.json). The dashboard's open-only trigger left follow-up commits unreviewed. This repository override adds push reviews without changing other repositories' settings. Greptile reads this setting from the PR's source branch, so older branches need the config too. For a branch without it, request a current review with `@greptileai review` after pushing. Use `@greptileai review this draft` for drafts.

## Checks

| Check | Runs on GitHub when | Local command |
Expand Down
31 changes: 19 additions & 12 deletions ios/HouseScan/Runtime/KeyframeStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,21 @@ final class KeyframeStore {
/// Bumped by `discardKeyframes`, so a write that started before it doesn't land in the list.
private var epoch = 0

/// Makes a new, empty scan folder and deletes every other one: only the current scan is kept
/// on the phone. Covers both a start over (the previous scan's folder) and launch (folders a
/// quit or crashed run left behind), since both make a new store. The folders to delete are
/// listed here, before a newer store can exist, and deleted later off the main actor
/// (`ScanFolderCleanup`), so a deletion that runs late can't take a newer scan's folder.
init() {
/// Makes a new, empty scan folder and deletes the old ones `ScanFolderCleanup` lists: it keeps
/// the two most recent completed scans (Saved scans offers them) and any folder whose bundle
/// is there but not whole. Covers both a start over (the previous scan's folder) and launch
/// (folders a quit or crashed run left behind), since both make a new store.
///
/// The folders are listed here, before a newer store can exist, so a deletion that runs late
/// can't take a newer scan's folder. They are deleted later off the main actor, once
/// `bundleWrites` ends: the scan bundle writes started before this store, of which the
/// previous scan's may still be assembling its packet in a folder listed as never packaged.
/// Start over doesn't wait for it; only the deletion does. Nil, as at launch, waits for none.
init(deletingAfter bundleWrites: Task<Void, Never>? = nil) {
let scans = Self.scansRoot
directory = scans.appending(path: UUID().uuidString, directoryHint: .isDirectory)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
Self.delete(ScanFolderCleanup(root: scans, keeping: directory.lastPathComponent))
Self.delete(ScanFolderCleanup(root: scans, keeping: directory.lastPathComponent), after: bundleWrites)
}

/// Caches/Scans: one folder per scan. Saved scans lists the completed ones here.
Expand Down Expand Up @@ -246,7 +251,8 @@ final class KeyframeStore {

/// Zips the packet folder into `scan.zip`, manifest.json at the zip's root, and removes the
/// folder. The zip is what "Share scan" offers; the upload sends scene.json alone. It stays on
/// the phone until the next scan unless the homeowner shares it.
/// the phone, listed in Saved scans, until two newer completed scans push it out
/// (`ScanFolderCleanup`).
///
/// Streamed to disk one file at a time: a long walk's JPEGs held in memory twice (the entries
/// and the archive) is what an in-memory build cost.
Expand All @@ -267,11 +273,12 @@ final class KeyframeStore {
try? files.removeItem(at: folder)
}

/// Off the main actor. A write still in flight for a deleted folder fails, because its
/// directory is gone, and is dropped like any failed write.
nonisolated private static func delete(_ cleanup: ScanFolderCleanup) {
/// Off the main actor, after `bundleWrites`. A keyframe write still in flight for a deleted
/// folder fails, because its directory is gone, and is dropped like any failed write; the
/// bundle lists only keyframes already written.
nonisolated private static func delete(_ cleanup: ScanFolderCleanup, after bundleWrites: Task<Void, Never>?) {
Task.detached(priority: .utility) {
for (url, error) in cleanup.run() {
for (url, error) in await cleanup.run(after: { await bundleWrites?.value }) {
RuntimeLog.engine.error("could not delete old scan \(url.lastPathComponent, privacy: .public): \(String(describing: error), privacy: .public)")
}
}
Expand Down
17 changes: 16 additions & 1 deletion ios/HouseScan/Runtime/ScanEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2220,7 +2220,18 @@ final class ScanEngine {
/// place, so it is not offered while a write is under way, and writes run one after another:
/// a retry's write waits for the last one, and a write already superseded is skipped.
/// `inputs` were captured with the upload's scene (`captureUpload`); this rereads nothing.
///
/// Inputs read from an earlier store write into that store's folder, which the current
/// store's cleanup may delete; they are refused before they change what this scan offers.
/// The callers already drop a capture from before Start over; this keeps that true here.
///
/// Start over doesn't wait for a write in flight: the next store's cleanup waits for
/// `bundleTask` before deleting the folder, so the write's bundle lands and is kept.
func saveBundle(_ inputs: PacketInputs?) {
if let inputs, inputs.storeDirectory != store.directory {
RuntimeLog.engine.error("scan bundle not written: its capture belongs to a scan already started over")
return
}
state.shareableScan = nil
bundleSerial += 1
let serial = bundleSerial
Expand Down Expand Up @@ -2271,7 +2282,11 @@ final class ScanEngine {
meterTracking = nil
meterAnchorPresence = MeterAnchorPresence()
meterPlaneSource = .detectedPlane
store = KeyframeStore()
// The old scan's bundle may still be packing in the folder the new store's cleanup lists
// as never packaged. Start over doesn't wait for it; the cleanup's deletion does, for this
// chain head (every write waits for the one before it). `generation` was raised above,
// so a queued write for the old scan still skips, and a finished one isn't offered here.
store = KeyframeStore(deletingAfter: bundleTask)
recorder = Self.makeRecorder(store)
live?.setRecorder(recorder)
motion.stop()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ import Foundation
/// can exist, and only those are deleted, whenever the deletion gets to run. Listing at deletion
/// time instead let a cleanup that ran late delete a scan started after it: store A is made,
/// Start over makes B, and A's cleanup then found B in the listing and deleted the scan in use.
///
/// A folder whose bundle is still being built has no `scan.zip` yet, so it is listed as never
/// packaged. The packet is assembled in the folder first and zipped last, and Start over doesn't
/// wait for that write, so deleting on the listing alone took the folder from under it and the
/// scan never reached Saved scans. The app therefore deletes with `run(after:)`, waiting for the
/// bundle writes that were started before the listing; the recheck in `run` then keeps a folder
/// whose bundle landed meanwhile.
public struct ScanFolderCleanup: Sendable {
/// The bundle Share scan offers. A scan is completed when this file is a whole capture packet
/// (`PacketArchiveCheck`); the file being there is not enough.
Expand Down Expand Up @@ -132,7 +139,8 @@ public struct ScanFolderCleanup: Sendable {
/// The deletion runs later than the listing, off the main actor, and a folder can change in
/// between: a bundle write can start in a folder listed as never packaged, or finish in one.
/// Each entry is read again and deleted only if it is still what it was when listed; one that
/// changed is left for the next cleanup. A change after this second read is not caught.
/// changed is left for the next cleanup. A change after this second read is not caught, so a
/// write still going when this runs can lose its folder: `run(after:)` waits for known writers.
public func run() -> [(url: URL, error: any Error)] {
let files = FileManager.default
return obsolete.compactMap { url in
Expand All @@ -146,4 +154,14 @@ public struct ScanFolderCleanup: Sendable {
}
}
}

/// `run()`, once `wait` returns. The app's `wait` is the scan bundle writes started before
/// this cleanup was listed (one chain: each write waits for the one before it), so a folder
/// listed while its packet was still being assembled is judged again only after that write
/// ends: a bundle that landed keeps the folder, and a write that failed before zipping leaves
/// it to be deleted. A `wait` that returns at once, as at launch with no write, is `run()`.
public func run(after wait: () async -> Void) async -> [(url: URL, error: any Error)] {
await wait()
return run()
Comment on lines +163 to +165

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reapply retention after the awaited writer finishes

When cleanup starts while two completed scans already exist and the previous scan is still assembling its packet, the snapshot keeps the two completed folders and marks the active folder obsolete. After the wait, that folder's new completed bundle no longer matches listedAs, so run() skips it without evicting the oldest completed folder. This leaves three completed archives on disk, and repeated rapid start-overs can leave more, until another cleanup happens; SavedScanCatalog hides the extras because it still returns only two. Recalculate retention among the originally listed folders after the writer finishes while continuing to exclude folders created after the snapshot.

AGENTS.md reference: AGENTS.md:L16-L20

Useful? React with 👍 / 👎.

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.

Confirmed. If two completed scans exist and the previous scan is still packing, the listing keeps the two and marks the packing folder obsolete. After the write lands, run() skips that folder (its entry changed from .absent to .complete) and evicts nothing, so three archives stay on disk. The extra is bounded: the next store creation (the next Start over or launch) lists again and trims back to the two newest, and Saved scans lists only those two in the meantime. Fixing it means ranking completed scans again after the wait, including folders the listing kept, so it changes the listing-time retention rule this PR builds on. I have routed it to the PR's acceptance owner and left this thread open.

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.

Disposition: accepted as a trade-off of this fix, recorded in the PR's Risk section, with no code change here. The requirement for B44 is that no folder is deleted while its packet is being written, not that the phone holds exactly two archives at every moment. After a write completes during the wait, more completed archives can stay on disk until the next store is created; the next Start over or launch lists again and trims to the two newest. Saved scans shows only the newest two meanwhile. Correcting my earlier reply: I can't claim a fixed bound. The extra archives come from writes that complete during a wait, and nobody has measured their size. Ranking again after the wait, limited to the folders in the original listing, would be a separate retention-policy change, not part of this fix. Leaving this open for human review.

Comment on lines +163 to +165

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Delete unrelated obsolete scans before waiting

When Start over runs on a nearly full device and the snapshot contains both the active writer's folder and unrelated obsolete scans, this unconditional wait postpones every deletion until packet writing finishes. zipPacket must create scan.zip while the assembled packet still occupies disk space, so that writer can fail for lack of space even though cleanup already identified old folders that could have been removed safely; the cleanup frees them only after the archive has been lost. Delete obsolete paths that no captured writer can touch immediately, and defer only the writer-owned folders.

AGENTS.md reference: AGENTS.md:L16-L20

Useful? React with 👍 / 👎.

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.

Confirmed. run(after:) waits before deleting anything, so obsolete folders that no in-flight write can touch also wait for the packing write. Those are older completed scans past the two kept places, abandoned folders that were never packaged, and stray files. On a nearly full phone, that space isn't freed until after the zip is written. Splitting the deletion needs a rule for which folders the write chain can touch. The cleanup doesn't know that today, because every never-packaged folder looks the same. So it's a design change, not a bounds fix. I have routed it to the PR's acceptance owner and left this thread open.

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.

Disposition: accepted as a trade-off of this fix, recorded in the PR's Risk section, with no code change here. The cost is real: on a nearly full phone the save can fail for lack of space, where deleting unrelated old data first might have let it finish. The choice favors never deleting source data from under a write. An early split by entry type would depend on an undocumented ordering between writes and retention, and would still leave photo-only folders waiting, so this PR adds no second deletion mode without a demonstrated need. The existing error handling stays, and the app still promises no saved file until the zip is complete. Leaving this open for human review.

}
}
Loading
Loading