From 4fcab2c7896efcc4a02c78f471985847d575114a Mon Sep 17 00:00:00 2001 From: roboomp Date: Sun, 9 Aug 2026 23:52:27 +0000 Subject: [PATCH] fix(dap): drain runInTerminal debuggee stdout The runInTerminal reverse-request handler spawned the debuggee via ptree.spawn (which always pipes stdout) but discarded the ChildProcess after reading its PID, leaving stdout unconsumed. ptree drains only stderr; the exposed stdout stream must be read or Bun buffers it unboundedly in-process. An output-heavy debuggee therefore grew omp's memory toward OOM (measured ~3.9GB RSS for a ~2GB-output child) and its output was lost from the session. Continuously drain the child's stdout into the session output buffer via truncateOutput, bounding memory at MAX_OUTPUT_BYTES and surfacing the program's output like the adapter's own output events. Fixes #8111 --- packages/coding-agent/CHANGELOG.md | 4 ++ packages/coding-agent/src/dap/session.ts | 27 ++++++++++++ .../test/debug/dap-multi-session.test.ts | 43 +++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a4d66e8d2..534a2e7c1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the DAP `runInTerminal` reverse request leaving the spawned debuggee's stdout undrained: the child was spawned with a piped stdout that was never consumed, so a chatty debuggee's output buffered unboundedly in the omp process (toward OOM) and was lost from the session output. Its stdout is now continuously drained into the session output buffer. ([#8111](https://github.com/can1357/oh-my-pi/issues/8111)) + ## [17.2.12] - 2026-08-08 ### Fixed diff --git a/packages/coding-agent/src/dap/session.ts b/packages/coding-agent/src/dap/session.ts index e1e07f7ee..9cc5c02bf 100644 --- a/packages/coding-agent/src/dap/session.ts +++ b/packages/coding-agent/src/dap/session.ts @@ -219,6 +219,30 @@ function truncateOutput(session: DapSession, output: string): void { } } +/** + * Drain a `runInTerminal` debuggee's stdout into the session output buffer. + * + * `ptree.spawn` always pipes stdout and only eagerly drains stderr; the exposed + * stdout stream must be consumed or Bun buffers it unboundedly in this process + * (a chatty debuggee grows omp toward OOM). The reverse-request path has no + * terminal surface here, so route the child's stdout through {@link + * truncateOutput}: this bounds memory at `MAX_OUTPUT_BYTES` and surfaces the + * program's output to the agent, mirroring the adapter's own `output` events. + * Runs in the background for the child's lifetime; a killed child or closed pipe + * ends the loop quietly. + */ +async function drainTerminalStdout(stream: ReadableStream, session: DapSession): Promise { + const decoder = new TextDecoder(); + try { + for await (const chunk of stream) { + truncateOutput(session, decoder.decode(chunk, { stream: true })); + } + truncateOutput(session, decoder.decode()); + } catch { + // Child killed or pipe closed mid-stream; nothing more to surface. + } +} + function summarizeBreakpointCount(breakpoints: Map): number { let total = 0; for (const entries of breakpoints.values()) { @@ -1356,6 +1380,9 @@ export class DapSessionManager { }, detached: true, }); + // Consume the child's stdout — ptree pipes it but drains only stderr, + // so an unconsumed stream buffers unboundedly in this process. + void drainTerminalStdout(proc.stdout, session); return { processId: proc.pid } satisfies DapRunInTerminalResponse; }); client.onReverseRequest("startDebugging", async rawArgs => { diff --git a/packages/coding-agent/test/debug/dap-multi-session.test.ts b/packages/coding-agent/test/debug/dap-multi-session.test.ts index 5046ab257..17b81e893 100644 --- a/packages/coding-agent/test/debug/dap-multi-session.test.ts +++ b/packages/coding-agent/test/debug/dap-multi-session.test.ts @@ -8,6 +8,7 @@ import type { DapResolvedAdapter, DapThread, } from "@oh-my-pi/pi-coding-agent/dap/types"; +import { type ChildProcess, ptree } from "@oh-my-pi/pi-utils"; const TEST_ADAPTER: DapResolvedAdapter = { name: "js-debug-adapter", @@ -373,4 +374,46 @@ describe("DAP multi-session debugging", () => { await manager.terminate(undefined, 1_000); }); + + it("drains a runInTerminal debuggee's stdout into the session output buffer", async () => { + const root = new FakeDapClient(undefined, "launch", true); + spyOn(DapClient, "spawn").mockResolvedValue(root as unknown as DapClient); + + // Synthetic debuggee stdout: >64KB ahead of a unique terminal marker, + // then a trailing sentinel and EOF. The handler discards the ptree child + // after reading its PID, so the drain must consume this whole stream and + // route it to the session output — undrained, the marker never reaches + // the buffer. The sentinel after the marker guarantees the marker chunk + // is routed (in the read cycle before it) before `closed` resolves. + const marker = "__RUNINTERMINAL_MARKER__"; + const enc = new TextEncoder(); + const chunks = [enc.encode("x".repeat(128 * 1024)), enc.encode(`${marker}\n`), enc.encode("tail\n")]; + let next = 0; + const closed = Promise.withResolvers(); + const stdout = new ReadableStream({ + pull(controller) { + if (next < chunks.length) { + controller.enqueue(chunks[next++]); + } else { + controller.close(); + closed.resolve(); + } + }, + }); + const spawnSpy = spyOn(ptree, "spawn").mockReturnValue({ pid: 4242, stdout } as unknown as ChildProcess); + + const manager = new DapSessionManager(); + await manager.launch({ adapter: TEST_ADAPTER, program: "/tmp/target.js", cwd: "/tmp" }, undefined, 1_000); + + await root.triggerReverse("runInTerminal", { args: ["/usr/bin/debuggee", "--verbose"] }); + // The stream reaching EOF proves the drain consumed it end to end; the + // marker (routed before close) is then present in the session output. + await closed.promise; + + expect(spawnSpy).toHaveBeenCalledTimes(1); + expect(spawnSpy.mock.calls[0]?.[0]).toEqual(["/usr/bin/debuggee", "--verbose"]); + expect(manager.getOutput().output).toContain(marker); + + await manager.terminate(undefined, 1_000); + }); });