Skip to content

Commit 46091fa

Browse files
Merge pull request #1324 from corbitsdev/cl-9892-latest-race
fix(session): make latest symlink update race-safe
2 parents 15b7d88 + 7ddce4a commit 46091fa

2 files changed

Lines changed: 46 additions & 22 deletions

File tree

‎src/session/index.ts‎

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,6 @@ import { LOG_NAMESPACE_ROOT } from "../branding.js";
2222

2323
const log = getLogger([LOG_NAMESPACE_ROOT, "session"]);
2424

25-
function isENOENT(err: unknown): boolean {
26-
return (
27-
typeof err === "object" &&
28-
err !== null &&
29-
"code" in err &&
30-
(err as { code?: unknown }).code === "ENOENT"
31-
);
32-
}
33-
3425
// ---------------------------------------------------------------------------
3526
// UUIDv7 generator (no external dependencies)
3627
// ---------------------------------------------------------------------------
@@ -179,19 +170,25 @@ export async function initSessionDir(
179170
const dir = await migrateLegacySessionIfNeeded(cwd, sessionId, home);
180171
await mkdir(join(dir, "context"), { recursive: true });
181172

182-
// Update the `latest` symlink to point to this session.
183-
const linkPath = latestSymlinkPath(cwd, home);
184-
await mkdir(dirname(linkPath), { recursive: true });
185-
186-
// Remove existing symlink first, then create new one.
187-
await unlink(linkPath).catch((err: unknown) => {
188-
if (isENOENT(err)) return;
189-
log.debug("failed to replace latest symlink at {path}: {error}", {
190-
path: linkPath,
173+
// Best-effort `latest` symlink update. A temp symlink plus atomic rename
174+
// keeps concurrent inits from racing on EEXIST; failures here must never
175+
// abort session creation, so every error is swallowed below.
176+
try {
177+
const linkPath = latestSymlinkPath(cwd, home);
178+
await mkdir(dirname(linkPath), { recursive: true });
179+
const tmpPath = `${linkPath}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
180+
try {
181+
await symlink(sessionId, tmpPath);
182+
await rename(tmpPath, linkPath);
183+
} catch (err: unknown) {
184+
await unlink(tmpPath).catch(() => undefined);
185+
throw err;
186+
}
187+
} catch (err: unknown) {
188+
log.debug("failed to update latest symlink: {error}", {
191189
error: err instanceof Error ? err.message : String(err),
192190
});
193-
});
194-
await symlink(sessionId, linkPath);
191+
}
195192

196193
return dir;
197194
}

‎src/session/session-dir.test.ts‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { afterEach, beforeEach, expect, test } from "bun:test";
2-
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2+
import {
3+
mkdir,
4+
readFile,
5+
readdir,
6+
readlink,
7+
rm,
8+
writeFile,
9+
} from "node:fs/promises";
310
import { existsSync } from "node:fs";
4-
import { join } from "node:path";
11+
import { dirname, join } from "node:path";
512
import { tmpdir } from "node:os";
613
import { execFileSync } from "node:child_process";
714

@@ -82,6 +89,26 @@ test("listSessions finds legacy sessions and migrates them", async () => {
8289
expect(existsSync(legacy)).toBe(false);
8390
});
8491

92+
test("concurrent initSessionDir does not throw on latest symlink race", async () => {
93+
const ids = Array.from({ length: 16 }, () => generateSessionId());
94+
const dirs = await Promise.all(
95+
ids.map((id) => initSessionDir(cwd, id, home)),
96+
);
97+
for (const [i, dir] of dirs.entries()) {
98+
expect(dir).toBe(sessionDir(cwd, ids[i] as string, home));
99+
expect(existsSync(join(dir, "context"))).toBe(true);
100+
}
101+
// The winning `latest` symlink must resolve to one of the raced ids.
102+
const projectRoot = dirname(dirs[0] as string);
103+
const latestTarget = await readlink(join(projectRoot, "latest"));
104+
expect(ids).toContain(latestTarget);
105+
// No temp-symlink strays (`latest.<pid>.<rand>.tmp`) may be left behind.
106+
const entries = await readdir(projectRoot);
107+
expect(
108+
entries.filter((e) => e.startsWith("latest.") && e.endsWith(".tmp")),
109+
).toEqual([]);
110+
});
111+
85112
test("migrateLegacySessionIfNeeded does not migrate main-repo .agent-state from a worktree cwd", async () => {
86113
const main = join(cwd, "main");
87114
await mkdir(main, { recursive: true });

0 commit comments

Comments
 (0)