Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
31 changes: 31 additions & 0 deletions src/utils/bounded-concurrency.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
items: readonly T[],
limit: number,
fn: (item: T, index: number) => Promise<void>,
): Promise<void> {
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<void> => {
for (;;) {
const index = cursor++;
if (index >= items.length) {
return;
}
await fn(items[index]!, index);
}
};
await Promise.all(Array.from({ length: width }, worker));
}
30 changes: 15 additions & 15 deletions src/utils/jvm-orphan-reaper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -127,21 +129,19 @@ export async function listLinux(): Promise<TaggedJvm[]> {
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;
}

Expand Down
2 changes: 2 additions & 0 deletions src/utils/proc-scan-concurrency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** How many `/proc/<pid>/cmdline` reads the orphan reapers keep in flight. */
export const PROC_SCAN_CONCURRENCY = 32;
30 changes: 15 additions & 15 deletions src/utils/proxy-orphan-reaper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -163,21 +165,19 @@ export async function listLinuxProxies(): Promise<TaggedProxy[]> {
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;
}

Expand Down
104 changes: 104 additions & 0 deletions tests/unit/utils/bounded-concurrency.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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]);
});
});
21 changes: 21 additions & 0 deletions tests/unit/utils/jvm-orphan-reaper-internals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>;
Expand Down Expand Up @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/utils/proxy-orphan-reaper-internals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>;
const mockReaddir = fsp.readdir as unknown as ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading