mirror of
https://github.com/mentalfaculty/LLVS.git
synced 2026-09-27 13:04:25 +02:00
Closes audit item 14. Box refuses a create whose name is already taken, and the sync writes the changes file before the version file. A version upload that failed after its changes had succeeded therefore hit that conflict on every later attempt, and the version could never land. The store stayed stuck on it for good, with each retry looking like a fresh network failure rather than a permanent one. A name conflict now uploads a new revision of the existing file instead. The file id comes from the 409 itself, which names the file holding the name, so the common path costs no extra request. If the error does not carry one, a folder listing finds it, but only when Box says the name is in use: 409 also covers transient conditions such as `operation_blocked_temporary`, which are retried, and listing an ever-growing folder on each of those is the cost this approach exists to avoid. A conflict that resolves to nothing is rethrown, so the wedge this fixes cannot come back through a 409 whose code is missing. The create is attempted first rather than looking for the file beforehand. Looking first would not be safe: the check and the create are not atomic, so a second device can slip between them, which is the same race item 15 fixes on Google Drive. Box enforces name uniqueness where Drive does not, so letting its own constraint arbitrate is the answer that holds under concurrency. It is also far cheaper, because a look costs a full paged listing of a folder that holds one file per version. Uploading a revision rather than deleting and re-creating keeps the file id stable and leaves no window where the name exists nowhere. The retry builds a fresh byte stream. These are `InputStream`s, which read once and cannot be rewound, so reusing the failed attempt's stream would have uploaded an empty file rather than failing. Verified by compiling under --enable-all-traits only. `LLVSBox` needs the vendor SDK and a live Box account, and the package has never tested it; the conflict shape was checked against `ConflictErrorContextInfoField` in the vendored SDK rather than against a live response. Recorded in AUDIT.md under item 24. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013kU8zpV5HdgtcqyTbDBkQg
15 KiB
15 KiB
LLVS Audit — 2026-09-18
Read-only audit at commit 92fe81b (tag 0.9). swift test passes: 170 tests. Findings come from reading the code. Items marked ✔ were re-checked by hand; the rest are reasoned but not reproduced with a failing test.
Critical
- ✅ FIXED (branch
safety-pass) Cache never evicts —Sources/LLVS/General/Cache.swift:88.dropLast()is non-mutating and its result is discarded, sogenerationsgrows without bound. Lines 43 and 76 userepeating: Generation(), which shares one class instance across all generations. Fix:removeLast()(done). The sharedGeneration()instance is benign (it ages out) and is still open. - ✅ FIXED (branch
safety-pass;mergeHeadsandStoreCoordinator.merge()nowthrows)try!inmergeHeads—Sources/LLVS/Core/Store.swift:316. Any arbiter or I/O error crashes the app. Reachable throughStoreCoordinator.merge(). Fix: make itthrows. - ✅ FIXED (branch
safety-pass;FileZone.swift:64try?on read still open) Non-atomic writes —Store.swift:519,FileZone.swift:57,FileSystemExchange.swift:104,108.data.write(to:)without.atomic. A crash mid-write leaves a truncated version JSON, after whichStore.initfails. Another process can read a partial file, andFileZonecaches it.FileZone.swift:64usestry?, which turns read errors into "missing". - ✅ FIXED (branch
safety-pass) Greatest common ancestor is not always the greatest —History.swift:111-146. The search from the second version returns the first common ancestor it reaches, which can be an ancestor of a nearer one. The merge is still valid, but it reports false conflicts, and a timestamp arbiter can then discard a newer edit. Fix: gather all common ancestors and drop any that is an ancestor of another. - ✅ PARTLY FIXED (no longer crashes; the zone owner is still hardcoded, so shared databases still do not work) CloudKit shared database crashes —
CloudKitExchange.swift:101-105,150.createZoneOperationis nil for non-private scopes but is force-unwrapped.zoneIDhardcodesCKCurrentUserDefaultName(:83), which is wrong for another owner's zone.
Important — core
- ✅ FIXED (branch
safety-pass) Optional merge loses data —Sources/LLVSModel/Mergeable.swift:59-60.(_, _, .none)returnsself, so (nil, some, nil) drops the value the other branch inserted. - Map bucketing degenerates with LLVSModel IDs —
Map.swift:49. IDs like"Contact/uuid"all land in node"Co", so each save rewrites a node that lists every Contact. O(N) per write. Two options. (a) Bucket by a hash of the whole ID. This is the real fix, but the bucket name is in the stored file paths, so it needs a store format version, a one-time rebuild on open, and a rule that stops two devices syncing under different schemes. (b) Cheaper: haveLLVSModelbuild IDs as"<uuid>/Contact"instead of"Contact/<uuid>", so the random part is the prefix. No migration, and it fixes the common case, but not a user who picks their own colliding keys. Not measured; deferred 2026-09-18. - ✅ PARTLY FIXED (step errors now throw, NULL checked on the right column, empty blob, 5 s busy timeout; still open: no transactions, WAL or statement reuse, and
SQLiteZoneis not thread-safe whileStoredoes not serialise zone access) SQLite —SQLiteDatabase.swift:129treats BUSY/error as end-of-rows (reads as "missing"); no busy timeout; :190-201 checks NULL on column 0 instead of the requested column; zero-length blob crashes onbytes!; no transactions, WAL, or statement reuse. - ✅ PARTLY FIXED (zones are made in
Store.init, which now throws;isExchangingis behind aMutex; the configuration properties are documented as setup-only;saveis still read-then-write) Unprotected shared state —Store.swift:50-58lazy zones race on first access and usetry!.StoreCoordinator.swift:27-34,212:exchange,mergeArbiter,isExchangingare unsynchronised in an@unchecked Sendableclass.save(:169) is read-then-write. - ✅ PARTLY FIXED (
Exchange.swiftdone;Version.swift:25andStore.swift:279still open) Force unwraps on remote data —Exchange.swift:99,124,Version.swift:25,Store.swift:279. A backend that returns fewer changes than asked crashes the app. - ✅ FIXED Macro defects —
MergeableModelMacro.swift:46,85. Onlybindings.firstis merged (var a, bskipsb). Generated methods have no access modifier, so apublicstruct does not compile.
Important — exchanges
- ✅ PARTLY FIXED (retries once only; rate-limit and
retryAfterhandling still open) CloudKit retry loop —CloudKitExchange.swift:162treats.partialFailureas an expired token and retries recursively with no limit or backoff. No handling ofrequestRateLimited,zoneBusy,limitExceeded,retryAfter. - ✅ FIXED (versions are added in dependency order; there is still no ack to the sender) Multipeer push drops versions —
MultipeerExchange.swift:268adds versions in arrival order;sendorders them bySet. A version whose predecessor has not arrived throws, the rest of the batch is dropped, and the sender reports success. - ✅ FIXED (a name conflict now uploads a new revision of the existing file instead of failing; the file id comes from the 409 itself, with a folder listing as a last resort. Verified by compiling only —
LLVSBoxneeds the vendor SDK and a live account, see item 24) Box stuck version —BoxExchange.swift:109always creates a new file. If changes upload and the version upload fails, the retry hits a name conflict forever. - ✅ FIXED (every lookup picks the lowest matching id, so all devices agree without coordinating; the create re-queries and takes that winner, which may be another device's folder; the duplicate is left in place because it may already hold another device's versions) Google Drive duplicate folders —
GoogleDriveFileSystem.swift:271-296is check-then-create, and Drive allows duplicate names. Two devices on first sync can split permanently. - ✔ FIXED (all five: PKCE with S256 and a constant-time
statecheck; single-flight refresh inOAuthTokenStore; theSecItemAddstatus is logged; the session is held by a box until its callback fires; form bodies use the RFC 3986 unreserved set) OAuth — no PKCE and nostateparameter; no single-flight token refresh (OneDrive rotates refresh tokens);SecItemAddstatus ignored;ASWebAuthenticationSessionis not retained; form bodies use.urlQueryAllowed, which leaves+ & =unescaped. - ✔ FIXED (all four backends route through
HTTPClient; refresh-on-401 with single-flight in both OAuth backends; pCloud downloads check their status) No retry/backoff in WebDAV, Google Drive, OneDrive, pCloud. No 429/Retry-Afterhandling, no refresh-and-retry on 401. pCloud downloads ignore HTTP status. - ✅ FIXED (branch
safety-pass)FileSystemExchangelists withoptions: [](:66), so.DS_Storebecomes a version ID andretrievethrows.
Important — snapshots
- ✅ FIXED (chunks live under
snapshots/<snapshotId>/; upload order is chunks, then manifest, then delete the replaced snapshot; a manifest id that is not path-safe is refused. The hash and size checks arrived with item 20) Chunk names are not scoped by snapshot ID. Chunks are overwritten in place and the manifest is written last, so a reader with the old manifest can assemble mixed chunks. No hash or size check. - ✅ FIXED (staging directory, then move; size check against the manifest) Bootstrap unzips straight into the live store root. A failure midway leaves version files without values, and those versions are never re-fetched.
- ✅ PARTLY FIXED (manifest is scanned before zipping, so the archive is a superset; still no lock) The zip is taken from a live store with no lock.
versionCountandlatestVersionIdare scanned after zipping and can disagree with the archive.
Docs, tests, infrastructure
- ✅ FIXED (README rewritten against the current API) README code samples do not compile. L68-72, L227-228, L264-268 use completion handlers; L219-221 omits
usesFileCoordination:. L100 says macOS 10.15 / iOS 13; L88 saysfrom: "0.3.0"; L293-300 says four targets and no dependencies. No mention of LLVSModel or the new backends. L328-332 lists samples that no longer exist. docs/is a 2019 Jekyll blog that teaches the removed Combine API and links to a deleted sample.- 🔶 PARTLY FIXED (Google Drive, OneDrive and WebDAV are now covered through a
URLProtocolfake server inLLVSNetworkTests; CloudKit, Box and pCloud still have none, because each needs its vendor SDK and a live account) About 3,000 lines of backend code have no tests: CloudKit, Google Drive, OneDrive, WebDAV (including the pureWebDAVResponseParser), Box, pCloud,FolderBasedExchange,Cache,ExchangeSerializer. NoassertMacroExpansiontests.StoreCoordinatorhas no dedicated tests.- Worth doing without a live account:
CloudKitExchange.chunkRecordNameandlegacyChunkRecordNamearestaticand pure, and they encode a wire format. Changing either silently makes every chunk in every existing store unreachable or un-deletable, and nothing would fail. They need a test target forLLVSCloudKit, which does not exist yet.
- Worth doing without a live account:
- ✅ ADDED
.github/workflows/ci.yml(not yet seen to run on GitHub) No CI. No.github/. - ✅ FIXED going forward (0.10.0 is three-part; the old tags are left as they are) Tags 0.7, 0.8, 0.9 are two-part. SPM
from:needs three-part semver. Duplicate old tags exist (0.1 and 0.1.0, etc.). - ✅ FIXED for Box and pCloud (branch
safety-pass, package traitsBox/PCloud; ZIPFoundation in core still open) Box and pCloud SDKs are in every consumer's dependency graph, because they are top-level package dependencies. Core LLVS depends on ZIPFoundation for one file. - ✅ FIXED (untracked) Root
Package.resolvedis tracked, but.gitignore:4ignores**/Package.resolved. - ✅ FIXED (renamed to
LICENSE, with an "MIT License" title)LICENCE.txtuses the British spelling; GitHub and Swift Package Index licence detection may miss it. - ✅ FIXED (every target is in Swift 6 language mode; the named blockers are all resolved) Swift 5 language mode everywhere. Swift 6 blockers: global
log(non-Sendable, shadows Foundationlog()),Historyescapes fromqueryHistory, non-SendableStorecaptured in@Sendableclosures,Zone/MergeArbiter/DynamicTaskBatchernot Sendable.
Minor / dead code
StoreCoordinator.swift:95hard-codesFileStorage; SQLite cannot be used through the coordinator.defaultCacheDirectoryis unused.DataCompression.swift:34-45: an uncompressed payload that starts with"LLZF"gets decompressed on read.FileZone.swift:71-74: IDs that differ only in case collide on case-insensitive APFS.- Dead:
Map.zoneReferences,purgeCache,Store.storedVersionIds,versionIdson both zones,greatestCommonAncestor(ofAll:),Result.voidResult/isSuccess,MapType.userDefined(hitsfatalError),Exchange.swift:98-102,ArrayDiff(unused inside the package). CONTRIBUTING.mdis only a 2019 CLA.SQLite3is exposed as a public product. Stale remote branches:api-refactor,indexes,loco-swiftui/*,claude/*,lowdown.
Suggested order
- Safety pass (small, test-first): items 1, 2, 3, 6, 10, 18. One failing test each, then the fix.
- GCA fix (item 4) with a DAG test that reproduces the false conflict.
- CI: a GitHub Actions workflow that runs
swift teston macOS. - README rewrite against the current API; delete or archive
docs/. - Tag
0.10.0in three-part semver. - Split heavy backends: move Box and pCloud to their own packages, or behind package traits, so consumers do not pull the SDKs.
- Shared HTTP layer for WebDAV / Google Drive / OneDrive with retry, backoff, and 401 refresh. Add PKCE.
- Snapshot hardening: snapshot-ID-scoped chunk names, a hash in the manifest, unzip to a temp directory and then move.
- Map bucketing by hash (format change; needs a migration plan).
- Swift 6 language mode, target by target, starting with LLVSModel.
Follow-ups from the code reviews of safety-pass
Done: StoreCoordinator.merge() merges head by head in a stable order and keeps the heads that merged; branch metadata of the wrong type no longer traps (valueIfDecodable()); twice-inserted optionals use salvaging(from:); FileZone rethrows real read errors; each cache generation has its own object; dead greatestCommonAncestor(ofAll:) deleted; Package.resolved untracked.
Open:
README.md:70and:266callmerge()withouttry. Fix in the README rewrite. Put the source breaks (merge()andmergeHeadsnow throw; Box and pCloud need traits) in the release notes..atomiccosts about 2x on small value writes (measured 0.43 s vs 0.85 s per 5000 writes). Accepted.- Criss-cross merges have more than one greatest common ancestor. LLVS picks one (the most recent). Like non-recursive git, this can silently pick a side: value R=0, X=0, Y=1; M1 keeps 1, M2 deliberately resolves back to 0; with base X the merge takes 1 with no conflict. A recursive merge base would fix it. Out of scope for now.
Version.MetadataValue.value()andinit(_:)still usetry!(documented; public API).@MergeableModelsilently skips tuple patterns (var (a, b) = (1, 2)), andlazy vargives a confusing compile error. Emit a macro diagnostic for both.SQLiteDatabase.Error.queryFailedcarries the code but notsqlite3_errmsg.StoreCoordinatornever callsstore.reloadHistory()except inbootstrapFromSnapshot, so a process does not see versions written by another process (app extension) until the app calls it. Consider calling it at the start ofmerge().StoreCoordinator.init(snapshotPolicy:)passesdefaultStoreDirectoryas the cache directory;defaultCacheDirectoryis unused.- CI has not run yet. The first run is also the first real Swift 6.1 build (local toolchain is newer).
- Snapshot restore: done are the manifest SHA-256, the staging directory, versions-last moves, store directories only, and an error when an existing file differs in size (eg a SQLite database). Still open: chunk names are not scoped by snapshot ID (item 19), so a download during a replacement now fails cleanly and must be retried; a version file can be zipped without its values when the store is written during the zip.
- Snapshots from 0.9 have no hash. The version-count guard for them depends on the order of entries in the archive. Comparing the staged file count with the archive's entry count would be stronger.
- Zipping a live SQLite database in the middle of a transaction can capture a hot journal or a torn file.
Store.initderivesvalues/,versions/andmaps/from the unresolved root URL, whilerootDirectoryURLis resolved. No failure seen.WebDAVResponseParserignores the namespace URI. Turning onshouldProcessNamespacesand checking forDAV:would be stricter.Storeis@unchecked Sendable, and vouches transitively for whateverStoragethe caller supplies. The protocol now documents that implementations must be thread-safe, but nothing enforces it.