diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a4f59bc..771b32af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/packages/shared/src/interfaces/adapter-policy-ruby.ts b/packages/shared/src/interfaces/adapter-policy-ruby.ts index b571a720..a144ef0f 100644 --- a/packages/shared/src/interfaces/adapter-policy-ruby.ts +++ b/packages/shared/src/interfaces/adapter-policy-ruby.ts @@ -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: [], diff --git a/packages/shared/src/interfaces/adapter-policy.ts b/packages/shared/src/interfaces/adapter-policy.ts index ae7df401..0c6bff2c 100644 --- a/packages/shared/src/interfaces/adapter-policy.ts +++ b/packages/shared/src/interfaces/adapter-policy.ts @@ -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. */ diff --git a/src/proxy/dap-proxy-worker.ts b/src/proxy/dap-proxy-worker.ts index 1c0340ff..b4550392 100644 --- a/src/proxy/dap-proxy-worker.ts +++ b/src/proxy/dap-proxy-worker.ts @@ -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; @@ -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); } @@ -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 @@ -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, + isAttachMode: boolean + ): Promise { + 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 */ diff --git a/tests/adapters/ruby/unit/adapter-policy-ruby.test.ts b/tests/adapters/ruby/unit/adapter-policy-ruby.test.ts index 994c4515..0c9cb7e2 100644 --- a/tests/adapters/ruby/unit/adapter-policy-ruby.test.ts +++ b/tests/adapters/ruby/unit/adapter-policy-ruby.test.ts @@ -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'] diff --git a/tests/proxy/dap-proxy-worker.test.ts b/tests/proxy/dap-proxy-worker.test.ts index 6244ddfb..c6abf1e1 100644 --- a/tests/proxy/dap-proxy-worker.test.ts +++ b/tests/proxy/dap-proxy-worker.test.ts @@ -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) => { + 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 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