fix: clean up stranded .gsd.lock/ directory to prevent false lock conflicts (#1251)

* fix: clean up stranded .gsd.lock/ directory to prevent false lock conflicts

Three fixes for stranded proper-lockfile lock directories:

1. releaseSessionLock: explicitly removes .gsd.lock/ after releasing
   the OS lock and deleting auto.lock

2. acquireSessionLock: when lock acquisition fails, checks if auto.lock
   is missing or the owning PID is dead. If so, removes the stale
   .gsd.lock/ dir and retries acquisition instead of failing.

3. process.on('exit') handler: registered at lock acquisition time as
   a safety net — cleans up .gsd.lock/ on normal process exit if
   releaseSessionLock wasn't called.

Fixes #1245

* fix: move gsdDir declaration before try/catch to fix TS2304 scope error

gsdDir was declared inside the try block but referenced in the catch
block's retry logic, causing 'Cannot find name gsdDir' build failures.
This commit is contained in:
Tom Boucher 2026-03-18 17:02:37 -04:00 committed by GitHub
parent 4d9aef5705
commit de1256d352

View file

@ -17,7 +17,7 @@
*/
import { createRequire } from "node:module";
import { existsSync, readFileSync, mkdirSync, unlinkSync } from "node:fs";
import { existsSync, readFileSync, mkdirSync, unlinkSync, rmSync, statSync } from "node:fs";
import { join, dirname } from "node:path";
import { gsdRoot } from "./paths.js";
import { atomicWriteSync } from "./atomic-write.js";
@ -92,11 +92,12 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
return acquireFallbackLock(basePath, lp, lockData);
}
const gsdDir = gsdRoot(basePath);
try {
// Try to acquire an exclusive OS-level lock on the lock file.
// We lock the directory (gsdRoot) since proper-lockfile works best
// on directories, and the lock file itself may not exist yet.
const gsdDir = gsdRoot(basePath);
mkdirSync(gsdDir, { recursive: true });
const release = lockfile.lockSync(gsdDir, {
@ -109,16 +110,53 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
_lockedPath = basePath;
_lockPid = process.pid;
// Safety net: clean up lock dir on process exit if _releaseFunction
// wasn't called (e.g., normal exit after clean completion) (#1245).
const lockDirForCleanup = join(gsdDir + ".lock");
process.once("exit", () => {
try {
if (_releaseFunction) { _releaseFunction(); _releaseFunction = null; }
} catch { /* best-effort */ }
try {
if (existsSync(lockDirForCleanup)) rmSync(lockDirForCleanup, { recursive: true, force: true });
} catch { /* best-effort */ }
});
// Write the informational lock data
atomicWriteSync(lp, JSON.stringify(lockData, null, 2));
return { acquired: true };
} catch (err) {
// Lock is held by another process
// Lock is held by another process — or the .gsd.lock/ directory is stranded.
// Check: if auto.lock is gone and no process is alive, the lock dir is stale.
const existingData = readExistingLockData(lp);
const existingPid = existingData?.pid;
// If no lock file or no alive process, try to clean up and re-acquire (#1245)
if (!existingData || (existingPid && !isPidAlive(existingPid))) {
try {
const lockDir = join(gsdDir + ".lock");
if (existsSync(lockDir)) rmSync(lockDir, { recursive: true, force: true });
if (existsSync(lp)) unlinkSync(lp);
// Retry acquisition after cleanup
const release = lockfile.lockSync(gsdDir, {
realpath: false,
stale: 300_000,
update: 10_000,
});
_releaseFunction = release;
_lockedPath = basePath;
_lockPid = process.pid;
atomicWriteSync(lp, JSON.stringify(lockData, null, 2));
return { acquired: true };
} catch {
// Retry also failed — fall through to the error path
}
}
const reason = existingPid
? `Another auto-mode session (PID ${existingPid}) is already running on this project.`
? `Another auto-mode session (PID ${existingPid}) appears to be running.\nStop it with \`kill ${existingPid}\` before starting a new session.`
: `Another auto-mode session is already running on this project.`;
return { acquired: false, reason, existingPid };
@ -233,6 +271,17 @@ export function releaseSessionLock(basePath: string): void {
// Non-fatal
}
// Remove the proper-lockfile directory (.gsd.lock/) if it exists.
// proper-lockfile creates this directory as the OS-level lock mechanism.
// If the process exits without calling _releaseFunction (SIGKILL, crash),
// this directory is stranded and blocks the next session (#1245).
try {
const lockDir = join(gsdRoot(basePath) + ".lock");
if (existsSync(lockDir)) rmSync(lockDir, { recursive: true, force: true });
} catch {
// Non-fatal
}
_lockedPath = null;
_lockPid = 0;
}