Compare commits

...
5 changed files with 813 additions and 31 deletions

No files matched your search

@@ -25,7 +25,7 @@ test("close-path connection log updates await the indexedDB persist task", () =>
source,
/const updateConnectionLog = useCallback\([\s\S]*?return commitConnectionLogs\(/,
);
assert.match(source, /rebaseQueuedConnectionLogs\(\s*next,\s*removedIds,\s*changedIds,/);
assert.match(source, /rebaseQueuedConnectionLogs\(\s*memory,\s*removedIds,\s*changedIds,/);
assert.match(
source,
/if \(!options\?\.pruneMainBlob\) \{[\s\S]*?rebaseQueuedConnectionLogs\(/,
+107 -30
View File
@@ -62,7 +62,8 @@ import {
} from "../../domain/globalHistory";
import {
buildTerminalDataMapFromLogs,
mergeConnectionLogsFromStorage,
acknowledgePendingConnectionLogWrites,
mergeConnectionLogsWithPendingMutations,
mergeTerminalDataIntoLogs,
mergeTerminalDataMapsForStorage,
pruneInMemoryUnsavedTerminalData,
@@ -70,6 +71,7 @@ import {
retainNewestUnsavedReplays,
pruneTerminalDataMapForStorage,
type ConnectionLogTerminalDataMap,
type PendingConnectionLogWrite,
} from "../../domain/connectionLogTerminalData";
import {
tryLoadAll as tryLoadConnectionLogTerminalData,
@@ -429,6 +431,8 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
const proxyProfilesReadSeq = useRef(0);
const groupConfigsReadSeq = useRef(0);
const connectionLogsRef = useRef<ConnectionLog[]>([]);
const connectionLogWriteGenerationRef = useRef(0);
const connectionLogPendingRef = useRef(new Map<string, PendingConnectionLogWrite>());
const connectionLogTerminalDataRef = useRef<ConnectionLogTerminalDataMap>({});
const connectionLogTerminalDataPersistedRef = useRef<ConnectionLogTerminalDataMap>({});
const connectionLogTerminalDataRevRawRef = useRef<string | null>(null);
@@ -518,17 +522,23 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
const unsavedTerminalDataPending = logs.some(
(log) => !log.saved && log.terminalData !== undefined,
);
let mainReplay: ConnectionLogTerminalDataMap = {};
const writeMain = (value: ConnectionLog[]) => {
const written = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, value);
if (written) mainReplay = buildTerminalDataMapFromLogs(value);
return written;
};
let mainPersisted = true;
if (options?.pruneMainBlob) {
const prunedMain = pruneConnectionLogsForStorage(logs);
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, prunedMain);
mainPersisted = writeMain(prunedMain);
if (!mainPersisted && unsavedTerminalDataPending) {
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, logs);
mainPersisted = writeMain(logs);
}
}
let sidePersisted = true;
let sidePersisted = false;
if (mainPersisted || !options?.pruneMainBlob) {
sidePersisted = await syncConnectionLogTerminalDataMap(logs, {
usePersistedLogIdsFromDisk: Boolean(options?.pruneMainBlob && mainPersisted),
@@ -545,11 +555,16 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
logs,
options?.removedIds ?? new Set<string>(),
options?.changedIds ?? new Set<string>(),
connectionLogsRef.current,
mergeConnectionLogsWithPendingMutations(
connectionLogsRef.current,
freshLogs,
connectionLogTerminalDataPersistedRef.current,
connectionLogPendingRef.current,
),
freshLogs,
)
: logs;
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, toWrite);
mainPersisted = writeMain(toWrite);
} else if (!sidePersisted && unsavedTerminalDataPending && mainPersisted) {
// IndexedDB rejected the replay after the pruned metadata write. Put the
// buffers back on the current main blob so a restart still has them,
@@ -560,12 +575,12 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
let fallbackLogs = freshLogs
? mergeTerminalDataIntoLogs(freshLogs, buildTerminalDataMapFromLogs(logs))
: logs;
sidePersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, fallbackLogs);
sidePersisted = writeMain(fallbackLogs);
let keep = fallbackLogs.filter((log) => !log.saved && log.terminalData).length;
while (!sidePersisted && keep > 1) {
keep = Math.max(1, Math.floor(keep / 2));
fallbackLogs = retainNewestUnsavedReplays(fallbackLogs, keep);
sidePersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, fallbackLogs);
sidePersisted = writeMain(fallbackLogs);
}
}
@@ -578,7 +593,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
"[useVaultState] Failed to persist connection log terminal replay data side store.",
);
}
return mainPersisted && sidePersisted;
return { metadataPersisted: mainPersisted, replayPersisted: sidePersisted, mainReplay };
}, [syncConnectionLogTerminalDataMap]);
const commitConnectionLogs = useCallback((
@@ -589,28 +604,81 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
const next = recipe(prev);
const prevById = new Map(prev.map((log) => [log.id, log]));
const nextIds = new Set<string>();
const changedIds = new Set<string>();
const generation = ++connectionLogWriteGenerationRef.current;
for (const log of next) {
nextIds.add(log.id);
const before = prevById.get(log.id);
if (!before || before !== log) changedIds.add(log.id);
if (!before || before !== log) {
const previous = connectionLogPendingRef.current.get(log.id);
const changes: Partial<ConnectionLog> = { ...previous?.changes };
const fieldGenerations = { ...previous?.fieldGenerations };
for (const field of Object.keys({ ...before, ...log }) as Array<keyof ConnectionLog>) {
if (!before || before[field] !== log[field]) {
Object.assign(changes, { [field]: log[field] });
fieldGenerations[field] = generation;
}
}
connectionLogPendingRef.current.set(log.id, {
generation, log, changes, fieldGenerations, added: previous?.added ?? !before,
});
}
}
for (const log of prev) {
if (!nextIds.has(log.id)) {
connectionLogPendingRef.current.set(log.id, { generation, log: null });
}
}
const removedIds = new Set(prev.filter((log) => !nextIds.has(log.id)).map((log) => log.id));
connectionLogsRef.current = next;
setConnectionLogs(next);
// The task may run after another window writes. Rebase onto the logs that
// exist then. Restore only ids this recipe added or replaced.
return enqueueTerminalDataTask(() => persistConnectionLogState(
rebaseQueuedConnectionLogs(
next,
removedIds,
changedIds,
return enqueueTerminalDataTask(() => withVaultImportLock("connection-logs", async () => {
// The shared lock covers the read/rebase as well as both storage writes.
// Include uncommitted generations from failed or newer queued captures.
await absorbRemoteTerminalDataIfNeeded();
const pending = new Map(connectionLogPendingRef.current);
const removedIds = new Set<string>();
const changedIds = new Set<string>();
for (const [id, mutation] of pending) {
if (mutation.log === null) removedIds.add(id);
else changedIds.add(id);
}
const storedBefore = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
const memory = mergeConnectionLogsWithPendingMutations(
connectionLogsRef.current,
localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS),
),
{ ...options, removedIds, changedIds },
));
}, [enqueueTerminalDataTask, persistConnectionLogState]);
storedBefore ?? connectionLogsRef.current,
connectionLogTerminalDataPersistedRef.current,
pending,
);
const persisted = await persistConnectionLogState(
rebaseQueuedConnectionLogs(
memory,
removedIds,
changedIds,
memory,
storedBefore,
),
{ ...options, removedIds, changedIds },
);
// A replay/quota failure must not retain already-committed add or
// metadata ownership: retrying it could undo a peer bookmark/deletion.
connectionLogPendingRef.current = acknowledgePendingConnectionLogWrites(
connectionLogPendingRef.current, pending, persisted,
);
if (persisted.metadataPersisted || persisted.replayPersisted) {
const stored = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
if (stored) {
const merged = mergeConnectionLogsWithPendingMutations(
connectionLogsRef.current,
stored,
connectionLogTerminalDataRef.current,
connectionLogPendingRef.current,
);
connectionLogsRef.current = merged;
setConnectionLogs(merged);
}
}
return persisted.metadataPersisted && persisted.replayPersisted;
}));
}, [absorbRemoteTerminalDataIfNeeded, enqueueTerminalDataTask, persistConnectionLogState]);
const applyConnectionLogsFromStorage = useCallback((
prev: ConnectionLog[],
@@ -618,7 +686,12 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
terminalDataMap: ConnectionLogTerminalDataMap,
) => {
connectionLogTerminalDataRef.current = terminalDataMap;
return mergeConnectionLogsFromStorage(prev, storedLogs, terminalDataMap);
return mergeConnectionLogsWithPendingMutations(
prev,
storedLogs,
terminalDataMap,
connectionLogPendingRef.current,
);
}, []);
// Encrypt outside the lock, then under the lock prune script bindings against
@@ -2077,7 +2150,10 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
}
if (key === STORAGE_KEY_CONNECTION_LOGS) {
const next = safeParse<ConnectionLog[]>(event.newValue) ?? [];
// A queued peer event may predate our successful commit. Read the
// current blob, then preserve only still-owned local generations.
const next = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
if (!next) return;
const merged = applyConnectionLogsFromStorage(
connectionLogsRef.current,
next,
@@ -2123,7 +2199,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
if (!syncConnectionLogTerminalData) return;
if (raw === connectionLogTerminalDataRevRawRef.current) return;
const seq = ++connectionLogTerminalDataLoadSeq.current;
void terminalDataReadyRef.current.then(() => enqueueTerminalDataTask(async () => {
void terminalDataReadyRef.current.then(() => enqueueTerminalDataTask(() => withVaultImportLock("connection-logs", async () => {
if (seq !== connectionLogTerminalDataLoadSeq.current) return;
const map = await loadConnectionLogTerminalData();
if (seq !== connectionLogTerminalDataLoadSeq.current) return;
@@ -2133,14 +2209,15 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
);
connectionLogTerminalDataPersistedRef.current = map;
connectionLogTerminalDataRef.current = map;
const merged = mergeConnectionLogsFromStorage(
connectionLogsRef.current,
const merged = mergeConnectionLogsWithPendingMutations(
connectionLogsRef.current,
localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? connectionLogsRef.current,
map,
connectionLogPendingRef.current,
);
connectionLogsRef.current = merged;
setConnectionLogs(merged);
}));
})));
};
const handleLocalStorageAdapterChanged = (event: Event) => {
+197
View File
@@ -5,14 +5,17 @@ import type { ConnectionLog } from "./models.ts";
import { selectConnectionLogForTerminalDataCapture } from "./connectionLog.ts";
import {
MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES,
acknowledgePendingConnectionLogWrites,
diffTerminalDataMaps,
mergeConnectionLogsFromStorage,
mergeConnectionLogsWithPendingMutations,
mergeTerminalDataIntoLogs,
mergeTerminalDataMapsForStorage,
pruneInMemoryUnsavedTerminalData,
pruneTerminalDataMapForStorage,
rebaseQueuedConnectionLogs,
retainNewestUnsavedReplays,
type PendingConnectionLogWrite,
} from "./connectionLogTerminalData.ts";
const baseLog: ConnectionLog = {
@@ -29,6 +32,200 @@ const baseLog: ConnectionLog = {
saved: false,
};
test("pending capture survives a peer snapshot and queued persistence rebase", () => {
const local = { ...baseLog, id: "local", startTime: 3000, terminalData: "capture" };
const peer = { ...baseLog, id: "peer", startTime: 2000 };
const memory = mergeConnectionLogsWithPendingMutations(
[local, baseLog], [peer, baseLog], {}, new Map([[local.id, { log: local, changes: local, added: true }]]),
);
const persisted = rebaseQueuedConnectionLogs(
[local, baseLog], new Set(), new Set([local.id]), memory, [peer, baseLog],
);
assert.deepEqual(persisted.map((log) => log.id), ["local", "peer", "log-base"]);
assert.equal(persisted[0].terminalData, "capture");
});
test("pending tombstone wins over a stale peer snapshot without deleting peer-only logs", () => {
const peer = { ...baseLog, id: "peer" };
const merged = mergeConnectionLogsWithPendingMutations(
[], [baseLog, peer], {}, new Map([[baseLog.id, { log: null }]]),
);
assert.deepEqual(merged, [peer]);
});
test("unrelated pending mutation never resurrects a remotely deleted memory row", () => {
const local = { ...baseLog, id: "local", terminalData: "capture" };
const deleted = { ...baseLog, id: "remote-deleted", terminalData: "old" };
const merged = mergeConnectionLogsWithPendingMutations(
[local, deleted], [], {}, new Map([[local.id, { log: local, changes: local, added: true }]]),
);
assert.deepEqual(merged, [local]);
});
test("a pending final capture does not resurrect an existing log a peer deleted", () => {
const captured = { ...baseLog, terminalData: "final capture" };
const merged = mergeConnectionLogsWithPendingMutations(
[captured], [], {},
new Map([[baseLog.id, { log: captured, changes: { terminalData: "final capture" }, added: false }]]),
);
assert.deepEqual(merged, []);
});
test("pending capture preserves a peer bookmark and pending bookmark preserves a peer capture", () => {
const captured = { ...baseLog, terminalData: "new capture", endTime: 2000 };
const bookmarked = { ...baseLog, saved: true };
const captureMerged = mergeConnectionLogsWithPendingMutations(
[captured], [bookmarked], {},
new Map([[baseLog.id, { log: captured, changes: { terminalData: "new capture", endTime: 2000 } }]]),
);
assert.equal(captureMerged[0].saved, true);
assert.equal(captureMerged[0].terminalData, "new capture");
const oldBookmark = { ...bookmarked, terminalData: "old capture" };
const bookmarkMerged = mergeConnectionLogsWithPendingMutations(
[oldBookmark], [captured], {}, new Map([[baseLog.id, { log: oldBookmark, changes: { saved: true } }]]),
);
assert.equal(bookmarkMerged[0].saved, true);
assert.equal(bookmarkMerged[0].terminalData, "new capture");
const sideStoreMerged = mergeConnectionLogsWithPendingMutations(
[oldBookmark], [baseLog], { [baseLog.id]: "new capture" },
new Map([[baseLog.id, { log: oldBookmark, changes: { saved: true } }]]),
);
assert.equal(sideStoreMerged[0].terminalData, "new capture");
});
test("pending replay overlay retains the newest 50 unsaved buffers and all saved buffers", () => {
const logs = Array.from({ length: 65 }, (_, i) => ({
...baseLog, id: `log-${i}`, startTime: i, terminalData: `data-${i}`,
}));
const saved = { ...baseLog, id: "saved", startTime: -1, saved: true, terminalData: "saved data" };
const merged = mergeConnectionLogsWithPendingMutations(
[], [...logs, saved], {}, new Map(logs.map((log) => [log.id, { log, changes: log }])),
);
assert.equal(merged.length, 66);
assert.equal(merged.filter((log) => !log.saved && log.terminalData).length, 50);
assert.equal(merged.find((log) => log.id === "log-14")?.terminalData, undefined);
assert.equal(merged.find((log) => log.id === "log-15")?.terminalData, "data-15");
assert.equal(merged.find((log) => log.id === "saved")?.terminalData, "saved data");
});
test("older successful write retires inherited fields but keeps a later capture generation", () => {
const first: PendingConnectionLogWrite = {
generation: 1, added: true, log: baseLog,
changes: { saved: false, terminalData: "first" }, fieldGenerations: { saved: 1, terminalData: 1 },
};
const later: PendingConnectionLogWrite = {
generation: 2, added: true, log: { ...baseLog, terminalData: "final" },
changes: { saved: false, terminalData: "final" }, fieldGenerations: { saved: 1, terminalData: 2 },
};
const pending = acknowledgePendingConnectionLogWrites(
new Map([[baseLog.id, later]]), new Map([[baseLog.id, first]]),
);
assert.deepEqual(pending.get(baseLog.id)?.changes, { terminalData: "final" });
assert.equal(pending.get(baseLog.id)?.added, false);
const merged = mergeConnectionLogsWithPendingMutations(
[later.log!], [{ ...baseLog, saved: true }], {}, pending,
);
assert.equal(merged[0].saved, true);
assert.equal(merged[0].terminalData, "final");
});
test("older successful capture cannot acknowledge a newer pending deletion", () => {
const first: PendingConnectionLogWrite = { generation: 1, log: baseLog };
const deleted: PendingConnectionLogWrite = { generation: 2, log: null };
const pending = acknowledgePendingConnectionLogWrites(
new Map([[baseLog.id, deleted]]), new Map([[baseLog.id, first]]),
);
assert.equal(pending.get(baseLog.id), deleted);
assert.equal(acknowledgePendingConnectionLogWrites(pending, pending).size, 0);
});
test("acknowledging one commit retains another failed or newer pending id for retry", () => {
const first: PendingConnectionLogWrite = { generation: 1, log: baseLog };
const retry: PendingConnectionLogWrite = { generation: 2, added: true, log: { ...baseLog, id: "retry" } };
const pending = acknowledgePendingConnectionLogWrites(
new Map([[baseLog.id, first], ["retry", retry]]), new Map([[baseLog.id, first]]),
);
assert.deepEqual([...pending.keys()], ["retry"]);
assert.equal(mergeConnectionLogsWithPendingMutations([], [], {}, pending)[0].id, "retry");
});
test("metadata-only success retires add ownership without discarding failed replay", () => {
const captured = { ...baseLog, terminalData: "failed replay" };
const write: PendingConnectionLogWrite = {
generation: 1, added: true, log: captured, changes: captured,
fieldGenerations: Object.fromEntries(Object.keys(captured).map((field) => [field, 1])),
};
const pending = acknowledgePendingConnectionLogWrites(
new Map([[baseLog.id, write]]), new Map([[baseLog.id, write]]),
{ metadataPersisted: true, replayPersisted: false },
);
assert.equal(pending.get(baseLog.id)?.added, false);
assert.deepEqual(pending.get(baseLog.id)?.changes, { terminalData: "failed replay" });
const bookmarked = mergeConnectionLogsWithPendingMutations(
[captured], [{ ...baseLog, saved: true }], {}, pending,
);
assert.equal(bookmarked[0].saved, true);
assert.equal(bookmarked[0].terminalData, "failed replay");
assert.deepEqual(mergeConnectionLogsWithPendingMutations([captured], [], {}, pending), []);
});
test("partial acknowledgment preserves newer metadata and replay generations", () => {
const first: PendingConnectionLogWrite = {
generation: 1, added: true, log: baseLog, changes: { saved: false, terminalData: "first" },
fieldGenerations: { saved: 1, terminalData: 1 },
};
const later: PendingConnectionLogWrite = {
generation: 2, added: true, log: { ...baseLog, saved: true, terminalData: "later" },
changes: { saved: true, terminalData: "later" }, fieldGenerations: { saved: 2, terminalData: 2 },
};
const pending = acknowledgePendingConnectionLogWrites(
new Map([[baseLog.id, later]]), new Map([[baseLog.id, first]]),
{ metadataPersisted: true, replayPersisted: false },
);
assert.equal(pending.get(baseLog.id)?.added, false);
assert.deepEqual(pending.get(baseLog.id)?.changes, later.changes);
});
test("failed metadata keeps add ownership even when the replay is durable", () => {
const captured = { ...baseLog, terminalData: "durable replay" };
const write: PendingConnectionLogWrite = {
generation: 1, added: true, log: captured, changes: captured,
fieldGenerations: Object.fromEntries(Object.keys(captured).map((field) => [field, 1])),
};
const pending = acknowledgePendingConnectionLogWrites(
new Map([[baseLog.id, write]]), new Map([[baseLog.id, write]]),
{ metadataPersisted: false, replayPersisted: true },
);
assert.equal(pending.get(baseLog.id)?.added, true);
assert.equal(pending.get(baseLog.id)?.changes?.saved, false);
assert.equal(Object.hasOwn(pending.get(baseLog.id)?.changes ?? {}, "terminalData"), false);
});
test("durable saved replay and deletion retire ownership despite a failed side store", () => {
const saved: PendingConnectionLogWrite = {
generation: 1, added: true, log: { ...baseLog, saved: true, terminalData: "saved" },
changes: { saved: true, terminalData: "saved" }, fieldGenerations: { saved: 1, terminalData: 1 },
};
const deleted: PendingConnectionLogWrite = { generation: 2, log: null };
const writes = new Map([[baseLog.id, saved], ["deleted", deleted]]);
assert.equal(acknowledgePendingConnectionLogWrites(writes, writes,
{ metadataPersisted: true, replayPersisted: false, mainReplay: { [baseLog.id]: "saved" } }).size, 0);
});
test("a stale saved flag cannot acknowledge replay omitted after a peer unbookmark", () => {
const write: PendingConnectionLogWrite = {
generation: 1, log: { ...baseLog, saved: true, terminalData: "failed final" },
changes: { terminalData: "failed final" }, fieldGenerations: { terminalData: 1 },
};
for (const mainReplay of [{}, { [baseLog.id]: "older durable bytes" }]) {
const pending = acknowledgePendingConnectionLogWrites(
new Map([[baseLog.id, write]]), new Map([[baseLog.id, write]]),
{ metadataPersisted: true, replayPersisted: false, mainReplay },
);
assert.deepEqual(pending.get(baseLog.id)?.changes, { terminalData: "failed final" });
}
});
test("selectConnectionLogForTerminalDataCapture picks the active log for a normal session exit", () => {
const matchingLog = { ...baseLog, id: "active", startTime: 2000 };
const staleLog = {
+93
View File
@@ -49,6 +49,99 @@ export const mergeConnectionLogsFromStorage = (
return changed ? merged : next;
};
/**
* A peer snapshot must not discard a local mutation that has not committed.
* Overlay only owned ids, including explicit deletion tombstones. Unowned
* memory rows stay absent when another window deletes them.
*/
export type PendingConnectionLogMutation = {
log: ConnectionLog | null;
/** Only a new local row may be inserted when disk no longer contains it. */
added?: boolean;
/** Fields actually changed here; a capture must not undo a peer bookmark. */
changes?: Partial<ConnectionLog>;
};
export type PendingConnectionLogWrite = PendingConnectionLogMutation & {
generation: number;
fieldGenerations?: Partial<Record<keyof ConnectionLog, number>>;
};
/** Retire only row/field generations whose storage write actually committed. */
export const acknowledgePendingConnectionLogWrites = (
pending: ReadonlyMap<string, PendingConnectionLogWrite>,
committed: ReadonlyMap<string, PendingConnectionLogWrite>,
durability: {
metadataPersisted: boolean;
replayPersisted: boolean;
mainReplay?: ConnectionLogTerminalDataMap;
} = { metadataPersisted: true, replayPersisted: true },
): Map<string, PendingConnectionLogWrite> => {
const remaining = new Map(pending);
for (const [id, written] of committed) {
const current = remaining.get(id);
if (!current) continue;
if (current.generation === written.generation && durability.metadataPersisted
&& (durability.replayPersisted || written.log === null)) {
remaining.delete(id);
} else if (current.log !== null && written.log !== null) {
const changes = { ...current.changes };
const fieldGenerations = { ...current.fieldGenerations };
for (const field of Object.keys(written.fieldGenerations ?? {}) as Array<keyof ConnectionLog>) {
// Use the bytes actually written, not this pending log's saved flag:
// a peer may have unbookmarked it before the snapshot was rebased.
const durable = field === "terminalData"
? durability.replayPersisted || (Object.hasOwn(durability.mainReplay ?? {}, id)
&& durability.mainReplay?.[id] === written.changes?.terminalData)
: durability.metadataPersisted;
if (durable && fieldGenerations[field] === written.fieldGenerations?.[field]) {
delete changes[field];
delete fieldGenerations[field];
}
}
const added = durability.metadataPersisted && written.added ? false : current.added;
if (Object.keys(changes).length === 0 && !added) remaining.delete(id);
else remaining.set(id, {
...current, changes, fieldGenerations, added,
});
}
}
return remaining;
};
export const mergeConnectionLogsWithPendingMutations = (
prev: ConnectionLog[],
next: ConnectionLog[],
terminalDataMap: ConnectionLogTerminalDataMap,
pending: ReadonlyMap<string, PendingConnectionLogMutation>,
): ConnectionLog[] => {
// Incoming disk/side-store bytes are authoritative unless this window owns
// a newer capture. In particular, bookmarking must not replay stale bytes
// over a final capture that a peer just committed.
const prevById = new Map(prev.map((log) => [log.id, log]));
const hydrated = next.map((log) => {
const terminalData = log.terminalData ?? terminalDataMap[log.id] ?? prevById.get(log.id)?.terminalData;
return terminalData === undefined || terminalData === log.terminalData
? log
: { ...log, terminalData };
});
if (pending.size === 0) return pruneInMemoryUnsavedTerminalData(hydrated);
const byId = new Map(hydrated.map((log) => [log.id, log]));
for (const [id, mutation] of pending) {
if (mutation.log === null) byId.delete(id);
else {
const stored = byId.get(id);
if (!stored && !mutation.added) continue;
byId.set(id, stored && mutation.changes
? { ...stored, ...mutation.changes }
: mutation.log);
}
}
return pruneInMemoryUnsavedTerminalData(
[...byId.values()].sort((a, b) => b.startTime - a.startTime),
);
};
/**
* Apply a queued connection-log snapshot when it runs, not when it was captured.
* Disk may have gained another window's logs during the wait. Keep those logs.
@@ -0,0 +1,415 @@
"use strict";
// Uses real Chromium storage and the production React hook.
// Run --check-bundle with Node to validate packaging without launching Electron.
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const esbuild = require("esbuild");
const appRoot = path.resolve(__dirname, "..");
function buildRenderer() {
return esbuild.buildSync({
stdin: {
resolveDir: appRoot, loader: "tsx", contents: `
import React from "react";
import { createRoot } from "react-dom/client";
import { useVaultState } from "./application/state/useVaultState";
import * as store from "./infrastructure/persistence/connectionLogTerminalDataStore";
import { localStorageAdapter as adapter } from "./infrastructure/persistence/localStorageAdapter";
import * as keys from "./infrastructure/config/storageKeys";
let latest: ReturnType<typeof useVaultState> | null = null;
let root: ReturnType<typeof createRoot> | null = null;
const events: string[] = [];
const errors: string[] = [];
window.addEventListener("storage", e => { if (e.key) events.push(e.key); });
window.addEventListener("error", e => errors.push(String(e.error?.stack || e.message)));
window.addEventListener("unhandledrejection", e => errors.push(String(e.reason?.stack || e.reason)));
function Fixture() { latest = useVaultState(); return React.createElement("div", null, latest.isInitialized ? "ready" : "loading"); }
export function mount() {
if (root) throw new Error("already mounted");
root = createRoot(document.getElementById("root")!);
root.render(React.createElement(Fixture));
}
export async function seed(logs: any[], legacy: Record<string, string>, existing = {}) {
if (root) throw new Error("cannot seed a mounted hook");
localStorage.clear();
if (!await store.clear()) throw new Error("real IndexedDB clear failed");
// Keep unrelated optional startup migrations empty. No desktop bridge is mocked.
for (const key of [keys.STORAGE_KEY_HOSTS, keys.STORAGE_KEY_KEYS, keys.STORAGE_KEY_SNIPPETS,
keys.STORAGE_KEY_GROUPS, keys.STORAGE_KEY_IDENTITIES, keys.STORAGE_KEY_PROXY_PROFILES,
keys.STORAGE_KEY_NOTES, keys.STORAGE_KEY_NOTE_GROUPS, keys.STORAGE_KEY_GROUP_CONFIGS]) adapter.write(key, []);
if (!adapter.write(keys.STORAGE_KEY_CONNECTION_LOGS, logs)) throw new Error("metadata seed quota");
if (!adapter.write(keys.STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, legacy)) throw new Error("legacy seed quota");
if (!await store.putMany(existing)) throw new Error("existing replay seed failed");
}
export async function migrate() {
return store.migrateLegacyConnectionLogTerminalData({
read: () => adapter.read(keys.STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA),
remove: () => adapter.remove(keys.STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA),
});
}
export async function snapshot() {
return {
initialized: latest?.isInitialized ?? false,
logs: latest?.connectionLogs ?? [],
diskLogs: adapter.read(keys.STORAGE_KEY_CONNECTION_LOGS) ?? [],
replay: await store.tryLoadAll(),
legacyPresent: localStorage.getItem(keys.STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) !== null,
rev: adapter.read(keys.STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV),
events: [...events], errors: [...errors],
native: { indexedDB: indexedDB.constructor.name, secureContext: isSecureContext, locks: typeof navigator.locks?.request },
};
}
export async function addAndCapture(log: any, terminalData: string) {
if (!latest?.isInitialized) throw new Error("hook not ready");
const id = latest.addConnectionLog(log);
if (!await latest.updateConnectionLog(id, { terminalData, endTime: log.startTime + 1 })) throw new Error("capture persistence returned false");
return id;
}
export async function failedAddAndCapture(log: any, terminalData: string) {
// Fault only this renderer's native write boundaries. Reads and metadata-only
// writes still use genuine browser storage; the production hook is unchanged.
const transaction = IDBDatabase.prototype.transaction;
const setItem = Storage.prototype.setItem;
const faults = { indexedDB: 0, replayFallback: 0 };
IDBDatabase.prototype.transaction = function (...args: any[]) {
if (this.name === store.CONNECTION_LOG_TERMINAL_DATA_DB_NAME && args[1] === "readwrite") {
faults.indexedDB++;
throw new DOMException("injected replay write failure", "QuotaExceededError");
}
return transaction.apply(this, args as Parameters<typeof transaction>);
};
Storage.prototype.setItem = function (key: string, value: string) {
if (this === localStorage && key === keys.STORAGE_KEY_CONNECTION_LOGS
&& JSON.parse(value).some((entry: any) => !entry.saved && entry.terminalData !== undefined)) {
faults.replayFallback++;
throw new DOMException("injected replay fallback failure", "QuotaExceededError");
}
return setItem.call(this, key, value);
};
try {
const id = latest!.addConnectionLog(log);
const persisted = await latest!.updateConnectionLog(id, { terminalData });
return { id, persisted, faults };
} finally {
IDBDatabase.prototype.transaction = transaction;
Storage.prototype.setItem = setItem;
}
}
export function toggleSaved(id: string) { latest!.toggleConnectionLogSaved(id); }
export function deleteLog(id: string) { latest!.deleteConnectionLog(id); }
export function clearUnsaved() { latest!.clearUnsavedConnectionLogs(); }
export async function capture(id: string, terminalData: string) {
return latest!.updateConnectionLog(id, { terminalData });
}
let releaseWrites: (() => void) | null = null;
export async function holdWrites() {
let ready!: () => void;
const started = new Promise<void>(resolve => { ready = resolve; });
const gate = new Promise<void>(resolve => { releaseWrites = resolve; });
void navigator.locks.request("netcatty:vault-import:connection-logs", async () => { ready(); await gate; });
await started;
}
export function resumeWrites() { releaseWrites?.(); releaseWrites = null; }
const pendingCaptures = new Map<string, Promise<boolean>>();
export function startCapture(id: string, terminalData: string) {
const token = crypto.randomUUID();
pendingCaptures.set(token, latest!.updateConnectionLog(id, { terminalData }));
return token;
}
export async function waitCapture(token: string) { return pendingCaptures.get(token); }
export function staleMetadataEvent(logs: any[]) {
window.dispatchEvent(new StorageEvent("storage", {
key: keys.STORAGE_KEY_CONNECTION_LOGS, newValue: JSON.stringify(logs), storageArea: localStorage,
}));
}
`,
},
bundle: true, format: "iife", globalName: "ConnectionLogAudit", platform: "browser",
target: "chrome142", write: false,
define: { "process.env.NODE_ENV": '"production"', "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
}).outputFiles[0].text;
}
if (process.argv.includes("--check-bundle")) {
const bundle = buildRenderer();
process.stdout.write(`CONNECTION_LOG_AUDIT_BUNDLE_OK ${JSON.stringify({ bytes: Buffer.byteLength(bundle) })}\n`);
} else if (!process.versions.electron) {
require("node:test")("connection-log persistence uses genuine browser IndexedDB", {
skip: "run with Electron; --check-bundle validates the renderer bundle without launching",
}, () => {});
} else {
const { app, BrowserWindow, session } = require("electron");
const http = require("node:http");
const crypto = require("node:crypto");
const temp = require("../electron/bridges/tempDirBridge.cjs");
const userData = fs.mkdtempSync(`${temp.getTempFilePath("connection-log-persistence-audit")}-`);
app.setPath("userData", userData);
app.on("window-all-closed", () => {});
const windows = new Set();
const completed = [];
let server;
let cleaned = false;
const deadline = setTimeout(() => finish(1, new Error("audit exceeded 90 seconds")), 90000);
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const sha = text => crypto.createHash("sha256").update(text).digest("hex");
const payload = label => `\x1b[31m${label}\x1b[0m\r\nNUL:\x00 UTF8:\u4f60\u597d \ud83d\udc08\r\n` + Array.from({ length: 96 }, (_, i) => `${label} row ${i} ${"x".repeat(48)}\r\n`).join("");
const log = (id, startTime, saved = false, terminalData) => ({ id, hostId: "", hostLabel: id,
hostname: "localhost", username: "fixture", protocol: "local", localUsername: "fixture",
localHostname: "fixture", startTime, endTime: startTime + 1, saved,
...(terminalData === undefined ? {} : { terminalData }), themeId: "fixture-theme", fontSize: 14 });
const call = (win, method, ...args) => win.webContents.executeJavaScript(
`ConnectionLogAudit[${JSON.stringify(method)}](...${JSON.stringify(args)})`, true,
);
async function until(label, fn, timeout = 10000) {
const end = Date.now() + timeout;
let last;
while (Date.now() < end) {
last = await fn();
if (last) return last;
await sleep(25);
}
throw new Error(`Timed out: ${label}`);
}
function pass(name, details = {}) {
completed.push({ name, ...details });
process.stdout.write(`CONNECTION_LOG_PERSISTENCE_PASS ${JSON.stringify(completed.at(-1))}\n`);
}
async function finish(code, error) {
if (cleaned) return;
cleaned = true;
clearTimeout(deadline);
if (error) {
console.error(error.stack || error);
for (const win of windows) {
if (win.isDestroyed()) continue;
try {
const state = await Promise.race([call(win, "snapshot"), sleep(1000).then(() => null)]);
if (state) console.error("CONNECTION_LOG_FAILURE_STATE", JSON.stringify({
windowId: win.id, initialized: state.initialized, errors: state.errors,
memory: state.logs.map(x => ({ id: x.id, saved: x.saved, hasReplay: Boolean(x.terminalData) })),
disk: state.diskLogs.map(x => ({ id: x.id, saved: x.saved, hasReplay: Boolean(x.terminalData) })),
replayIds: Object.keys(state.replay || {}), rev: state.rev, events: state.events.length,
}));
} catch (snapshotError) { console.error(String(snapshotError)); }
}
}
for (const win of windows) if (!win.isDestroyed()) win.destroy();
server?.close();
try { await session.defaultSession.clearStorageData(); } catch {}
try { fs.rmSync(userData, { recursive: true, force: true }); } catch (cleanupError) { console.warn(String(cleanupError)); }
if (code === 0) process.stdout.write(`CONNECTION_LOG_PERSISTENCE_OK ${JSON.stringify({ completed, scope: "real Chromium storage and production useVaultState; renderer reopen, not OS-process restart" })}\n`);
app.exit(code);
}
void app.whenReady().then(async () => {
const renderer = buildRenderer();
server = http.createServer((_request, response) => {
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
response.end("<!doctype html><html><body><div id='root'></div></body></html>");
});
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
const url = `http://127.0.0.1:${server.address().port}/fixture`;
async function makeWindow() {
const win = new BrowserWindow({ show: false, width: 640, height: 480,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } });
windows.add(win);
await win.loadURL(url);
await win.webContents.executeJavaScript(renderer);
return win;
}
async function mount(win) {
await call(win, "mount");
return until("production useVaultState initialization", async () => {
const s = await call(win, "snapshot");
if (s.errors.length) throw new Error(s.errors.join("\n"));
return s.initialized && s;
});
}
async function converged(wins, predicate, label) {
return until(label, async () => {
const states = await Promise.all(wins.map(win => call(win, "snapshot")));
for (const s of states) assert.deepEqual(s.errors, []);
return states.every(predicate) && states;
});
}
let a = await makeWindow();
const simple = { "migration-a": payload("migration-a"), "migration-b": payload("migration-b") };
await call(a, "seed", [], simple);
const migrated = await call(a, "migrate");
assert.equal(migrated.migrated, true);
assert.equal(migrated.durable, true);
assert.deepEqual(migrated.map, simple);
let state = await call(a, "snapshot");
assert.deepEqual(state.replay, simple);
assert.equal(state.legacyPresent, false);
assert.equal(state.native.indexedDB, "IDBFactory");
assert.equal(state.native.locks, "function");
assert.equal(state.native.secureContext, true);
pass("legacy migration into empty native IndexedDB", { rows: 2, native: state.native });
const legacy = {};
const logs = Array.from({ length: 65 }, (_, i) => {
const id = `unsaved-${String(i).padStart(3, "0")}`;
legacy[id] = payload(id);
return log(id, 1000 + i);
});
const saved = { "saved-main-a": payload("saved-main-a"), "saved-side-b": payload("saved-side-b"), "saved-main-c": payload("saved-main-c") };
logs.push(log("saved-main-a", 1, true, saved["saved-main-a"]), log("saved-side-b", 2, true), log("saved-main-c", 3, true, saved["saved-main-c"]));
legacy["saved-side-b"] = saved["saved-side-b"];
const authoritative = payload("existing-IDB-wins-over-stale-legacy");
await call(a, "seed", logs, legacy, { "unsaved-064": authoritative });
state = await mount(a);
const retainedIds = Array.from({ length: 50 }, (_, i) => `unsaved-${String(i + 15).padStart(3, "0")}`);
assert.deepEqual(Object.keys(state.replay).sort(), [...retainedIds, "saved-side-b"].sort());
assert.equal(state.replay["unsaved-064"], authoritative);
assert.equal(state.legacyPresent, false);
assert.equal(state.logs.length, 68);
assert.equal(state.diskLogs.length, 68);
assert.equal(state.logs.filter(x => !x.saved && x.terminalData).length, 50);
for (const entry of state.logs) {
if (entry.saved) assert.equal(entry.terminalData, saved[entry.id]);
else assert.equal(entry.terminalData, retainedIds.includes(entry.id) ? (entry.id === "unsaved-064" ? authoritative : legacy[entry.id]) : undefined);
}
assert.equal(state.diskLogs.find(x => x.id === "saved-main-a").terminalData, saved["saved-main-a"]);
assert.equal(state.diskLogs.filter(x => !x.saved && x.terminalData).length, 0);
pass("production hook migration retains newest 50 of 65 unsaved replays", { metadata: 68, unsavedReplays: 50, savedReplayHashes: Object.fromEntries(Object.entries(saved).map(([id, data]) => [id, sha(data)])) });
let b = await makeWindow();
await mount(b);
await converged([a, b], s => s.logs.length === 68 && s.logs.every(x => !x.saved || x.terminalData === saved[x.id]), "second window hydrates saved replays");
const firstData = payload("new-A-final-capture");
const firstId = await call(a, "addAndCapture", log("ignored", 2000), firstData);
await converged([a, b], s => s.logs.some(x => x.id === firstId && x.terminalData === firstData) && s.replay[firstId] === firstData, "A capture reaches B through real storage events");
const secondData = payload("new-B-final-capture");
const secondId = await call(b, "addAndCapture", log("ignored", 2001), secondData);
await converged([a, b], s => s.logs.some(x => x.id === secondId && x.terminalData === secondData) && s.replay[secondId] === secondData, "B capture reaches A through real storage events");
pass("bidirectional close-capture convergence", { ids: [firstId, secondId] });
await call(b, "toggleSaved", firstId);
saved[firstId] = firstData;
await converged([a, b], s => s.logs.some(x => x.id === firstId && x.saved && x.terminalData === firstData) && s.diskLogs.some(x => x.id === firstId && x.saved && x.terminalData === firstData), "bookmark is durable and visible in both windows");
pass("bookmark retains exact replay in main localStorage blob");
const concurrent = [];
for (let round = 0; round < 3; round += 1) {
const leftData = payload(`concurrent-A-${round}`);
const rightData = payload(`concurrent-B-${round}`);
const [left, right] = await Promise.all([
call(a, "addAndCapture", log("ignored", 3000 + round * 2), leftData),
call(b, "addAndCapture", log("ignored", 3001 + round * 2), rightData),
]);
concurrent.push([left, leftData], [right, rightData]);
await converged([a, b], s => concurrent.every(([id, data]) =>
s.logs.some(x => x.id === id && x.terminalData === data) && s.diskLogs.some(x => x.id === id) && s.replay[id] === data), `concurrent two-window captures round ${round + 1}`);
}
state = await call(b, "snapshot");
assert.ok(state.events.includes("netcatty_connection_log_terminal_data_rev_v1"));
assert.ok(state.events.includes("netcatty_connection_logs_v1"));
assert.ok(state.logs.filter(x => !x.saved && x.terminalData).length <= 50);
pass("three rounds of concurrent two-window capture preserve all new logs", { captured: concurrent.length, nativeStorageEvents: state.events.length });
const bookmarkedFinal = payload("concurrent-bookmark-final-capture");
await Promise.all([
call(a, "capture", secondId, bookmarkedFinal),
call(b, "toggleSaved", secondId),
]);
saved[secondId] = bookmarkedFinal;
await converged([a, b], s => s.logs.some(x => x.id === secondId && x.saved && x.terminalData === bookmarkedFinal)
&& s.diskLogs.some(x => x.id === secondId && x.saved && x.terminalData === bookmarkedFinal), "concurrent final capture and bookmark retain both fields");
pass("concurrent same-log capture and bookmark preserve final bytes and saved flag");
const refreshed = payload("updated-unsaved-capture");
assert.equal(await call(a, "capture", concurrent[0][0], refreshed), true);
concurrent[0][1] = refreshed;
await converged([a, b], s => s.logs.some(x => x.id === concurrent[0][0] && x.terminalData === refreshed)
&& s.replay[concurrent[0][0]] === refreshed, "new IndexedDB revision replaces old in-memory replay");
pass("peer replay revision refreshes existing unsaved bytes");
// Queue a peer deletion before an existing log's late capture. The real
// Web Lock makes this interleaving deterministic without faking storage.
const deletedCaptureId = concurrent[1][0];
await call(a, "holdWrites");
await call(b, "deleteLog", deletedCaptureId);
await sleep(50);
const token = await call(a, "startCapture", deletedCaptureId, payload("late-deleted-capture"));
await sleep(50);
await call(a, "resumeWrites");
await call(a, "waitCapture", token);
await converged([a, b], s => !s.logs.some(x => x.id === deletedCaptureId)
&& !s.diskLogs.some(x => x.id === deletedCaptureId) && !Object.hasOwn(s.replay, deletedCaptureId), "late capture must not resurrect peer-deleted log");
concurrent.splice(1, 1);
pass("late capture does not resurrect a peer deletion");
// A controlled stale notification exercises the production event handler;
// real cross-window event delivery is separately verified above.
await call(a, "staleMetadataEvent", logs);
await converged([a, b], s => concurrent.every(([id, data]) => s.logs.some(x => x.id === id && x.terminalData === data))
&& !s.logs.some(x => x.id === deletedCaptureId), "stale event reads current metadata without resurrecting deletion");
pass("delayed stale notification cannot remove committed logs or restore deleted logs");
await call(a, "deleteLog", "unsaved-040");
await converged([a, b], s => !s.logs.some(x => x.id === "unsaved-040") && !s.diskLogs.some(x => x.id === "unsaved-040") && !Object.hasOwn(s.replay, "unsaved-040"), "deletion converges in metadata and IndexedDB");
pass("delete removes matching replay in both windows");
// A pruned metadata write can succeed while both replay destinations fail.
// Failed replay ownership must not keep the already-committed addition or
// metadata fields alive across a subsequent peer bookmark/deletion.
async function failedCapture(label, startTime) {
const data = payload(label);
const result = await call(a, "failedAddAndCapture", log("ignored", startTime), data);
assert.equal(result.persisted, false);
assert.ok(result.faults.indexedDB > 0);
assert.ok(result.faults.replayFallback > 0);
const snapshot = await call(a, "snapshot");
assert.equal(snapshot.diskLogs.find(x => x.id === result.id)?.saved, false);
assert.equal(snapshot.diskLogs.find(x => x.id === result.id)?.terminalData, undefined);
assert.equal(Object.hasOwn(snapshot.replay, result.id), false);
await converged([b], s => s.logs.some(x => x.id === result.id), "peer sees metadata after failed replay capture");
return { ...result, data };
}
const partialBookmark = await failedCapture("partial-write-bookmark", 4000);
await call(b, "toggleSaved", partialBookmark.id);
await converged([b], s => s.logs.some(x => x.id === partialBookmark.id && x.saved)
&& s.diskLogs.some(x => x.id === partialBookmark.id && x.saved), "peer bookmark commits after partial capture");
assert.equal(await call(a, "capture", partialBookmark.id, partialBookmark.data), true);
saved[partialBookmark.id] = partialBookmark.data;
await converged([a, b], s => s.logs.some(x => x.id === partialBookmark.id && x.saved && x.terminalData === partialBookmark.data)
&& s.diskLogs.some(x => x.id === partialBookmark.id && x.saved && x.terminalData === partialBookmark.data), "partial-write retry retains peer bookmark and exact replay");
pass("metadata-only success retires stale fields while retry preserves peer bookmark", { faults: partialBookmark.faults });
const partialDelete = await failedCapture("partial-write-delete", 4001);
await call(b, "deleteLog", partialDelete.id);
await converged([b], s => !s.logs.some(x => x.id === partialDelete.id)
&& !s.diskLogs.some(x => x.id === partialDelete.id), "peer deletion commits after partial capture");
assert.equal(await call(a, "capture", partialDelete.id, partialDelete.data), true);
await converged([a, b], s => !s.logs.some(x => x.id === partialDelete.id)
&& !s.diskLogs.some(x => x.id === partialDelete.id) && !Object.hasOwn(s.replay, partialDelete.id), "partial-write retry never resurrects peer deletion");
pass("metadata-only success retires addition ownership before peer deletion", { faults: partialDelete.faults });
// Both renderer heaps are discarded; only persisted browser storage survives.
a.destroy(); b.destroy();
a = await makeWindow();
state = await mount(a);
for (const [id, data] of concurrent) assert.equal(state.logs.find(x => x.id === id)?.terminalData, data);
for (const [id, data] of Object.entries(saved)) assert.equal(state.logs.find(x => x.id === id)?.terminalData, data);
assert.equal(state.logs.some(x => x.id === "unsaved-040"), false);
pass("fresh renderer reload preserves captures, bookmarks and deletion");
b = await makeWindow();
await mount(b);
await call(b, "clearUnsaved");
await converged([a, b], s => s.logs.length === Object.keys(saved).length && s.diskLogs.length === Object.keys(saved).length && s.logs.every(x => x.saved && x.terminalData === saved[x.id]) && Object.keys(s.replay).every(id => Object.hasOwn(saved, id)), "clear-unsaved preserves all bookmarked replay data");
a.destroy(); b.destroy();
const finalWindow = await makeWindow();
state = await mount(finalWindow);
assert.equal(state.logs.length, Object.keys(saved).length);
for (const [id, data] of Object.entries(saved)) {
assert.equal(state.logs.find(x => x.id === id)?.terminalData, data);
assert.equal(state.diskLogs.find(x => x.id === id)?.terminalData, data);
}
pass("clear-unsaved and fresh renderer reload retain saved logs byte-for-byte", { saved: Object.keys(saved).length });
await finish(0);
}).catch(error => finish(1, error));
}