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);
});

View File

@@ -448,15 +448,32 @@ export function loadSessionStore(
return store;
}
export async function saveSessionStore(
async function saveSessionStoreUnlocked(
storePath: string,
store: Record<string, SessionEntry>,
) {
): Promise<void> {
// Invalidate cache on write to ensure consistency
invalidateSessionStoreCache(storePath);
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
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`;
try {
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 = {
timeoutMs?: number;
pollIntervalMs?: number;
@@ -571,7 +597,7 @@ export async function updateSessionStoreEntry(params: {
if (!patch) return existing;
const next = mergeSessionEntry(existing, patch);
store[sessionKey] = next;
await saveSessionStore(storePath, store);
await saveSessionStoreUnlocked(storePath, store);
return next;
});
}
@@ -584,20 +610,22 @@ export async function updateLastRoute(params: {
accountId?: string;
}) {
const { storePath, sessionKey, provider, to, accountId } = params;
const store = loadSessionStore(storePath);
const existing = store[sessionKey];
const now = Date.now();
const next = mergeSessionEntry(existing, {
updatedAt: Math.max(existing?.updatedAt ?? 0, now),
lastProvider: provider,
lastTo: to?.trim() ? to.trim() : undefined,
lastAccountId: accountId?.trim()
? accountId.trim()
: existing?.lastAccountId,
return await withSessionStoreLock(storePath, async () => {
const store = loadSessionStore(storePath);
const existing = store[sessionKey];
const now = Date.now();
const next = mergeSessionEntry(existing, {
updatedAt: Math.max(existing?.updatedAt ?? 0, now),
lastProvider: provider,
lastTo: to?.trim() ? to.trim() : undefined,
lastAccountId: accountId?.trim()
? 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).