From e239df281651f2632da5f1e143ab40c146f6074c Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 27 Aug 2026 18:57:52 +0000 Subject: [PATCH] fix(#529): latch child 'initialized' events so a same-chunk arrival can't stall adoption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-attach 'initialized' event can share a socket chunk with the attach response. MinimalDapClient dispatches events synchronously while the response's awaiter is still parked in the microtask queue, so the waitForEvent listener that handlePostAttachInit registers afterwards never sees it — the 3s wait times out in full, and the CDP function-breakpoint bridge attaches only after the forced entry pause has been auto-continued. Pre-launch function breakpoints on ESM module-scoped names then silently never arm (they can only resolve at a pause, and the missed entry pause was the last one). Count 'initialized' events from wireChildEvents onward and have both waiters (initializeChild's and handlePostAttachInit's) consume the latch against a baseline snapshotted before their triggering request is sent — same shape as the #515 init-ACK latch. The latch check and waitForEvent's listener registration share one synchronous frame, so no event can slip between them. Fixes #529 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RiTRwc8dshMYgmbp5o5m5t --- src/proxy/child-session-manager.ts | 54 +++++++++++++--- tests/proxy/child-session-manager.test.ts | 75 +++++++++++++++++++++++ 2 files changed, 121 insertions(+), 8 deletions(-) diff --git a/src/proxy/child-session-manager.ts b/src/proxy/child-session-manager.ts index 4bf59627..774eb798 100644 --- a/src/proxy/child-session-manager.ts +++ b/src/proxy/child-session-manager.ts @@ -177,6 +177,10 @@ export class ChildSessionManager extends EventEmitter { // State tracking private adoptionInProgress = false; private sawChildStop = false; + // Latched in wireChildEvents: an 'initialized' event can arrive in the same + // socket chunk as the request's response, i.e. before any waitForEvent + // listener registered by the response's awaiter exists (issue #529) + private childInitializedCount = 0; // An adopted child's connection closed and no new adoption has started // since — routed commands can only hit the parent, where child-required // ones (e.g. js-debug 'pause') would silently no-op (issue #513) @@ -547,11 +551,16 @@ export class ChildSessionManager extends EventEmitter { // Configure child session await death.race(this.configureChild(child, pendingId, parentConfig)); - // Attach to pending target + // Attach to pending target. Snapshot the 'initialized' latch first: + // js-debug can deliver the post-attach 'initialized' in the same socket + // chunk as the attach response, and a listener registered only after + // attachChild resolves would miss it — stalling adoption 3s and pushing + // the CDP bridge attach past the entry pause (issue #529) + const postAttachInitBaseline = this.childInitializedCount; await this.attachChild(child, pendingId, parentConfig, death); // Handle post-attach initialization if needed - await death.race(this.handlePostAttachInit(child)); + await death.race(this.handlePostAttachInit(child, postAttachInitBaseline)); // Connect the CDP function-breakpoint bridge BEFORE forcing the entry // pause so the proxy's sticky Debugger.paused replay plus a live @@ -631,10 +640,12 @@ export class ChildSessionManager extends EventEmitter { }; logger.info(`[child:${pendingId}] initialize`); + const initializedBaseline = this.childInitializedCount; await child.sendRequest('initialize', initArgs); - - // Wait for initialized event - await this.waitForEvent(child, 'initialized', this.dapBehavior.childInitTimeout || 12000); + + // Wait for initialized event (latched — it may have arrived with the + // initialize response itself, issue #529) + await this.waitForChildInitialized(child, initializedBaseline, this.dapBehavior.childInitTimeout || 12000); } /** @@ -869,11 +880,13 @@ export class ChildSessionManager extends EventEmitter { } /** - * Handle post-attach initialization (some adapters emit another 'initialized') + * Handle post-attach initialization (some adapters emit another 'initialized'). + * `initializedBaseline` is the latch count snapshotted before the attach + * request was sent, so an event that raced the attach response still counts. */ - private async handlePostAttachInit(child: MinimalDapClient): Promise { + private async handlePostAttachInit(child: MinimalDapClient, initializedBaseline: number): Promise { // Wait briefly for a post-attach initialized event - const sawPostInit = await this.waitForEvent(child, 'initialized', 3000, false); + const sawPostInit = await this.waitForChildInitialized(child, initializedBaseline, 3000, false); if (sawPostInit && this.dapBehavior.mirrorBreakpointsToChild) { // Re-send configuration after post-attach initialized @@ -946,6 +959,9 @@ export class ChildSessionManager extends EventEmitter { if (evt.event === 'stopped') { this.sawChildStop = true; } + if (evt.event === 'initialized') { + this.childInitializedCount++; + } const bridge = this.cdpBridge; if (!bridge) { // Forward child events through parent @@ -987,6 +1003,28 @@ export class ChildSessionManager extends EventEmitter { }); } + /** + * Wait until the child has emitted more 'initialized' events than + * `baseline`. The count is latched in wireChildEvents from connect time, so + * an event delivered in the same socket chunk as a request's response — + * dispatched before the response's awaiter could register a listener — is + * not lost (issue #529; same shape as the #515 init-ACK latch). The latch + * check and waitForEvent's listener registration share one synchronous + * frame, so no event can slip between them. + */ + private waitForChildInitialized( + child: MinimalDapClient, + baseline: number, + timeoutMs: number, + required: boolean = true + ): Promise { + if (this.childInitializedCount > baseline) { + logger.info(`[ChildSessionManager:${this.instanceId}] 'initialized' already latched (count=${this.childInitializedCount}); skipping wait`); + return Promise.resolve(true); + } + return this.waitForEvent(child, 'initialized', timeoutMs, required); + } + /** * Wait for a specific event with timeout */ diff --git a/tests/proxy/child-session-manager.test.ts b/tests/proxy/child-session-manager.test.ts index 686f3cd6..67b31537 100644 --- a/tests/proxy/child-session-manager.test.ts +++ b/tests/proxy/child-session-manager.test.ts @@ -31,6 +31,10 @@ class MockMinimalDapClient extends EventEmitter { // Emit a post-attach 'initialized' (some adapters re-initialize after // attach; drives handlePostAttachInit's replay path) static emitInitializedAfterAttach = false; + // Commands that emit 'initialized' synchronously during sendRequest, i.e. + // before the caller's await resumes — models the event arriving in the same + // socket chunk as the response (issue #529) + static emitInitializedSyncOn = new Set(); // When set, returned verbatim for 'threads' static threadsResponse: unknown | undefined = undefined; // When true, shutdown() throws (drives the parent shutdown catch arm) @@ -75,6 +79,13 @@ class MockMinimalDapClient extends EventEmitter { if (command === 'attach' && MockMinimalDapClient.emitInitializedAfterAttach) { setTimeout(() => this.emit('event', { event: 'initialized' }), 5); } + if (MockMinimalDapClient.emitInitializedSyncOn.has(command)) { + // Same-chunk semantics (issue #529): the real client dispatches an event + // that shares a socket chunk with the response synchronously, while the + // response's awaiter is still parked in the microtask queue — so a + // listener registered after `await sendRequest(...)` never sees it. + this.emit('event', { event: 'initialized' }); + } // Simulate responses if (command === 'initialize') { @@ -141,6 +152,7 @@ describe('ChildSessionManager', () => { MockMinimalDapClient.setBreakpointsResponses = []; MockMinimalDapClient.emitStoppedAfterAttach = false; MockMinimalDapClient.emitInitializedAfterAttach = false; + MockMinimalDapClient.emitInitializedSyncOn.clear(); MockMinimalDapClient.threadsResponse = undefined; MockMinimalDapClient.shutdownThrows = false; }); @@ -193,6 +205,69 @@ describe('ChildSessionManager', () => { } }); + it('does not stall adoption when the post-attach initialized rides the attach response (issue #529)', async () => { + // The post-attach 'initialized' is emitted synchronously during the + // attach request — before handlePostAttachInit could register a + // listener. Without the latch, its 3s waitForEvent times out in full, + // delaying the CDP bridge attach past the entry pause. + vi.useFakeTimers(); + try { + MockMinimalDapClient.emitInitializedSyncOn.add('attach'); + MockMinimalDapClient.emitStoppedAfterAttach = true; + + const createPromise = manager.createChildSession({ + pendingId: 'test-pending-race', + host: 'localhost', + port: 9229, + parentConfig: { type: 'pwa-node', request: 'launch' } + }); + let resolved = false; + void createPromise.then(() => { resolved = true; }, () => { resolved = true; }); + + // Well under the 3s post-attach wait: adoption must already be done. + await vi.advanceTimersByTimeAsync(1000); + expect(resolved).toBe(true); + await createPromise; + + // sawPostInit=true also gates the post-attach mirror re-send: a + // second setExceptionBreakpoints proves the latch was consumed + // rather than the wait timing out to false. + const child = MockMinimalDapClient.instances[0]; + const exceptionRequests = child.requests.filter(r => r.command === 'setExceptionBreakpoints'); + expect(exceptionRequests.length).toBeGreaterThanOrEqual(2); + } finally { + vi.useRealTimers(); + } + }); + + it('does not stall adoption when initialized rides the initialize response (issue #529)', async () => { + vi.useFakeTimers(); + try { + MockMinimalDapClient.suppressInitialized = true; + MockMinimalDapClient.emitInitializedSyncOn.add('initialize'); + MockMinimalDapClient.emitStoppedAfterAttach = true; + + const createPromise = manager.createChildSession({ + pendingId: 'test-pending-race-init', + host: 'localhost', + port: 9229, + parentConfig: { type: 'pwa-node', request: 'launch' } + }); + let resolved = false; + void createPromise.then(() => { resolved = true; }, () => { resolved = true; }); + + // Well under initializeChild's 12s wait (and the 3s post-attach one). + // The post-attach wait sees no second 'initialized' and legitimately + // times out at 3s — advance past it, but nowhere near 12s. + await vi.advanceTimersByTimeAsync(4000); + expect(resolved).toBe(true); + await createPromise; + expect(manager.hasActiveChildren()).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + it('sends resolved exception filters to the child when a break mode is set (issue #220)', async () => { vi.useFakeTimers(); try {