fix(sessions): lock store saves; wait for bash close

This commit is contained in:
Peter Steinberger
2026-01-10 17:47:04 +01:00
parent a54706a063
commit 60bf349201
2 changed files with 47 additions and 17 deletions

View File

@@ -436,7 +436,9 @@ export function createBashTool(
); );
}; };
child.once("exit", (code, exitSignal) => { // `exit` can fire before stdio fully flushes (notably on Windows).
// `close` waits for streams to close, so aggregated output is complete.
child.once("close", (code, exitSignal) => {
handleExit(code, exitSignal); handleExit(code, exitSignal);
}); });

View File

@@ -448,15 +448,32 @@ export function loadSessionStore(
return store; return store;
} }
export async function saveSessionStore( async function saveSessionStoreUnlocked(
storePath: string, storePath: string,
store: Record<string, SessionEntry>, store: Record<string, SessionEntry>,
) { ): Promise<void> {
// Invalidate cache on write to ensure consistency // Invalidate cache on write to ensure consistency
invalidateSessionStoreCache(storePath); invalidateSessionStoreCache(storePath);
await fs.promises.mkdir(path.dirname(storePath), { recursive: true }); await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
const json = JSON.stringify(store, null, 2); const json = JSON.stringify(store, null, 2);
// Windows: avoid atomic rename swaps (can be flaky under concurrent access).
// We serialize writers via the session-store lock instead.
if (process.platform === "win32") {
try {
await fs.promises.writeFile(storePath, json, "utf-8");
} catch (err) {
const code =
err && typeof err === "object" && "code" in err
? String((err as { code?: unknown }).code)
: null;
if (code === "ENOENT") return;
throw err;
}
return;
}
const tmp = `${storePath}.${process.pid}.${crypto.randomUUID()}.tmp`; const tmp = `${storePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
try { try {
await fs.promises.writeFile(tmp, json, "utf-8"); await fs.promises.writeFile(tmp, json, "utf-8");
@@ -490,6 +507,15 @@ export async function saveSessionStore(
} }
} }
export async function saveSessionStore(
storePath: string,
store: Record<string, SessionEntry>,
): Promise<void> {
await withSessionStoreLock(storePath, async () => {
await saveSessionStoreUnlocked(storePath, store);
});
}
type SessionStoreLockOptions = { type SessionStoreLockOptions = {
timeoutMs?: number; timeoutMs?: number;
pollIntervalMs?: number; pollIntervalMs?: number;
@@ -571,7 +597,7 @@ export async function updateSessionStoreEntry(params: {
if (!patch) return existing; if (!patch) return existing;
const next = mergeSessionEntry(existing, patch); const next = mergeSessionEntry(existing, patch);
store[sessionKey] = next; store[sessionKey] = next;
await saveSessionStore(storePath, store); await saveSessionStoreUnlocked(storePath, store);
return next; return next;
}); });
} }
@@ -584,20 +610,22 @@ export async function updateLastRoute(params: {
accountId?: string; accountId?: string;
}) { }) {
const { storePath, sessionKey, provider, to, accountId } = params; const { storePath, sessionKey, provider, to, accountId } = params;
const store = loadSessionStore(storePath); return await withSessionStoreLock(storePath, async () => {
const existing = store[sessionKey]; const store = loadSessionStore(storePath);
const now = Date.now(); const existing = store[sessionKey];
const next = mergeSessionEntry(existing, { const now = Date.now();
updatedAt: Math.max(existing?.updatedAt ?? 0, now), const next = mergeSessionEntry(existing, {
lastProvider: provider, updatedAt: Math.max(existing?.updatedAt ?? 0, now),
lastTo: to?.trim() ? to.trim() : undefined, lastProvider: provider,
lastAccountId: accountId?.trim() lastTo: to?.trim() ? to.trim() : undefined,
? accountId.trim() lastAccountId: accountId?.trim()
: existing?.lastAccountId, ? accountId.trim()
: existing?.lastAccountId,
});
store[sessionKey] = next;
await saveSessionStoreUnlocked(storePath, store);
return next;
}); });
store[sessionKey] = next;
await saveSessionStore(storePath, store);
return next;
} }
// Decide which session bucket to use (per-sender vs global). // Decide which session bucket to use (per-sender vs global).