diff --git a/src/resources/extensions/gsd/session-lock.ts b/src/resources/extensions/gsd/session-lock.ts index 38c9d8708..3a05cb303 100644 --- a/src/resources/extensions/gsd/session-lock.ts +++ b/src/resources/extensions/gsd/session-lock.ts @@ -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; }