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 @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`packageManager` pinned to pnpm@10.33.0** — a different pnpm major regenerated `pnpm-lock.yaml` incompatibly (one contributor diff silently dropped the entire security `overrides` block); the pin plus a CONTRIBUTING note make the wrong-pnpm case fail fast, and the workflows read the pin instead of duplicating a version (#478)

### 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)
- **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
5 changes: 5 additions & 0 deletions packages/shared/src/interfaces/adapter-policy-ruby.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ export const RubyAdapterPolicy: AdapterPolicy = {
getInitializationBehavior: () => {
return {
sendLaunchBeforeConfig: true,
// rdbg can process 'initialize' (proving it with the 'initialized' event)
// yet never send the response — its DAP send silently skips writing when
// the socket slot is momentarily unset (issue #492). Don't let the missing
// response park the launch until the 30s deadline.
initializeResponseOptional: true,
// rdbg only offers 'any' (break on every raise) — no uncaught-only filter
exceptionFilters: {
uncaught: [],
Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/interfaces/adapter-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,14 @@ export interface AdapterPolicy {
* Some adapters send initialized only AFTER processing the attach request, so waiting
* for initialized before sending attach causes a deadlock. */
sendAttachBeforeInitialized?: boolean;
/** Whether a launch may proceed when the 'initialized' event has arrived but the
* 'initialize' response has not (issue #492). rdbg intermittently emits the event
* and then never sends the response, which would otherwise park initialization
* until the 30s proxy deadline kills the session. When set, the proxy races the
* response against the event plus a short grace period and, if the event wins,
* continues the launch with unknown capabilities. Launch mode only — attach
* initialization is unaffected. */
initializeResponseOptional?: boolean;
/** Concrete DAP exceptionBreakpointFilters IDs per abstract breakOnExceptions
* mode (issue #220). Omitted (or an empty array for a mode) means the mode
* is unsupported for this adapter and setExceptionBreakpoints is skipped. */
Expand Down
84 changes: 78 additions & 6 deletions src/proxy/dap-proxy-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ export type DapProxyWorkerHooks = {
*/
export const MAX_QUEUED_COMMANDS = 256;

/**
* How long after the 'initialized' event to keep waiting for the initialize
* response before proceeding without it (issue #492, policies declaring
* initializeResponseOptional). In every healthy handshake the response
* precedes the event, so this only delays the recovery path — matched to the
* Phase-1 initialized wait used by the launch-before-config flow.
*/
export const INITIALIZE_RESPONSE_GRACE_MS = 2000;

export class DapProxyWorker {
private logger: ILogger | null = null;
private dapClient: IDapClient | null = null;
Expand Down Expand Up @@ -536,11 +545,7 @@ export class DapProxyWorker {
}

// Initialize DAP session with correct adapterId
const capabilities = await this.connectionManager!.initializeSession(
this.dapClient,
payload.sessionId,
this.adapterPolicy.getDapAdapterConfiguration().type
);
const capabilities = await this.awaitInitializeResponse(payload, initBehavior, isAttachMode);
if (capabilities) {
this.captureAdapterCapabilities(capabilities);
}
Expand Down Expand Up @@ -597,7 +602,6 @@ export class DapProxyWorker {

this.deferInitializedHandling = false;
await this.handleInitializedEvent();
/* istanbul ignore next -- Go/Java launch sequence: covered by E2E/integration tests */
} else if (initBehavior.sendLaunchBeforeConfig) {
// TWO-PHASE INITIALIZED HANDLING for adapters like Go/Delve, Java/JDI bridge
// Phase 1: Brief wait — some adapters send initialized immediately after initialize
Expand Down Expand Up @@ -660,6 +664,74 @@ export class DapProxyWorker {
}
}

/**
* Await the adapter's initialize response. For launch-mode policies that
* declare initializeResponseOptional (rdbg, issue #492), the response is
* raced against the already-armed 'initialized' event plus a grace period:
* rdbg can process the request — proving it with the event — yet never send
* the response, which would otherwise park this await until the parent's
* 30s deadline kills the session. If the event wins, the launch proceeds
* with unknown capabilities (a documented-legal value) and a late response
* still captures them.
*/
private async awaitInitializeResponse(
payload: ProxyInitPayload,
initBehavior: ReturnType<AdapterPolicy['getInitializationBehavior']>,
isAttachMode: boolean
): Promise<DebugProtocol.Capabilities | undefined> {
const initPromise = this.connectionManager!.initializeSession(
this.dapClient!,
payload.sessionId,
this.adapterPolicy.getDapAdapterConfiguration().type
);

if (isAttachMode || !initBehavior.initializeResponseOptional || !this.initializedEventPromise) {
return initPromise;
}

const requestSentAt = Date.now();
// `settled` gates the grace timer: when the response wins the race, the
// event's continuation (which may fire later, or in the same tick) must
// not arm a stray timer.
let settled = false;
let graceTimer: NodeJS.Timeout | undefined;
const eventThenGrace = this.initializedEventPromise.then(
() => new Promise<'initialize-response-missing'>((resolve) => {
if (settled) {
resolve('initialize-response-missing'); // unobserved: the race is already decided
return;
}
graceTimer = setTimeout(() => resolve('initialize-response-missing'), INITIALIZE_RESPONSE_GRACE_MS);
})
);

try {
const raced = await Promise.race([initPromise, eventThenGrace]);
if (raced !== 'initialize-response-missing') {
return raced;
}
} finally {
settled = true;
if (graceTimer) {
clearTimeout(graceTimer);
}
}

this.logger!.warn(
`[Worker] 'initialized' event arrived but the 'initialize' response is still missing ` +
`${Date.now() - requestSentAt}ms after the request (issue #492: rdbg can silently drop the ` +
`response frame). Proceeding with the launch without adapter capabilities.`
);
// A late response still yields capabilities; the request's own timeout
// rejection must not become an unhandled rejection.
initPromise.then((caps) => {
if (caps) {
this.captureAdapterCapabilities(caps);
}
}).catch(() => {});
return undefined;
}

/**
* Set up DAP event handlers
*/
Expand Down
3 changes: 3 additions & 0 deletions tests/adapters/ruby/unit/adapter-policy-ruby.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,9 @@ describe('RubyAdapterPolicy behavior surface', () => {
});
expect(RubyAdapterPolicy.getInitializationBehavior?.()).toEqual({
sendLaunchBeforeConfig: true,
// rdbg can emit 'initialized' yet never send the initialize response;
// the proxy must not park on the response forever (issue #492)
initializeResponseOptional: true,
exceptionFilters: {
uncaught: [],
all: ['any']
Expand Down
210 changes: 210 additions & 0 deletions tests/proxy/dap-proxy-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1482,6 +1482,216 @@ describe('DapProxyWorker', () => {
expect(outputMessages().length).toBe(before);
});

describe('initialize response optional (issue #492)', () => {
const rubyLaunchPayload = (): ProxyInitPayload => ({
cmd: 'init',
sessionId: 'ruby-492-session',
language: 'ruby',
executablePath: 'ruby',
adapterHost: '127.0.0.1',
adapterPort: 8124,
logDir: '/logs',
scriptPath: '/path/to/fizzbuzz.rb',
launchConfig: { request: 'launch', type: 'rdbg' },
adapterCommand: {
command: 'rdbg',
args: ['--open', '--host', '127.0.0.1', '--port', '8124', '-c', '--', 'ruby', '/path/to/fizzbuzz.rb']
}
});

const makeStubs = (initializeSessionImpl: () => Promise<unknown>) => {
const processStub = {
spawn: vi.fn().mockResolvedValue({
process: new EventEmitter() as unknown as ChildProcess,
pid: 4242
}),
shutdown: vi.fn().mockResolvedValue(undefined)
};
const connectionStub = {
connectWithRetry: vi.fn().mockResolvedValue(mockDapClient),
setAdapterPolicy: vi.fn(),
setupEventHandlers: vi.fn((client: EventEmitter, handlers: Record<string, () => void>) => {
if (handlers.onInitialized) client.on('initialized', handlers.onInitialized);
}),
initializeSession: vi.fn().mockImplementation(initializeSessionImpl),
sendLaunchRequest: vi.fn().mockResolvedValue(undefined),
sendAttachRequest: vi.fn().mockResolvedValue(undefined),
setBreakpoints: vi.fn().mockResolvedValue(undefined),
sendConfigurationDone: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined)
};
return { processStub, connectionStub };
};

const wireWorker = (
payload: ProxyInitPayload,
processStub: unknown,
connectionStub: unknown,
policy: typeof RubyAdapterPolicy
) => {
(worker as any).logger = mockLogger;
(worker as any).processManager = processStub;
(worker as any).connectionManager = connectionStub;
(worker as any).adapterPolicy = policy;
(worker as any).adapterState = policy.createInitialState();
(worker as any).currentInitPayload = payload;
(worker as any).currentSessionId = payload.sessionId;
(worker as any).state = ProxyState.INITIALIZING;
};

const capabilitiesStatuses = () => mockMessageSender.send.mock.calls.filter(
([message]) => message.type === 'status' && message.status === 'adapter_capabilities'
);

it('ruby launch proceeds when the initialize response never arrives but initialized did (issue #492)', async () => {
vi.useFakeTimers();
try {
const payload = rubyLaunchPayload();
// The #492 shape: rdbg processed the request (the 'initialized' event
// proves it) but the initialize response frame never arrives.
const { processStub, connectionStub } = makeStubs(() => new Promise(() => {}));
wireWorker(payload, processStub, connectionStub, RubyAdapterPolicy);

const startPromise = (worker as any).startAdapterAndConnect(payload);
await vi.advanceTimersByTimeAsync(0); // reach the parked initialize await
(mockDapClient as EventEmitter).emit('initialized');
await vi.advanceTimersByTimeAsync(2000); // grace period for a late response

await startPromise;

expect(connectionStub.sendLaunchRequest).toHaveBeenCalledTimes(1);
expect(connectionStub.sendConfigurationDone).toHaveBeenCalledTimes(1);
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('issue #492'));
// No response ⇒ no capabilities to report.
expect(capabilitiesStatuses()).toHaveLength(0);
const statusCall = mockMessageSender.send.mock.calls.find(
([message]: [StatusMessage]) => message.type === 'status' && message.status === 'adapter_configured_and_launched'
);
expect(statusCall).toBeDefined();
} finally {
vi.useRealTimers();
}
});

it('a late initialize response still captures capabilities after the event won the race', async () => {
vi.useFakeTimers();
try {
const payload = rubyLaunchPayload();
let resolveInit!: (value: unknown) => void;
const { processStub, connectionStub } = makeStubs(
() => new Promise((resolve) => { resolveInit = resolve; })
);
wireWorker(payload, processStub, connectionStub, RubyAdapterPolicy);

const startPromise = (worker as any).startAdapterAndConnect(payload);
await vi.advanceTimersByTimeAsync(0);
(mockDapClient as EventEmitter).emit('initialized');
await vi.advanceTimersByTimeAsync(2000);
await startPromise;
expect(capabilitiesStatuses()).toHaveLength(0);

// The response limps in after the launch already proceeded.
resolveInit({ supportsConfigurationDoneRequest: true });
await vi.advanceTimersByTimeAsync(0);
expect(capabilitiesStatuses()).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});

it('healthy ruby launch still captures capabilities and never warns', async () => {
const payload = rubyLaunchPayload();
const capabilities = { supportsConfigurationDoneRequest: true };
const { processStub, connectionStub } = makeStubs(async () => {
setImmediate(() => (mockDapClient as EventEmitter).emit('initialized'));
return capabilities;
});
wireWorker(payload, processStub, connectionStub, RubyAdapterPolicy);

await (worker as any).startAdapterAndConnect(payload);

expect(connectionStub.sendLaunchRequest).toHaveBeenCalledTimes(1);
const capsCalls = capabilitiesStatuses();
expect(capsCalls).toHaveLength(1);
expect(capsCalls[0][0].capabilities).toEqual(capabilities);
expect(mockLogger.warn).not.toHaveBeenCalledWith(expect.stringContaining('issue #492'));
});

it('a policy without the opt-in still blocks on a missing initialize response', async () => {
vi.useFakeTimers();
try {
const payload: ProxyInitPayload = {
cmd: 'init',
sessionId: 'go-492-session',
executablePath: 'dlv',
adapterHost: 'localhost',
adapterPort: 12345,
logDir: '/logs',
scriptPath: '/path/to/main.go',
scriptArgs: [],
stopOnEntry: false,
justMyCode: false,
adapterCommand: {
command: 'dlv',
args: ['dap', '--listen', 'localhost:12345']
}
};
const { processStub, connectionStub } = makeStubs(() => new Promise(() => {}));
wireWorker(payload, processStub, connectionStub, GoAdapterPolicy);

let settled = false;
const startPromise = (worker as any).startAdapterAndConnect(payload)
.then(() => { settled = true; }, () => { settled = true; });
await vi.advanceTimersByTimeAsync(0);
(mockDapClient as EventEmitter).emit('initialized');
await vi.advanceTimersByTimeAsync(10000);

expect(settled).toBe(false);
expect(connectionStub.sendLaunchRequest).not.toHaveBeenCalled();
void startPromise;
} finally {
vi.useRealTimers();
}
});

it('ruby attach still blocks on a missing initialize response (launch-only recovery)', async () => {
vi.useFakeTimers();
try {
const payload: ProxyInitPayload = {
cmd: 'init',
sessionId: 'ruby-attach-492-session',
language: 'ruby',
executablePath: 'ruby',
adapterHost: '127.0.0.1',
adapterPort: 8123,
logDir: '/logs',
scriptPath: 'attach://remote',
launchConfig: {
request: 'attach',
type: 'rdbg',
host: '127.0.0.1',
port: 12345
}
};
const { processStub, connectionStub } = makeStubs(() => new Promise(() => {}));
wireWorker(payload, processStub, connectionStub, RubyAdapterPolicy);

let settled = false;
const startPromise = (worker as any).startAdapterAndConnect(payload)
.then(() => { settled = true; }, () => { settled = true; });
await vi.advanceTimersByTimeAsync(0);
(mockDapClient as EventEmitter).emit('initialized');
await vi.advanceTimersByTimeAsync(10000);

expect(settled).toBe(false);
expect(connectionStub.sendAttachRequest).not.toHaveBeenCalled();
void startPromise;
} finally {
vi.useRealTimers();
}
});
});

it('holds terminated and dap_connection_closed until adapter stdio drains (issue #222)', async () => {
// A debuggee printing to a block-buffered pipe flushes everything at
// exit, milliseconds AFTER the adapter's terminated event / socket
Expand Down
Loading