Order the notes cache by sequence, not by who resolves last
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 4s
Build App (Preview) / build-macos (pull_request) Successful in 2m48s
Build App (Preview) / build-linux (pull_request) Successful in 6m2s
Build App (Preview) / build-windows (pull_request) Successful in 6m4s
Build App (Preview) / prune-previews (pull_request) Successful in 4s

One gesture puts two requests in flight. With the tab already loaded, clicking
the dock toggle while the textarea has focus fires `blur` -> `saveNote` and the
dock's mount -> `list_notes` in the same tick. The save finishes and its re-read
writes the post-save list; the mount's read -- issued earlier, still out -- then
lands its pre-save snapshot on top, and both panels show stale text until
something else refreshes.

`mutationChains` could not have caught this: it orders a project's writes
against each other and the mount load is a read outside it. Putting the read on
the chain would work, but it buys correctness with latency the user feels -- a
panel mount waiting behind `save_note`'s double-fsync write -- and leaves a
"mutation chain" holding reads.

The two requests are not competing for a resource; the loser's result is simply
older. So every write into `notesByProject[p]` now claims a per-project sequence
when the request behind it is issued, and `commitNotes` drops one whose sequence
predates what is already cached. Reads take their sequence at issue time, since
being ordered by resolution is the bug. Local patches -- the filter behind a
confirmed delete, the prepend behind a failed re-read -- take a fresh one at
commit time, because they are authoritative then rather than derived from an
earlier read, and anything still in flight behind them is genuinely stale. A
failed read commits under its *own* sequence, not a fresh one, so its empty list
cannot beat a later read that has the real answer.

`isCurrent()` stays, and is not folded in. It guards `setSaveState`, not the
cache: it asks whether this *panel* is still showing the project a save was made
for, which a per-project counter cannot answer -- two panels on one project share
every sequence value. Ordering and panel identity are two questions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
This commit is contained in:
2026-09-01 14:23:55 -07:00
co-authored by Claude Opus 5
parent 436b6dd470
commit 2708772bf9
2 changed files with 163 additions and 7 deletions
+74
View File
@@ -369,4 +369,78 @@ describe("useNotes", () => {
expect(dock.result.current.loading).toBe(false); expect(dock.result.current.loading).toBe(false);
expect(dock.result.current.notes).toHaveLength(1); expect(dock.result.current.notes).toHaveLength(1);
}); });
it("does not let a slow mount read overwrite a fresher post-save refresh", async () => {
// One gesture, two requests. The tab is already loaded; the user clicks the
// dock toggle with the textarea focused, so `blur` → `saveNote` and the
// dock's mount → `list_notes` are issued in the same tick. The save's
// re-read writes the post-save list; the mount's read — issued earlier,
// still in flight — must not then land its pre-save snapshot on top of it.
const files = fakeBackend({ p1: [note({ body: "before" })] });
const tab = renderHook(() => useNotes("p1"));
await waitFor(() => expect(tab.result.current.loading).toBe(false));
expect(tab.result.current.notes[0].body).toBe("before");
// The dock's mount read: it snapshots the list as it is *now* (pre-save)
// and hangs, standing in for a plain read that is slower than
// `save_note`'s double-fsync write plus the re-read behind it.
let releaseMountRead: (() => void) | undefined;
listNotes.mockImplementationOnce(async (p: string) => {
const preSave = [...(files[p] ?? [])];
await new Promise<void>((resolve) => {
releaseMountRead = resolve;
});
return preSave;
});
const dock = renderHook(() => useNotes("p1"));
expect(releaseMountRead).toBeDefined();
// The save and its re-read complete while that read is still out.
await act(async () => {
await tab.result.current.saveNote(note({ body: "after" }));
});
expect(tab.result.current.notes[0].body).toBe("after");
// Now the stale read lands.
await act(async () => {
releaseMountRead!();
await Promise.resolve();
});
expect(tab.result.current.notes[0].body).toBe("after");
expect(dock.result.current.notes[0].body).toBe("after");
});
it("does not let a slow mount read resurrect a note deleted while it was in flight", async () => {
// The other half of the same ordering rule: a confirmed delete is newer
// than any read issued before it finished, so the read's pre-delete list
// must not be written back over the shortened one.
const files = fakeBackend({ p1: [note()] });
const tab = renderHook(() => useNotes("p1"));
await waitFor(() => expect(tab.result.current.loading).toBe(false));
let releaseMountRead: (() => void) | undefined;
listNotes.mockImplementationOnce(async (p: string) => {
const preDelete = [...(files[p] ?? [])];
await new Promise<void>((resolve) => {
releaseMountRead = resolve;
});
return preDelete;
});
const dock = renderHook(() => useNotes("p1"));
expect(releaseMountRead).toBeDefined();
await act(async () => {
await tab.result.current.deleteNote("n1");
});
expect(tab.result.current.notes).toHaveLength(0);
await act(async () => {
releaseMountRead!();
await Promise.resolve();
});
expect(tab.result.current.notes).toHaveLength(0);
expect(dock.result.current.notes).toHaveLength(0);
});
}); });
+89 -7
View File
@@ -56,6 +56,78 @@ function enqueueMutation<T>(projectId: string, run: () => Promise<T>): Promise<T
return result; return result;
} }
/**
* Per-project write ordering for the shared notes cache.
*
* `mutationChains` orders a project's *writes* against each other. It says
* nothing about reads, and the mount load is a read that runs outside it — so
* one gesture can put two requests in flight at once and let the slower one
* win. Clicking the dock toggle with the textarea focused fires `blur` →
* `saveNote` and the dock's mount → `list_notes` in the same tick: the save
* finishes, its re-read writes the post-save list, and then the mount's read —
* issued earlier, still out — lands its pre-save snapshot on top. Both panels
* show stale text until something else refreshes. It needs the plain read to
* be slower than `save_note`'s double-fsync write plus a second read, so it is
* narrow, but it was reproduced.
*
* The fix is a sequence number rather than a chain, because the two requests
* are not competing for a resource — the loser's result is simply *older*, and
* the cheapest correct thing to do with it is throw it away. Every write
* claims a sequence when the request behind it is issued, and `commitNotes`
* drops one whose sequence predates what is already cached. That also closes a
* hole identity comparison cannot: on a `p1 → p2 → p1` switch a read from the
* *first* p1 era is indistinguishable from a current one by project id, and
* would land its stale list on the second era's.
*
* Note what this deliberately does **not** replace. `isCurrent()` asks whether
* this *panel* is still showing the project a save was made for, which governs
* a per-panel `SaveIndicator` and not the shared cache at all; a per-project
* counter cannot answer it. Ordering and panel identity are two questions, and
* they keep two guards.
*
* Entries are two integers per project and are never pruned: they must outlive
* every request that could still land, and the map is monotone, so a stale
* sequence can never be reissued.
*/
const notesSequences = new Map<string, { issued: number; committed: number }>();
function sequenceFor(projectId: string): { issued: number; committed: number } {
let seq = notesSequences.get(projectId);
if (!seq) notesSequences.set(projectId, (seq = { issued: 0, committed: 0 }));
return seq;
}
/**
* Claim the sequence for a write about to be issued.
*
* Called immediately before the request whose result it will commit, so that
* ordering is by *issue* time. Resolution order is exactly what cannot be
* trusted here.
*/
function issueNotesWrite(projectId: string): number {
const seq = sequenceFor(projectId);
seq.issued += 1;
return seq.issued;
}
/**
* Write a list into the cache under the sequence it was issued at, unless
* something newer has already been committed.
*
* A local patch — the filter behind a confirmed delete, say — is authoritative
* at the moment it applies rather than derived from an earlier read, so it
* claims its sequence here: `issued` is never below `committed`, so a freshly
* claimed one always wins, and anything still in flight behind it is correctly
* treated as stale.
*/
function commitNotes(projectId: string, seq: number, notes: Note[]): boolean {
const sequence = sequenceFor(projectId);
if (seq <= sequence.committed) return false;
sequence.committed = seq;
useAppState.getState().setProjectNotes(projectId, notes);
return true;
}
/** /**
* Re-read the canonical list into the shared cache. * Re-read the canonical list into the shared cache.
* *
@@ -63,11 +135,16 @@ function enqueueMutation<T>(projectId: string, run: () => Promise<T>): Promise<T
* the record's position has changed and positional patching would disagree * the record's position has changed and positional patching would disagree
* with what a reload would show. The backend owns the order; the webview never * with what a reload would show. The backend owns the order; the webview never
* sorts. A failed re-read leaves the cache alone rather than clearing it. * sorts. A failed re-read leaves the cache alone rather than clearing it.
*
* `true` means "the cache is current", which is why a superseded commit still
* returns it: whatever beat this read was issued later and therefore read the
* same write or a later one.
*/ */
async function refresh(projectId: string): Promise<boolean> { async function refresh(projectId: string): Promise<boolean> {
const seq = issueNotesWrite(projectId);
try { try {
const reloaded = await commands.listNotes(projectId); const reloaded = await commands.listNotes(projectId);
useAppState.getState().setProjectNotes(projectId, reloaded); commitNotes(projectId, seq, reloaded);
return true; return true;
} catch { } catch {
return false; return false;
@@ -104,10 +181,11 @@ export function useNotes(projectId: string) {
const store = useAppState.getState(); const store = useAppState.getState();
if (store.notesLoading[projectId]) return; if (store.notesLoading[projectId]) return;
store.setNotesLoading(projectId, true); store.setNotesLoading(projectId, true);
const seq = issueNotesWrite(projectId);
commands commands
.listNotes(projectId) .listNotes(projectId)
.then((loaded) => { .then((loaded) => {
useAppState.getState().setProjectNotes(projectId, loaded); commitNotes(projectId, seq, loaded);
}) })
.catch((e) => { .catch((e) => {
// A project that has never been read caches the empty list, so a panel // A project that has never been read caches the empty list, so a panel
@@ -117,9 +195,12 @@ export function useNotes(projectId: string) {
// must not blank both of them. Same rule as `refresh()`. Neither // must not blank both of them. Same rule as `refresh()`. Neither
// branch has a stale-project hazard, because the write is keyed by the // branch has a stale-project hazard, because the write is keyed by the
// project it belongs to. // project it belongs to.
const store = useAppState.getState(); //
if (store.notesByProject[projectId] === undefined) { // The commit goes under this read's own sequence, not a fresh one: a
store.setProjectNotes(projectId, []); // *later* read still in flight has the newer answer and must not be
// dropped in favour of this failure's empty list.
if (useAppState.getState().notesByProject[projectId] === undefined) {
commitNotes(projectId, seq, []);
} }
pushToast({ pushToast({
kind: "error", kind: "error",
@@ -216,7 +297,7 @@ export function useNotes(projectId: string) {
// The note exists; only the re-read failed. Show it rather than // The note exists; only the re-read failed. Show it rather than
// leaving the user with a button that did nothing visible. // leaving the user with a button that did nothing visible.
const store = useAppState.getState(); const store = useAppState.getState();
store.setProjectNotes(projectId, [ commitNotes(projectId, issueNotesWrite(projectId), [
saved, saved,
...(store.notesByProject[projectId] ?? []), ...(store.notesByProject[projectId] ?? []),
]); ]);
@@ -240,8 +321,9 @@ export function useNotes(projectId: string) {
try { try {
await commands.deleteNote(projectId, noteId); await commands.deleteNote(projectId, noteId);
const store = useAppState.getState(); const store = useAppState.getState();
store.setProjectNotes( commitNotes(
projectId, projectId,
issueNotesWrite(projectId),
(store.notesByProject[projectId] ?? []).filter((n) => n.id !== noteId), (store.notesByProject[projectId] ?? []).filter((n) => n.id !== noteId),
); );
return true; return true;