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:
parent
50f95d6fa7
commit
2bc92afa6b
5 changed files with 138 additions and 13 deletions
|
|
@ -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)}`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue