mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
fix(ui): the viewer server exits when the command it runs under is killed (#2119)
`codegraph ui` re-execs itself with `--liftoff-only`; the shim the user started blocks in spawnSync and cannot forward a signal. Killing it by pid — a process manager, an IDE task, `kill` — left the re-exec'd server serving its port forever (found during a sweep: servers from hours earlier were still answering on ports 4801–4808 against deleted indexes, holding their stdout pipes so the harness that started them never exited). Ctrl+C was unaffected (the signal reaches the whole process group). `index`/`init` already guard this with the #277 PPID watchdog. Factor it out of installCommandSupervision as `watchParent(onLost)` and use it in `ui` with the same shutdown as SIGINT/SIGTERM (close the index, then the socket). The liveness watchdog is deliberately not installed for `ui`: a long Steps computation can legitimately hold the event loop. Test: POSIX end-to-end through the real relaunch (CODEGRAPH_WASM_RELAUNCHED unset, 200ms poll) — SIGKILL the shim, the port stops answering; confirmed failing without the fix. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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/<company>/<app>/`, 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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<typeof setInterval> | 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user