fix: enforce one interactive sf per repo
This commit is contained in:
parent
3650cc3c41
commit
ffd2512906
5 changed files with 378 additions and 12 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -97,3 +97,5 @@ bun.lock
|
|||
.serena/
|
||||
repowise.db
|
||||
.sf/mcp.json
|
||||
.sf/interactive.lock
|
||||
.sf/interactive.lock.d/
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
36
src/cli.ts
36
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();
|
||||
}
|
||||
|
|
|
|||
210
src/interactive-session-lock.ts
Normal file
210
src/interactive-session-lock.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
140
src/tests/interactive-session-lock.test.ts
Normal file
140
src/tests/interactive-session-lock.test.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue