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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user