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 @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed
- **Ruby launches survive rdbg's dropped `initialize` response** — rdbg can process `initialize` (provably: it emits the `initialized` event 34ms later) yet never send the response, and the proxy awaited that response unconditionally — so the launch sat silent until the 30s init deadline killed it blaming adapter startup, even though an immediate retry always worked. Ruby launch initialization now races the response against the `initialized` event plus a 2s grace period: when the event wins, the launch proceeds with unknown capabilities (a late response still captures them) and a warning names what happened. Attach sessions and other adapters are unchanged (#492)
- **The 30s proxy-init timeout now says what actually stalled instead of blaming adapter installation** — the old message ("the debug adapter failed to start or is not properly configured — check that it is installed") was written for the nothing-ever-connected case and was confidently wrong for every other stage, sending agents to verify healthy toolchains with nowhere to go next (#492's incident: adapter spawned, connected, and emitted events — one response frame was missing). The worker now reports init progress to the parent (process spawned + PID, DAP transport connected, which handshake request is in flight), and the timeout message reflects the reached stage — e.g. *connected to the debug adapter, but the "initialize" request never received a response; the adapter process is running (PID N); an adapter-side protocol stall, not a missing install* — with the structured facts (`initProgress`, `proxyLogPath`) included in the failed `start_debugging` result's `data`, where the agent can actually see them. The install hint survives only for the case it correctly describes (#493)
- **An adopted thread anchor survives `list_threads` and follow-up inspection calls** — the anchor adopted by `get_stack_trace`'s frameless-thread fallback (or an explicit `get_stack_trace {threadId}`, #465) was silently reverted to the stale stopped-event thread by the functional-core state echo on the very next DAP response or event: `list_threads` clobbered it, and even the documented `threadId` recovery survived exactly one inspection call because that call's own response reverted it — so stack → threads → locals answered about the frameless thread and returned nothing. The echo no longer overwrites the live anchor (the imperative fast path is authoritative), and adoptions are mirrored into the functional-core snapshot so the two stores can't disagree again (#496)
- **js breakpoints tell the truth about binding — attach and launch** — js-debug binds breakpoints in a child session while the parent answers with provisional stubs (`verified:false`, "Unbound breakpoint", parent-space ids), and nothing reconciled the two on the attach path: a breakpoint set after `attach_to_process` raced a fire-and-forget child mirror, and one set before attach stayed "Unbound breakpoint" with no adapterId forever — even after it demonstrably fired — because js-debug answers a no-change re-send with an empty echo and emits no late bind event for it. The proxy now hands the child's authoritative `setBreakpoints` response back through the sync path (marked child-sourced, bounded 3s wait), `attach_to_process` gains the post-launch belt-and-braces re-sync (with a clear+re-set to force a fresh echo for already-registered sets), and every previously-silent mirror failure mode logs. Child-origin `breakpoint` events are tagged at the proxy, so a late parent stub whose integer id collides with the stored child id can no longer downgrade a verified record by id-match, and parent-space/provisional-stub ids never enter the store (the #495 intermittent signature); `syncBreakpointsForFile` also normalizes raw l10n keys before storing, closing #471's last gap (#500, #495)
- **Attach-mode `statement`/`expectedContent` rejection states the real reason** — the error claimed the file "is a class name or remote path" even for a readable local `.py` the server had just echoed contents from, sending agents down a path-problem rabbit hole that doesn't exist; the rejection (which is the documented contract) now says what's actually going on — content addressing is not supported for attach sessions because the debuggee's loaded source, not the host's copy, is the authority — while the class-name wording remains for the case it genuinely describes (Java FQCNs) (#497)
Expand Down
10 changes: 6 additions & 4 deletions docs/architecture/component-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,10 +420,12 @@ export const ErrorMessages = {
`This typically means the debug adapter has crashed or lost connection. ` +
`Try restart_debugging to relaunch the session. If the problem persists, check the debug adapter logs.`,

proxyInitTimeout: (timeout: number) =>
`Debug proxy initialization did not complete within ${timeout}s. ` +
`This may indicate that the debug adapter failed to start or is not properly configured. ` +
`Check that the required debug adapter is installed and accessible.`
proxyInitTimeout: (timeout: number, progress?: ProxyInitProgress) =>
// Invariant first sentence; the rest reflects how far initialization
// actually got (spawned? transport connected? which DAP request is
// unanswered?) so the error never blames a missing install when the
// adapter demonstrably connected (issue #493)
`Debug proxy initialization did not complete within ${timeout}s. ` + /* stage-aware detail */
};
```

Expand Down
10 changes: 6 additions & 4 deletions docs/architecture/system-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,10 +303,12 @@ The system uses centralized error messages (`src/utils/error-messages.ts`) with:

Example from error-messages.ts:
```typescript
proxyInitTimeout: (timeout: number) =>
`Debug proxy initialization did not complete within ${timeout}s. ` +
`This may indicate that the debug adapter failed to start or is not properly configured. ` +
`Check that the required debug adapter is installed and accessible.`
proxyInitTimeout: (timeout: number, progress?: ProxyInitProgress) =>
// Invariant first sentence; the rest reflects how far initialization got
// (spawned? transport connected? which DAP request is unanswered?) so the
// error never blames a missing install when the adapter demonstrably
// connected (issue #493)
`Debug proxy initialization did not complete within ${timeout}s. ` + /* stage-aware detail */
```

## Next Steps
Expand Down
10 changes: 6 additions & 4 deletions docs/patterns/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ export const ErrorMessages = {
`If the operation is expected to take this long, retry with a larger 'timeout' (ms) argument. ` +
`Note the operation may still be running in the debuggee.`,

proxyInitTimeout: (timeout: number) =>
`Debug proxy initialization did not complete within ${timeout}s. ` +
`This may indicate that the debug adapter failed to start or is not properly configured. ` +
`Check that the required debug adapter is installed and accessible.`,
proxyInitTimeout: (timeout: number, progress?: ProxyInitProgress) =>
// Invariant first sentence; the rest reflects how far initialization
// actually got (spawned? transport connected? which DAP request is
// unanswered?) so the error never blames a missing install when the
// adapter demonstrably connected (issue #493)
`Debug proxy initialization did not complete within ${timeout}s. ` + /* stage-aware detail */,

stepStillRunning: (graceSeconds: number) =>
`Step dispatched; the program is still executing after ${graceSeconds}s ` +
Expand Down
2 changes: 2 additions & 0 deletions src/dap-core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export type ProxyStatusMessage =
| { type: 'status'; sessionId: string; status: 'adapter_capabilities'; capabilities: DebugProtocol.Capabilities; data?: unknown }
| { type: 'status'; sessionId: string; status: 'function_breakpoints_synced'; functionBreakpoints: Array<{ name: string; verified: boolean; id?: number; line?: number; source?: string }>; data?: unknown }
| { type: 'status'; sessionId: string; status: 'breakpoints_synced'; breakpoints: Array<{ id?: string; file: string; line: number; verified: boolean; adapterId?: number; boundLine?: number; message?: string }>; data?: unknown }
| { type: 'status'; sessionId: string; status: 'adapter_spawned'; pid?: number; data?: unknown }
| { type: 'status'; sessionId: string; status: 'dap_handshake_stage'; stage: 'transport_connected' | 'request_pending' | 'response_received'; command?: string; data?: unknown }
| { type: 'status'; sessionId: string; status: 'adapter_exited' | 'dap_connection_closed' | 'terminated'; code?: number | null; signal?: NodeJS.Signals | null; expected?: boolean; data?: unknown };

export type ProxyDapEventMessage = {
Expand Down
54 changes: 42 additions & 12 deletions src/proxy/dap-proxy-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,8 @@ export class DapProxyWorker {
}
this.adapterExitCodeIsDebuggeeExitCode = spawnConfig.adapterExitCodeIsDebuggeeExitCode === true;
this.logger!.info(`[Worker] Adapter spawned with PID: ${spawnResult.pid}`);
// Init-progress fact for the parent's proxyInitTimeout diagnosis (issue #493).
this.sendStatus('adapter_spawned', { pid: spawnResult.pid });

this.adapterProcess.on('error', (err) => {
this.logger!.error('[Worker] Adapter process error:', err);
Expand Down Expand Up @@ -506,6 +508,11 @@ export class DapProxyWorker {
// sessions (js-debug) can apply the same filters (issue #220).
this.dapClient.setExceptionBreakMode?.(payload.breakOnExceptions ?? 'none');

// Init-progress fact for the parent's proxyInitTimeout diagnosis (issue
// #493). Deliberately NOT 'adapter_connected', whose parent handler marks
// the session initialized to unblock the js-debug queueing handshake.
this.sendStatus('dap_handshake_stage', { stage: 'transport_connected' });

// Set up event handlers
this.setupDapEventHandlers();

Expand Down Expand Up @@ -557,10 +564,14 @@ export class DapProxyWorker {
// so the attach response must not be awaited before handleInitializedEvent.
const attachPayload = payload.launchConfig || {};
this.logger!.info(`[Worker] Attach-first mode — sending attach. Keys: ${Object.keys(attachPayload).join(', ')}`);
this.sendStatus('dap_handshake_stage', { stage: 'request_pending', command: 'attach' });
const attachRequest = this.connectionManager!.sendAttachRequest(
this.dapClient,
attachPayload
);
).then((attachResult) => {
this.sendStatus('dap_handshake_stage', { stage: 'response_received', command: 'attach' });
return attachResult;
});
// Surface early attach failures (connection refused, bad args) instead
// of waiting out the initialized timeout. Promise.race subscribes to
// every arm, so this rejection is consumed even when another arm wins.
Expand Down Expand Up @@ -595,10 +606,10 @@ export class DapProxyWorker {

this.logger!.info('[Worker] "initialized" event received, sending attach request');

await this.connectionManager!.sendAttachRequest(
this.dapClient,
await this.trackedHandshakeRequest('attach', () => this.connectionManager!.sendAttachRequest(
this.dapClient!,
payload.launchConfig || {}
);
));

this.deferInitializedHandling = false;
await this.handleInitializedEvent();
Expand All @@ -618,14 +629,14 @@ export class DapProxyWorker {
}

// Standard two-phase: send launch, wait for response, then configurationDone
await this.connectionManager!.sendLaunchRequest(
this.dapClient,
await this.trackedHandshakeRequest('launch', () => this.connectionManager!.sendLaunchRequest(
this.dapClient!,
payload.scriptPath,
payload.scriptArgs,
payload.stopOnEntry,
payload.justMyCode,
payload.launchConfig
);
));

if (!receivedBeforeLaunch) {
// Phase 2: Wait for initialized after launch
Expand All @@ -646,14 +657,14 @@ export class DapProxyWorker {
// Python/debugpy sends "initialized" AFTER receiving the launch request
this.logger!.info('[Worker] Sending launch request with scriptPath:', payload.scriptPath);

await this.connectionManager!.sendLaunchRequest(
this.dapClient,
await this.trackedHandshakeRequest('launch', () => this.connectionManager!.sendLaunchRequest(
this.dapClient!,
payload.scriptPath,
payload.scriptArgs,
payload.stopOnEntry,
payload.justMyCode,
payload.launchConfig
);
));
}
}

Expand All @@ -674,16 +685,35 @@ export class DapProxyWorker {
* with unknown capabilities (a documented-legal value) and a late response
* still captures them.
*/
/**
* Run a blocking init-phase DAP request bracketed by handshake-stage
* statuses, so the parent knows which request is outstanding if the init
* deadline fires (issue #493). A rejection leaves the request marked
* pending — its own error carries the diagnosis then.
*/
private async trackedHandshakeRequest<T>(command: string, run: () => Promise<T>): Promise<T> {
this.sendStatus('dap_handshake_stage', { stage: 'request_pending', command });
const result = await run();
this.sendStatus('dap_handshake_stage', { stage: 'response_received', command });
return result;
}

private async awaitInitializeResponse(
payload: ProxyInitPayload,
initBehavior: ReturnType<AdapterPolicy['getInitializationBehavior']>,
isAttachMode: boolean
): Promise<DebugProtocol.Capabilities | undefined> {
const initPromise = this.connectionManager!.initializeSession(
this.sendStatus('dap_handshake_stage', { stage: 'request_pending', command: 'initialize' });
// Promise.resolve: initializeSession stubs may return bare undefined
// (documented-legal for degenerate adapters).
const initPromise = Promise.resolve(this.connectionManager!.initializeSession(
this.dapClient!,
payload.sessionId,
this.adapterPolicy.getDapAdapterConfiguration().type
);
)).then((caps) => {
this.sendStatus('dap_handshake_stage', { stage: 'response_received', command: 'initialize' });
return caps;
});

if (isAttachMode || !initBehavior.initializeResponseOptional || !this.initializedEventPromise) {
return initPromise;
Expand Down
39 changes: 37 additions & 2 deletions src/proxy/proxy-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import type {
ProxyDapResponseMessage,
ProxyMessage
} from '../dap-core/types.js';
import { ErrorMessages } from '../utils/error-messages.js';
import { ErrorMessages, ProxyInitProgress } from '../utils/error-messages.js';
import { ProxyConfig } from './proxy-config.js';
import type { BreakpointSyncResult, FunctionBreakpointSyncResult } from './dap-proxy-interfaces.js';
import { IPC_HEARTBEAT, IPC_HEARTBEAT_TICK } from './dap-proxy-interfaces.js';
Expand Down Expand Up @@ -168,6 +168,13 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
private activeLaunchBarrierRequestId: string | null = null;
private proxyMessageCounter = 0;
private exitEmitted = false;
/**
* How far worker-side initialization got, from the worker's progress
* statuses (adapter_spawned / dap_handshake_stage). Read only when the init
* deadline fires, to say what actually stalled instead of blaming adapter
* installation (issue #493).
*/
private initProgress: ProxyInitProgress = { transportConnected: false };
/**
* Listeners installed on the proxy process (and its stderr stream) by
* setupEventHandlers, tracked so a failed start() can detach them — a stale
Expand Down Expand Up @@ -305,7 +312,13 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error(ErrorMessages.proxyInitTimeout(30)));
const error = new Error(ErrorMessages.proxyInitTimeout(30, this.initProgress)) as Error & {
initProgress?: ProxyInitProgress;
};
// Structured copy of the facts behind the message, for the tool
// result's error payload (issue #493).
error.initProgress = { ...this.initProgress };
reject(error);
}, 30000);

const cleanup = () => {
Expand Down Expand Up @@ -1207,6 +1220,28 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
this.emit('adapter-capabilities', message.capabilities);
break;

case 'adapter_spawned':
// Like adapter_capabilities: handled here only, no dap-core case, so
// never double-processed. Progress fact for the init-timeout
// diagnosis (issue #493) — no event to emit.
this.initProgress.adapterPid = typeof message.pid === 'number' ? message.pid : undefined;
this.logger.info(`[ProxyManager] Adapter process spawned (PID ${message.pid ?? 'unknown'})`);
break;

case 'dap_handshake_stage':
// Like adapter_capabilities: handled here only, no dap-core case (issue #493).
if (message.stage === 'transport_connected') {
this.initProgress.transportConnected = true;
} else if (message.stage === 'request_pending') {
this.initProgress.pendingCommand = message.command;
} else if (message.stage === 'response_received' && this.initProgress.pendingCommand === message.command) {
this.initProgress.pendingCommand = undefined;
}
this.logger.info(
`[ProxyManager] DAP handshake stage: ${message.stage}${message.command ? ` (${message.command})` : ''}`
);
break;

case 'function_breakpoints_synced':
// Like adapter_capabilities: emitted here only, no dap-core case, so
// never double-processed (issue #302).
Expand Down
24 changes: 23 additions & 1 deletion src/session/session-manager-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,9 @@ export abstract class SessionManagerOperations extends SessionManagerData {
}>>`;
}

// Structured init-progress facts from a proxy init timeout (issue #493)
const initProgress = (error as { initProgress?: Record<string, unknown> })?.initProgress;

// Comprehensive error capture for debugging Windows CI issues
const errorDetails: Record<string, unknown> = {
type: error?.constructor?.name || 'Unknown',
Expand All @@ -1002,6 +1005,7 @@ export abstract class SessionManagerOperations extends SessionManagerData {
syscall: (error as Record<string, unknown>)?.syscall,
path: (error as Record<string, unknown>)?.path,
toString: error?.toString ? error.toString() : 'No toString',
initProgress,
proxyLogPath,
proxyLogTail
};
Expand Down Expand Up @@ -1069,7 +1073,25 @@ export abstract class SessionManagerOperations extends SessionManagerData {
};
}

return { success: false, error: errorMessage, state: session.state, errorType, errorCode };
// Surface the diagnosis in the tool result, not just the server log
// (issue #493): which init stage stalled, and where the full proxy log
// lives — the agent reading the error has no other way to these facts.
const diagnosticData: Record<string, unknown> = {};
if (initProgress) {
diagnosticData.initProgress = initProgress;
}
if (proxyLogPath) {
diagnosticData.proxyLogPath = proxyLogPath;
}

return {
success: false,
error: errorMessage,
state: session.state,
errorType,
errorCode,
...(Object.keys(diagnosticData).length > 0 ? { data: diagnosticData } : {})
};
}
}

Expand Down
Loading
Loading