Conversation
…trying The client folded every non-2xx into one `badResponse(Int, String)` case and never retried anything. The noop-cloud server is deliberately expressive about which kind of "no" it is answering — its own `/ingest` handler says so in a comment — and all of it was thrown away before it reached a human. - 507 `insufficient_space` means "your backup is fine, the server has no room, retry later". It read as "returned an error (507)", i.e. indistinguishable from a 400. - 400 `corrupt_sqlite` means "never send these bytes again". Same sentence. - 503 `storage_not_configured` on `/deepbuf` means an OPTIONAL archive lane was never switched on. It appeared next to a real `/ingest` failure with nothing to tell them apart, so one broken thing read as two. - 401 means the token is wrong and retrying cannot help. Same sentence again. And with no retry at all, a single 500 from a restarting server ended the whole sync; the next attempt was not seconds later but whenever the 20h `autoSyncIfDue` or 4h `backgroundSyncIfDue` window next opened — on a process that in practice runs `autoSyncIfDue` once per launch. One unlucky second cost a day. Split `CloudSyncError` into typed cases with a retryable/terminal classification and messages that say what happens next, add a bounded exponential backoff with a jitter band for the transient classes only, and treat a not-configured deep-buffer lane as a skip rather than a failure. The retry budget is deliberately small (~4s worst case, one retry only for the 100-300MB `/ingest` body): the target cadence is a sync on every phone unlock, so giving up early costs one more unlock, not one more day. `CloudSyncClient`'s 1h request ceiling and `CloudSyncGate`'s 2h stale reclaim both sit far above it, so the ladder can never be what wedges a sync. Also: a pull that applied edits now uploads unconditionally. `contentToken()` keys `workout` on COUNT alone and `sleepSession` on (count, MAX(endTs)), so `fix_workout` (tombstone, upsert, delete — +1 then -1) and restaging any night that is not the most recent both leave the token byte-identical, and the skip-unchanged gate then silently declined to upload a real correction. `ContentToken.swift`'s doc comment claimed such an edit "still moves the hrSample/dailyMetric segments"; it does not, and now says so.
vishk23
pushed a commit
that referenced
this pull request
Aug 24, 2026
"Duplicate as manual" is offered only on read-only rows - strap, Apple, lifting,
activity file - and the menu describes it as a copy path that leaves the original
alone. It did not.
The copy is built with source "manual" so the form treats it as editable, and the
sheet hands that pre-fill straight back as `replacing`. It claims to be a manual
row while carrying the ORIGINAL's natural key, so a save with a changed sport or
start reached three different consumers, each of which then acted on the original:
- the store deleted the old key under the active strap id, where a
strap-recorded session lives;
- Apple's #10 GPS route re-key read the route under the original's key, stored
it under the duplicate's, and REMOVED it from the original - the source
session's map and distance vanish;
- Android's Health Connect write-back deleted "noop-workout-<startTs>", which
carries no deviceId at all, so the original's records went and a duplicate
saved at a new start left them deleted with nothing to restore them.
Android already had a guard for exactly this - pass `replacing` only for a MANUAL
or DETECTED row - and it could never work, because it tested the copy's source
and the copy says "manual" by design. Apple had no guard.
Both platforms now carry the distinction where a row's source cannot express it:
an isCopy flag on the sheet target, and `replacing` nil for a duplicate. That
stops it reaching every consumer rather than making one consumer's key safe,
which is what the earlier namespace re-seed did (#1489) - sound for the store,
silent about everything downstream.
Health Connect write-back is opt-in, so that face only affected users who enabled
it. The route and store faces did not need it.
Android: 5 tests - a duplicate replaces nothing despite reading as manual, a real
manual edit and a detected bout still do, an imported row is still refused
without the flag, and a fresh add replaces nothing either way. Extracted as
WorkoutEditing.replacingRowFor so the decision is testable rather than sitting
inside a Composable. Apple compiled via app-build, both legs.
On hardware: duplicate a strap session, change its sport, confirm the original
keeps its route, its distance and its Health Connect record.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #7 (
fix/cloudsync-gate-leak) — based on its head, so this PR shows only its own commit. GitHub will retarget it tomainwhen #7 merges.The part of the ten days this fixes
#7 explains why a phone stopped syncing at all: the process-wide gate was stuck held, so every entry point bailed instantly. That is the whole story of why nothing ran. This PR is about the other half — what happened on the days something did run, and why nobody could tell.
The server was only broken for part of that window, and it was loudly specific about how.
POST /ingestanswers507 insufficient_spacewhen the volume is full, and its handler says in a comment exactly why the status matters: so the phone reads it as "server has no room, keep the backup and retry later" rather than "this upload is malformed, discard it". It answers400 corrupt_sqlitefor the opposite case.POST /deepbufanswers503 storage_not_configuredwhen object storage was never set up — an optional feature, not a fault.The client threw all of that away:
So every one of those became the same sentence with a different number in it. On the Data Sources card, side by side, sat:
One of those was a real outage. The other was a feature that had never been switched on. Nothing in the app distinguished them, so one broken thing read as two, and the message that mattered was buried next to one that never did.
And there was no retry and no backoff anywhere. A single 500 from a restarting server ended the sync. The next attempt was not seconds later — it was whenever the 20h
autoSyncIfDueor 4hbackgroundSyncIfDuewindow next opened, andautoSyncIfDueruns once per launch on a process that stays resident. One unlucky second cost a day.What changed
1.
CloudSyncErrorbecomes a taxonomy (Strand/CloudSync/CloudSyncError.swift). One place —CloudSyncClient.throwIfNotOK— turns(status, body)into exactly one case, driven by the server's own machine-readableerrorcode, so no endpoint can grow a private interpretation of a status and no new endpoint can re-collapse them.507 insufficient_space/storage_unavailableserverOutOfSpace500/502/504, and any other503serverFaultnetwork400 corrupt_sqlite,bad_zip,413 too_large,404rejected(status:code:detail:)401/403unauthorized503 storage_not_configuredfeatureNotConfiguredCancellationErrororURLError.cancelled)cancelleddecode2. Bounded retry with exponential backoff + a jitter band (
CloudSyncRetry.swift), consultingisRetryableand nothing else.The budget is deliberately small: ~1s then ~3s, three attempts, ~4s worst case. The target cadence is a sync every time the phone is unlocked, so giving up early now costs one more unlock rather than one more day — which makes a long in-sync ladder the wrong trade. A retry here rides out a blip a human wouldn't notice; it does not try to out-wait an outage.
CloudSyncClient.syncSession's 1h request ceiling andCloudSyncGate.staleHoldS's 2h reclaim both sit far above it, so the ladder can never itself be what wedges a sync./ingestgets its own tighter budget — one retry, not two. Resending a 100-300MB.noopbakis minutes of cellular data. It is still worth one, because the most common/ingestrefusal (507) is answered from the declaredContent-Lengthbefore the server accepts a byte of the body, so that attempt cost nothing to make and nothing to repeat.Jitter is a full band around each delay rather than a bare doubling: every phone an outage knocked offline otherwise retries on the same schedule and returns as a thundering herd.
3. The deep-buffer 503 is a skip, not a failure.
DeepBufferDrainSummarygainsskippedNotConfigured, deliberately noterror—erroris what becomes a user-visible· Deep buffers: …suffix. The watermark stays exactly where it was, so the moment a bucket is configured the same bytes ship; nothing is written off. The lane is then not re-probed for 6h, because the 503 only arrives after the chunk body is sent, and re-uploading megabytes on every unlock to be told "no" is not a no-op. Six hours, not sticky, so provisioning heals on its own the same day. (Tigris has since been provisioned, so this path should now succeed — the handling still has to be right, and a500 deepbuf_failedstill reports as a real failure.)4.
cloudsync.lastStatussays something a human can act on. Every message now ends with what happens next:Failed — The server has no room for the backup right now (insufficient_space). Nothing was lost — your data is safe on this device and the next sync will try again. only 300 MB free · 4:12 PMFailed — Cloud sync rejected this device's token (401). Re-enter the server token in Data Sources — retrying won't help. · 4:12 PMA failure is now prefixed, not merely worded differently: success and failure used to produce the same shape of sentence with the same trailing timestamp, so
…returned an error (507) · 4:12 PMwas easy to read at a glance as "it synced at 4:12" — and during the outage this string and its timestamp were the only signals available.5. A pull that applied edits now uploads unconditionally. Found while tracing the same "a real change never reached the server" theme, and it is a genuine hole, not a hypothetical:
contentToken()keysworkoutonCOUNTalone.fix_workouttombstones the original, upserts the corrected copy under thenoop-clouddevice, then deletes the original — +1 then −1 — and touches no other table the token reads. Token byte-identical.sleepSessionon(COUNT, MAX(endTs)).adjust_sleep_bounds/edit_sleep_stagesUPDATEa row in place, so restaging any night that is not the most recent moves neither. Token byte-identical.In both cases the skip-unchanged gate silently declined to upload a correction the user had just made.
ContentToken.swift's doc comment actively claimed the opposite ("still moves thehrSample/dailyMetricsegments in the same edit"); that is false, and it now records the residue instead.Fixed at the call site, not the fingerprint:
summary.applied > 0 || didRecomputeis the same fact, already computed, right where the decision is made. Redesigning the token would mean content-hashing tables in an upstream-shared package and would force every device one full re-upload just to change the token's format. The token keeps doing what it is good at — cheaply proving a device with no new samples and no applied edits has nothing to ship.Deconfliction
fix/cloudsync-gate-leak) — based on its head; zero overlap. fix(cloudsync): stop a stopped sync from wedging every sync after it #7 owns the gate lifetime and the session timeouts, this owns the error taxonomy and the retry ladder. The two bounds are complementary and the comments cross-reference: fix(cloudsync): stop a stopped sync from wedging every sync after it #7'sstaleHoldS(2h) >syncSession's resource ceiling (1h) > this PR's whole retry ladder (~4s).cloudsync-background-upload— that branch adds afileUploaderinjection hook at the top ofsendUpload(_:fromFile:)and makesIngestResponseinternal; this PR changes thecatchat the bottom of the same functions and the bodies of the public methods.git merge-treereports the same two conflicting files before and after this commit (CloudSyncClient.swift,CloudSyncModel.swift) — those conflicts already exist between fix(cloudsync): stop a stopped sync from wedging every sync after it #7 and that branch, and this adds none.CloudSyncBackgroundRefresh.bgTaskIdentifier/ScheduledDebugExport.bgTaskIdentifierhardcodingcom.noopapp.*) is deliberately untouched here — a separate change owns it.Verification
Strand/CloudSync/is app-target Swift, so no default CI covers it — local builds are the verification.xcodebuildStrand macOS,CLOUD_SYNCsetxcodebuildStrand macOS, flag absent (default offline build)xcodebuildNOOPiOS,generic/platform=iOSStrandTestsfull suiteswift testinPackages/WhoopStoreThe flag-absent build is checked deliberately: all of this stays inside
#if CLOUD_SYNC, so "fully offline" remains a property of the default binary's bytes. The only change outside the gate is a doc comment inContentToken.swift.New tests worth naming:
testEachServerStatusMapsToItsOwnTypedError— the whole table, one row per codetest503IsOnlyNotConfiguredWhenTheServerSaysSo— the body, not the status, decidestestOnlyTransientClassesAreRetryable— the classification the retry engine reads, in one placetestUploadCorruptSqliteIsTerminalAndNeverResendsTheBody— exactly one requesttestUpload507SurfacesAsOutOfSpaceAndIsRetried— exactly two, never threetestCancellationIsNeverMistakenForANetworkFault— both shapes land on.cancelled, so aBGAppRefreshTaskat expiry never sits out a backoff it cannot survivetestACancelledBackoffAbandonsTheRetryLoop— same hazard, from inside the laddertestNotConfiguredServerIsRecordedAsASkipNotAnError/…LeavesTheWatermarkUntouched/testARealDeepBufferFailureIsStillReportedAsAnError— the skip is scoped to one status, not an amnestytestTheWholeStandardLadderIsOverInSecondsNotMinutes— pins the budget against future creeptestAFailureIsMarkedAsOneAndASuccessIsNot— the card cannot show a failure as if it syncedCloudSyncRetryPolicy.delayS(beforeAttempt:jitterUnit:)takes the random draw as a parameter andwithCloudSyncRetrytakes itssleep, so the entire schedule is pure arithmetic — nothing here sleeps and nothing is flaky. Same reasoning as #7'sbegin(now:).Cross-platform
No Android twin — Android has no cloud sync at all. The only
CloudSynchits underandroid/are a MaterialIcons.Filled.CloudSyncon the local backup screen.ContentToken.swiftis in an upstream-shared package, but it has no Kotlin counterpart (grep -rn contentToken android/→ nothing) because it exists solely to serve this fork-only skip-unchanged gate — and the change there is a doc comment, altering no stored bytes and no computed value.