fix(studio): fix the caption overrides save protocol and a false-armed autosave - #3541
fix(studio): fix the caption overrides save protocol and a false-armed autosave#3541rajanpanth wants to merge 2 commits into
Conversation
somanshreddy
left a comment
There was a problem hiding this comment.
Review — two independent passes (Codex unbiased + mine), reconciled at source at head ebe6704. The two target bugs are fixed correctly and I verified both: the conditional-write protocol (undefined/null → If-None-Match: "*", known ETag → If-Match, a second 409 escapes after exactly one retry — 409 isn't in isRetryableStudioSaveError's {408,425,429,≥500} set, so retryStudioSave doesn't double-retry it), and the prevModel reorder that kills the false-armed autosave. Nice, careful work.
Codex surfaced a set of save-path races; I verified each at source and re-graded several down — recording the reasoning so it's auditable:
Should-fix (the one I'd want addressed or consciously deferred)
Overlapping saves aren't serialized — an older snapshot can overwrite a newer edit (useCaptionSync.ts save path). Each save() captures an immutable body, but there's no in-flight guard: if a PUT is still awaiting when the 800 ms debounce fires again, two saves run concurrently against the shared versionRef. editSeqRef is only consulted to clear pending, never to abort a stale attempt. On the normal ordering it self-heals (older lands first → newer 409s → adopts → retries → newer wins), but if the two PUTs reorder, or the older one's adopt-and-retry lands last, the older body wins. Needs a slow save + a concurrent edit, so it's not the common path — but it is real data loss. Suggest serializing saves (single-flight, coalesce to latest) or tagging each attempt with editSeqRef and aborting stale ones before each retry. Reasonable as a fast-follow given this PR fixes a strictly worse bug (every save failing) — your call whether it blocks.
Re-graded down (verified, not blocking)
- Non-identical 409 overwrites the other writer — this is deliberate and documented: the code comment states caption-overrides.json has exactly one writer and intentionally adopts last-write-wins because "there is no conflict UI for captions." Acceptable for a derived autosave file; only flagging so it's a conscious call — a second tab / manual edit is the one genuine two-writer case and it gets clobbered silently.
- Switch-flush doesn't cancel the armed debounce — mechanically true, but not data loss:
retrySaveis registered assaveitself (:241), andsave()buildsbodyfromgetState().modelsynchronously (:169) before the async PUT, so the flush sends the latest edit; the leftover timer fires afterreset(), seesmodel===null, and no-ops. Worth cancelling the timer in the flush for cleanliness (avoids a redundant late save), but no correctness impact. - Suppress token skips the next edit if the user exits edit mode mid-load — real but pre-existing: the
suppressSaveRefcheck sits after theisEditModeearly-return onmaintoo; this diff only moved theprevModelupdate above the guard, it didn't change suppress consumption. Latent fast-follow, not introduced here. (A scoped generation token instead of a shared boolean would fix it.)
Non-blocking
versionRefisn't project-scoped — persists forStudioApp's lifetime whileprojectIdRefchanges on hash-nav; a late response from a prior project could apply a stale version. Narrow (loadOverrides re-runs on switch), but a{projectId, version}pair + ignoring mismatched responses would close it.- Network failures throw a plain
Error, soretryStudioSave's network-retry path (StudioSaveNetworkError) never triggers for captions (its tests assert plain errors aren't retried). ThrowStudioSaveNetworkErrorto matchuseFileManager.
Praise
The version state machine and the exactly-once conflict retry are correct, the identical-content 409 shortcut is a nice touch, and the prevModel/hasPendingSave fixes are well-reasoned with genuine regression tests.
Verdict: Comment (not blocking). The delivered fix is correct; I'm withholding a stamp only on the overlapping-save race (real data loss, narrow) — happy to stamp once that's addressed or explicitly deferred to a fast-follow.
Provenance: Codex mapped the race surface (I'd caught only the network-error nit on my own pass); I verified each at source and re-graded B1/B3/B4 down with source-specific reasons. Credit to it for the overlapping-save race, which is the real one.
|
Thanks for the two-pass review, and for re-grading the rest at source rather than passing them through. Pushed Overlapping saves are now serialized. You had the failure mode exactly right: each I mutation-tested the regression rather than trusting it: with the new test in place and only the source reverted, it fails with Also took the network-error nit: On the three you re-graded down, I agree with your reading on each and have left them alone:
The two non-blocking items (project-scoping CI note: the |
…d autosave Rewrote the caption-overrides.json save path to match useFileManager.ts's conditional-write protocol: track the content version, send If-None-Match on first save and If-Match once a version is known, and retry once on 409 by adopting the server's current version. Also fixed a bug in the autosave subscription's model-change tracking. prevModel only updated inside the isEditMode guard, so setModel() called before setEditMode(true), the real activation order in useCaptionDetection.ts, was invisible to it. The next tick's setEditMode(true) then looked like a model change and armed a spurious save. Compute modelChanged and update prevModel unconditionally at the top of every tick instead. Gated the composition-switch flush in useCaptionDetection.ts behind hasPendingSave, since it previously fired a PUT on every composition switch regardless of whether anything had actually been edited.
Each save() captured its own body but nothing stopped two PUTs being in flight at once, and both raced the shared versionRef. The common ordering self-heals (the older lands first, the newer 409s, adopts the version and retries), but if the two reorder, or the older one's adopt-and-retry lands last, the older body is written and the newer edit is silently lost. It needs a slow save overlapping a fresh edit, so it is off the common path, but it is real data loss. Saves now run one at a time. A save requested while one is in flight replaces any already-queued attempt instead of stacking, so the next PUT always carries the newest body and no edit is dropped on the way. Also throw StudioSaveNetworkError rather than a plain Error on fetch rejection, so retryStudioSave's network-retry path applies to captions the way it already does in useFileManager.
100e9fe to
c5775f8
Compare
|
Rebase cleared the real failure: The one remaining red,
I do not have re-run rights here. Happy to push a no-op commit to retrigger if you would rather see it green before stamping. |
What
Fixes #3501.
useCaptionSync's save path now follows the same conditional-write protocoluseFileManager.tsalready uses for the main composition file, and a false-armed autosave on edit-mode activation is fixed.Why
caption-overrides.jsonwasPUTwithout anIf-Match/If-None-Matchheader at all, so the server's 428 (Precondition Required) response was never satisfied and every save failed, surfacing as a toast even when the user hadn't edited anything.That second half traced to a bug in the autosave subscription's own change tracking, separate from the missing headers:
prevModelwas only updated inside the!state.isEditModeearly-return's guarded branch. The real activation order (useCaptionDetection.ts:setModel(), thensetEditMode(true)) callssetModel()before entering edit mode, so that call was invisible toprevModel. The next tick'ssetEditMode(true)then looked like a model change against the staleprevModeland armed a save nobody asked for.How
versionReftouseCaptionSync(undefined= unknown,null= confirmed absent, string = known ETag), mirroringuseFileManager.ts.loadOverrides()captures the version from the GET response.putOverrides()sendsIf-None-Match: "*"when no version is known yet,If-Match: <version>once one is, via the existingretryStudioSave/StudioFileConflictErrormachinery. On a 409, adopts the server'scurrentVersionand retries once.prevModeltracking: computemodelChanged/updateprevModelunconditionally at the top of every subscription tick, before theisEditModeearly return, so asetModel()that happens beforesetEditMode(true)is no longer invisible.hasPendingSaveto the caption store, set whenever a real edit arms a save, cleared once it's sent. GateduseCaptionDetection.ts's composition-switch flush behind it, since it previously calledretrySave?.()unconditionally on every switch, not just when there was something to save.Test plan
useCaptionSync.test.tsx(5 cases): first save sendsIf-None-Match: "*"; a save afterloadOverrides()sendsIf-Matchwith the captured version; a 409 retries once with the adopted version; edit-mode activation with no prior edit does not arm a save (regression test for theprevModelbug); a genuine edit still arms one.useCaptionDetection.flushGate.test.tsx(3 cases) covers the gated flush directly.Ran the full studio suite before and after: same 56 pre-existing failures both times (401→403 files affected, 3984→3992 tests passing), so this branch doesn't touch anything outside its own scope.
lefthook run pre-commit(lint, format, fallow, typecheck, filesize, largefiles, tracked-artifacts, commitlint) all green on the actual commit.