From 22c083db4afb340c718c75b2f5ea7196c37ce850 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:25:05 -0700 Subject: [PATCH] fix(client-runtime): a queued-edit save stays claimed until it settles Beginning or ending an edit cleared the save claim, so a replacement edit could start a second save while the first was in flight. The server orders saves by arrival, so the older one could land last and overwrite the newer. The claim is now released only by the save's finisher. Co-Authored-By: Claude Opus 5.5 --- .../client-runtime/src/queuedRunEdit.test.ts | 22 ++++++++++++++++++- packages/client-runtime/src/queuedRunEdit.ts | 12 ++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/client-runtime/src/queuedRunEdit.test.ts b/packages/client-runtime/src/queuedRunEdit.test.ts index b9d22b3fa9a51..24d2dc42402d6 100644 --- a/packages/client-runtime/src/queuedRunEdit.test.ts +++ b/packages/client-runtime/src/queuedRunEdit.test.ts @@ -102,7 +102,6 @@ describe("makeQueuedRunEditStore", () => { const finishStale = store.startSave("env:thread"); begin(); store.update("env:thread", (edit) => ({ ...edit, text: "newer" })); - expect(registry.get(store.savingAtom)).toEqual({}); expect(finishStale?.(true)).toBe(false); expect(finishStale?.(false)).toBe(false); @@ -111,6 +110,27 @@ describe("makeQueuedRunEditStore", () => { expect(exits).toEqual([{ threadKey: "env:thread", text: "newer" }]); }); + // The server orders saves by arrival, not by edit age, so a second save could + // land before the first and then be overwritten by it. + it("keeps the save claimed until it settles, even when the edit is begun again or ended", () => { + const { registry, store, begin } = setup(); + begin(); + const finishStale = store.startSave("env:thread"); + begin(); + expect(registry.get(store.savingAtom)).toEqual({ "env:thread": true }); + expect(store.startSave("env:thread")).toBeNull(); + store.end("env:thread"); + begin(); + expect(store.startSave("env:thread")).toBeNull(); + + expect(finishStale?.(true)).toBe(false); + expect(registry.get(store.savingAtom)).toEqual({}); + expect(finishStale?.(false)).toBe(false); + const finishNewer = store.startSave("env:thread"); + expect(finishNewer?.(true)).toBe(true); + expect(store.get("env:thread")).toBeNull(); + }); + it("does not revive a cancelled edit when its save fails", () => { const { registry, runs, store, exits, begin } = setup(); begin(); diff --git a/packages/client-runtime/src/queuedRunEdit.ts b/packages/client-runtime/src/queuedRunEdit.ts index 65b29647c934b..5d9377b263840 100644 --- a/packages/client-runtime/src/queuedRunEdit.ts +++ b/packages/client-runtime/src/queuedRunEdit.ts @@ -141,7 +141,6 @@ export function makeQueuedRunEditStore(opti if (watch === undefined) return; unsubscribe(watch); watches.delete(threadKey); - setSaving(threadKey, false); }; return { @@ -178,21 +177,26 @@ export function makeQueuedRunEditStore(opti }, /** * Claims the open edit for a save, or returns null when there is no edit - * or a save already holds it. The exit watch pauses so the save's result - * decides the edit's fate. + * or a save is in flight. The exit watch pauses so the save's result decides + * the edit's fate. The claim outlives the edit: the server orders saves by + * arrival, so an overlapping older save could overwrite a newer one. */ startSave(threadKey: string): FinishQueuedRunEditSave | null { const watch = watches.get(threadKey); if (watch === undefined || threadKey in registry.get(savingAtom)) return null; setSaving(threadKey, true); unsubscribe(watch); + let settled = false; return (succeeded) => { + if (!settled) { + settled = true; + setSaving(threadKey, false); + } if (watches.get(threadKey) !== watch) return false; if (succeeded) { stopWatch(threadKey); write(threadKey, null); } else { - setSaving(threadKey, false); subscribe(threadKey, watch); } return true;