diff --git a/CHANGELOG.md b/CHANGELOG.md index fc2b38fb..04eabbec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **CodeLLDB ships as per-platform npm packages (esbuild pattern)** — five new packages `@debugmcp/codelldb-{win32-x64,darwin-x64,darwin-arm64,linux-x64,linux-arm64}` (versioned by the CodeLLDB release, currently 1.11.8, payload staged from the digest-pinned VSIXs) are `optionalDependencies` of `@debugmcp/mcp-debugger`, so npm installs exactly the one matching your os/cpu. Rust and C/C++ debugging now work out of the box on every platform npm serves — previously the CLI tarball bundled linux-x64 only — and the core tarball shrinks from ~54 MB to a few MB. The resolver probes the installed platform package last — after the vendor tree and after `CODELLDB_PATH` — so an explicit `CODELLDB_PATH` still overrides the auto-installed package, and installs with `--omit=optional` keep working via `CODELLDB_PATH` (#383) ### Fixed +- **Startup orphan reapers no longer spike memory on hosts with many processes** — both reapers walked `/proc` with an unbounded `Promise.all` over every pid, so a host with ~1600 processes held that many pending promises and read buffers at once while the libuv threadpool retired four at a time. The resulting peak survived for the whole process lifetime, because V8 does not hand grown arenas back to the OS. Reads are now capped at 32 in flight: measured on a 1600-process Linux host the two reapers add 32 MB instead of 203 MB and finish faster (254 ms vs 377 ms), which takes a freshly started stdio server from 287 MB to 190 MB RSS - **Docker image builds vendor only the image's own platform** — the builder stage now sets `CODELLDB_VENDOR_ALL=false`, so the prebuild vendor step reuses the digest-verified engine the Dockerfile already fetched instead of re-downloading ~450 MB of win32/darwin CodeLLDB payloads the Linux image never uses (the `.dockerignore` excludes them from the context, so every fresh build paid that download and could fail on any network blip) - **vendor-codelldb.js can no longer die silently with exit 0** — the script-level root cause behind #389 (the Docker workaround shipped in v0.24.2 stands): a stalled extract-zip promise drained the event loop and Node exited 0 with no failure output. Extraction now runs under a watchdog (default 120 s, `CODELLDB_EXTRACT_TIMEOUT_MS`) whose pending timer keeps the event loop alive and converts a stall into a normal retry/failure, and a premature-exit guard forces exit code 1 with a requested/completed/unresolved-platforms diagnostic if the process would otherwise exit 0 before vendoring finished (#389) diff --git a/src/utils/bounded-concurrency.ts b/src/utils/bounded-concurrency.ts new file mode 100644 index 00000000..5b70b7ce --- /dev/null +++ b/src/utils/bounded-concurrency.ts @@ -0,0 +1,31 @@ +/** + * Runs `fn` over `items` with at most `limit` calls in flight, so peak memory + * scales with `limit` instead of `items.length`. + * + * Completion order is not guaranteed. A rejection propagates to the caller, + * but cancels nothing: only the failing worker stops, and the surviving + * workers keep draining the remaining items in the background even after the + * returned promise has rejected. Catch inside `fn` to survive single failures. + */ +export async function forEachBounded( + items: readonly T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + if (items.length === 0) { + return; + } + const requested = Number.isFinite(limit) ? Math.floor(limit) : 1; + const width = Math.max(1, Math.min(requested, items.length)); + let cursor = 0; + const worker = async (): Promise => { + for (;;) { + const index = cursor++; + if (index >= items.length) { + return; + } + await fn(items[index]!, index); + } + }; + await Promise.all(Array.from({ length: width }, worker)); +} diff --git a/src/utils/jvm-orphan-reaper.ts b/src/utils/jvm-orphan-reaper.ts index 74b070f8..403db43c 100644 --- a/src/utils/jvm-orphan-reaper.ts +++ b/src/utils/jvm-orphan-reaper.ts @@ -19,6 +19,8 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import * as fs from 'node:fs/promises'; +import { forEachBounded } from './bounded-concurrency.js'; +import { PROC_SCAN_CONCURRENCY } from './proc-scan-concurrency.js'; const execFileAsync = promisify(execFile); @@ -127,21 +129,19 @@ export async function listLinux(): Promise { return []; } const result: TaggedJvm[] = []; - await Promise.all( - entries.map(async (entry) => { - if (!/^\d+$/.test(entry)) return; - const pid = Number(entry); - let raw: string; - try { - raw = await fs.readFile(`/proc/${entry}/cmdline`, 'utf8'); - } catch { - return; // disappeared, or permission denied - } - const args = raw.split('\0').filter((s) => s.length > 0); - const tagged = parseArgs(pid, args); - if (tagged) result.push(tagged); - }), - ); + await forEachBounded(entries, PROC_SCAN_CONCURRENCY, async (entry) => { + if (!/^\d+$/.test(entry)) return; + const pid = Number(entry); + let raw: string; + try { + raw = await fs.readFile(`/proc/${entry}/cmdline`, 'utf8'); + } catch { + return; // disappeared, or permission denied + } + const args = raw.split('\0').filter((s) => s.length > 0); + const tagged = parseArgs(pid, args); + if (tagged) result.push(tagged); + }); return result; } diff --git a/src/utils/proc-scan-concurrency.ts b/src/utils/proc-scan-concurrency.ts new file mode 100644 index 00000000..7223c883 --- /dev/null +++ b/src/utils/proc-scan-concurrency.ts @@ -0,0 +1,2 @@ +/** How many `/proc//cmdline` reads the orphan reapers keep in flight. */ +export const PROC_SCAN_CONCURRENCY = 32; diff --git a/src/utils/proxy-orphan-reaper.ts b/src/utils/proxy-orphan-reaper.ts index 6e91f9ab..da4e3801 100644 --- a/src/utils/proxy-orphan-reaper.ts +++ b/src/utils/proxy-orphan-reaper.ts @@ -34,6 +34,8 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import * as fs from 'node:fs/promises'; import { isPidAlive, SignalFn } from './jvm-orphan-reaper.js'; +import { forEachBounded } from './bounded-concurrency.js'; +import { PROC_SCAN_CONCURRENCY } from './proc-scan-concurrency.js'; const execFileAsync = promisify(execFile); @@ -163,21 +165,19 @@ export async function listLinuxProxies(): Promise { return []; } const result: TaggedProxy[] = []; - await Promise.all( - entries.map(async (entry) => { - if (!/^\d+$/.test(entry)) return; - const pid = Number(entry); - let raw: string; - try { - raw = await fs.readFile(`/proc/${entry}/cmdline`, 'utf8'); - } catch { - return; // disappeared, or permission denied - } - const args = raw.split('\0').filter((s) => s.length > 0); - const tagged = parseProxyArgs(pid, args); - if (tagged) result.push(tagged); - }), - ); + await forEachBounded(entries, PROC_SCAN_CONCURRENCY, async (entry) => { + if (!/^\d+$/.test(entry)) return; + const pid = Number(entry); + let raw: string; + try { + raw = await fs.readFile(`/proc/${entry}/cmdline`, 'utf8'); + } catch { + return; // disappeared, or permission denied + } + const args = raw.split('\0').filter((s) => s.length > 0); + const tagged = parseProxyArgs(pid, args); + if (tagged) result.push(tagged); + }); return result; } diff --git a/tests/unit/utils/bounded-concurrency.test.ts b/tests/unit/utils/bounded-concurrency.test.ts new file mode 100644 index 00000000..39c606aa --- /dev/null +++ b/tests/unit/utils/bounded-concurrency.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi } from 'vitest'; +import { forEachBounded } from '../../../src/utils/bounded-concurrency.js'; + +/** Records how many calls were in flight at the same time. */ +function makeTracker() { + let inFlight = 0; + let peak = 0; + const release: Array<() => void> = []; + const fn = vi.fn(async () => { + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => release.push(resolve)); + inFlight--; + }); + const releaseAll = () => { + while (release.length > 0) release.shift()!(); + }; + return { fn, releaseAll, peak: () => peak }; +} + +describe('forEachBounded', () => { + it('does nothing for an empty list', async () => { + const fn = vi.fn(async () => {}); + await forEachBounded([], 8, fn); + expect(fn).not.toHaveBeenCalled(); + }); + + it('visits every item exactly once, with its index', async () => { + const seen: Array<[string, number]> = []; + await forEachBounded(['a', 'b', 'c', 'd', 'e'], 2, async (item, index) => { + seen.push([item, index]); + }); + expect(seen.sort((l, r) => l[1] - r[1])).toEqual([ + ['a', 0], + ['b', 1], + ['c', 2], + ['d', 3], + ['e', 4], + ]); + }); + + it('keeps at most `limit` calls in flight', async () => { + const items = Array.from({ length: 500 }, (_, i) => i); + let inFlight = 0; + let peak = 0; + const fn = vi.fn(async () => { + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setImmediate(resolve)); + inFlight--; + }); + + await forEachBounded(items, 4, fn); + + expect(fn).toHaveBeenCalledTimes(500); + expect(peak).toBe(4); + }); + + it('never starts more workers than there are items', async () => { + const { fn, releaseAll, peak } = makeTracker(); + const done = forEachBounded([1, 2], 32, fn); + await vi.waitFor(() => expect(fn).toHaveBeenCalledTimes(2)); + releaseAll(); + await done; + expect(peak()).toBe(2); + }); + + it.each([0, -1, 0.5, Number.NaN])('treats limit %s as sequential', async (limit) => { + const { fn, releaseAll, peak } = makeTracker(); + const done = forEachBounded([1, 2, 3], limit, fn); + await vi.waitFor(() => expect(fn).toHaveBeenCalledTimes(1)); + expect(peak()).toBe(1); + releaseAll(); + await vi.waitFor(() => expect(fn).toHaveBeenCalledTimes(2)); + releaseAll(); + await vi.waitFor(() => expect(fn).toHaveBeenCalledTimes(3)); + releaseAll(); + await done; + expect(peak()).toBe(1); + }); + + it('propagates a rejection from the callback', async () => { + const boom = new Error('boom'); + await expect( + forEachBounded([1, 2, 3], 2, async (item) => { + if (item === 2) throw boom; + }), + ).rejects.toBe(boom); + }); + + it('rejection stops only the failing worker; survivors keep draining', async () => { + const seen: number[] = []; + const err = await forEachBounded([1, 2, 3, 4], 2, async (item) => { + if (item === 1) throw new Error('first worker dies'); + seen.push(item); + await new Promise((resolve) => setImmediate(resolve)); + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + // The surviving worker is not cancelled: it drains the remaining items in + // the background even though the returned promise already rejected. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(seen).toEqual([2, 3, 4]); + }); +}); diff --git a/tests/unit/utils/jvm-orphan-reaper-internals.test.ts b/tests/unit/utils/jvm-orphan-reaper-internals.test.ts index 2523e8f4..d606e401 100644 --- a/tests/unit/utils/jvm-orphan-reaper-internals.test.ts +++ b/tests/unit/utils/jvm-orphan-reaper-internals.test.ts @@ -29,6 +29,7 @@ import { listDarwin, listWindows, } from '../../../src/utils/jvm-orphan-reaper.js'; +import { PROC_SCAN_CONCURRENCY } from '../../../src/utils/proc-scan-concurrency.js'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockExecFile = execFile as unknown as ReturnType; @@ -228,6 +229,26 @@ describe('listLinux', () => { ]); }); + it('bounds concurrent cmdline reads on hosts with many processes', async () => { + const pids = Array.from({ length: 500 }, (_, i) => String(1000 + i)); + mockReaddir.mockResolvedValueOnce(pids as never); + + let inFlight = 0; + let peak = 0; + mockReadFile.mockImplementation(async () => { + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setImmediate(resolve)); + inFlight--; + return 'bash\0-l\0'; + }); + + await listLinux(); + + expect(mockReadFile).toHaveBeenCalledTimes(pids.length); + expect(peak).toBeLessThanOrEqual(PROC_SCAN_CONCURRENCY); + }); + it('returns empty array when no /proc entries match the marker', async () => { mockReaddir.mockResolvedValueOnce(['100', '200'] as never); mockReadFile.mockResolvedValue('java\0-jar\0app.jar\0' as never); diff --git a/tests/unit/utils/proxy-orphan-reaper-internals.test.ts b/tests/unit/utils/proxy-orphan-reaper-internals.test.ts index 2efa008a..6adaaac7 100644 --- a/tests/unit/utils/proxy-orphan-reaper-internals.test.ts +++ b/tests/unit/utils/proxy-orphan-reaper-internals.test.ts @@ -29,6 +29,7 @@ import { listDarwinProxies, listWindowsProxies, } from '../../../src/utils/proxy-orphan-reaper.js'; +import { PROC_SCAN_CONCURRENCY } from '../../../src/utils/proc-scan-concurrency.js'; const mockExecFile = execFile as unknown as ReturnType; const mockReaddir = fsp.readdir as unknown as ReturnType; @@ -261,6 +262,26 @@ describe('listLinuxProxies', () => { { pid: 100, ownerPid: 42, sessionId: 'tag-a' }, ]); }); + + it('bounds concurrent cmdline reads on hosts with many processes', async () => { + const pids = Array.from({ length: 500 }, (_, i) => String(1000 + i)); + mockReaddir.mockResolvedValueOnce(pids as never); + + let inFlight = 0; + let peak = 0; + mockReadFile.mockImplementation(async () => { + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setImmediate(resolve)); + inFlight--; + return 'bash\0-l\0'; + }); + + await listLinuxProxies(); + + expect(mockReadFile).toHaveBeenCalledTimes(pids.length); + expect(peak).toBeLessThanOrEqual(PROC_SCAN_CONCURRENCY); + }); }); describe('listDarwinProxies', () => {