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 {