Skip to content

Commit c8f98b0

Browse files
committed
fix(session): make latest symlink update race-safe
1 parent c5113a2 commit c8f98b0

2 files changed

Lines changed: 26 additions & 20 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(() => {});
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: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,15 @@ test("listSessions finds legacy sessions and migrates them", async () => {
8282
expect(existsSync(legacy)).toBe(false);
8383
});
8484

85+
test("concurrent initSessionDir does not throw on latest symlink race", async () => {
86+
const ids = Array.from({ length: 16 }, () => generateSessionId());
87+
const dirs = await Promise.all(ids.map((id) => initSessionDir(cwd, id, home)));
88+
for (const [i, dir] of dirs.entries()) {
89+
expect(dir).toBe(sessionDir(cwd, ids[i] as string, home));
90+
expect(existsSync(join(dir, "context"))).toBe(true);
91+
}
92+
});
93+
8594
test("migrateLegacySessionIfNeeded does not migrate main-repo .agent-state from a worktree cwd", async () => {
8695
const main = join(cwd, "main");
8796
await mkdir(main, { recursive: true });

0 commit comments

Comments
 (0)