From 3a903e82664ede542705a5b72f1ca23b45a61768 Mon Sep 17 00:00:00 2001 From: Finomosec <1665799+Finomosec@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:29:33 +0200 Subject: [PATCH 1/2] perf(reaper): bound /proc scan concurrency at startup Both orphan reapers walked /proc with an unbounded Promise.all over every pid, holding a pending promise plus a read buffer per process while the libuv threadpool retired four at a time. On a host with ~1600 processes that peak was ~200 MB, and it stayed resident for the whole process lifetime because V8 does not hand grown arenas back to the OS. Capping reads at 32 in flight cuts the two reapers' overhead from 203 MB to 32 MB and is faster (254 ms vs 377 ms); a freshly started stdio server drops from 287 MB to 190 MB RSS. Signed-off-by: Finomosec <1665799+Finomosec@users.noreply.github.com> --- CHANGELOG.md | 1 + src/utils/bounded-concurrency.ts | 29 ++++++ src/utils/jvm-orphan-reaper.ts | 30 +++---- src/utils/proc-scan-concurrency.ts | 2 + src/utils/proxy-orphan-reaper.ts | 30 +++---- tests/unit/utils/bounded-concurrency.test.ts | 90 +++++++++++++++++++ .../utils/jvm-orphan-reaper-internals.test.ts | 21 +++++ .../proxy-orphan-reaper-internals.test.ts | 21 +++++ 8 files changed, 194 insertions(+), 30 deletions(-) create mode 100644 src/utils/bounded-concurrency.ts create mode 100644 src/utils/proc-scan-concurrency.ts create mode 100644 tests/unit/utils/bounded-concurrency.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 77118917..2f958fed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,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..4382759a --- /dev/null +++ b/src/utils/bounded-concurrency.ts @@ -0,0 +1,29 @@ +/** + * 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 and leaves the + * remaining items unprocessed; 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..cf397de5 --- /dev/null +++ b/tests/unit/utils/bounded-concurrency.test.ts @@ -0,0 +1,90 @@ +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); + }); +}); 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', () => { From e69e586cbab18d77652b15c6d111fa91ca0a0c54 Mon Sep 17 00:00:00 2001 From: JF Date: Fri, 21 Aug 2026 19:19:14 -0400 Subject: [PATCH 2/2] docs(bounded-concurrency): correct forEachBounded rejection semantics + pin with test A rejection does not leave the remaining items unprocessed: Promise.all rejects early but cancels nothing, so only the failing worker stops and the surviving workers keep draining items in the background after the returned promise has rejected. Moot for the reapers (their callback never throws) but the helper is generic and the old comment would mislead the next caller. New test pins the actual behavior. Co-Authored-By: Claude Fable 5 --- src/utils/bounded-concurrency.ts | 6 ++++-- tests/unit/utils/bounded-concurrency.test.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/utils/bounded-concurrency.ts b/src/utils/bounded-concurrency.ts index 4382759a..5b70b7ce 100644 --- a/src/utils/bounded-concurrency.ts +++ b/src/utils/bounded-concurrency.ts @@ -2,8 +2,10 @@ * 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 and leaves the - * remaining items unprocessed; catch inside `fn` to survive single failures. + * 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[], diff --git a/tests/unit/utils/bounded-concurrency.test.ts b/tests/unit/utils/bounded-concurrency.test.ts index cf397de5..39c606aa 100644 --- a/tests/unit/utils/bounded-concurrency.test.ts +++ b/tests/unit/utils/bounded-concurrency.test.ts @@ -87,4 +87,18 @@ describe('forEachBounded', () => { }), ).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]); + }); });