From de1256d352343dd825fa807b0e3fd51cac855be8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 18 Mar 2026 17:02:37 -0400 Subject: [PATCH] fix: clean up stranded .gsd.lock/ directory to prevent false lock conflicts (#1251) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- src/resources/extensions/gsd/session-lock.ts | 57 ++++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) 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; }