fix: enforce one interactive sf per repo

This commit is contained in:
Mikael Hugo 2026-05-05 20:55:53 +02:00
parent 3650cc3c41
commit ffd2512906
5 changed files with 378 additions and 12 deletions

2
.gitignore vendored
View file

@ -97,3 +97,5 @@ bun.lock
.serena/
repowise.db
.sf/mcp.json
.sf/interactive.lock
.sf/interactive.lock.d/

View file

@ -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",

View file

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

View file

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

View file

@ -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",
);
}
});