diff --git a/apps/host-daemon/src/runtime-manager.test.ts b/apps/host-daemon/src/runtime-manager.test.ts index c308380a19..685057b9e1 100644 --- a/apps/host-daemon/src/runtime-manager.test.ts +++ b/apps/host-daemon/src/runtime-manager.test.ts @@ -1,4 +1,5 @@ -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; +import { once } from "node:events"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -155,7 +156,11 @@ function getProvisionWorkspacePath(args: ProvisionWorkspaceArgs): string { } } -function createFakeWorkspace(path: string, isGitRepo = true) { +function createFakeWorkspace( + path: string, + isGitRepo = true, + options: { managed?: boolean } = {}, +) { const status: GetStatusResult = makeWorkspaceStatus({ mergeBase: makeWorkspaceMergeBase(), }); @@ -172,7 +177,7 @@ function createFakeWorkspace(path: string, isGitRepo = true) { let sharedGitRefsFingerprintError: Error | null = null; const workspace = { path, - managed: false, + managed: options.managed ?? false, isGitRepo, isWorktree: false, getDefaultBranch: vi.fn(async () => "main"), @@ -1720,6 +1725,73 @@ describe("RuntimeManager", () => { expect(workspace.destroy).toHaveBeenCalledTimes(1); }); + it.skipIf(process.platform === "win32")( + "kills detached processes rooted in a managed workspace before destroying it", + async () => { + const workspacePath = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), "bb-destroy-env-")), + ); + const managedWorkspace = createFakeWorkspace(workspacePath, true, { + managed: true, + }); + const runtime = createFakeRuntime(); + const manager = new RuntimeManager({ + provisionWorkspace: + createProvisionWorkspaceMock(workspacePath).mockResolvedValue( + managedWorkspace, + ), + createRuntime: vi.fn(() => runtime), + }); + await manager.ensureEnvironment({ + environmentId: "env-procs", + workspacePath, + }); + // A new-session process is out of reach of any process-group kill. + const orphan = spawn("sh", ["-c", "sleep 300 & echo $!; wait"], { + cwd: workspacePath, + detached: true, + stdio: ["ignore", "pipe", "ignore"], + }); + orphan.unref(); + const grandchildPid = Number( + String((await once(orphan.stdout, "data"))[0]).trim(), + ); + const isAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }; + try { + expect(isAlive(grandchildPid)).toBe(true); + + await manager.destroyEnvironment("env-procs"); + + expect(managedWorkspace.destroy).toHaveBeenCalledTimes(1); + const deadline = Date.now() + 5000; + while ( + (isAlive(grandchildPid) || isAlive(orphan.pid ?? 0)) && + Date.now() < deadline + ) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + expect(isAlive(grandchildPid)).toBe(false); + expect(isAlive(orphan.pid ?? 0)).toBe(false); + } finally { + for (const pid of [grandchildPid, orphan.pid ?? 0]) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // already gone + } + } + await fs.rm(workspacePath, { recursive: true, force: true }); + } + }, + ); + it("forgets a retired environment without destroying its workspace", async () => { const workspace = createFakeWorkspace("/tmp/env-retired"); const runtime = createFakeRuntime(); diff --git a/apps/host-daemon/src/runtime-manager.ts b/apps/host-daemon/src/runtime-manager.ts index 2e35d96c3e..3442aa7ed6 100644 --- a/apps/host-daemon/src/runtime-manager.ts +++ b/apps/host-daemon/src/runtime-manager.ts @@ -9,6 +9,7 @@ import { type ReapedIdleProviderSession, } from "@bb/agent-runtime"; import type { Logger } from "@bb/logger"; +import { killProcessesWithCwdUnder } from "@bb/process-utils"; import type { PendingInteractionCreate, PendingInteractionResolution, @@ -1122,10 +1123,49 @@ export class RuntimeManager { this.entries.delete(environmentId); await this.stopWatchingStatus(entry); await entry.runtime.shutdown(); + await this.killManagedWorkspaceProcesses(entry); await entry.workspace.destroy(); await this.cleanupUnusedInjectedSkillStagingDirs([]); } + /** + * Reaps every process still rooted in a managed workspace before its + * directory is removed. Runtime and terminal shutdown signal their own + * process groups, but processes that start a new session (`setsid`, + * `nohup`, detached dev servers) survive that and would otherwise keep + * running with a cwd in a deleted directory. + */ + private async killManagedWorkspaceProcesses( + entry: RuntimeEntry, + ): Promise { + if (!entry.workspace.managed) { + return; + } + try { + const killed = await killProcessesWithCwdUnder({ + directory: entry.workspace.path, + }); + if (killed.length > 0) { + this.options.logger?.warn( + { + environmentId: entry.environmentId, + workspacePath: entry.workspace.path, + pids: killed.map((process) => process.pid), + }, + "Killed processes still running in a destroyed environment", + ); + } + } catch (error) { + this.options.logger?.warn( + { + environmentId: entry.environmentId, + reason: error instanceof Error ? error.message : String(error), + }, + "Failed to reap processes in a destroyed environment", + ); + } + } + async forgetEnvironment(environmentId: string): Promise { const existing = this.entries.get(environmentId); const pending = this.pendingEntries.get(environmentId); diff --git a/apps/host-daemon/src/terminals/terminal-manager.ts b/apps/host-daemon/src/terminals/terminal-manager.ts index d5d88dd006..96a9ac12e5 100644 --- a/apps/host-daemon/src/terminals/terminal-manager.ts +++ b/apps/host-daemon/src/terminals/terminal-manager.ts @@ -6,7 +6,10 @@ import path from "node:path"; import { spawn as spawnPty } from "node-pty"; import type { TerminalSessionCloseReason } from "@bb/domain"; import type { HostDaemonDaemonWsMessage } from "@bb/host-daemon-contract"; -import { sanitizeInheritedChildProcessEnv } from "@bb/process-utils"; +import { + killProcessGroup, + sanitizeInheritedChildProcessEnv, +} from "@bb/process-utils"; import type { HostDaemonServerTerminalMessage } from "../server-connection-support.js"; import type { HostDaemonLogger } from "../logger.js"; import { RuntimeManager } from "../runtime-manager.js"; @@ -54,7 +57,7 @@ export interface TerminalPtyExit { } export interface TerminalPtyProcess { - kill(signal?: string): void; + kill(signal?: NodeJS.Signals): void; onData(listener: (data: string) => void): TerminalPtyDisposable; onExit(listener: (event: TerminalPtyExit) => void): TerminalPtyDisposable; resize(cols: number, rows: number): void; @@ -209,7 +212,14 @@ export const nodePtyAdapter: TerminalPtyAdapter = { rows: args.rows, }); return { - kill: (signal) => pty.kill(signal), + // The pty child is a session leader, so its pid is also its process + // group id. Signal the whole group so background jobs die with the + // shell instead of surviving with a cwd in a removed workspace. + kill: (signal) => + killProcessGroup({ + child: { pid: pty.pid, kill: (groupSignal) => pty.kill(groupSignal) }, + signal: signal ?? "SIGHUP", + }), onData: (listener) => pty.onData(listener), onExit: (listener) => pty.onExit((event) => diff --git a/packages/agent-runtime/src/runtime-provider-process.ts b/packages/agent-runtime/src/runtime-provider-process.ts index 9be419bc1b..ed7534ccca 100644 --- a/packages/agent-runtime/src/runtime-provider-process.ts +++ b/packages/agent-runtime/src/runtime-provider-process.ts @@ -3,8 +3,11 @@ import { randomUUID } from "node:crypto"; import { createInterface } from "node:readline"; import type { HostDaemonAcpLaunchSpec } from "@bb/host-daemon-contract"; import { + isProcessGroupAlive, + killProcessGroup, sanitizeInheritedChildProcessEnv, spawnPortablePipedProcess, + supportsProcessGroups, } from "@bb/process-utils"; import type { ProviderAdapter, @@ -316,16 +319,26 @@ export class RuntimeProviderProcessManager { shutdownPromises.push( new Promise((resolve) => { const timer = setTimeout(() => { - providerProcess.child.kill("SIGKILL"); + killProcessGroup({ + child: providerProcess.child, + signal: "SIGKILL", + }); resolve(); }, 5000); providerProcess.child.on("exit", () => { + // Keep the SIGKILL timer while group members outlive the leader. + if (isProcessGroupAlive(providerProcess.child)) { + return; + } clearTimeout(timer); resolve(); }); - providerProcess.child.kill("SIGTERM"); + killProcessGroup({ + child: providerProcess.child, + signal: "SIGTERM", + }); }), ); } @@ -375,10 +388,13 @@ export class RuntimeProviderProcessManager { ...processConfig.env, }; + // Lead a process group so shutdown can also reap grandchildren the + // provider CLI starts (background dev servers, MCP servers, ...). const child = spawnPortablePipedProcess({ command: processConfig.command, args: processConfig.args, cwd: this.args.workspacePath, + detached: supportsProcessGroups(), env, }); let finalizeExit: () => void = () => undefined; @@ -531,19 +547,32 @@ export class RuntimeProviderProcessManager { await new Promise((resolve) => { const timeoutMs = args.timeoutMs ?? 5000; const softTimer = setTimeout(() => { - if (!hasChildProcessExited(args.providerProcess.child)) { - args.providerProcess.child.kill("SIGKILL"); + if ( + !hasChildProcessExited(args.providerProcess.child) || + isProcessGroupAlive(args.providerProcess.child) + ) { + killProcessGroup({ + child: args.providerProcess.child, + signal: "SIGKILL", + }); } }, timeoutMs); const hardTimer = setTimeout(resolve, timeoutMs + 1000); args.providerProcess.child.once("exit", () => { + // Keep the SIGKILL timer while group members outlive the leader. + if (isProcessGroupAlive(args.providerProcess.child)) { + return; + } clearTimeout(softTimer); clearTimeout(hardTimer); resolve(); }); - args.providerProcess.child.kill("SIGTERM"); + killProcessGroup({ + child: args.providerProcess.child, + signal: "SIGTERM", + }); }); } diff --git a/packages/host-workspace/src/provisioning.ts b/packages/host-workspace/src/provisioning.ts index 705b0d6157..d00e267424 100644 --- a/packages/host-workspace/src/provisioning.ts +++ b/packages/host-workspace/src/provisioning.ts @@ -8,9 +8,10 @@ import { type ProvisioningTranscriptEntry, } from "@bb/domain"; import { + killProcessGroup, sanitizeInheritedChildProcessEnv, spawnPortableOutputProcess, - type PortableOutputChildProcess, + supportsProcessGroups, } from "@bb/process-utils"; import { Workspace } from "./workspace.js"; import { tryWithCheckoutMutationLock } from "./checkout-mutation-lock.js"; @@ -88,11 +89,6 @@ interface BuildSetupScriptCommandArgs { scriptPath: string; } -interface KillSetupScriptProcessArgs { - child: PortableOutputChildProcess; - signal: NodeJS.Signals; -} - const SETUP_SCRIPT_ABORT_KILL_GRACE_MS = 2_000; function emitProgress( @@ -211,23 +207,6 @@ export function buildSetupScriptCommand( }; } -function shouldRunSetupScriptInProcessGroup(): boolean { - return process.platform !== "win32"; -} - -function killSetupScriptProcess(args: KillSetupScriptProcessArgs): void { - if (shouldRunSetupScriptInProcessGroup() && args.child.pid !== undefined) { - try { - process.kill(-args.child.pid, args.signal); - return; - } catch { - // Fall back to killing the direct child if the process group is gone. - } - } - - args.child.kill(args.signal); -} - function createProvisionCancelledError(cause?: unknown): WorkspaceError { return new WorkspaceError( "provision_cancelled", @@ -568,7 +547,7 @@ export async function runSetupScript( command: command.command, args: command.args, cwd: args.workspacePath, - detached: shouldRunSetupScriptInProcessGroup(), + detached: supportsProcessGroups(), env, }); @@ -597,7 +576,7 @@ export async function runSetupScript( const timeout = setTimeout(() => { timedOut = true; - killSetupScriptProcess({ + killProcessGroup({ child, signal: "SIGKILL", }); @@ -607,12 +586,12 @@ export async function runSetupScript( return; } abortRequested = true; - killSetupScriptProcess({ + killProcessGroup({ child, signal: "SIGTERM", }); abortKillTimeout = setTimeout(() => { - killSetupScriptProcess({ + killProcessGroup({ child, signal: "SIGKILL", }); diff --git a/packages/process-utils/src/index.ts b/packages/process-utils/src/index.ts index bc7aa03be3..f5201dfab8 100644 --- a/packages/process-utils/src/index.ts +++ b/packages/process-utils/src/index.ts @@ -1,7 +1,16 @@ import type { ChildProcess, StdioOptions } from "node:child_process"; import { randomUUID } from "node:crypto"; import { mkdirSync, writeFileSync } from "node:fs"; -import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { lstat, readdir, readlink, realpath } from "node:fs/promises"; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from "node:path"; import type { Readable, Writable } from "node:stream"; import crossSpawn from "cross-spawn"; @@ -36,6 +45,29 @@ export interface PortableOutputChildProcess extends PortableChildProcess { stderr: Readable; } +export interface KillProcessGroupArgs { + child: { + pid?: number | undefined; + kill: (signal: NodeJS.Signals) => unknown; + }; + signal: NodeJS.Signals; +} + +export interface ProcessWithCwd { + pid: number; + cwd: string; +} + +export interface ListProcessesWithCwdUnderArgs { + directory: string; +} + +export interface KillProcessesWithCwdUnderArgs { + directory: string; + /** Time to wait after SIGTERM before SIGKILL. Defaults to 2000ms. */ + graceMs?: number; +} + export interface ResolveContainedPathArgs { rootPath: string; candidatePath: string; @@ -45,17 +77,14 @@ export interface SanitizeInheritedChildProcessEnvArgs { env: NodeJS.ProcessEnv; } -export type SafeProcessDiagnosticKind = - | "startupFailure" - | "uncaughtException"; +export type SafeProcessDiagnosticKind = "startupFailure" | "uncaughtException"; export interface SafeProcessDiagnosticsOptions { logsDir: string; processName: string; } -export interface WriteSafeProcessDiagnosticReportArgs - extends SafeProcessDiagnosticsOptions { +export interface WriteSafeProcessDiagnosticReportArgs extends SafeProcessDiagnosticsOptions { kind: SafeProcessDiagnosticKind; error: unknown; now?: () => Date; @@ -145,6 +174,225 @@ export function spawnPortableOutputProcess( return child; } +/** + * True when the platform supports POSIX process groups. Callers spawn with + * `detached: true` so the child leads its own group and this helper can signal + * the whole group, including grandchildren. + */ +export function supportsProcessGroups(): boolean { + return process.platform !== "win32"; +} + +/** + * Sends `signal` to the child's process group when possible, and falls back + * to the direct child when the group is gone or unsupported. + */ +export function killProcessGroup(args: KillProcessGroupArgs): void { + if (supportsProcessGroups() && args.child.pid !== undefined) { + try { + process.kill(-args.child.pid, args.signal); + return; + } catch { + // Fall back to killing the direct child if the process group is gone. + } + } + args.child.kill(args.signal); +} + +/** + * True when at least one process still belongs to the group led by `child`. + * Returns false when the platform has no process groups or the child was not + * spawned as a group leader. + */ +export function isProcessGroupAlive(child: { + pid?: number | undefined; +}): boolean { + if (!supportsProcessGroups() || child.pid === undefined) { + return false; + } + try { + process.kill(-child.pid, 0); + return true; + } catch { + return false; + } +} + +function isPathUnderDirectory(candidate: string, directory: string): boolean { + // Linux reports a removed cwd as " (deleted)". + const normalized = candidate.endsWith(" (deleted)") + ? candidate.slice(0, -" (deleted)".length) + : candidate; + return ( + normalized === directory || normalized.startsWith(`${directory}${sep}`) + ); +} + +async function listLinuxProcessCwds(): Promise { + const entries = await readdir("/proc"); + const results: ProcessWithCwd[] = []; + await Promise.all( + entries.map(async (entry) => { + if (!/^\d+$/.test(entry)) { + return; + } + try { + const cwd = await readlink(`/proc/${entry}/cwd`); + results.push({ pid: Number(entry), cwd }); + } catch { + // Process exited or is not readable by this user. + } + }), + ); + return results; +} + +async function listLsofProcessCwds(): Promise { + const child = spawnPortableOutputProcess({ + command: "lsof", + args: ["-a", "-d", "cwd", "-F", "pn", "-w", "-n"], + }); + const chunks: Buffer[] = []; + child.stdout.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + child.stderr.resume(); + await new Promise((resolveExit) => { + child.once("error", () => resolveExit()); + child.once("exit", () => resolveExit()); + }); + const results: ProcessWithCwd[] = []; + let pid: number | null = null; + for (const line of Buffer.concat(chunks).toString("utf8").split("\n")) { + if (line.startsWith("p")) { + pid = Number(line.slice(1)); + } else if (line.startsWith("n") && pid !== null) { + results.push({ pid, cwd: line.slice(1) }); + } + } + return results; +} + +/** + * Resolves the canonical path of `directory` for cwd matching. Returns null + * when the final path component is a symlink: a workspace root that was + * swapped for a link must not redirect the sweep to unrelated processes. + */ +async function resolveSweepDirectory( + directory: string, +): Promise { + const resolved = resolve(directory); + let parent = dirname(resolved); + try { + parent = await realpath(parent); + } catch { + // The parent may already be gone; match against the given path. + } + const canonical = join(parent, basename(resolved)); + try { + if ((await lstat(canonical)).isSymbolicLink()) { + return null; + } + } catch { + // The directory itself may already be removed. Processes can still hold + // it as a "(deleted)" cwd, so keep matching on the path. + } + return canonical; +} + +/** + * Lists processes whose current working directory is `directory` or a path + * inside it. Excludes the current process. Returns [] on unsupported + * platforms and when `directory` is a symlink. + */ +export async function listProcessesWithCwdUnder( + args: ListProcessesWithCwdUnderArgs, +): Promise { + if (process.platform === "win32") { + return []; + } + const directory = await resolveSweepDirectory(args.directory); + if (directory === null) { + return []; + } + const all = + process.platform === "linux" + ? await listLinuxProcessCwds() + : await listLsofProcessCwds(); + return all.filter( + (entry) => + entry.pid !== process.pid && isPathUnderDirectory(entry.cwd, directory), + ); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function delay(ms: number): Promise { + await new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); +} + +const MAX_CWD_SWEEP_ROUNDS = 5; + +function signalProcesses( + targets: ProcessWithCwd[], + signal: NodeJS.Signals, + signalled: Map, +): void { + for (const target of targets) { + try { + process.kill(target.pid, signal); + signalled.set(target.pid, target); + } catch { + // Already exited. + } + } +} + +/** + * Sends SIGTERM to every process rooted in `directory`, waits up to + * `graceMs`, rescans, and SIGKILLs the processes that are still rooted there. + * Each signal follows a fresh scan, so a reused pid or a process that + * appeared during shutdown never receives a stale signal. Repeats until a + * scan finds nothing, bounded by a small round limit. Returns the processes + * that received a signal. + */ +export async function killProcessesWithCwdUnder( + args: KillProcessesWithCwdUnderArgs, +): Promise { + const graceMs = args.graceMs ?? 2000; + const signalled = new Map(); + for (let round = 0; round < MAX_CWD_SWEEP_ROUNDS; round += 1) { + const targets = await listProcessesWithCwdUnder({ + directory: args.directory, + }); + if (targets.length === 0) { + break; + } + signalProcesses(targets, "SIGTERM", signalled); + const deadline = Date.now() + graceMs; + while ( + Date.now() < deadline && + targets.some((target) => isProcessAlive(target.pid)) + ) { + await delay(50); + } + const survivors = await listProcessesWithCwdUnder({ + directory: args.directory, + }); + if (survivors.length === 0) { + break; + } + signalProcesses(survivors, "SIGKILL", signalled); + await delay(50); + } + return Array.from(signalled.values()); +} + export function resolveContainedPath( args: ResolveContainedPathArgs, ): string | null { diff --git a/packages/process-utils/test/process-tree.test.ts b/packages/process-utils/test/process-tree.test.ts new file mode 100644 index 0000000000..ccd0c0729f --- /dev/null +++ b/packages/process-utils/test/process-tree.test.ts @@ -0,0 +1,176 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + isProcessGroupAlive, + killProcessGroup, + killProcessesWithCwdUnder, + listProcessesWithCwdUnder, + spawnPortablePipedProcess, +} from "../src/index.js"; + +const posixOnly = process.platform === "win32" ? describe.skip : describe; + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitFor(check: () => boolean, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (!check()) { + if (Date.now() > deadline) { + throw new Error("Timed out waiting for condition"); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} + +async function readFirstLine(stream: NodeJS.ReadableStream): Promise { + const [chunk] = await once(stream, "data"); + return String(chunk).trim(); +} + +posixOnly("process tree helpers", () => { + const cleanupPids: number[] = []; + const cleanupDirs: string[] = []; + + afterEach(() => { + for (const pid of cleanupPids) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // already gone + } + } + cleanupPids.length = 0; + for (const dir of cleanupDirs) { + rmSync(dir, { recursive: true, force: true }); + } + cleanupDirs.length = 0; + }); + + it("kills the grandchild when the leader is signalled by process group", async () => { + const child = spawnPortablePipedProcess({ + command: "sh", + args: ["-c", "sleep 300 & echo $!; wait"], + detached: true, + }); + const grandchildPid = Number(await readFirstLine(child.stdout)); + cleanupPids.push(grandchildPid); + expect(isAlive(grandchildPid)).toBe(true); + const exited = once(child, "exit"); + + killProcessGroup({ child, signal: "SIGKILL" }); + + await waitFor(() => !isAlive(grandchildPid)); + await exited; + }); + + it("finds and kills processes whose cwd is inside a directory", async () => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "bb-cwd-sweep-"))); + cleanupDirs.push(dir); + // A detached session leader: not reachable by any process-group kill. + const child = spawn("sh", ["-c", "sleep 300 & echo $!; wait"], { + cwd: dir, + detached: true, + stdio: ["ignore", "pipe", "ignore"], + }); + child.unref(); + const grandchildPid = Number(await readFirstLine(child.stdout)); + cleanupPids.push(child.pid ?? 0, grandchildPid); + + const found = await listProcessesWithCwdUnder({ directory: dir }); + expect(found.map((entry) => entry.pid)).toEqual( + expect.arrayContaining([child.pid, grandchildPid]), + ); + expect(found.map((entry) => entry.pid)).not.toContain(process.pid); + + const killed = await killProcessesWithCwdUnder({ + directory: dir, + graceMs: 200, + }); + expect(killed.map((entry) => entry.pid)).toEqual( + expect.arrayContaining([child.pid, grandchildPid]), + ); + await waitFor(() => !isAlive(grandchildPid) && !isAlive(child.pid ?? 0)); + + // A sibling directory with a shared prefix must not match. + expect( + await listProcessesWithCwdUnder({ directory: `${dir}-other` }), + ).toEqual([]); + }); + + it("reports a live group after the leader exits and an empty one after the members die", async () => { + const child = spawnPortablePipedProcess({ + command: "sh", + args: ["-c", "sleep 300 & echo $!"], + detached: true, + }); + const grandchildPid = Number(await readFirstLine(child.stdout)); + cleanupPids.push(grandchildPid); + await once(child, "exit"); + + expect(isProcessGroupAlive(child)).toBe(true); + process.kill(grandchildPid, "SIGKILL"); + await waitFor(() => !isProcessGroupAlive(child)); + }); + + it("rescans and kills processes that appear while the first targets shut down", async () => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "bb-cwd-respawn-"))); + cleanupDirs.push(dir); + // On SIGTERM the target starts a new-session child and exits. + const child = spawn( + "sh", + [ + "-c", + "trap 'setsid sleep 300 >/dev/null 2>&1 < /dev/null & exit 0' TERM; echo ready; while :; do sleep 0.05; done", + ], + { cwd: dir, detached: true, stdio: ["ignore", "pipe", "ignore"] }, + ); + child.unref(); + await readFirstLine(child.stdout); + cleanupPids.push(child.pid ?? 0); + + const killed = await killProcessesWithCwdUnder({ + directory: dir, + graceMs: 500, + }); + + expect(killed.length).toBeGreaterThanOrEqual(2); + for (const target of killed) { + cleanupPids.push(target.pid); + } + await waitFor(() => killed.every((target) => !isAlive(target.pid))); + expect(await listProcessesWithCwdUnder({ directory: dir })).toEqual([]); + }); + + it("does not follow a symlinked workspace root", async () => { + const target = realpathSync(mkdtempSync(join(tmpdir(), "bb-cwd-target-"))); + const linkParent = mkdtempSync(join(tmpdir(), "bb-cwd-link-")); + cleanupDirs.push(target, linkParent); + const link = join(linkParent, "workspace"); + symlinkSync(target, link); + const child = spawn("sleep", ["300"], { cwd: target, stdio: "ignore" }); + cleanupPids.push(child.pid ?? 0); + await waitFor(() => (child.pid ?? 0) > 0); + + expect(await listProcessesWithCwdUnder({ directory: target })).toEqual([ + { pid: child.pid, cwd: target }, + ]); + expect(await listProcessesWithCwdUnder({ directory: link })).toEqual([]); + }); + + it("returns an empty list for a directory that no process uses", async () => { + const dir = mkdtempSync(join(tmpdir(), "bb-cwd-empty-")); + cleanupDirs.push(dir); + expect(await listProcessesWithCwdUnder({ directory: dir })).toEqual([]); + }); +});