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.d/570.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Persist structured proxy-failure diagnostics for mid-session adapter/proxy deaths and expose them on errored `list_debug_sessions` entries and failed or empty `get_stack_trace` responses.
3 changes: 3 additions & 0 deletions docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ Lists all active debugging sessions.
- `"stopped"`: Session stopped (program terminated)
- `"error"`: Session encountered an error

Errored sessions include optional `diagnostics` with the current launch attempt's `proxyLogPath`. The record is retained for proxy initialization failures and for proxy/adapter deaths after initialization, and is cleared when a new launch or attach attempt begins.

---

### close_debug_session
Expand Down Expand Up @@ -586,6 +588,7 @@ Gets the current call stack.
- The filtered stack is never empty when the adapter reported frames: if *every* frame is internal (e.g. a goroutine paused inside the Go runtime), the top internal frame is kept so `get_scopes`/`evaluate_expression` still have a valid `frameId`, and the `note` says so.
- When an explicit thread reports no frames, the response remains anchored to that thread and its `note` suggests a frame-bearing alternative when one is available.
- When the implicit stopped thread is frameless, stack, locals, and default evaluation share one resolver. It scans siblings, prefers a thread whose frames the language policy recognizes as user code over runtime-only stacks, adopts it once, and discloses the switch in `note`/`anchorNote`.
- Failed or empty stack responses include the session's optional `diagnostics` when the proxy failed, matching `list_debug_sessions`.

---

Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export type {
SessionStopExceptionInfo,
ExceptionBreakMode,
SessionOutputEntry,
SessionFailureDiagnostics,

// Debug info types
Variable,
Expand Down
10 changes: 10 additions & 0 deletions packages/shared/src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,14 @@ export interface SessionOutputEntry {
redacted?: boolean;
}

/** Pointers that make an errored session's proxy failure actionable. */
export interface SessionFailureDiagnostics {
/** Server-host path to the current launch attempt's proxy log. */
proxyLogPath?: string;
/** MCP resource URI for remote-safe access to the proxy log, when available. */
proxyLogResource?: string;
}

/**
* Public session snapshot returned by create/list operations. Unlike the
* internal DebugSession, it carries no live handles and adds observed
Expand All @@ -386,6 +394,8 @@ export interface DebugSessionInfo {
lastStop?: SessionStopInfo;
/** Debuggee exit code from the DAP 'exited' event, when the adapter reports one */
exitCode?: number;
/** Present when the session is in ERROR because its proxy failed. */
diagnostics?: SessionFailureDiagnostics;
/**
* Live DAP mirror endpoint from expose_session (issue #217), host/port
* only — the attach token is returned solely by the expose_session tool.
Expand Down
16 changes: 13 additions & 3 deletions src/server/handlers/inspection-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export const getVariablesTool: ToolHandler = async (ctx, args) => {
export const getStackTraceTool: ToolHandler = async (ctx, args) => {
requireSessionId(args);

const failureDiagnostics = () =>
ctx.sessionManager.getSession(args.sessionId)?.failureDiagnostics;

try {
// Default to false for cleaner output
const includeInternals = args.includeInternals ?? false;
Expand All @@ -63,7 +66,10 @@ export const getStackTraceTool: ToolHandler = async (ctx, args) => {
...(typeof stackTrace.threadId === 'number' ? { threadId: stackTrace.threadId } : {}),
includeInternals,
stopReason: lastStop?.reason,
lastStop
lastStop,
...(stackTrace.frames.length === 0 && failureDiagnostics()
? { diagnostics: failureDiagnostics() }
: {})
};
// Anything the result needs explaining (not paused, stack came
// from a different thread, all threads frameless) plus the
Expand All @@ -85,13 +91,17 @@ export const getStackTraceTool: ToolHandler = async (ctx, args) => {
} catch (error) {
const sessionResult = sessionErrorToResult(error);
if (sessionResult) {
return sessionResult;
const diagnostics = failureDiagnostics();
return diagnostics
? sessionErrorToResult(error, { diagnostics })!
: sessionResult;
}
if (error instanceof Error && !(error instanceof McpError)) {
// DAP-level failures (e.g. "Child session not ready ...")
// must surface as errors, not as an empty-but-successful
// stack trace (issue #124).
return failureResult(error.message);
const diagnostics = failureDiagnostics();
return failureResult(error.message, diagnostics ? { diagnostics } : undefined);
}
// Re-throw unexpected errors
throw error;
Expand Down
3 changes: 3 additions & 0 deletions src/server/handlers/session-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ export async function handleListDebugSessions(ctx: ToolContext): Promise<ToolRes
if (session.exitCode !== undefined) {
mappedSession.exitCode = session.exitCode;
}
if (session.diagnostics) {
mappedSession.diagnostics = session.diagnostics;
}
if (session.exposure) {
// Mirror endpoint host/port; the token never leaves expose_session.
mappedSession.exposure = session.exposure;
Expand Down
4 changes: 4 additions & 0 deletions src/session/attach/attach-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export class AttachController {
sessionLifecycle: SessionLifecycleState.ACTIVE,
attachMode: true,
});
session.failureDiagnostics = undefined;

try {
// For attach mode, we use a placeholder scriptPath
Expand Down Expand Up @@ -345,6 +346,9 @@ export class AttachController {
// (issue #561) — an attach that dies during proxy initialization used
// to leave the adapter's own complaint unreadable.
const diagnosticData = await failProxySetup(this.ctx, session, error, 'attachToProcess');
session.failureDiagnostics = Object.keys(diagnosticData).length > 0
? diagnosticData
: undefined;
const message = error instanceof Error ? error.message : String(error);
// A close that landed during the teardown removed the session; the
// state write would throw. Report the failure as-is.
Expand Down
10 changes: 10 additions & 0 deletions src/session/launch/debug-launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export class DebugLauncher {
session.exitCode = undefined;
session.lastProxyExit = undefined;
session.lastProxyError = undefined;
session.failureDiagnostics = undefined;
this.ctx.logger.info(`[SessionManager] Session ${sessionId} lifecycle state set to ACTIVE`);

// Record the launch spec for restart_debugging BEFORE attempting the
Expand Down Expand Up @@ -289,6 +290,9 @@ export class DebugLauncher {
dryRunTimeoutError,
'startDebugging'
);
session.failureDiagnostics = Object.keys(diagnosticData).length > 0
? diagnosticData
: undefined;

return {
success: false,
Expand Down Expand Up @@ -392,6 +396,9 @@ export class DebugLauncher {
new Error(errorMessage),
'startDebugging'
);
finalSession.failureDiagnostics = Object.keys(diagnosticData).length > 0
? diagnosticData
: undefined;
return {
success: false,
state: SessionState.ERROR,
Expand Down Expand Up @@ -466,6 +473,9 @@ export class DebugLauncher {
};
} catch (error) {
const diagnosticData = await failProxySetup(this.ctx, session, error, 'startDebugging');
session.failureDiagnostics = Object.keys(diagnosticData).length > 0
? diagnosticData
: undefined;

const errorMessage = error instanceof Error ? error.message : String(error);

Expand Down
6 changes: 3 additions & 3 deletions src/session/launch/proxy-failure-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* Both paths now call `logProxyFailure`, which is why it lives here rather than
* in either one.
*/
import { sanitizeStderrTail } from '@debugmcp/shared';
import { sanitizeStderrTail, type SessionFailureDiagnostics } from '@debugmcp/shared';
import type { ManagedSession } from '../session-store.js';
import type { IFileSystem, ILogger } from '../../interfaces/external-dependencies.js';
import type { ProxyInitProgress } from '../../utils/error-messages.js';
Expand All @@ -30,7 +30,7 @@ const PROXY_LOG_TAIL_LINES = 80;
export const PROXY_LOG_TAIL_MAX_BYTES = 64 * 1024;

/** The pointers a failed launch/attach returns to the caller (issue #493 / #551). */
export interface ProxyFailureDiagnostics {
export interface ProxyFailureDiagnostics extends SessionFailureDiagnostics {
initProgress?: ProxyInitProgress;
proxyLogPath?: string;
}
Expand All @@ -45,7 +45,7 @@ export interface ProxyFailureLogDeps {
}

/** The two operations that can fail this way, named as they appear in the log. */
export type ProxyFailureOperation = 'startDebugging' | 'attachToProcess';
export type ProxyFailureOperation = 'startDebugging' | 'attachToProcess' | 'proxyExit';

/**
* What `failProxySetup` needs on top of the log deps: the facade's
Expand Down
47 changes: 46 additions & 1 deletion src/session/session-manager-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ import { normalizeBreakpointMessage } from '../utils/breakpoint-message.js';
import { consumeChildOrigin } from '../utils/child-origin-events.js';
import { isPidAlive } from '../utils/jvm-orphan-reaper.js';
import { IAdapterRegistry } from '@debugmcp/shared';
import type { ProxyFailureDiagnostics } from './launch/proxy-failure-diagnostics.js';
import {
collectProxyFailureDiagnostics,
logProxyFailure,
type ProxyFailureDiagnostics
} from './launch/proxy-failure-diagnostics.js';
import type { AnchorResolution } from './breakpoints/anchor-resolution.js';

// Custom launch arguments interface extending DebugProtocol.LaunchRequestArguments
Expand Down Expand Up @@ -392,6 +396,35 @@ export abstract class SessionManagerCore extends EventEmitter {
this.logger.info('All debug sessions closed');
}

/**
* Persist the public failure pointers synchronously, then write the bounded
* structured log record in the background. EventEmitter does not await
* listeners, so the session projection must be complete before the handler
* returns. A generation guard prevents a slow log read from reviving an old
* launch's diagnostics after restart_debugging installs a new proxy.
*/
private recordProxyFailure(session: ManagedSession, error: unknown): void {
const generation = session.proxyGeneration;
session.failureDiagnostics = collectProxyFailureDiagnostics(session, error);

void logProxyFailure(
{ logger: this.logger, fileSystem: this.fileSystem },
session,
error,
'proxyExit'
).then((diagnostics) => {
if (session.proxyGeneration === generation && session.state === SessionState.ERROR) {
session.failureDiagnostics = diagnostics;
}
}).catch((diagnosticsError: unknown) => {
// logProxyFailure is total by contract; retain the synchronously
// collected pointers even if a future implementation regresses that.
this.logger.warn(
`[SessionManager] Failed to finish proxy-exit diagnostics for session ${session.id}: ${diagnosticsError instanceof Error ? diagnosticsError.message : String(diagnosticsError)}`
);
});
}

protected setupProxyEventHandlers(
session: ManagedSession,
proxyManager: IProxyManager,
Expand All @@ -406,6 +439,7 @@ export abstract class SessionManagerCore extends EventEmitter {
session.lastStop = undefined;
session.exitCode = undefined;
session.adapterCapabilities = undefined;
session.failureDiagnostics = undefined;
// Adapter degradation notes are per-launch (issue #441).
session.adapterNotices = [];
// Mandatory (issue #217): the relaunch's new proxyManager reports
Expand Down Expand Up @@ -1072,6 +1106,7 @@ export abstract class SessionManagerCore extends EventEmitter {
this.logger.error(`[ProxyManager ${sessionId}] Error:`, error);
session.lastProxyError = error.message;
this._updateSessionState(session, SessionState.ERROR);
this.recordProxyFailure(session, error);

// Clean up listeners since proxy is in error state
this.cleanupProxyEventHandlers(session, proxyManager);
Expand All @@ -1095,6 +1130,7 @@ export abstract class SessionManagerCore extends EventEmitter {
this.logger.debug(`[SessionManager] handleExit: session=${sessionId} currentState=${session.state} code=${code} signal=${signal} expected=${expected}`);
this.logger.info(`[ProxyManager ${sessionId}] Exit: code=${code}, signal=${signal}, expected=${expected}`);
session.lastProxyExit = { code, signal, expected };
const stateBeforeExit = session.state;
if (session.state !== SessionState.STOPPED && session.state !== SessionState.ERROR) {
if (expected === true) {
// Orderly debuggee termination (issue #258): the worker saw a
Expand Down Expand Up @@ -1123,6 +1159,15 @@ export abstract class SessionManagerCore extends EventEmitter {
}
}

if (stateBeforeExit !== SessionState.ERROR && session.state === SessionState.ERROR) {
const exitDescription = `code=${code ?? 'null'}${signal ? `, signal=${signal}` : ''}`;
const exitError = Object.assign(
new Error(`Debug proxy exited unexpectedly (${exitDescription})`),
{ code, signal, expected }
);
this.recordProxyFailure(session, exitError);
}

// Clean up listeners since proxy is gone
this.cleanupProxyEventHandlers(session, proxyManager);
session.lastProxyPid = proxyManager.getProxyPid?.() ?? session.lastProxyPid;
Expand Down
6 changes: 6 additions & 0 deletions src/session/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type { DebugProtocol } from '@vscode/debugprotocol';
import { IProxyManager } from '../proxy/proxy-manager.js';
import { OutputRingBuffer } from './output-buffer.js';
import type { PauseIntent } from './execution/pause-intent.js';
import type { ProxyFailureDiagnostics } from './launch/proxy-failure-diagnostics.js';

export interface ToolchainValidationState {
compatible: boolean;
Expand Down Expand Up @@ -106,6 +107,8 @@ export interface ManagedSession extends DebugSessionInfo {
lastProxyExit?: ProxyExitState;
/** Last proxy error-event message for the current launch. */
lastProxyError?: string;
/** Actionable pointers retained when the current launch's proxy fails. */
failureDiagnostics?: ProxyFailureDiagnostics;
// True once the first 'stopped' event after launch has been observed.
// Used by the auto-continue trigger to identify the initial entry stop
// even when the adapter reports a non-'entry' reason (e.g., js-debug
Expand Down Expand Up @@ -273,6 +276,9 @@ export class SessionStore {
updatedAt: s.updatedAt,
lastStop: s.lastStop,
exitCode: s.exitCode,
...(s.state === SessionState.ERROR && s.failureDiagnostics
? { diagnostics: s.failureDiagnostics }
: {}),
// Mirror endpoint without the token (issue #217); the isRunning gate
// keeps the projection honest on teardown paths that skip cleanup.
...(s.exposure && s.proxyManager?.isRunning()
Expand Down
6 changes: 5 additions & 1 deletion tests/core/unit/server/handlers/session-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ describe('session tool handlers', () => {
language: 'python',
state: 'active',
createdAt: now,
updatedAt: now
updatedAt: now,
diagnostics: { proxyLogPath: '/logs/proxy-session-1.log' }
}]);

const result = await handleListDebugSessions(ctx);
Expand All @@ -45,6 +46,9 @@ describe('session tool handlers', () => {
name: 'Test Session',
language: 'python'
});
expect(payload.sessions[0].diagnostics).toEqual({
proxyLogPath: '/logs/proxy-session-1.log'
});
});
});
});
11 changes: 10 additions & 1 deletion tests/core/unit/server/server-inspection-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ describe('Server Inspection Tools Tests', () => {
it('echoes the inspected thread and frameless-thread note (issue #553)', async () => {
const mockSession = {
lastStop: { reason: 'pause', threadId: 1 },
failureDiagnostics: { proxyLogPath: '/logs/proxy-test-session.log' },
proxyManager: {
getCurrentThreadId: vi.fn().mockReturnValue(1),
setCurrentThreadId: vi.fn()
Expand Down Expand Up @@ -229,6 +230,7 @@ describe('Server Inspection Tools Tests', () => {
expect(content.threadId).toBe(4);
expect(content.lastStop.threadId).toBe(1);
expect(content.note).toMatch(/Signal Dispatcher/);
expect(content.diagnostics).toEqual({ proxyLogPath: '/logs/proxy-test-session.log' });
expect(mockSessionManager.getStackTraceDetailed).toHaveBeenCalledWith(
'test-session', 4, false
);
Expand Down Expand Up @@ -297,7 +299,10 @@ describe('Server Inspection Tools Tests', () => {
});

it('should handle missing proxy manager', async () => {
const mockSession = { proxyManager: null };
const mockSession = {
proxyManager: null,
failureDiagnostics: { proxyLogPath: '/logs/proxy-test-session.log' }
};
mockSessionManager.getSession.mockReturnValue(mockSession);

const result = await callToolHandler({
Expand All @@ -312,10 +317,12 @@ describe('Server Inspection Tools Tests', () => {
const content = JSON.parse(result.content[0].text);
expect(content.success).toBe(false);
expect(content.error).toContain('no active proxy for session test-session');
expect(content.diagnostics).toEqual({ proxyLogPath: '/logs/proxy-test-session.log' });
});

it('should handle missing thread ID', async () => {
const mockSession = {
failureDiagnostics: { proxyLogPath: '/logs/proxy-test-session.log' },
proxyManager: {
getCurrentThreadId: vi.fn().mockReturnValue(null)
}
Expand All @@ -339,6 +346,7 @@ describe('Server Inspection Tools Tests', () => {

it('should surface SessionManager errors as a truthful tool-level failure', async () => {
const mockSession = {
failureDiagnostics: { proxyLogPath: '/logs/proxy-test-session.log' },
proxyManager: {
getCurrentThreadId: vi.fn().mockReturnValue(1)
}
Expand All @@ -360,6 +368,7 @@ describe('Server Inspection Tools Tests', () => {
const content = JSON.parse(result.content[0].text);
expect(content.success).toBe(false);
expect(content.error).toContain('Stack trace failed');
expect(content.diagnostics).toEqual({ proxyLogPath: '/logs/proxy-test-session.log' });
});
});

Expand Down
1 change: 1 addition & 0 deletions tests/core/unit/session/debug-result-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ describe('DebugResultData type contract (issue #590)', () => {
expectTypeOf<keyof DebugResultData>().toEqualTypeOf<
| 'initProgress'
| 'proxyLogPath'
| 'proxyLogResource'
| 'message'
| 'warning'
| 'pending'
Expand Down
Loading
Loading