Skip to content

Commit 856d4ba

Browse files
authored
[codex] Normalize persisted session JSON strings (#51)
2 parents 6341dac + 7a32746 commit 856d4ba

4 files changed

Lines changed: 42 additions & 4 deletions

File tree

docs/specs/layout.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,8 @@ Pane IDs are session IDs. `TerminalPane` calls `getOrCreateTerminal(id)` on Reac
271271

272272
Layout, scrollback, cwd, minimized items, and alert state are saved to persistent storage via a debounced save (500ms). Saves are triggered by layout changes, panel add/remove, and a 30s periodic interval. Saves are flushed immediately on PTY exit, `pagehide`, and extension shutdown requests.
273273

274+
Saved snapshots are read through `readPersistedSession()`, which accepts the canonical object shape and defensively parses a JSON-stringified blob before validation and migration. This keeps malformed storage inert while covering hosts that hand back serialized JSON instead of the parsed object.
275+
274276
On startup, recovery is priority-based:
275277
1. **Resume** (webview hidden/shown, live PTYs): request PTY list + replay data from platform, `resumeTerminal()` for each (500ms timeout). If the saved session covers every live PTY, restore the saved dockview layout when its visible panel set matches and reattach saved minimized items as doors. This still counts as a live resume when every live session is minimized, so recovery must not fall through to cold restore just because the visible `paneIds` list is empty.
276278
2. **Restore** (app restart, cold start): restore layout from serialized dockview state, `restoreTerminal()` for each pane with saved cwd + scrollback, and spawn each PTY with the current default shell selection

docs/specs/vscode.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,8 @@ interface PersistedDoor {
244244
5. Graceful shutdown: save state -> SIGTERM -> 2s wait -> force kill
245245
6. On activate: saved state loaded and passed to routers for cold-start restore
246246

247+
Every saved-session entry point must pass through `readPersistedSession()`. That reader accepts both the canonical parsed object and a JSON-stringified session blob before validating/migrating, which covers VS Code state APIs that may hand back the inner serialized JSON string.
248+
247249
### Theme integration
248250

249251
Two-layer CSS variable system: VS Code injects `--vscode-*` tokens; `lib/src/theme.css` maps them directly to semantic `--color-*` tokens for use in Tailwind utility classes. The webview entry point installs `installVscodeThemeVarResolver()` before React renders. That resolver reads VSCode-provided variables, materializes only missing MouseTerm-consumed variables on `body.style`, and watches `body`/`html` class and style mutations so theme changes recompute those materialized values.

lib/src/lib/session-migration.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,28 @@ describe('readPersistedSession', () => {
201201
expect(readPersistedSession(v3)).toBe(v3);
202202
});
203203

204+
it('reads a JSON-stringified v3 blob', () => {
205+
const v3 = {
206+
version: 3 as const,
207+
layout: null,
208+
panes: [{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: 'saved output', resumeCommand: null }],
209+
doors: [],
210+
};
211+
212+
expect(readPersistedSession(JSON.stringify(v3))).toEqual(v3);
213+
});
214+
215+
it('decodes escaped control bytes in JSON-stringified scrollback', () => {
216+
const v3 = {
217+
version: 3 as const,
218+
layout: null,
219+
panes: [{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: '\u001b[31mred', resumeCommand: null }],
220+
doors: [],
221+
};
222+
223+
expect(readPersistedSession(JSON.stringify(v3))?.panes[0].scrollback).toBe('\x1b[31mred');
224+
});
225+
204226
it('migrates a v2 blob on read (numeric TODO → boolean)', () => {
205227
const v2 = {
206228
version: 2 as const,
@@ -254,6 +276,8 @@ describe('readPersistedSession', () => {
254276
expect(readPersistedSession(undefined)).toBeNull();
255277
expect(readPersistedSession({ version: 99 })).toBeNull();
256278
expect(readPersistedSession('not an object')).toBeNull();
279+
expect(readPersistedSession('{')).toBeNull();
280+
expect(readPersistedSession(JSON.stringify({ version: 99 }))).toBeNull();
257281
expect(readPersistedSession({ version: 2, layout: null, panes: 'nope' })).toBeNull();
258282
expect(readPersistedSession({ version: 2, layout: null, panes: [], doors: {} })).toBeNull();
259283
expect(readPersistedSession({ version: 1, layout: null, panes: [] as const, detached: {} })).toBeNull();

lib/src/lib/session-types.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,9 +191,19 @@ export function migrateSessionV2toV3(v2: PersistedSessionV2): PersistedSession {
191191
}
192192

193193
export function readPersistedSession(raw: unknown): PersistedSession | null {
194-
if (!isRecord(raw)) return null;
195-
if (isPersistedSessionV3(raw)) return raw;
196-
if (isPersistedSessionV2(raw)) return migrateSessionV2toV3(raw);
197-
if (isPersistedSessionV1(raw)) return migrateSessionV2toV3(migrateSessionV1toV2(raw));
194+
const value = parseJsonString(raw);
195+
if (!isRecord(value)) return null;
196+
if (isPersistedSessionV3(value)) return value;
197+
if (isPersistedSessionV2(value)) return migrateSessionV2toV3(value);
198+
if (isPersistedSessionV1(value)) return migrateSessionV2toV3(migrateSessionV1toV2(value));
198199
return null;
199200
}
201+
202+
function parseJsonString(raw: unknown): unknown {
203+
if (typeof raw !== 'string') return raw;
204+
try {
205+
return JSON.parse(raw);
206+
} catch {
207+
return raw;
208+
}
209+
}

0 commit comments

Comments
 (0)