diff --git a/.gitignore b/.gitignore index 600e98136..d19dd5bf1 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,5 @@ bun.lock .serena/ repowise.db .sf/mcp.json +.sf/interactive.lock +.sf/interactive.lock.d/ diff --git a/biome.json b/biome.json index 82a04738e..711bb6704 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.13/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.14/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/src/cli.ts b/src/cli.ts index ba38fd9f7..21c56c761 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -23,6 +23,7 @@ import { } from "./cli-web-branch.js"; import { error, formatStructuredError } from "./errors.js"; import { printHelp, printSubcommandHelp } from "./help-text.js"; +import { acquireInteractiveSessionLock } from "./interactive-session-lock.js"; import { runOnboarding, shouldRunOnboarding } from "./onboarding.js"; import { migratePiCredentials } from "./pi-migration.js"; import { getProjectSessionsDir } from "./project-sessions.js"; @@ -908,6 +909,25 @@ const sessionManager = cliFlags._selectedSessionPath ? SessionManager.continueRecent(cwd, projectSessionsDir) : SessionManager.create(cwd, projectSessionsDir); +if (!process.stdin.isTTY || !process.stdout.isTTY) { + const missing = + !process.stdin.isTTY && !process.stdout.isTTY + ? "stdin and stdout are" + : !process.stdin.isTTY + ? "stdin is" + : "stdout is"; + printNonTtyErrorAndExit(missing, true); +} + +const interactiveLock = acquireInteractiveSessionLock( + cwd, + sessionManager.getSessionFile(), +); +if (!interactiveLock.acquired) { + process.stderr.write(`${interactiveLock.message}\n`); + process.exit(1); +} + exitIfManagedResourcesAreNewer(agentDir); initResources(agentDir); markStartup("initResources"); @@ -999,16 +1019,6 @@ if (enabledModelPatterns && enabledModelPatterns.length > 0) { } } -if (!process.stdin.isTTY || !process.stdout.isTTY) { - const missing = - !process.stdin.isTTY && !process.stdout.isTTY - ? "stdin and stdout are" - : !process.stdin.isTTY - ? "stdin is" - : "stdout is"; - printNonTtyErrorAndExit(missing, true); -} - // Welcome screen — shown on every fresh interactive session before TUI takes over. // Skip when the first-run banner was already printed in loader.ts (prevents double banner). if (!process.env.SF_FIRST_RUN_BANNER) { @@ -1034,4 +1044,8 @@ if (!process.env.SF_FIRST_RUN_BANNER) { const interactiveMode = new InteractiveMode(session); markStartup("InteractiveMode"); printStartupTimings(); -await interactiveMode.run(); +try { + await interactiveMode.run(); +} finally { + interactiveLock.release(); +} diff --git a/src/interactive-session-lock.ts b/src/interactive-session-lock.ts new file mode 100644 index 000000000..09ea7c5cd --- /dev/null +++ b/src/interactive-session-lock.ts @@ -0,0 +1,210 @@ +import { randomUUID } from "node:crypto"; +import { + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; + +const LOCK_FILE = "interactive.lock"; +const LOCK_DIR = "interactive.lock.d"; + +export type InteractiveSessionLockConflict = { + acquired: false; + lockPath: string; + pid: number | null; + startedAt: string | null; + sessionFile: string | null; + message: string; +}; + +export type InteractiveSessionLockHandle = { + acquired: true; + lockPath: string; + release(): void; +}; + +export type InteractiveSessionLockResult = + | InteractiveSessionLockHandle + | InteractiveSessionLockConflict; + +type InteractiveSessionLockData = { + pid: number; + ppid: number; + cwd: string; + startedAt: string; + sessionFile?: string; + token: string; +}; + +/** + * Acquire the per-repository interactive-session lock. + * + * Purpose: prevent two TUI sessions from mutating the same repo-scoped SF state + * at the same time while still recovering automatically from dead lock owners. + * + * Consumer: cli.ts before InteractiveMode starts. + */ +export function acquireInteractiveSessionLock( + basePath: string, + sessionFile?: string, +): InteractiveSessionLockResult { + const cwd = resolve(basePath); + const sfDir = join(cwd, ".sf"); + const lockDir = join(sfDir, LOCK_DIR); + const lockPath = join(sfDir, LOCK_FILE); + const token = randomUUID(); + const data: InteractiveSessionLockData = { + pid: process.pid, + ppid: process.ppid, + cwd, + startedAt: new Date().toISOString(), + ...(sessionFile ? { sessionFile } : {}), + token, + }; + + mkdirSync(sfDir, { recursive: true }); + for (let attempt = 0; attempt < 2; attempt++) { + try { + mkdirSync(lockDir); + writeFileSync(lockPath, JSON.stringify(data, null, 2), "utf-8"); + const release = () => releaseInteractiveSessionLock(basePath, token); + registerInteractiveLockExitHandler(release); + return { acquired: true, lockPath, release }; + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") throw err; + const existing = readInteractiveSessionLockData(lockPath); + if (!isInteractiveLockOwnerAlive(existing, cwd)) { + removeInteractiveLock(lockPath, lockDir); + continue; + } + return { + acquired: false, + lockPath, + pid: existing?.pid ?? null, + startedAt: existing?.startedAt ?? null, + sessionFile: existing?.sessionFile ?? null, + message: formatInteractiveSessionLockConflict(existing, lockPath), + }; + } + } + + const existing = readInteractiveSessionLockData(lockPath); + return { + acquired: false, + lockPath, + pid: existing?.pid ?? null, + startedAt: existing?.startedAt ?? null, + sessionFile: existing?.sessionFile ?? null, + message: formatInteractiveSessionLockConflict(existing, lockPath), + }; +} + +/** + * Release a per-repository interactive-session lock if it is still ours. + * + * Purpose: avoid deleting another live TUI session's lock after a fast restart + * or PID reuse. + * + * Consumer: cli.ts finally blocks and the process exit handler registered by + * acquireInteractiveSessionLock(). + */ +export function releaseInteractiveSessionLock( + basePath: string, + token: string, +): void { + const cwd = resolve(basePath); + const lockPath = join(cwd, ".sf", LOCK_FILE); + const lockDir = join(cwd, ".sf", LOCK_DIR); + const existing = readInteractiveSessionLockData(lockPath); + if (existing?.token !== token) return; + removeInteractiveLock(lockPath, lockDir); +} + +function readInteractiveSessionLockData( + lockPath: string, +): InteractiveSessionLockData | null { + try { + const parsed = JSON.parse(readFileSync(lockPath, "utf-8")); + if ( + parsed && + typeof parsed === "object" && + typeof parsed.pid === "number" && + typeof parsed.cwd === "string" && + typeof parsed.startedAt === "string" && + typeof parsed.token === "string" + ) { + return parsed as InteractiveSessionLockData; + } + } catch { + /* stale or corrupt lock metadata */ + } + return null; +} + +function isInteractiveLockOwnerAlive( + data: InteractiveSessionLockData | null, + expectedCwd: string, +): boolean { + if (!data || data.pid <= 0 || data.pid === process.pid) return false; + try { + process.kill(data.pid, 0); + } catch { + return false; + } + const ownerCwd = readProcessCwd(data.pid); + if (ownerCwd && ownerCwd !== expectedCwd) return false; + return true; +} + +function readProcessCwd(pid: number): string | null { + try { + const link = readlinkSync(`/proc/${pid}/cwd`); + return resolve(link); + } catch { + return null; + } +} + +function removeInteractiveLock(lockPath: string, lockDir: string): void { + try { + rmSync(lockPath, { force: true }); + } catch { + /* best-effort */ + } + try { + const stat = lstatSync(lockDir); + if (stat.isDirectory()) rmSync(lockDir, { recursive: true, force: true }); + } catch { + /* best-effort */ + } +} + +function formatInteractiveSessionLockConflict( + data: InteractiveSessionLockData | null, + lockPath: string, +): string { + const pid = data?.pid ? String(data.pid) : "unknown"; + const startedAt = data?.startedAt ? ` started ${data.startedAt}` : ""; + const session = data?.sessionFile + ? `\n[sf] Active session: ${data.sessionFile}` + : ""; + return ( + `[sf] Another interactive sf session is already running for this repo (PID ${pid}${startedAt}).` + + session + + `\n[sf] Close that terminal first, or remove stale lock ${lockPath} if the process is gone.` + ); +} + +function registerInteractiveLockExitHandler(release: () => void): void { + process.once("exit", release); + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.once(signal, () => { + release(); + process.exit(signal === "SIGINT" ? 130 : 143); + }); + } +} diff --git a/src/tests/interactive-session-lock.test.ts b/src/tests/interactive-session-lock.test.ts new file mode 100644 index 000000000..6f2ad6fb0 --- /dev/null +++ b/src/tests/interactive-session-lock.test.ts @@ -0,0 +1,140 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + acquireInteractiveSessionLock, + type InteractiveSessionLockResult, +} from "../interactive-session-lock.js"; + +describe("interactive session lock", () => { + let dirs: string[] = []; + let children: ChildProcess[] = []; + let handles: InteractiveSessionLockResult[] = []; + + afterEach(() => { + for (const handle of handles) { + if (handle.acquired) handle.release(); + } + handles = []; + for (const child of children) { + if (!child.killed) child.kill("SIGTERM"); + } + children = []; + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }); + } + dirs = []; + }); + + it("acquires and releases a repo-scoped interactive lock", () => { + const dir = makeProjectDir(); + const result = acquireInteractiveSessionLock(dir, "/tmp/session.jsonl"); + handles.push(result); + + expect(result.acquired).toBe(true); + if (!result.acquired) return; + const data = JSON.parse(readFileSync(result.lockPath, "utf-8")); + expect(data.pid).toBe(process.pid); + expect(data.cwd).toBe(resolve(dir)); + + result.release(); + expect(() => readFileSync(result.lockPath, "utf-8")).toThrow(); + }); + + it("refuses a second live interactive owner for the same repo", () => { + const dir = makeProjectDir(); + const child = spawn( + process.execPath, + ["-e", "setInterval(() => {}, 1000)"], + { + cwd: dir, + stdio: "ignore", + }, + ); + children.push(child); + writeLock(dir, { + pid: child.pid ?? 0, + ppid: process.pid, + cwd: resolve(dir), + startedAt: "2026-05-05T00:00:00.000Z", + sessionFile: "/tmp/live-session.jsonl", + token: "live-owner", + }); + + const result = acquireInteractiveSessionLock(dir); + handles.push(result); + + expect(result.acquired).toBe(false); + if (result.acquired) return; + expect(result.pid).toBe(child.pid); + expect(result.message).toContain("Another interactive sf session"); + }); + + it("recovers automatically from a stale interactive lock", () => { + const dir = makeProjectDir(); + writeLock(dir, { + pid: 9_999_999, + ppid: 1, + cwd: resolve(dir), + startedAt: "2026-05-05T00:00:00.000Z", + token: "dead-owner", + }); + + const result = acquireInteractiveSessionLock(dir); + handles.push(result); + + expect(result.acquired).toBe(true); + if (!result.acquired) return; + const data = JSON.parse(readFileSync(result.lockPath, "utf-8")); + expect(data.pid).toBe(process.pid); + expect(data.token).not.toBe("dead-owner"); + }); + + it("treats a live pid from another cwd as stale pid reuse", () => { + const dir = makeProjectDir(); + writeLock(dir, { + pid: process.pid, + ppid: process.ppid, + cwd: "/tmp/not-this-repo", + startedAt: "2026-05-05T00:00:00.000Z", + token: "reused-pid", + }); + + const result = acquireInteractiveSessionLock(dir); + handles.push(result); + + expect(result.acquired).toBe(true); + }); + + function makeProjectDir(): string { + const dir = join( + tmpdir(), + `sf-interactive-lock-${process.pid}-${Date.now()}-${dirs.length}`, + ); + mkdirSync(dir, { recursive: true }); + dirs.push(dir); + return dir; + } + + function writeLock( + dir: string, + data: { + pid: number; + ppid: number; + cwd: string; + startedAt: string; + sessionFile?: string; + token: string; + }, + ): void { + const sfDir = join(dir, ".sf"); + mkdirSync(join(sfDir, "interactive.lock.d"), { recursive: true }); + writeFileSync( + join(sfDir, "interactive.lock"), + JSON.stringify(data, null, 2), + "utf-8", + ); + } +});