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
85 changes: 85 additions & 0 deletions examples/javascript/fork_attach_target.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Forking attach target for the JavaScript attach smoke tests (issue #501).
*
* Started as: node --inspect=127.0.0.1:<port> fork_attach_target.js
*
* The parent ticks forever and fork()s a child every 2s; each child announces
* itself over the IPC channel and exits, and the parent logs
* `child-handshake <n> pid=<pid>` on receipt — mirroring the fork + init-ACK
* pattern of mcp-debugger's own ProxyManager. A child that never completes the
* handshake (parked in waitForDebugger by a debugger's auto-attach bootloader)
* is logged as `child-wedged pid=<pid>` and killed so wedges cannot pile up.
*/
import { fork } from 'child_process';
import { fileURLToPath } from 'url';

const selfPath = fileURLToPath(import.meta.url);

if (process.send) {
// Child branch: announce over IPC, then exit once the parent acks (or after
// a short grace so an unacked child never lingers)
process.send({ type: 'child-ready', pid: process.pid });
process.on('message', (msg) => {
if (msg && msg.type === 'ack') {
process.exit(0);
}
});
setTimeout(() => process.exit(0), 2000);
} else {
const FORK_INTERVAL_MS = 2000;
const HANDSHAKE_TIMEOUT_MS = 5000;
const MAX_CONCURRENT_CHILDREN = 5;

let tickCounter = 0;
let handshakeCounter = 0;
const pendingChildren = new Set();

function tick() {
tickCounter += 1;
if (tickCounter % 10 === 0) {
console.log(`tick ${tickCounter}`);
}
}

function spawnChild() {
if (pendingChildren.size >= MAX_CONCURRENT_CHILDREN) {
return;
}
// execArgv: [] so children don't inherit the parent's --inspect flag and
// fight over its port. The #501 wedge mechanism is unaffected: js-debug's
// auto-attach bootloader rides NODE_OPTIONS (env), which fork() inherits.
const child = fork(selfPath, [], { execArgv: [], stdio: ['inherit', 'inherit', 'inherit', 'ipc'] });
pendingChildren.add(child);

const wedgeTimer = setTimeout(() => {
console.log(`child-wedged pid=${child.pid}`);
child.kill('SIGKILL');
}, HANDSHAKE_TIMEOUT_MS);

child.on('message', (msg) => {
if (msg && msg.type === 'child-ready') {
clearTimeout(wedgeTimer);
handshakeCounter += 1;
console.log(`child-handshake ${handshakeCounter} pid=${msg.pid}`);
try {
child.send({ type: 'ack' });
} catch {
// Child may already have exited on its own grace timer
}
}
});
child.on('exit', () => {
clearTimeout(wedgeTimer);
pendingChildren.delete(child);
});
child.on('error', () => {
clearTimeout(wedgeTimer);
pendingChildren.delete(child);
});
}

setInterval(tick, 100);
setInterval(spawnChild, FORK_INTERVAL_MS);
spawnChild();
console.log('fork attach target started');
}
12 changes: 11 additions & 1 deletion packages/adapter-javascript/src/javascript-debug-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ export class JavascriptDebugAdapter extends EventEmitter implements IDebugAdapte
'continueOnAttach',
'trace',
'websocketAddress',
'attachExistingChildren'
'attachExistingChildren',
'autoAttachChildProcesses'
] as const;

private state: AdapterState = AdapterState.UNINITIALIZED;
Expand Down Expand Up @@ -680,6 +681,15 @@ export class JavascriptDebugAdapter extends EventEmitter implements IDebugAdapte
name: 'Attach to Node.js process',
host: (host as string | undefined) || '127.0.0.1',
port: port as number | undefined,
// js-debug's pwa-node attach defaults this to true, injecting its
// NODE_OPTIONS bootloader into the inspected process; every fork() then
// parks under waitForDebugger and only one child can be adopted (#501).
// Default it off like launch mode does; an explicit caller value wins
// (never silently override a supported key — cf. #499).
autoAttachChildProcesses:
typeof rest.autoAttachChildProcesses === 'boolean'
? rest.autoAttachChildProcesses
: false,
} as LanguageSpecificAttachConfig;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,5 +376,39 @@ describe('JavascriptDebugAdapter.transformLaunchConfig', () => {
expect(cfg.skipFiles).toEqual(['<node_internals>/**']);
expect(cfg.continueOnAttach).toBe(true);
});

it('defaults autoAttachChildProcesses to false on attach (issue #501)', () => {
const adapter = new JavascriptDebugAdapter(deps);
const cfg = adapter.transformAttachConfig({
request: 'attach',
port: 9229
} as any) as Record<string, unknown>;

// js-debug's pwa-node attach defaults this to true, which parks every
// fork() of the inspected process in waitForDebugger
expect(cfg.autoAttachChildProcesses).toBe(false);
});

it('respects a caller-supplied autoAttachChildProcesses (issue #501)', () => {
const adapter = new JavascriptDebugAdapter(deps);
const optIn = adapter.transformAttachConfig({
request: 'attach',
port: 9229,
autoAttachChildProcesses: true
} as any) as Record<string, unknown>;
expect(optIn.autoAttachChildProcesses).toBe(true);

const optOut = adapter.transformAttachConfig({
request: 'attach',
port: 9229,
autoAttachChildProcesses: false
} as any) as Record<string, unknown>;
expect(optOut.autoAttachChildProcesses).toBe(false);
});

it('lists autoAttachChildProcesses as a supported attach key (issue #501)', () => {
const adapter = new JavascriptDebugAdapter(deps);
expect(adapter.supportedAttachKeys).toContain('autoAttachChildProcesses');
});
});
});
26 changes: 23 additions & 3 deletions packages/shared/src/interfaces/adapter-policy-js.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,20 @@ export const JsDebugAdapterPolicy: AdapterPolicy = {
if (typeof stopOnEntryValue === 'boolean') {
attachArgs.stopOnEntry = stopOnEntryValue;
}
// js-debug's pwa-node attach defaults autoAttachChildProcesses to true,
// which bootloads every fork() of the inspected process into
// waitForDebugger; with single-child adoption those forks wedge (#501).
// The MCP path already defaults this off in transformAttachConfig; this
// guard makes the policy self-contained for embedders that bypass the
// adapter transform. A caller-supplied boolean is respected, sourced
// like stopOnEntry above: launchConfig (via callerAttachExtras), then
// dapLaunchArgs.
if (typeof attachArgs.autoAttachChildProcesses !== 'boolean') {
attachArgs.autoAttachChildProcesses =
typeof a.autoAttachChildProcesses === 'boolean'
? (a.autoAttachChildProcesses as boolean)
: false;
}
try {
console.info(`[JsDebugAdapterPolicy] [JS] Sending 'attach' to ${attachPort} (address=${attachHost})`);
await pm.sendDapRequest('attach', attachArgs);
Expand Down Expand Up @@ -748,11 +762,17 @@ export const JsDebugAdapterPolicy: AdapterPolicy = {
const cfg = args?.configuration ?? {};
const pendingId: string | undefined = cfg?.__pendingTargetId;

// Send acknowledgment
// Send acknowledgment. The early success ack is correct: js-debug
// ignores the response body — a pending target is resolved only by
// a fresh DAP connection attaching with its __pendingTargetId.
context.sendResponse(request, {});

if (pendingId && typeof pendingId === 'string') {
// Check if not already adopted
// Bookkeeping invariant (issues #249/#501): the id is added here,
// before the adoption/release runs; MinimalDapClient removes it
// again when adoption throws or the release fails, so a re-sent
// startDebugging can retry. Adopted and released targets stay
// recorded — both are settled server-side.
if (!context.adoptedTargets.has(pendingId)) {
context.adoptedTargets.add(pendingId);
return {
Expand Down
Loading
Loading