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 docs/development/setup-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ TEST_TIMEOUT=30000
| `DEBUG` | Enable debug output (e.g., `DEBUG=debug-mcp:*`) | Not set |
| `DAP_TRACE` | Set to `1` to trace every DAP frame to a per-session `dap-trace-<sessionId>.ndjson` (capped at 50 MB) | Not set |
| `DAP_TRACE_FILE` | Explicit DAP trace file path (implies tracing on) | Not set |
| `MCP_SKIP_ORPHAN_REAPERS` | Set to `1` to skip the startup orphan-process scans (e.g. PID-namespaced containers where orphans are impossible) | Not set |

## Troubleshooting Setup Issues

Expand Down
49 changes: 19 additions & 30 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ process.argv = process.argv.map(arg =>
);

import { createLogger } from './utils/logger.js';
import { reapOrphanJvms } from './utils/jvm-orphan-reaper.js';
import { reapOrphanProxies } from './utils/proxy-orphan-reaper.js';
import { runStartupJanitor } from './utils/startup-janitor.js';
import { DebugMcpServer } from './server.js';
import { setupErrorHandlers } from './cli/error-handlers.js';
import {
Expand Down Expand Up @@ -97,30 +96,17 @@ export async function main(): Promise<void> {
// below uses this to decide which orphans from prior runs are ours to kill.
process.env.MCP_DEBUGGER_MAIN_PID = String(process.pid);

// Best-effort cleanup of debuggee JVMs and proxy worker chains leaked by
// prior crashed runs. Awaited synchronously so a fresh server starts in a
// known-clean state; the two reapers run concurrently so their worst-case
// process-listing timeouts don't stack. Failures here must never block
// startup — both functions are designed not to throw, and allSettled is
// belt-and-suspenders.
const [jvmOutcome, proxyOutcome] = await Promise.allSettled([
reapOrphanJvms({ selfPid: process.pid, logger }),
reapOrphanProxies({ selfPid: process.pid, logger })
]);
if (jvmOutcome.status === 'fulfilled') {
if (jvmOutcome.value.killed.length > 0) {
logger.info(`[startup] Reaped ${jvmOutcome.value.killed.length} orphan JVM(s) from prior runs`);
}
} else {
logger.warn(`[startup] Orphan JVM reaper failed: ${(jvmOutcome.reason as Error)?.message ?? String(jvmOutcome.reason)}`);
}
if (proxyOutcome.status === 'fulfilled') {
if (proxyOutcome.value.killed.length > 0) {
logger.info(`[startup] Reaped ${proxyOutcome.value.killed.length} orphan proxy worker(s) from prior runs`);
}
} else {
logger.warn(`[startup] Orphan proxy reaper failed: ${(proxyOutcome.reason as Error)?.message ?? String(proxyOutcome.reason)}`);
}
// Best-effort cleanup of debris from prior crashed runs (orphan JVMs and
// proxy workers, stale session logs) runs fire-and-forget from the
// server-starting command actions below — never for --version/--help, and
// never blocking the transport from coming up (issue #399). Safe alongside
// the live server: reapers only kill processes whose recorded owner pid is
// dead. MCP_SKIP_ORPHAN_REAPERS=1 skips the process scans entirely.
const kickStartupJanitor = () => {
void runStartupJanitor({ logger }).catch(() => {
// runStartupJanitor logs its own failures; this guard is belt-and-suspenders.
});
};

// Setup error handlers
setupErrorHandlers({ logger });
Expand All @@ -129,19 +115,22 @@ export async function main(): Promise<void> {
const program = createCLI('debug-mcp-server', 'Step-through debugging MCP server for LLMs', getVersion());

// Setup commands
setupStdioCommand(program, (options) =>
handleStdioCommand(options, { logger, serverFactory: createDebugMcpServer })
);

setupStdioCommand(program, (options) => {
kickStartupJanitor();
return handleStdioCommand(options, { logger, serverFactory: createDebugMcpServer });
});

// The SSE/HTTP command modules pull in express and the SDK's HTTP transport
// stacks; import them only when their subcommand actually runs so stdio mode
// (the common case) never pays for them (issue #400).
setupSSECommand(program, async (options) => {
kickStartupJanitor();
const { handleSSECommand } = await import('./cli/sse-command.js');
return handleSSECommand(options, { logger, serverFactory: createDebugMcpServer });
});

setupHttpCommand(program, async (options) => {
kickStartupJanitor();
const { handleHttpCommand } = await import('./cli/http-command.js');
return handleHttpCommand(options, { logger, serverFactory: createDebugMcpServer });
});
Expand Down
113 changes: 16 additions & 97 deletions src/utils/jvm-orphan-reaper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,12 @@
* Only listing tagged JVMs is platform-divergent. The kill path uses Node's
* portable process.kill, which maps to TerminateProcess on Windows.
*/
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);
import { scanLinux, scanDarwin, scanWindows, scanProcessArgs, type ScannedProcess } from './process-scan.js';

const JVM_MARKER = '-Dmcp.debugger.jvm=true';
const OWNER_PID_PREFIX = '-Dmcp.debugger.owner_pid=';
const SESSION_TAG_PREFIX = '-Dmcp.debugger.session_tag=';

const LIST_TIMEOUT_MS = 5000;
const LIST_MAX_BUFFER = 10 * 1024 * 1024;

export interface TaggedJvm {
pid: number;
ownerPid: number;
Expand Down Expand Up @@ -107,106 +98,34 @@ export async function reapOrphanJvms(opts: ReapOptions): Promise<ReapResult> {
return result;
}

export async function listTaggedJvms(): Promise<TaggedJvm[]> {
switch (process.platform) {
case 'linux':
return listLinux();
case 'darwin':
return listDarwin();
case 'win32':
return listWindows();
default:
return [];
// The platform walks live in process-scan.ts (issue #399: shared with the
// proxy reaper); these wrappers apply the JVM matcher over the scan rows.
function matchTaggedJvms(processes: ScannedProcess[]): TaggedJvm[] {
const result: TaggedJvm[] = [];
for (const p of processes) {
const tagged = parseArgs(p.pid, p.args);
if (tagged) result.push(tagged);
}
return result;
}

export async function listTaggedJvms(): Promise<TaggedJvm[]> {
return matchTaggedJvms(await scanProcessArgs({ windowsProcessNames: ['java.exe'] }));
}

/** @internal Exposed for unit tests; not part of the public module API. */
export async function listLinux(): Promise<TaggedJvm[]> {
let entries: string[];
try {
entries = await fs.readdir('/proc');
} catch {
return [];
}
const result: TaggedJvm[] = [];
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;
return matchTaggedJvms(await scanLinux());
}

/** @internal Exposed for unit tests; not part of the public module API. */
export async function listDarwin(): Promise<TaggedJvm[]> {
// -ww disables column truncation; otherwise long java cmdlines lose the
// -D markers we depend on. -A lists all users' processes (we filter by
// owner_pid liveness anyway).
const { stdout } = await execFileAsync('ps', ['-ww', '-A', '-o', 'pid=,command='], {
timeout: LIST_TIMEOUT_MS,
maxBuffer: LIST_MAX_BUFFER,
});
const result: TaggedJvm[] = [];
for (const line of stdout.split('\n')) {
const trimmed = line.replace(/\s+$/, '');
if (!trimmed) continue;
const match = trimmed.match(/^\s*(\d+)\s+(.*)$/);
if (!match) continue;
const pid = Number(match[1]);
const args = match[2].split(/\s+/).filter(Boolean);
const tagged = parseArgs(pid, args);
if (tagged) result.push(tagged);
}
return result;
return matchTaggedJvms(await scanDarwin());
}

/** @internal Exposed for unit tests; not part of the public module API. */
export async function listWindows(): Promise<TaggedJvm[]> {
// Get-CimInstance is the modern path; wmic is deprecated and missing on
// fresh Windows 11 installs. ConvertTo-Json -Compress keeps stdout small.
// -NoProfile skips loading user profile scripts (faster, more deterministic).
const ps = `Get-CimInstance Win32_Process -Filter "Name='java.exe'" | Select-Object ProcessId, CommandLine | ConvertTo-Json -Compress`;
let stdout: string;
try {
const r = await execFileAsync('powershell.exe', ['-NoProfile', '-Command', ps], {
timeout: LIST_TIMEOUT_MS,
maxBuffer: LIST_MAX_BUFFER,
windowsHide: true,
});
stdout = r.stdout;
} catch {
return [];
}
const trimmed = stdout.trim();
if (!trimmed) return [];
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return [];
}
const items = Array.isArray(parsed) ? parsed : [parsed];
const result: TaggedJvm[] = [];
for (const item of items) {
if (!item || typeof item !== 'object') continue;
const obj = item as { ProcessId?: number; CommandLine?: string | null };
const pid = obj.ProcessId;
const cmdline = obj.CommandLine;
if (typeof pid !== 'number' || typeof cmdline !== 'string') continue;
// -D args don't contain unescaped whitespace, so naive split is enough.
const args = cmdline.split(/\s+/).filter(Boolean);
const tagged = parseArgs(pid, args);
if (tagged) result.push(tagged);
}
return result;
return matchTaggedJvms(await scanWindows(['java.exe']));
}

/** @internal Exposed for unit tests; not part of the public module API. */
Expand Down
134 changes: 134 additions & 0 deletions src/utils/process-scan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* Shared cross-platform process scan (issue #399).
*
* Both orphan reapers need the same raw data — (pid, argv) for running
* processes — and used to gather it independently: two /proc walks on Linux,
* two identical `ps -ww -A` execs on Darwin. One scan now produces the rows
* and each reaper contributes a matcher over them. Windows stays one
* name-filtered CIM query per process name (java.exe / node.exe): a single
* unfiltered Win32_Process query would be strictly more expensive than the
* two filtered ones.
*/
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);

const LIST_TIMEOUT_MS = 5000;
const LIST_MAX_BUFFER = 10 * 1024 * 1024;

export interface ScannedProcess {
pid: number;
args: string[];
}

export interface ProcessScanOptions {
/** win32 only: Win32_Process Name filters, one CIM query per name. */
windowsProcessNames: string[];
}

export async function scanProcessArgs(options: ProcessScanOptions): Promise<ScannedProcess[]> {
switch (process.platform) {
case 'linux':
return scanLinux();
case 'darwin':
return scanDarwin();
case 'win32':
return scanWindows(options.windowsProcessNames);
default:
return [];
}
}

/** @internal Exposed for unit tests; not part of the public module API. */
export async function scanLinux(): Promise<ScannedProcess[]> {
let entries: string[];
try {
entries = await fs.readdir('/proc');
} catch {
return [];
}
const result: ScannedProcess[] = [];
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);
result.push({ pid, args });
});
return result;
}

/** @internal Exposed for unit tests; not part of the public module API. */
export async function scanDarwin(): Promise<ScannedProcess[]> {
// -ww disables column truncation; otherwise long cmdlines lose the marker
// args the reapers depend on. -A lists all users' processes (matchers
// filter by owner_pid liveness anyway).
const { stdout } = await execFileAsync('ps', ['-ww', '-A', '-o', 'pid=,command='], {
timeout: LIST_TIMEOUT_MS,
maxBuffer: LIST_MAX_BUFFER,
});
const result: ScannedProcess[] = [];
for (const line of stdout.split('\n')) {
const trimmed = line.replace(/\s+$/, '');
if (!trimmed) continue;
const match = trimmed.match(/^\s*(\d+)\s+(.*)$/);
if (!match) continue;
const pid = Number(match[1]);
const args = match[2].split(/\s+/).filter(Boolean);
result.push({ pid, args });
}
return result;
}

/** @internal Exposed for unit tests; not part of the public module API. */
export async function scanWindows(processNames: string[]): Promise<ScannedProcess[]> {
const result: ScannedProcess[] = [];
for (const name of processNames) {
// Get-CimInstance is the modern path; wmic is deprecated and missing on
// fresh Windows 11 installs. ConvertTo-Json -Compress keeps stdout small.
// -NoProfile skips loading user profile scripts (faster, more deterministic).
const ps = `Get-CimInstance Win32_Process -Filter "Name='${name}'" | Select-Object ProcessId, CommandLine | ConvertTo-Json -Compress`;
let stdout: string;
try {
const r = await execFileAsync('powershell.exe', ['-NoProfile', '-Command', ps], {
timeout: LIST_TIMEOUT_MS,
maxBuffer: LIST_MAX_BUFFER,
windowsHide: true,
});
stdout = r.stdout;
} catch {
continue; // one failing query must not fail the others
}
const trimmed = stdout.trim();
if (!trimmed) continue;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
continue;
}
const items = Array.isArray(parsed) ? parsed : [parsed];
for (const item of items) {
if (!item || typeof item !== 'object') continue;
const obj = item as { ProcessId?: number; CommandLine?: string | null };
const pid = obj.ProcessId;
const cmdline = obj.CommandLine;
if (typeof pid !== 'number' || typeof cmdline !== 'string') continue;
// Naive whitespace split is enough for the reapers' marker args: they
// never contain whitespace, and a fragmenting path keeps the fragment
// holding the marker intact.
const args = cmdline.split(/\s+/).filter(Boolean);
result.push({ pid, args });
}
}
return result;
}
Loading
Loading