fix(bg-shell): recover from deleted cwd in timers (#2850)

* test(integration): suppress npm pack buffer overflows

* fix(bg-shell): recover from deleted cwd in timers
This commit is contained in:
mastertyko 2026-03-27 16:54:31 +01:00 committed by GitHub
parent 50f95d6fa7
commit 2bc92afa6b
5 changed files with 138 additions and 13 deletions

View file

@ -22,7 +22,7 @@ import {
loadManifest,
pruneDeadProcesses,
} from "./process-manager.js";
import { formatUptime, resolveBgShellPersistenceCwd } from "./utilities.js";
import { formatUptime, getBgShellLiveCwd, resolveBgShellPersistenceCwd } from "./utilities.js";
import { formatTokenCount } from "../shared/format-utils.js";
import type { BgShellSharedState } from "./index.js";
@ -213,7 +213,7 @@ export function registerBgShellLifecycle(pi: ExtensionAPI, state: BgShellSharedS
return {
render(width: number): string[] {
// ── Line 1: pwd (branch) [session] ... bg status ──
let pwd = process.cwd();
let pwd = getBgShellLiveCwd(state.latestCtx?.cwd);
const home = process.env.HOME || process.env.USERPROFILE;
if (home && pwd.startsWith(home)) {
pwd = `~${pwd.slice(home.length)}`;

View file

@ -42,16 +42,51 @@ export function formatTimeAgo(timestamp: number): string {
return formatDuration(Date.now() - timestamp) + " ago";
}
function deriveProjectRootFromAutoWorktree(cachedCwd?: string): string | undefined {
if (!cachedCwd) return undefined;
const match = cachedCwd.match(/^(.*?)[\\/]\.gsd[\\/]worktrees[\\/][^\\/]+(?:[\\/].*)?$/);
return match?.[1];
}
export function getBgShellLiveCwd(
cachedCwd?: string,
pathExists: (path: string) => boolean = existsSync,
getCwd: () => string = () => process.cwd(),
chdir: (path: string) => void = (path) => process.chdir(path),
): string {
try {
return getCwd();
} catch {
const projectRoot = deriveProjectRootFromAutoWorktree(cachedCwd);
const home = process.env.HOME || process.env.USERPROFILE;
const fallbacks = [projectRoot, cachedCwd, home, "/"].filter(
(candidate): candidate is string => Boolean(candidate),
);
for (const candidate of fallbacks) {
if (candidate !== "/" && !pathExists(candidate)) continue;
try {
chdir(candidate);
} catch {
// Best-effort only. Returning a known-good fallback is enough to avoid crashes.
}
return candidate;
}
return "/";
}
}
export function resolveBgShellPersistenceCwd(
cachedCwd: string,
liveCwd = process.cwd(),
liveCwd: string | undefined = undefined,
pathExists: (path: string) => boolean = existsSync,
): string {
const resolvedLiveCwd = liveCwd ?? getBgShellLiveCwd(cachedCwd, pathExists);
const cachedIsAutoWorktree = /(?:^|[\\/])\.gsd[\\/]worktrees[\\/]/.test(cachedCwd);
if (!cachedIsAutoWorktree) return cachedCwd;
if (cachedCwd === liveCwd && pathExists(cachedCwd)) return cachedCwd;
if (!pathExists(cachedCwd)) return liveCwd;
if (liveCwd !== cachedCwd) return liveCwd;
if (cachedCwd === resolvedLiveCwd && pathExists(cachedCwd)) return cachedCwd;
if (!pathExists(cachedCwd)) return resolvedLiveCwd;
if (resolvedLiveCwd !== cachedCwd) return resolvedLiveCwd;
return cachedCwd;
}

View file

@ -9,14 +9,28 @@ import { registerJournalTools } from "./journal-tools.js";
import { registerHooks } from "./register-hooks.js";
import { registerShortcuts } from "./register-shortcuts.js";
export function handleRecoverableExtensionProcessError(err: Error): boolean {
if ((err as NodeJS.ErrnoException).code === "EPIPE") {
process.exit(0);
}
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
const syscall = (err as NodeJS.ErrnoException).syscall;
if (syscall?.startsWith("spawn")) {
process.stderr.write(`[gsd] spawn ENOENT: ${(err as any).path ?? "unknown"} — command not found\n`);
return true;
}
if (syscall === "uv_cwd") {
process.stderr.write(`[gsd] ENOENT (${syscall}): ${err.message}\n`);
return true;
}
}
return false;
}
function installEpipeGuard(): void {
if (!process.listeners("uncaughtException").some((listener) => listener.name === "_gsdEpipeGuard")) {
const _gsdEpipeGuard = (err: Error): void => {
if ((err as NodeJS.ErrnoException).code === "EPIPE") {
process.exit(0);
}
if ((err as NodeJS.ErrnoException).code === "ENOENT" && (err as any).syscall?.startsWith("spawn")) {
process.stderr.write(`[gsd] spawn ENOENT: ${(err as any).path ?? "unknown"} — command not found\n`);
if (handleRecoverableExtensionProcessError(err)) {
return;
}
throw err;
@ -45,4 +59,3 @@ export function registerGsdExtension(pi: ExtensionAPI): void {
registerShortcuts(pi);
registerHooks(pi);
}

View file

@ -0,0 +1,59 @@
import test from "node:test";
import assert from "node:assert/strict";
import { handleRecoverableExtensionProcessError } from "../bootstrap/register-extension.ts";
test("handleRecoverableExtensionProcessError swallows spawn ENOENT", () => {
let stderr = "";
const originalWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = ((chunk: string | Uint8Array) => {
stderr += String(chunk);
return true;
}) as typeof process.stderr.write;
try {
const handled = handleRecoverableExtensionProcessError(
Object.assign(new Error("missing binary"), {
code: "ENOENT",
syscall: "spawn npm",
path: "npm",
}),
);
assert.equal(handled, true);
assert.match(stderr, /spawn ENOENT: npm/);
} finally {
process.stderr.write = originalWrite;
}
});
test("handleRecoverableExtensionProcessError swallows uv_cwd ENOENT", () => {
let stderr = "";
const originalWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = ((chunk: string | Uint8Array) => {
stderr += String(chunk);
return true;
}) as typeof process.stderr.write;
try {
const handled = handleRecoverableExtensionProcessError(
Object.assign(new Error("process.cwd failed"), {
code: "ENOENT",
syscall: "uv_cwd",
}),
);
assert.equal(handled, true);
assert.match(stderr, /ENOENT \(uv_cwd\): process\.cwd failed/);
} finally {
process.stderr.write = originalWrite;
}
});
test("handleRecoverableExtensionProcessError leaves unrelated errors unhandled", () => {
const handled = handleRecoverableExtensionProcessError(
Object.assign(new Error("permission denied"), {
code: "EPERM",
syscall: "open",
}),
);
assert.equal(handled, false);
});

View file

@ -1,7 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveBgShellPersistenceCwd } from "../resources/extensions/bg-shell/utilities.ts";
import {
getBgShellLiveCwd,
resolveBgShellPersistenceCwd,
} from "../resources/extensions/bg-shell/utilities.ts";
test("keeps non-worktree cwd unchanged", () => {
const cached = "/repo";
@ -43,3 +46,18 @@ test("keeps current auto-worktree cwd when it still matches process cwd", () =>
cached,
);
});
test("falls back to project root when process.cwd throws inside a stale auto-worktree", () => {
const cached = "/repo/.gsd/worktrees/M001";
const live = getBgShellLiveCwd(
cached,
(path) => path === "/repo",
() => {
throw Object.assign(new Error("uv_cwd"), { code: "ENOENT", syscall: "uv_cwd" });
},
() => {},
);
assert.equal(live, "/repo");
assert.equal(resolveBgShellPersistenceCwd(cached, live, (path) => path === "/repo"), "/repo");
});