From 77cfcf98b1df978f4df2255281531bdd8f38af72 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 4 Oct 2026 22:02:26 -0700 Subject: [PATCH 1/3] fix(session): make latest symlink update race-safe --- src/session/index.ts | 37 +++++++++++++++------------------ src/session/session-dir.test.ts | 9 ++++++++ 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/session/index.ts b/src/session/index.ts index 6c7886745..2bc475553 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -22,15 +22,6 @@ import { LOG_NAMESPACE_ROOT } from "../branding.js"; const log = getLogger([LOG_NAMESPACE_ROOT, "session"]); -function isENOENT(err: unknown): boolean { - return ( - typeof err === "object" && - err !== null && - "code" in err && - (err as { code?: unknown }).code === "ENOENT" - ); -} - // --------------------------------------------------------------------------- // UUIDv7 generator (no external dependencies) // --------------------------------------------------------------------------- @@ -179,19 +170,25 @@ export async function initSessionDir( const dir = await migrateLegacySessionIfNeeded(cwd, sessionId, home); await mkdir(join(dir, "context"), { recursive: true }); - // Update the `latest` symlink to point to this session. - const linkPath = latestSymlinkPath(cwd, home); - await mkdir(dirname(linkPath), { recursive: true }); - - // Remove existing symlink first, then create new one. - await unlink(linkPath).catch((err: unknown) => { - if (isENOENT(err)) return; - log.debug("failed to replace latest symlink at {path}: {error}", { - path: linkPath, + // Best-effort `latest` symlink update. A temp symlink plus atomic rename + // keeps concurrent inits from racing on EEXIST; failures here must never + // abort session creation, so every error is swallowed below. + try { + const linkPath = latestSymlinkPath(cwd, home); + await mkdir(dirname(linkPath), { recursive: true }); + const tmpPath = `${linkPath}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`; + try { + await symlink(sessionId, tmpPath); + await rename(tmpPath, linkPath); + } catch (err: unknown) { + await unlink(tmpPath).catch(() => {}); + throw err; + } + } catch (err: unknown) { + log.debug("failed to update latest symlink: {error}", { error: err instanceof Error ? err.message : String(err), }); - }); - await symlink(sessionId, linkPath); + } return dir; } diff --git a/src/session/session-dir.test.ts b/src/session/session-dir.test.ts index 85d58db06..e482eec07 100644 --- a/src/session/session-dir.test.ts +++ b/src/session/session-dir.test.ts @@ -82,6 +82,15 @@ test("listSessions finds legacy sessions and migrates them", async () => { expect(existsSync(legacy)).toBe(false); }); +test("concurrent initSessionDir does not throw on latest symlink race", async () => { + const ids = Array.from({ length: 16 }, () => generateSessionId()); + const dirs = await Promise.all(ids.map((id) => initSessionDir(cwd, id, home))); + for (const [i, dir] of dirs.entries()) { + expect(dir).toBe(sessionDir(cwd, ids[i] as string, home)); + expect(existsSync(join(dir, "context"))).toBe(true); + } +}); + test("migrateLegacySessionIfNeeded does not migrate main-repo .agent-state from a worktree cwd", async () => { const main = join(cwd, "main"); await mkdir(main, { recursive: true }); From 261daac7eb04c8c4dbe80dde7bc3cdd6049998c7 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 4 Oct 2026 22:33:39 -0700 Subject: [PATCH 2/3] test(session): harden latest symlink race test with readlink + tmp-stray assertions --- src/session/session-dir.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/session/session-dir.test.ts b/src/session/session-dir.test.ts index e482eec07..efafc8345 100644 --- a/src/session/session-dir.test.ts +++ b/src/session/session-dir.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, readdir, readlink, rm, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { execFileSync } from "node:child_process"; @@ -89,6 +89,15 @@ test("concurrent initSessionDir does not throw on latest symlink race", async () expect(dir).toBe(sessionDir(cwd, ids[i] as string, home)); expect(existsSync(join(dir, "context"))).toBe(true); } + // The winning `latest` symlink must resolve to one of the raced ids. + const projectRoot = dirname(dirs[0] as string); + const latestTarget = await readlink(join(projectRoot, "latest")); + expect(ids).toContain(latestTarget); + // No temp-symlink strays (`latest...tmp`) may be left behind. + const entries = await readdir(projectRoot); + expect( + entries.filter((e) => e.startsWith("latest.") && e.endsWith(".tmp")), + ).toEqual([]); }); test("migrateLegacySessionIfNeeded does not migrate main-repo .agent-state from a worktree cwd", async () => { From 7ddce4a53fc99909d04bb2e1145b4f0cd9111a99 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 10 Oct 2026 18:25:48 -0700 Subject: [PATCH 3/3] fix(session): satisfy oxlint no-empty-function and oxfmt after rebase --- src/session/index.ts | 2 +- src/session/session-dir.test.ts | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/session/index.ts b/src/session/index.ts index 2bc475553..ceac47dab 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -181,7 +181,7 @@ export async function initSessionDir( await symlink(sessionId, tmpPath); await rename(tmpPath, linkPath); } catch (err: unknown) { - await unlink(tmpPath).catch(() => {}); + await unlink(tmpPath).catch(() => undefined); throw err; } } catch (err: unknown) { diff --git a/src/session/session-dir.test.ts b/src/session/session-dir.test.ts index efafc8345..793d23742 100644 --- a/src/session/session-dir.test.ts +++ b/src/session/session-dir.test.ts @@ -1,5 +1,12 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdir, readFile, readdir, readlink, rm, writeFile } from "node:fs/promises"; +import { + mkdir, + readFile, + readdir, + readlink, + rm, + writeFile, +} from "node:fs/promises"; import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; @@ -84,7 +91,9 @@ test("listSessions finds legacy sessions and migrates them", async () => { test("concurrent initSessionDir does not throw on latest symlink race", async () => { const ids = Array.from({ length: 16 }, () => generateSessionId()); - const dirs = await Promise.all(ids.map((id) => initSessionDir(cwd, id, home))); + const dirs = await Promise.all( + ids.map((id) => initSessionDir(cwd, id, home)), + ); for (const [i, dir] of dirs.entries()) { expect(dir).toBe(sessionDir(cwd, ids[i] as string, home)); expect(existsSync(join(dir, "context"))).toBe(true);