Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/specs/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
33 changes: 33 additions & 0 deletions lib/src/components/Wall.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,39 @@ describe('Wall on the Lath engine', () => {
expect(previousWorkspaceSession(DEFAULT_WORKSPACE_ID)?.panes.find(p => p.id === 'surface:a')?.cwd).toBe('/fresh');
});

it('honors a no-probe flush queued behind a probing save', async () => {
await act(async () => root.render(<Wall initialPaneIds={['surface:a']} initialMode="command" />));
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(<Wall initialPaneIds={['surface:a']} initialMode="command" />));
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 () => {
Expand Down
17 changes: 8 additions & 9 deletions lib/src/components/wall/use-session-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export function useSessionPersistence({
}), [effectiveWorkspaceId]);
const sessionSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const sessionSavePromiseRef = useRef<Promise<void> | 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
Expand Down Expand Up @@ -179,27 +179,26 @@ export function useSessionPersistence({

const persistSessionNow = useCallback(async (options?: SaveOptions): Promise<void> => {
const runSave = (): Promise<void> => {
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
Expand Down
Loading