From ef4e9b3ab92150c92bc1bf935f520cd4c9597364 Mon Sep 17 00:00:00 2001 From: Ned Date: Wed, 21 Oct 2026 03:05:47 -0700 Subject: [PATCH] Honor the latest queued session flush options --- docs/specs/transport.md | 2 +- lib/src/components/Wall.test.tsx | 33 +++++++++++++++++++ .../wall/use-session-persistence.ts | 17 +++++----- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 8cb826b60..db0ae9c01 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -205,7 +205,7 @@ Carriers: the `boot_reserve_ids` and `surface_reserve_ids` invokes (standalone); **The Window wrapping lives at the standalone adapter boundary, never in the shared save/restore code**, which still operates on a bare `PersistedSession` per Workspace (`docs/specs/standalone.md` → Persistence). **A Window-persisting adapter answers through `getWindowState` / `saveWindowState`, and answers nothing on the bare-Session `getState` / `saveState` pair** — its blob is a Window and every shared reader of `getState` wants a Session. -**A save probes every non-browser pane's cwd in one host round trip where the adapter offers `getCwds`, and every save landing in one microtask shares that round trip**, falling back to one `getCwd` per id (rationale). **A flush may say `probeCwd: false`** and keep each pane's previously persisted cwd — the post-kill quit flush (`docs/specs/standalone.md` → "Quit flow"). +**A save probes every non-browser pane's cwd in one host round trip where the adapter offers `getCwds`, and every save landing in one microtask shares that round trip**, falling back to one `getCwd` per id (rationale). **Must retain the latest request's options when coalescing queued saves.** **A flush may say `probeCwd: false`** and keep each pane's previously persisted cwd — the post-kill quit flush (`docs/specs/standalone.md` → "Quit flow"). **`PersistedSession.version` is 4 and `PersistedWindow.version` 2; a reader must discard any other version as a fresh start, never migrate it.** Every writer stamps both, pinned by `standalone/scripts/persisted-format.json`; the standalone Rust host's discard: `docs/specs/standalone.md` → Persistence. diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index 98f17b03a..0dcb3140e 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -340,6 +340,39 @@ describe('Wall on the Lath engine', () => { expect(Object.keys(saved!.lathLayout!.leafMeta ?? {})).toContain('surface:a'); }); + it('honors a no-probe flush queued behind a probing save', async () => { + await act(async () => root.render()); + await flush(); + const handle = getWallHandle(DEFAULT_WORKSPACE_ID)!; + await act(async () => { await handle.flushPersistence(); }); + let resolveCwd!: (cwd: string) => void; + const probe = vi.spyOn(fake, 'getCwd').mockResolvedValue('/unexpected') + .mockImplementationOnce(() => new Promise((resolve) => { resolveCwd = resolve; })); + await act(async () => { + const first = handle.flushPersistence(); + const last = handle.flushPersistence({ probeCwd: false }); + resolveCwd('/retained'); + await Promise.all([first, last]); + }); + expect(probe).toHaveBeenCalledTimes(1); + expect((fake.getState() as PersistedSession).panes[0].cwd).toBe('/retained'); + }); + + it('probes for a normal flush queued behind a no-probe save', async () => { + await act(async () => root.render()); + await flush(); + const handle = getWallHandle(DEFAULT_WORKSPACE_ID)!; + await act(async () => { await handle.flushPersistence(); }); + const probe = vi.spyOn(fake, 'getCwd').mockResolvedValue('/fresh'); + await act(async () => { + const first = handle.flushPersistence({ probeCwd: false }); + const last = handle.flushPersistence(); + await Promise.all([first, last]); + }); + expect(probe).toHaveBeenCalledTimes(1); + expect((fake.getState() as PersistedSession).panes[0].cwd).toBe('/fresh'); + }); + it('manual keyboard splits enter passthrough on the new pane immediately', async () => { const onEvent = vi.fn(); await act(async () => { diff --git a/lib/src/components/wall/use-session-persistence.ts b/lib/src/components/wall/use-session-persistence.ts index 6734b1798..85cb95186 100644 --- a/lib/src/components/wall/use-session-persistence.ts +++ b/lib/src/components/wall/use-session-persistence.ts @@ -85,7 +85,7 @@ export function useSessionPersistence({ }), [effectiveWorkspaceId]); const sessionSaveTimerRef = useRef | null>(null); const sessionSavePromiseRef = useRef | null>(null); - const pendingSaveNeededRef = useRef(false); + const pendingSaveRef = useRef<{ options?: SaveOptions } | null>(null); // See session-dirty.ts for the conservative-under-races generation model. const trackerRef = useRef(createSessionDirtyTracker()); // This Workspace's own previous record, which is where a dead PTY's retained @@ -181,27 +181,26 @@ export function useSessionPersistence({ const persistSessionNow = useCallback(async (options?: SaveOptions): Promise => { const runSave = (): Promise => { - pendingSaveNeededRef.current = false; + const request = pendingSaveRef.current; + pendingSaveRef.current = null; // Clear dirty only on a fulfilled write (.then, not .finally). const token = trackerRef.current.beginSave(); - const savePromise = doSave(options) + const savePromise = doSave(request?.options) .then(() => { trackerRef.current.completeSave(token); }) .finally(() => { if (sessionSavePromiseRef.current === savePromise) { - sessionSavePromiseRef.current = pendingSaveNeededRef.current ? runSave() : null; + sessionSavePromiseRef.current = pendingSaveRef.current ? runSave() : null; } }); sessionSavePromiseRef.current = savePromise; return savePromise; }; - if (sessionSavePromiseRef.current) { - pendingSaveNeededRef.current = true; - } else { - runSave(); - } + // Coalesce requests, retaining the latest options even when a save is busy. + pendingSaveRef.current = { options }; + if (!sessionSavePromiseRef.current) runSave(); // Await until the pipeline idles so the resolution covers the LATEST queued // save, not just the one in flight. Swallow a per-save rejection here: a // failed save still chains its queued follow-up (via `.finally`), so