fix: gate every send on the conflict, not just the replays

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-16 01:02:50 +02:00
co-authored by Claude Opus 5
parent 0d4cbe5a88
commit 7166b32b41
3 changed files with 50 additions and 14 deletions
-3
View File
@@ -315,9 +315,6 @@ class Entry<V> {
// — a save landed, or the user put it back. Left standing, it would have the next read
// keep this value while advancing the baseline under it, and that gap drafted.
if (desired === null) this.local = false
// Back on the deployed value, so there is nothing of anyone's here for a read to defer to
// — a save landed, or the user put it back. Left standing, it would have the next read
// keep this value while advancing the baseline under it, and that gap drafted.
this.ports.hint(key, desired !== null)
// A rejected write is not retried: the row stays as the server has it until the conflict
// is resolved, by a reload or an overwrite. The refused payload stays parked, which is
+45 -4
View File
@@ -58,18 +58,27 @@ function fakeRows() {
},
flush: async (key) => {
// A POST carries what was queued when it went out; what is typed while it is in flight
// queues behind it. With nothing queued, a payload parked by a failure is replayed
// instead — but never one the server refused: that is parked to keep it, not to retry
// it, and sending it is the user's to ask for.
// queues behind it. With nothing queued, a payload parked for another attempt goes
// instead.
const fromQueue = queued.has(key.path)
const going = fromQueue
? { value: queued.get(key.path) }
: parked.has(key.path) && !conflicts.has(key.path)
: parked.has(key.path)
? { value: parked.get(key.path) }
: undefined
queued.delete(key.path)
if (holdFlush) await holdFlush
if (!going) return
// Nothing goes out while the key is conflicted, whether it was queued before the
// refusal or parked by it: the payload is kept so the edit survives, and which
// version wins is the user's to say. `postSave` gates every send this way.
if (conflicts.has(key.path)) {
parked.set(key.path, going.value)
return
}
// Nothing goes out while the key is conflicted, whether it was queued before the
// refusal or parked by it: the payload is kept so the edit survives, and which
// version wins is the user's to say. `postSave` gates every send this way.
// Replaying a parked payload is another POST, so another hand-over.
if (!fromQueue) handed(key.path)
// A POST that never reaches the server keeps its payload for the next attempt.
@@ -1979,6 +1988,38 @@ describe('item store: conflicts', () => {
})
})
it('does not let a debounce armed before a synthetic conflict send behind the user', async () => {
const rows = fakeRows()
const store = createItemStore(rows.port)
const key: ItemKey = { workspace: 'w', kind: 'resource', path: 'u/me/r' }
const a = adapter(async () => {
const draft = rows.sent.get('u/me/r') as Res | undefined
return { deployed: deployedRes, draft, draftSavedAt: draft ? 'T-theirs' : undefined }
})
const first = store.acquire(key, { workspace: 'w', path: 'u/me/r' }, a)
await settle()
// No draft exists, so this edit is queued with no baseline behind it. It is never
// attempted: the editor closes while it is still sitting in the debounce.
first.handle.value = { ...deployedRes, description: 'tab A, still debouncing' }
first.release()
// Another tab creates the draft meanwhile.
const theirs = { ...deployedRes, description: 'tab B' }
rows.sent.set('u/me/r', theirs)
rows.handExternally('u/me/r')
const second = store.acquire(key, { workspace: 'w', path: 'u/me/r' }, a)
await settle()
// Two drafts and no baseline to order them by, so the read raises the conflict itself.
expect(second.handle.status).toBe('conflicted')
// The debounce armed before any of that is still holding tab A's edit. Firing it would
// go out unconditional, take their draft with it, and answer the conflict nobody settled.
await rows.port.flush(key)
expect(rows.sent.get('u/me/r')).toEqual(theirs)
expect(second.handle.status).toBe('conflicted')
})
it('does not replay a first failed autosave over a draft created since', async () => {
const rows = fakeRows()
const store = createItemStore(rows.port)
+5 -7
View File
@@ -285,6 +285,11 @@ function formatSaveError(e: unknown): string {
async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise<void> {
const key = draftKey(opts.workspace, opts.itemKind, opts.path)
// Refused by the server, or on this tab's behalf, and not answered yet. The payload stays
// parked so the edit survives, but nothing sends it until the user picks a version, `force`
// being that choice. Gated here because every send but the page-hide beacon comes through,
// a debounce armed before the conflict was raised included.
if (conflicts.has(key) && !opts.force) return
const lastSync = getLastSyncEntry(key)?.lastSync
try {
const resp = await DraftService.updateDraft({
@@ -528,9 +533,6 @@ export const UserDraftDbSyncer = {
} catch {}
if (enabled) {
for (const [key, opts] of pendingSaveOpts) {
// A refused payload is parked to keep it, not to retry it: catching up here would
// send it the moment its baseline is acceptable, without the user resolving.
if (conflicts.has(key)) continue
debouncer.schedule(key, () => {
runner.submit(key, () => postSave(opts))
})
@@ -713,10 +715,6 @@ export const UserDraftDbSyncer = {
try {
const parked = pendingSaveOpts.get(key)
if (!parked) return
// The server refused this payload and nothing has resolved that yet. It stays parked,
// as the only copy of an edit that never landed, but replaying it here would decide
// the conflict on the user's behalf the moment its baseline is acceptable again.
if (conflicts.has(key)) return
if (opts?.honorAutosaveToggle && !autosaveEnabledState && parked.auto && parked.canBeDisabled)
return
await this.save({ ...parked, immediate: true })