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
5 changes: 5 additions & 0 deletions docs/javascript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,11 @@ If neither `tsx` nor `ts-node` is installed, the factory emits a warning (not an
- Browser/Chrome debugging not yet supported (Node.js via `pwa-node` only)
- Remote debugging requires manual configuration
- Some advanced DAP features may not be exposed through MCP tools
- Debuggee exit codes are captured via an injected preload (js-debug itself
never emits a DAP `exited` event), so `exitCode` is unavailable in two
cases: attach mode (the target's environment is not under mcp-debugger's
control) and signal-killed debuggees (`process.on('exit')` never runs).
A missing `exitCode` is never replaced with a guessed value.

## Examples

Expand Down
35 changes: 35 additions & 0 deletions packages/adapter-javascript/assets/exitcode-shim.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* mcp-debugger exit-code shim (issue #247).
*
* vscode-js-debug never emits a DAP 'exited' event, so mcp-debugger cannot
* learn the debuggee's exit code from the protocol. This preload (injected
* via NODE_OPTIONS --require by the JavaScript adapter's launch transform)
* records the exit code to a per-session temp file; the proxy worker reads
* it when 'terminated' arrives and replays it as a synthesized 'exited'.
*
* Only the root debuggee writes: the shim claims the file via an env marker
* that descendants (spawned children, worker_threads, cluster workers)
* inherit, so wrappers like tsx — which propagate their child's exit code —
* still record the correct value at the outermost process.
*
* Must never break the debuggee: every step is wrapped, and a missing file
* variable makes the shim a no-op.
*/
(function () {
try {
var file = process.env.MCP_DEBUGGER_EXITCODE_FILE;
if (!file) return;
if (process.env.MCP_DEBUGGER_EXITCODE_CLAIMED === '1') return;
process.env.MCP_DEBUGGER_EXITCODE_CLAIMED = '1';
var fs = require('fs');
process.on('exit', function (code) {
try {
fs.writeFileSync(file, String(code));
} catch (e) {
/* never break the debuggee */
}
});
} catch (e) {
/* never break the debuggee */
}
})();
3 changes: 2 additions & 1 deletion packages/adapter-javascript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"types": "dist/index.d.ts",
"files": [
"dist",
"vendor/js-debug"
"vendor/js-debug",
"assets"
],
"exports": {
".": {
Expand Down
55 changes: 55 additions & 0 deletions packages/adapter-javascript/src/javascript-debug-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
* @since 0.1.0
*/
import { EventEmitter } from 'events';
import * as os from 'os';
import * as path from 'path';
import { randomUUID } from 'crypto';
import { fileURLToPath } from 'url';
import type { DebugProtocol } from '@vscode/debugprotocol';
import {
Expand Down Expand Up @@ -380,6 +382,12 @@ export class JavascriptDebugAdapter extends EventEmitter implements IDebugAdapte
? (userEnv.NODE_ENV as string)
: 'development';

// js-debug never emits a DAP 'exited' event, so preload a shim that
// records the debuggee's exit code for the proxy worker to replay as a
// synthesized event (issue #247). Launch mode only - attach targets run
// with an environment we don't control.
this.injectExitCodeShim(mergedEnv);

// Skip files defaults with optional user merge (dedupe)
const defaultSkip = ['<node_internals>/**', '**/node_modules/**'];
const userSkip = Array.isArray(u.skipFiles) ? (u.skipFiles as string[]) : undefined;
Expand Down Expand Up @@ -559,6 +567,53 @@ export class JavascriptDebugAdapter extends EventEmitter implements IDebugAdapte
};
}

/**
* Inject the exit-code preload shim into the debuggee's environment
* (issue #247). The shim writes the debuggee's exit code to a per-session
* temp file; the proxy worker reads it on 'terminated' and synthesizes the
* DAP 'exited' event js-debug never sends. Missing shim asset degrades
* gracefully to today's behavior (no exitCode), never a failed launch.
*/
private injectExitCodeShim(env: Record<string, string>): void {
// Idempotency: the merged env starts from process.env, so a server
// itself launched with the shim must not append a second preload
if (/exitcode-shim\.cjs/.test(env.NODE_OPTIONS ?? '')) {
return;
}

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const candidates = [
path.resolve(__dirname, '../assets/exitcode-shim.cjs'),
path.resolve(__dirname, '../../assets/exitcode-shim.cjs'),
// In bundled npx distribution
path.resolve(__dirname, 'assets/exitcode-shim.cjs'),
// In container builds
'/app/packages/adapter-javascript/assets/exitcode-shim.cjs',
'/app/node_modules/@debugmcp/adapter-javascript/assets/exitcode-shim.cjs'
];

let shimPath: string | undefined;
try {
shimPath = candidates.find(p => this.dependencies.fileSystem?.existsSync?.(p));
} catch {
shimPath = undefined;
}

if (!shimPath) {
this.dependencies.logger?.warn?.(
'[JavascriptDebugAdapter] exitcode-shim.cjs not found; debuggee exit code will not be captured'
);
return;
}

env.MCP_DEBUGGER_EXITCODE_FILE = path.join(os.tmpdir(), `mcp-exitcode-${randomUUID()}.txt`);
// Double quotes survive NODE_OPTIONS parsing for paths with spaces;
// forward slashes sidestep backslash-escape ambiguity on Windows
const requireArg = `--require "${shimPath.replace(/\\/g, '/')}"`;
env.NODE_OPTIONS = env.NODE_OPTIONS ? `${env.NODE_OPTIONS} ${requireArg}`.trim() : requireArg;
}

// ===== Attach Support =====

supportsAttach(): boolean {
Expand Down
102 changes: 102 additions & 0 deletions packages/adapter-javascript/tests/unit/exitcode-shim.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Tests for assets/exitcode-shim.cjs (issue #247)
*
* js-debug never emits a DAP 'exited' event, so the debuggee itself records
* its exit code via this NODE_OPTIONS preload; the proxy worker replays it as
* a synthesized 'exited' event. These tests exercise the shim against real
* node child processes.
*/
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const shimPath = path.resolve(__dirname, '../../assets/exitcode-shim.cjs');

const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-exitcode-shim-test-'));
let fileCounter = 0;

function nextExitFile(): string {
return path.join(tempDir, `exit-${++fileCounter}.txt`);
}

function nodeOptionsFor(shim: string): string {
// Same quoting the adapter uses: double quotes + forward slashes so
// Windows paths with spaces survive NODE_OPTIONS parsing
return `--require "${shim.replace(/\\/g, '/')}"`;
}

function runNode(script: string, exitFile: string, extraEnv: Record<string, string> = {}): number {
try {
execFileSync(process.execPath, ['-e', script], {
env: {
...process.env,
NODE_OPTIONS: nodeOptionsFor(shimPath),
MCP_DEBUGGER_EXITCODE_FILE: exitFile,
...extraEnv
},
stdio: 'pipe'
});
return 0;
} catch (err) {
return (err as { status?: number }).status ?? -1;
}
}

describe('exitcode-shim.cjs', () => {
beforeEach(() => {
expect(fs.existsSync(shimPath), `shim asset missing at ${shimPath}`).toBe(true);
});

afterAll(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});

it('records exit code 0 for a clean exit', () => {
const exitFile = nextExitFile();
const status = runNode('process.exit(0)', exitFile);
expect(status).toBe(0);
expect(fs.readFileSync(exitFile, 'utf8').trim()).toBe('0');
});

it('records a non-zero explicit exit code', () => {
const exitFile = nextExitFile();
const status = runNode('process.exit(7)', exitFile);
expect(status).toBe(7);
expect(fs.readFileSync(exitFile, 'utf8').trim()).toBe('7');
});

it('records exit code 1 for an uncaught throw', () => {
const exitFile = nextExitFile();
const status = runNode('throw new Error("boom")', exitFile);
expect(status).toBe(1);
expect(fs.readFileSync(exitFile, 'utf8').trim()).toBe('1');
});

it('only the root process writes; descendants inherit the claim and skip', () => {
const exitFile = nextExitFile();
// Parent spawns a child that exits 3 (inheriting env incl. NODE_OPTIONS,
// so the child also loads the shim), then the parent exits 5. The file
// must hold the ROOT's code even though the child exited last-but-first.
const script = [
"const { spawnSync } = require('child_process');",
"spawnSync(process.execPath, ['-e', 'process.exit(3)'], { env: process.env, stdio: 'ignore' });",
'process.exit(5);'
].join('\n');
const status = runNode(script, exitFile);
expect(status).toBe(5);
expect(fs.readFileSync(exitFile, 'utf8').trim()).toBe('5');
});

it('does nothing when MCP_DEBUGGER_EXITCODE_FILE is unset', () => {
const exitFile = nextExitFile();
execFileSync(process.execPath, ['-e', 'process.exit(0)'], {
env: { ...process.env, NODE_OPTIONS: nodeOptionsFor(shimPath) },
stdio: 'pipe'
});
expect(fs.existsSync(exitFile)).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -256,4 +256,78 @@ describe('JavascriptDebugAdapter.transformLaunchConfig', () => {
expect(env.CUSTOM_ENV).toBe('1');
expect(process.env.CUSTOM_ENV).toBe(before.CUSTOM_ENV);
});

describe('exit code shim injection (issue #247)', () => {
// The shim resolution consults dependencies.fileSystem.existsSync (same
// pattern as buildAdapterCommand's vendor lookup)
const depsWithFs = {
...deps,
fileSystem: { existsSync: () => true }
} as unknown as import('@debugmcp/shared').AdapterDependencies;

it('injects MCP_DEBUGGER_EXITCODE_FILE and a NODE_OPTIONS --require of the shim', async () => {
const withFs = new JavascriptDebugAdapter(depsWithFs);
const cfg = await withFs.transformLaunchConfig({
program: path.resolve('/proj/app.js')
} as any);

const env = cfg.env as Record<string, string>;
expect(env.MCP_DEBUGGER_EXITCODE_FILE).toMatch(/mcp-exitcode-[0-9a-f-]+\.txt$/);
expect(env.NODE_OPTIONS ?? '').toMatch(/--require "[^"]*exitcode-shim\.cjs"/);
// Forward slashes only: backslash escaping in NODE_OPTIONS is ambiguous on Windows
const requireArg = /--require "([^"]*)"/.exec(env.NODE_OPTIONS)![1];
expect(requireArg).not.toContain('\\');
});

it('preserves pre-existing NODE_OPTIONS content', async () => {
const withFs = new JavascriptDebugAdapter(depsWithFs);
const cfg = await withFs.transformLaunchConfig({
program: path.resolve('/proj/app.js'),
env: { NODE_OPTIONS: '--max-old-space-size=2048' }
} as any);

const env = cfg.env as Record<string, string>;
expect(env.NODE_OPTIONS).toContain('--max-old-space-size=2048');
expect(env.NODE_OPTIONS).toMatch(/--require "[^"]*exitcode-shim\.cjs"/);
});

it('does not double-append when NODE_OPTIONS already carries the shim', async () => {
const withFs = new JavascriptDebugAdapter(depsWithFs);
const cfg = await withFs.transformLaunchConfig({
program: path.resolve('/proj/app.js'),
env: { NODE_OPTIONS: '--require "/prior/exitcode-shim.cjs"' }
} as any);

const env = cfg.env as Record<string, string>;
const occurrences = env.NODE_OPTIONS.match(/exitcode-shim\.cjs/g) ?? [];
expect(occurrences.length).toBe(1);
});

it('skips injection cleanly when the shim asset cannot be resolved', async () => {
const depsNoShim = {
...deps,
fileSystem: { existsSync: () => false }
} as unknown as import('@debugmcp/shared').AdapterDependencies;
const withoutShim = new JavascriptDebugAdapter(depsNoShim);

const cfg = await withoutShim.transformLaunchConfig({
program: path.resolve('/proj/app.js')
} as any);

const env = cfg.env as Record<string, string>;
expect(env.MCP_DEBUGGER_EXITCODE_FILE).toBeUndefined();
expect(env.NODE_OPTIONS ?? '').not.toContain('exitcode-shim');
});

it('leaves attach configs untouched', async () => {
const withFs = new JavascriptDebugAdapter(depsWithFs);
const cfg = await withFs.transformAttachConfig({
port: 9229
} as any);

const env = (cfg.env ?? {}) as Record<string, string>;
expect(env.MCP_DEBUGGER_EXITCODE_FILE).toBeUndefined();
expect(env.NODE_OPTIONS ?? '').not.toContain('exitcode-shim');
});
});
});
4 changes: 3 additions & 1 deletion src/proxy/dap-proxy-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ export function createProductionDependencies(

fileSystem: {
ensureDir: (path: string) => fs.ensureDir(path),
pathExists: (path: string) => fs.pathExists(path)
pathExists: (path: string) => fs.pathExists(path),
readFile: (path: string, encoding: 'utf8') => fs.readFile(path, encoding),
remove: (path: string) => fs.remove(path)
},

processSpawner: {
Expand Down
2 changes: 2 additions & 0 deletions src/proxy/dap-proxy-interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ export interface ILogger {
export interface IFileSystem {
ensureDir(path: string): Promise<void>;
pathExists(path: string): Promise<boolean>;
readFile(path: string, encoding: 'utf8'): Promise<string>;
remove(path: string): Promise<void>;
}

/**
Expand Down
Loading
Loading