Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/resources/extensions/gsd/auto-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { migrateToExternalState, recoverFailedMigration } from "./migrate-extern
import { collectSecretsFromManifest } from "../get-secrets-from-user.js";
import { gsdRoot, resolveMilestoneFile } from "./paths.js";
import { invalidateAllCaches } from "./cache.js";
import { writeLock, clearLock } from "./crash-recovery.js";
import { writeLock, clearLock, readCrashLock, isLockProcessAlive } from "./crash-recovery.js";
import {
acquireSessionLock,
releaseSessionLock,
Expand Down Expand Up @@ -620,6 +620,12 @@ export async function bootstrapAutoSession(
return false;
}

const startupLock = readCrashLock(base);
if (startupLock && !isLockProcessAlive(startupLock)) {
clearLock(base);
ctx.ui.notify("Cleared stale auto-mode worker state.", "info");
}

const lockResult = acquireSessionLock(base);
if (!lockResult.acquired) {
ctx.ui.notify(lockResult.reason, "error");
Expand Down
3 changes: 1 addition & 2 deletions src/resources/extensions/gsd/auto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,6 @@ import { createWorkspace, scopeMilestone } from "./workspace.js";
import {
registerAutoWorker,
markWorkerStopping,
markWorkerStoppingByPid,
} from "./db/auto-workers.js";
import { releaseMilestoneLease } from "./db/milestone-leases.js";
import { normalizeRealPath } from "./paths.js";
Expand Down Expand Up @@ -923,7 +922,7 @@ export function checkRemoteAutoSession(projectRoot: string): {

if (!isLockProcessAlive(lock)) {
// Stale lock from a dead process — not a live remote session
markWorkerStoppingByPid(normalizeRealPath(projectRoot), lock.pid);
clearLock(projectRoot);
return { running: false };
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";

import { checkRemoteAutoSession } from "../auto.ts";
import { openDatabase, closeDatabase, _getAdapter } from "../gsd-db.ts";
import { registerAutoWorker } from "../db/auto-workers.ts";
import { normalizeRealPath } from "../paths.ts";
import { readCrashLock } from "../crash-recovery.ts";

function makeBase(): string {
const base = mkdtempSync(join(tmpdir(), "gsd-remote-lock-cleanup-"));
mkdirSync(join(base, ".gsd"), { recursive: true });
return base;
}

function cleanup(base: string): void {
try { closeDatabase(); } catch {}
try { rmSync(base, { recursive: true, force: true }); } catch {}
}

function expireWorker(workerId: string): void {
const db = _getAdapter()!;
db.prepare(
`UPDATE workers SET last_heartbeat_at = '1970-01-01T00:00:00.000Z' WHERE worker_id = :worker_id`,
).run({ ":worker_id": workerId });
}

function setWorkerPid(workerId: string, pid: number): void {
const db = _getAdapter()!;
db.prepare(
`UPDATE workers SET pid = :pid WHERE worker_id = :worker_id`,
).run({ ":pid": pid, ":worker_id": workerId });
}

function findDeadPidCandidate(): number {
const candidates = [99_999, 199_999, 299_999, 399_999];
for (const pid of candidates) {
try {
process.kill(pid, 0);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ESRCH") return pid;
}
}
throw new Error("Could not find a dead PID candidate for stale-lock test");
}

test("checkRemoteAutoSession clears stale lock state when lock PID is dead", (t) => {
const base = makeBase();
t.after(() => cleanup(base));

openDatabase(join(base, ".gsd", "gsd.db"));
const workerId = registerAutoWorker({ projectRootRealpath: normalizeRealPath(base) });
setWorkerPid(workerId, findDeadPidCandidate());
expireWorker(workerId);

assert.ok(readCrashLock(base), "precondition: stale lock exists before remote session check");

const remote = checkRemoteAutoSession(base);
assert.deepEqual(remote, { running: false });
assert.equal(readCrashLock(base), null, "stale lock should be cleared by remote session check");
});
Original file line number Diff line number Diff line change
Expand Up @@ -105,17 +105,25 @@ test("auto bootstrap validates blocked directories before touching .gsd migratio
const bootstrapIdx = autoStartSrc.indexOf("export async function bootstrapAutoSession(");
const bootstrapBody = autoStartSrc.slice(bootstrapIdx);
const bootstrapValidationIdx = bootstrapBody.indexOf("validateDirectory(base)");
const staleCrashReadIdx = bootstrapBody.indexOf("const startupLock = readCrashLock(base)");
const staleCrashClearIdx = bootstrapBody.indexOf("clearLock(base);");
const lockIdx = bootstrapBody.indexOf("acquireSessionLock(base)");
const bootstrapMigrationIdx = bootstrapBody.indexOf("migrateToExternalState(base)");

assert.ok(bootstrapIdx > -1, "bootstrapAutoSession should exist");
assert.ok(bootstrapValidationIdx > -1, "bootstrapAutoSession should validate the base directory");
assert.ok(lockIdx > -1, "bootstrapAutoSession should acquire a session lock for safe projects");
assert.ok(bootstrapMigrationIdx > -1, "bootstrapAutoSession should still migrate safe projects");
assert.ok(staleCrashReadIdx > -1, "bootstrapAutoSession should probe stale crash lock state before lock acquisition");
assert.ok(staleCrashClearIdx > -1, "bootstrapAutoSession should clear stale crash lock state when detected");
assert.ok(
bootstrapValidationIdx < lockIdx && bootstrapValidationIdx < bootstrapMigrationIdx,
"fresh bootstrap must reject blocked directories before locking or migrating .gsd state",
);
assert.ok(
staleCrashReadIdx < lockIdx && staleCrashClearIdx < lockIdx,
"fresh bootstrap must auto-clear stale crash lock state before session lock acquisition",
);
});

test("fresh start registers the auto worker before bootstrap enters worktree flow (#5405)", () => {
Expand Down
Loading