mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 10:52:38 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9d7fffff8 | ||
|
|
5f9b5ac35f |
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\(/,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user