diff --git a/__tests__/cli-ui-command.test.ts b/__tests__/cli-ui-command.test.ts index cb9a5edca..d2bd84898 100644 --- a/__tests__/cli-ui-command.test.ts +++ b/__tests__/cli-ui-command.test.ts @@ -328,6 +328,29 @@ describe('codegraph ui — serving', () => { } }, 60_000); + // `codegraph ui` re-execs itself with `--liftoff-only`, and the command the + // user started blocks in spawnSync, unable to forward a signal: killing it by + // pid (a process manager, an IDE task, `kill`) used to leave the server + // serving the port forever. POSIX-only: it relies on reparenting. + it.runIf(process.platform !== 'win32')('stops serving when the command it was started as is killed', async () => { + const viewer = await startViewer(['--no-open', '--port', '0', projectDir], { + CODEGRAPH_WASM_RELAUNCHED: '', + CODEGRAPH_PPID_POLL_MS: '200', + }); + expect((await get(viewer.port, '/api/stats')).status).toBe(200); + viewer.child.kill('SIGKILL'); + const deadline = Date.now() + 15_000; + let serving = true; + while (serving && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 200)); + serving = await get(viewer.port, '/api/stats').then( + () => true, + () => false + ); + } + expect(serving).toBe(false); + }, 60_000); + it('refuses a foreign Host end-to-end', async () => { const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {}); try { diff --git a/docs/viewer-launch-changelog.md b/docs/viewer-launch-changelog.md index 79a50d451..4d6e75fac 100644 --- a/docs/viewer-launch-changelog.md +++ b/docs/viewer-launch-changelog.md @@ -169,6 +169,8 @@ These describe `codegraph ui` and its screens. They were taken out of `## [Unrel ## Fixes — Symbols, tests and the viewer +- **Stopping `codegraph ui` stops the server.** Killing the command by its process id, as a process manager, an editor task or `kill` does, left the server running on its port with no way to reach it, until the machine restarted. The server now notices it has been left behind and shuts down, closing the index first. Ctrl+C was never affected. + - **A SwiftUI view with a preview is no longer listed as a file that runs something.** A `#Preview { … }` sits at the top level of a view's file, so every view with one showed up under entry points as if it ran code. A preview is Xcode's canvas, not code the app runs: its calls no longer count, and a file whose only top-level code is previews leaves the list. - **The Map opens a Maven or Gradle project on its packages.** A Java project keeps every file under `src/main/java/org///`, and those folders hold nothing but the next one, so the Map drew the whole program as one `src/main/java/org` box and no grouping option reached further. A folder with one subfolder and no files of its own no longer counts as a level: petclinic opens on `owner`, `vet`, `model` and `system`, each labelled `src/main/java/…/petclinic/owner`, with the full path on hover. diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index e0404edae..38b4e55b5 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -63,7 +63,7 @@ import { ansiColorsEnabled } from '../ui/color'; import { buildNode25BlockBanner, buildNodeTooOldBanner, MIN_NODE_MAJOR } from './node-version-check'; import { installFatalHandlers } from './fatal-handler'; import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime-flags'; -import { installCommandSupervision } from './command-supervision'; +import { installCommandSupervision, watchParent } from './command-supervision'; import { EXTRACTION_VERSION } from '../extraction/extraction-version'; import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry'; // Value import, but dependency-free by design so `--help` text can name the @@ -2140,6 +2140,10 @@ ${BROWSER_ENV}=none to never open one. }; process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown); + // Killing the command the user started (its pid, not Ctrl+C's process + // group) leaves this re-exec'd server with no parent to forward the + // signal: shut down the same way instead of serving the port forever. + watchParent(shutdown); }); /** diff --git a/src/bin/command-supervision.ts b/src/bin/command-supervision.ts index 6031a4999..a55bbffb1 100644 --- a/src/bin/command-supervision.ts +++ b/src/bin/command-supervision.ts @@ -59,38 +59,50 @@ export function installCommandSupervision(label: string, watchdog: WatchdogOptio // PPID watchdog: detect that the parent (or the host threaded past the // relaunch shim) died and we've been orphaned, then exit instead of leaking. - // Baseline from the CLI entry's earliest-possible capture — reading - // process.ppid here would miss a launcher killed during startup (#1185). - const originalPpid = EARLY_PPID; - const hostPpid = parseHostPpid(process.env[HOST_PPID_ENV]); - const pollMs = parsePpidPollMs(process.env.CODEGRAPH_PPID_POLL_MS); - let ppidTimer: ReturnType | null = null; - if (pollMs > 0) { - ppidTimer = setInterval(() => { - const reason = supervisionLostReason({ - originalPpid, - currentPpid: process.ppid, - hostPpid, - isAlive: isProcessAlive, - }); - if (reason) { - try { - process.stderr.write(`[CodeGraph ${label}] Parent process exited (${reason}); aborting.\n`); - } catch { /* stderr gone with the parent — exit anyway */ } - process.exit(1); - } - }, pollMs); - // Never let the watchdog itself keep the process alive past its real work. - ppidTimer.unref(); - } + const stopParentWatch = watchParent((reason) => { + try { + process.stderr.write(`[CodeGraph ${label}] Parent process exited (${reason}); aborting.\n`); + } catch { /* stderr gone with the parent — exit anyway */ } + process.exit(1); + }); let stopped = false; return { stop(): void { if (stopped) return; stopped = true; - if (ppidTimer) clearInterval(ppidTimer); + stopParentWatch(); liveness?.stop(); }, }; } + +/** + * Call `onLost` once, when the parent — or the host threaded past the + * relaunch shim — goes away. The shim blocks in `spawnSync` and cannot forward + * a signal, so this is how a re-exec'd child learns it was orphaned. Returns a + * stop function. `CODEGRAPH_PPID_POLL_MS=0` disables it. + */ +export function watchParent(onLost: (reason: string) => void): () => void { + // Baseline from the CLI entry's earliest-possible capture — reading + // process.ppid here would miss a launcher killed during startup (#1185). + const originalPpid = EARLY_PPID; + const hostPpid = parseHostPpid(process.env[HOST_PPID_ENV]); + const pollMs = parsePpidPollMs(process.env.CODEGRAPH_PPID_POLL_MS); + if (pollMs <= 0) return () => {}; + const timer = setInterval(() => { + const reason = supervisionLostReason({ + originalPpid, + currentPpid: process.ppid, + hostPpid, + isAlive: isProcessAlive, + }); + if (reason) { + clearInterval(timer); + onLost(reason); + } + }, pollMs); + // Never let the watchdog itself keep the process alive past its real work. + timer.unref(); + return () => clearInterval(timer); +}