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
This commit is contained in:
roboomp
2026-08-09 23:52:27 +00:00
parent 45e12e5bb7
commit 4fcab2c789
3 changed files with 74 additions and 0 deletions
+4
View File
@@ -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
+27
View File
@@ -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<Uint8Array>, session: DapSession): Promise<void> {
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<string, DapBreakpointRecord[]>): 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 => {
@@ -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<void>();
const stdout = new ReadableStream<Uint8Array>({
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);
});
});