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
54 changes: 46 additions & 8 deletions src/proxy/child-session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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<void> {
private async handlePostAttachInit(child: MinimalDapClient, initializedBaseline: number): Promise<void> {
// 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<boolean> {
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
*/
Expand Down
75 changes: 75 additions & 0 deletions tests/proxy/child-session-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
// When set, returned verbatim for 'threads'
static threadsResponse: unknown | undefined = undefined;
// When true, shutdown() throws (drives the parent shutdown catch arm)
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -141,6 +152,7 @@ describe('ChildSessionManager', () => {
MockMinimalDapClient.setBreakpointsResponses = [];
MockMinimalDapClient.emitStoppedAfterAttach = false;
MockMinimalDapClient.emitInitializedAfterAttach = false;
MockMinimalDapClient.emitInitializedSyncOn.clear();
MockMinimalDapClient.threadsResponse = undefined;
MockMinimalDapClient.shutdownThrows = false;
});
Expand Down Expand Up @@ -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 {
Expand Down
Loading