Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 e645c12731 fix(vault): keep the connection-log quota hint on the vault hook
The domain helper held a process-global hint, so the same logs and map
could keep a different replay set after an earlier caller. The hint now
lives in a useVaultState ref and is passed in and returned. Drop order,
saved and unknown ids, and the size estimate are unchanged.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 08:41:33 +00:00
Grok Bot 5d977fb082 perf(vault): start quota retries from the newest set that last fit 2026-10-22 08:28:37 +00:00
Grok Bot e6ca31f44c fix(vault): keep the newest connection-log replays when the side store hits quota
Once the replay side store neared the localStorage quota (~48 x 1M-char
entries) every write failed with QuotaExceededError, so the stored map
froze with the oldest sessions and newer ones never survived a restart.
On a failed write, retry without the oldest unsaved replay buffers
(dropping 1, 2, 4, ... per attempt, so retries stay O(log n)) until it
fits or none are left. Storage format, per-entry cap, 50-entry cap and
log metadata are unchanged; saved logs and other windows' ids are never
dropped.
2026-10-22 08:28:37 +00:00
3 changed files with 208 additions and 2 deletions

No files matched your search

+20 -2
View File
@@ -65,6 +65,7 @@ import {
mergeTerminalDataIntoLogs,
mergeTerminalDataMapsForStorage,
pruneInMemoryUnsavedTerminalData,
writeTerminalDataMapWithQuotaRetry,
type ConnectionLogTerminalDataMap,
} from "../../domain/connectionLogTerminalData";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
@@ -300,9 +301,20 @@ const isSameTerminalDataMap = (
const writeConnectionLogTerminalDataMap = (
existing: ConnectionLogTerminalDataMap,
pruned: ConnectionLogTerminalDataMap,
logs: ConnectionLog[],
quotaFailedCharsRef: { current: number | null },
): boolean => {
if (isSameTerminalDataMap(existing, pruned)) return true;
return localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, pruned);
// On quota failure keep the newest replay buffers instead of none.
// The hint stays on the hook so the domain helper remains a pure calculation.
const result = writeTerminalDataMapWithQuotaRetry(
logs,
pruned,
(next) => localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, next),
quotaFailedCharsRef.current,
);
quotaFailedCharsRef.current = result.quotaFailedChars;
return result.persisted;
};
export const useVaultState = () => {
@@ -432,6 +444,7 @@ export const useVaultState = () => {
const proxyProfilesReadSeq = useRef(0);
const groupConfigsReadSeq = useRef(0);
const connectionLogTerminalDataRef = useRef<ConnectionLogTerminalDataMap>({});
const connectionLogQuotaFailedCharsRef = useRef<number | null>(null);
const pluginCredentialCatalogVersion = useRef(0);
const syncConnectionLogTerminalDataMap = useCallback((
@@ -450,7 +463,12 @@ export const useVaultState = () => {
localMaps,
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
);
const persisted = writeConnectionLogTerminalDataMap(existing, merged);
const persisted = writeConnectionLogTerminalDataMap(
existing,
merged,
logs,
connectionLogQuotaFailedCharsRef,
);
// On a failed write (e.g. QuotaExceeded once the side store nears ~50 MB)
// keep the same capped map instead of accumulating every log's replay
// buffer in memory; anything outside it would be pruned on the next write.
+130
View File
@@ -10,6 +10,7 @@ import {
mergeTerminalDataMapsForStorage,
pruneInMemoryUnsavedTerminalData,
pruneTerminalDataMapForStorage,
writeTerminalDataMapWithQuotaRetry,
} from "./connectionLogTerminalData.ts";
const baseLog: ConnectionLog = {
@@ -232,3 +233,132 @@ test("mergeTerminalDataMapsForStorage drops deleted log replay buffers", () => {
assert.equal(merged.local, "keep");
assert.equal(merged.deleted, undefined);
});
const quotaLogs = (count: number): ConnectionLog[] =>
Array.from({ length: count }, (_, index) => ({
...baseLog,
id: `log-${index}`,
startTime: index,
saved: false,
}));
const quotaWriter = (limitChars: number) => {
const calls: number[] = [];
const write = (next: Record<string, string>) => {
const size = Object.values(next).reduce((sum, value) => sum + value.length, 0);
calls.push(Object.keys(next).length);
return size <= limitChars;
};
return { calls, write };
};
test("writeTerminalDataMapWithQuotaRetry writes once when it fits", () => {
const logs = quotaLogs(5);
const map = Object.fromEntries(logs.map((log) => [log.id, "x".repeat(10)]));
const { calls, write } = quotaWriter(1000);
const result = writeTerminalDataMapWithQuotaRetry(logs, map, write, null);
assert.equal(result.persisted, true);
assert.equal(result.written, map);
assert.deepEqual(calls, [5]);
});
test("writeTerminalDataMapWithQuotaRetry drops the oldest unsaved entries until it fits", () => {
const logs = quotaLogs(50);
// Newest first, matching pruneTerminalDataMapForStorage order.
const map = Object.fromEntries(
[...logs].reverse().map((log) => [log.id, "x".repeat(10)]),
);
const { calls, write } = quotaWriter(10 * 47);
const result = writeTerminalDataMapWithQuotaRetry(logs, map, write, null);
assert.equal(result.persisted, true);
// 50 fails, drop 1 → 49 fails, drop 2 → 48 fails, drop 4 → 46 fits.
assert.deepEqual(calls, [50, 49, 48, 46]);
assert.equal(result.written["log-49"], map["log-49"]);
assert.equal(result.written["log-4"], map["log-4"]);
for (const id of ["log-0", "log-1", "log-2", "log-3"]) {
assert.equal(result.written[id], undefined);
}
// Key order (newest first) is preserved.
assert.deepEqual(Object.keys(result.written), Object.keys(map).filter((id) => id in result.written));
});
test("writeTerminalDataMapWithQuotaRetry never drops saved or unknown entries and stops after all unsaved are gone", () => {
const logs: ConnectionLog[] = [
...quotaLogs(20),
{ ...baseLog, id: "saved", startTime: -1, saved: true },
];
const map: Record<string, string> = Object.fromEntries(
logs.map((log) => [log.id, "x".repeat(10)]),
);
map["other-window"] = "y".repeat(10);
const { calls, write } = quotaWriter(5); // nothing fits
const result = writeTerminalDataMapWithQuotaRetry(logs, map, write, null);
assert.equal(result.persisted, false);
assert.equal(result.written, map);
// 22, then drop 1, 2, 4, 8, 16, then all 20 unsaved: bounded attempts.
assert.deepEqual(calls, [22, 21, 20, 18, 14, 6, 2]);
assert.ok(result.attempts <= 2 + Math.ceil(Math.log2(20)));
});
const newestFirstMap = (logs: ConnectionLog[], chars: number) =>
Object.fromEntries([...logs].reverse().map((log) => [log.id, "x".repeat(chars)]));
test("writeTerminalDataMapWithQuotaRetry starts the next write from the newest set that last fit", () => {
const logs = quotaLogs(50);
const quota = quotaWriter(10 * 47);
const first = writeTerminalDataMapWithQuotaRetry(logs, newestFirstMap(logs, 10), quota.write, null);
// Next close: oldest drops out of the 50, a newer session arrives.
const nextLogs = [...logs.slice(1), { ...baseLog, id: "log-50", startTime: 50 }];
const second = quotaWriter(10 * 47);
const result = writeTerminalDataMapWithQuotaRetry(
nextLogs,
newestFirstMap(nextLogs, 10),
second.write,
first.quotaFailedChars,
);
assert.equal(result.persisted, true);
assert.deepEqual(second.calls, [47]);
assert.equal(result.written["log-50"] !== undefined, true);
assert.equal(result.written["log-3"], undefined);
});
test("writeTerminalDataMapWithQuotaRetry grows back when entries get smaller", () => {
const logs = quotaLogs(50);
const first = writeTerminalDataMapWithQuotaRetry(
logs,
newestFirstMap(logs, 10),
quotaWriter(10 * 47).write,
null,
);
const small = quotaWriter(10 * 47);
const result = writeTerminalDataMapWithQuotaRetry(
logs,
newestFirstMap(logs, 5),
small.write,
first.quotaFailedChars,
);
assert.deepEqual(small.calls, [50]);
assert.equal(Object.keys(result.written).length, 50);
});
test("writeTerminalDataMapWithQuotaRetry falls back to dropping when the hint is too optimistic", () => {
const logs = quotaLogs(50);
const first = writeTerminalDataMapWithQuotaRetry(
logs,
newestFirstMap(logs, 10),
quotaWriter(10 * 47).write,
null,
);
const tighter = quotaWriter(10 * 30); // quota shrank (other keys grew)
const result = writeTerminalDataMapWithQuotaRetry(
logs,
newestFirstMap(logs, 10),
tighter.write,
first.quotaFailedChars,
);
assert.equal(result.persisted, true);
assert.equal(tighter.calls[0], 47);
assert.ok(Object.keys(result.written).length <= 30);
assert.equal(result.written["log-49"] !== undefined, true);
assert.ok(result.attempts <= 2 + Math.ceil(Math.log2(50)));
});
+58
View File
@@ -114,6 +114,64 @@ export const pruneInMemoryUnsavedTerminalData = (
return changed ? next : logs;
};
/**
* Write the side store; if the write is rejected (localStorage quota), retry
* without the oldest unsaved replay buffers so the newest sessions are kept.
* Drops 1, 2, 4, … oldest entries per retry, so attempts stay O(log n).
* Saved logs and ids not in `logs` are never dropped.
*
* `quotaFailedChars` is owned by the caller. It is the summed entry length of
* the smallest payload that last hit the quota, so the next write can start
* from the newest set that fits. A successful write of the full map returns
* null. The helper does not keep that hint itself.
*/
export const writeTerminalDataMapWithQuotaRetry = (
logs: ConnectionLog[],
map: ConnectionLogTerminalDataMap,
write: (next: ConnectionLogTerminalDataMap) => boolean,
quotaFailedChars: number | null,
): { persisted: boolean; written: ConnectionLogTerminalDataMap; attempts: number; quotaFailedChars: number | null } => {
const droppable = logs
.filter((log) => !log.saved && map[log.id] !== undefined)
.sort((a, b) => a.startTime - b.startTime)
.map((log) => log.id);
let chars = 0;
for (const data of Object.values(map)) chars += data.length;
let hint = quotaFailedChars;
let dropCount = 0;
if (hint !== null) {
while (dropCount < droppable.length && chars >= hint) {
chars -= map[droppable[dropCount]].length;
dropCount += 1;
}
}
let attempts = 0;
for (;;) {
let next = map;
if (dropCount > 0) {
const dropped = new Set(droppable.slice(0, dropCount));
next = {};
for (const [id, data] of Object.entries(map)) {
if (!dropped.has(id)) next[id] = data;
}
}
attempts += 1;
if (write(next)) {
if (dropCount === 0) hint = null;
return { persisted: true, written: next, attempts, quotaFailedChars: hint };
}
hint = hint === null ? chars : Math.min(hint, chars);
if (dropCount >= droppable.length) {
return { persisted: false, written: map, attempts, quotaFailedChars: hint };
}
const target = Math.min(Math.max(dropCount * 2, 1), droppable.length);
for (let i = dropCount; i < target; i += 1) chars -= map[droppable[i]].length;
dropCount = target;
}
};
/** Fold side-store maps together, then cap to the allowed unsaved/saved set. */
export const mergeTerminalDataMapsForStorage = (
logs: ConnectionLog[],