fix(coding-agent): key debug thread aggregation by owning session

Review feedback: DAP thread IDs are scoped per client session, so deduping
the tree-wide thread list by (id, name) collapsed distinct threads that
different children happen to share — e.g. multiple identical worker scripts
each exposing { id: 1, name: "worker.js" }. Key the dedupe set by the owning
session id + thread id instead, so every live thread is preserved and only an
exact repeat within a single session's response is dropped.

Fixes #6663
This commit is contained in:
roboomp
2026-07-26 03:50:50 +00:00
parent 04f61e2125
commit 80df85a84b
2 changed files with 52 additions and 3 deletions
+5 -1
View File
@@ -989,8 +989,12 @@ export class DapSessionManager {
continue;
}
target.threads = threads;
// DAP thread IDs are scoped per client session, so identical IDs from
// different sessions (e.g. two identical worker scripts) are distinct
// live threads and MUST be preserved; only collapse an exact repeat
// within a single session's response.
for (const thread of threads) {
const key = `${thread.id}\0${thread.name}`;
const key = `${target.id}\0${thread.id}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push(thread);
@@ -210,6 +210,9 @@ describe("DAP multi-session debugging", () => {
__pendingTargetId: "attached-child",
},
"attach",
true,
// Threadless launcher: answers `threads` with an empty list.
{ threads: [] },
);
const child = new FakeDapClient(undefined, "launch", false);
spyOn(DapClient, "spawn").mockResolvedValue(root as unknown as DapClient);
@@ -223,8 +226,8 @@ describe("DAP multi-session debugging", () => {
expect(active?.parentSessionId).toBeDefined();
expect(threads.threads).toEqual([{ id: 7, name: "target.js" }]);
expect(child.requests.filter(request => request.command === "threads")).toHaveLength(1);
// The root is queried too (no topology guess); here it just echoes the
// same thread, which dedupes away.
// The root is queried too (no topology guess), but being threadless it
// contributes nothing.
expect(root.requests.filter(request => request.command === "threads")).toHaveLength(1);
await manager.terminate(undefined, 100);
@@ -328,4 +331,46 @@ describe("DAP multi-session debugging", () => {
await manager.terminate(undefined, 1_000);
});
it("preserves per-session threads that share an id and name across children", async () => {
const root = new FakeDapClient(
{ name: "pool.mjs", type: "pwa-node", __pendingTargetId: "main", program: "/tmp/pool.mjs" },
"launch",
true,
{ threads: [] },
);
// Two identical worker scripts each expose the same session-local thread
// id and name; DAP scopes ids per session, so both are distinct threads.
const main = new FakeDapClient(undefined, "launch", true, {
threads: [{ id: 1, name: "worker.js" }],
stopThreadId: 1,
});
const worker = new FakeDapClient(undefined, "launch", false, {
threads: [{ id: 1, name: "worker.js" }],
});
const children = [main, worker];
spyOn(DapClient, "spawn").mockResolvedValue(root as unknown as DapClient);
spyOn(DapClient, "connect").mockImplementation(async () => {
const next = children.shift();
if (!next) throw new Error("Unexpected child DAP connection");
return next as unknown as DapClient;
});
const manager = new DapSessionManager();
await manager.launch({ adapter: TEST_ADAPTER, program: "/tmp/pool.mjs", cwd: "/tmp" }, undefined, 1_000);
await root.triggerReverse("startDebugging", {
request: "launch",
configuration: { name: "worker #2", type: "pwa-node" },
});
expect(manager.listSessions()).toHaveLength(3);
const threads = await manager.threads(undefined, 1_000);
// Both identical threads survive aggregation \u2014 not collapsed into one.
expect(threads.threads).toEqual([
{ id: 1, name: "worker.js" },
{ id: 1, name: "worker.js" },
]);
await manager.terminate(undefined, 1_000);
});
});