fix: prevent stdout/stderr interleaving in mcp_cli_bridge for large outputs#40037
Conversation
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
…utputs When process.stdout.write() on a pipe returns false (OS pipe buffer ~64 KiB full), Node.js buffers the remaining data. Any subsequent synchronous process.stderr.write() calls (via core.info keepalive pings) complete immediately and reach the OS before the buffered stdout tail flushes, corrupting JSON output with interleaved [info] log lines at ~65,536 bytes. Fix: - Add writeStdoutAndFlush() helper that awaits 'drain' event if stdout.write returns false (backpressure), ensuring full flush before continuing - Stop keepalive timer BEFORE writing output so no pings fire during writes - Make formatResponse() async and await all stdout writes - Update main() to await formatResponse/writeStdoutAndFlush calls Closes #40008 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
status tool|
✅ Design Decision Gate 🏗️ completed the design decision gate check. No ADR enforcement needed: PR #40037 does not have the 'implementation' label and has 0 new lines of code in business logic directories (≤100 threshold). |
|
✅ PR Code Quality Reviewer completed the code quality review. |
|
🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅ |
|
✅ Test Quality Sentinel completed test quality analysis. |
There was a problem hiding this comment.
✅ Ready to approve
The flushing/backpressure fix is correctly implemented, call sites are updated to await it, and tests cover the drain-ordering regression path (only a minor PR-description/lockfile-scope nit remains).
Note: this review does not count toward required approvals for merging.
Pull request overview
This PR hardens the mcp_cli_bridge Node.js wrapper against stdout/stderr interleaving when emitting large payloads to a piped stdout (notably on Linux), which can corrupt captured JSON when stderr log lines land mid-write.
Changes:
- Added
writeStdoutAndFlush()and convertedformatResponse()toasyncso stdout writes await backpressure (drain) before any stderr logging continues. - Updated
main()to stop MCP keepalive pings before writing output and to await all output writes. - Updated/added Vitest coverage for backpressure + ordering; plus a small install script sync-note clarification and several regenerated workflow lockfile allowlist entries.
File summaries
| File | Description |
|---|---|
| actions/setup/js/mcp_cli_bridge.cjs | Adds writeStdoutAndFlush, makes output formatting async, and stops keepalive before final output to prevent interleaving. |
| actions/setup/js/mcp_cli_bridge.test.cjs | Converts relevant tests to async and adds targeted tests for backpressure/drain ordering. |
| actions/setup-cli/install.sh | Clarifies which install script is canonical and that this file is copied/generated. |
| .github/workflows/spec-librarian.lock.yml | Regenerated lockfile content adding shell(awk) to the allowlist. |
| .github/workflows/go-fan.lock.yml | Regenerated lockfile content adding Bash(awk) to the allowlist. |
| .github/workflows/daily-testify-uber-super-expert.lock.yml | Regenerated lockfile content adding shell(awk) to the allowlist. |
| .github/workflows/daily-file-diet.lock.yml | Regenerated lockfile content adding shell(awk) to the allowlist. |
Copilot's findings
- Files reviewed: 7/7 changed files
- Comments generated: 1
Note
Your feedback helps us improve the quality of this feature.
Please use 👍 or 👎 to tell us whether this assessment is correct.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Copilot CLI tool arguments (sorted): | ||
| # --allow-tool github | ||
| # --allow-tool safeoutputs | ||
| # --allow-tool serena | ||
| # --allow-tool shell(awk) | ||
| # --allow-tool shell(cat pkg/**/*.go) | ||
| # --allow-tool shell(cat pkg/*/*.go) |
There was a problem hiding this comment.
The lockfile changes are intentional — they are generated by make recompile as required by the repository's workflow authoring conventions after any workflow markdown change. The four *.lock.yml diffs (shell(awk) / Bash(awk) allowlist additions) are unrelated to the bridge fix but are the correct result of compiling the current workflow sources.
There was a problem hiding this comment.
Skills-Based Review 🧠
Applied /diagnose and /tdd — requesting changes on one correctness gap and two test-hygiene issues.
📋 Key Themes & Highlights
Issues
- Missing
rejectpath inwriteStdoutAndFlush— a broken pipe (EPIPE) while waiting fordrainwill cause the Promise to hang indefinitely; the process never exits cleanly - Two
onceStub.mockRestore()calls not guarded bytry/finally— a failing assertion before either call leavesprocess.stdout.oncepermanently spied upon, potentially poisoning later tests in the same run - No EPIPE/error test — the missing reject path would not be caught by the test suite as written
Positive Highlights
- ✅ Excellent root-cause analysis in the PR body — the 64 KiB pipe-buffer boundary and the exact interleaving mechanism are precisely described
- ✅
stopKeepalive?.()moved before any output write is a smart defence-in-depth measure that eliminates the race even beforewriteStdoutAndFlushis invoked - ✅ The ordering-guarantee test (
formatResponse awaits stdout drain before writing to stderr) directly reproduces the production failure scenario — this is exactly the kind of regression test that/diagnosecalls for - ✅ All three
formatResponsecall sites uniformly upgraded toawait writeStdoutAndFlush()— no forgotten writes
🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer
| if (flushed) { | ||
| resolve(); | ||
| } else { | ||
| process.stdout.once("drain", resolve); |
There was a problem hiding this comment.
[/diagnose] writeStdoutAndFlush has no reject path — if stdout emits an error event while waiting for drain (e.g. a broken pipe / EPIPE), the Promise never settles and the process hangs indefinitely.
💡 Suggested fix
Add an error listener that rejects the promise, and clean up both listeners once either event fires:
function writeStdoutAndFlush(data) {
return new Promise((resolve, reject) => {
const flushed = process.stdout.write(data);
if (flushed) {
resolve();
} else {
const cleanup = () => {
process.stdout.removeListener("drain", onDrain);
process.stdout.removeListener("error", onError);
};
const onDrain = () => { cleanup(); resolve(); };
const onError = (err) => { cleanup(); reject(err); };
process.stdout.once("drain", onDrain);
process.stdout.once("error", onError);
}
});
}Broken-pipe errors are rare in CI but real when a consumer exits early. Without a reject path the process silently stalls.
There was a problem hiding this comment.
Fixed in commit a0b7c3e — writeStdoutAndFlush now registers both drain and error listeners, each removing the other before settling the Promise. No more possibility of an indefinite hang on EPIPE.
| expect(resolved).toBe(true); | ||
| expect(stdoutChunks).toContain("large payload\n"); | ||
|
|
||
| onceStub.mockRestore(); |
There was a problem hiding this comment.
[/tdd] onceStub.mockRestore() is not guarded — if any expect(...) above it throws, the spy is never cleaned up and will corrupt the process.stdout.once behaviour for all subsequent tests.
💡 Use try/finally
const onceStub = vi.spyOn(process.stdout, "once").mockImplementation(...);
try {
// ... test body ...
} finally {
onceStub.mockRestore();
}This pattern appears in two tests in this describe block (here and in formatResponse awaits stdout drain). Alternatively, register the spy in beforeEach and restore it in afterEach alongside the existing stdoutSpy.mockRestore() call.
There was a problem hiding this comment.
Fixed — onceStub.mockRestore() is now inside a try/finally block in this test.
| expect(stdoutIdx).toBeGreaterThanOrEqual(0); | ||
| expect(infoIdx).toBeGreaterThan(stdoutIdx); | ||
|
|
||
| onceStub.mockRestore(); |
There was a problem hiding this comment.
[/tdd] Same onceStub.mockRestore() cleanup issue here — if expect(infoIdx).toBeGreaterThan(stdoutIdx) fails, the spy leaks into subsequent tests.
💡 Use try/finally
try {
// assertions...
expect(stdoutIdx).toBeGreaterThanOrEqual(0);
expect(infoIdx).toBeGreaterThan(stdoutIdx);
} finally {
onceStub.mockRestore();
}There was a problem hiding this comment.
Fixed — onceStub.mockRestore() is now inside a try/finally block in this test as well.
| // writeStdoutAndFlush should resolve synchronously in this case. | ||
| await writeStdoutAndFlush("hello world\n"); | ||
|
|
||
| expect(stdoutChunks[0]).toBe("hello world\n"); |
There was a problem hiding this comment.
[/tdd] The three new tests cover the happy-path and ordering guarantee well, but there is no test for the error path: what happens when stdout emits error (EPIPE / broken pipe) while the Promise is waiting for drain?
💡 Suggested test skeleton
it("rejects when stdout emits error while waiting for drain", async () => {
stdoutSpy.mockImplementation(chunk => {
stdoutChunks.push(String(chunk));
return false; // backpressure
});
const error = new Error("EPIPE");
const onceStub = vi.spyOn(process.stdout, "once").mockImplementation((event, cb) => {
if (event === "error") {
// Fire asynchronously to simulate the stream erroring
Promise.resolve().then(() => cb(error));
}
return process.stdout;
});
try {
await expect(writeStdoutAndFlush("data\n")).rejects.toThrow("EPIPE");
} finally {
onceStub.mockRestore();
}
});Without this test the current implementation's missing reject path (see the companion comment on mcp_cli_bridge.cjs) would go undetected by the test suite.
There was a problem hiding this comment.
Added — "rejects when stdout emits error while waiting for drain (EPIPE)" test verifies the rejection path and asserts the error callback is captured before firing it.
|
@copilot run pr-finisher skill |
There was a problem hiding this comment.
The core fix is sound — stopping the keepalive timer before emitting stdout correctly eliminates the race between large buffered writes and async keepalive pings. The writeStdoutAndFlush helper and the async formatResponse refactor both look correct for the stated goal.
Findings summary (2 non-blocking)
Medium — missing error rejection in writeStdoutAndFlush
process.stdout.once("drain", resolve) is registered but there is no corresponding "error" listener. If stdout enters an error state before draining, the Promise hangs and await writeStdoutAndFlush(...) blocks forever. The drain listener also leaks since it is never removed on an error path. In practice Node.js exits on EPIPE for the default process.stdout pipe, so this is low-risk today — but the same helper could be reused or tested against a custom Writable, at which point the missing reject path becomes a hard hang with no timeout.
Medium — test mock cleanup not guarded by try/finally
Both writeStdoutAndFlush drain tests call onceStub.mockRestore() unconditionally at the end. If any assertion above that line throws, the spy on process.stdout.once is never restored, corrupting the isolation of subsequent tests. Should be wrapped in try/finally or handled via onTestFinished/a nested afterEach.
🔎 Code quality review by PR Code Quality Reviewer
| if (flushed) { | ||
| resolve(); | ||
| } else { | ||
| process.stdout.once("drain", resolve); |
There was a problem hiding this comment.
writeStdoutAndFlush Promise never rejects on stdout error — if process.stdout emits error instead of drain (e.g., broken pipe in a non-default environment), this Promise hangs indefinitely and await writeStdoutAndFlush(...) blocks the process forever. The drain listener is also never removed in that case.
💡 Suggested fix
Register an error listener alongside the drain listener so the Promise rejects and cleans up both listeners:
function writeStdoutAndFlush(data) {
return new Promise((resolve, reject) => {
const flushed = process.stdout.write(data);
if (flushed) {
resolve();
} else {
const onDrain = () => { process.stdout.removeListener("error", onError); resolve(); };
const onError = (err) => { process.stdout.removeListener("drain", onDrain); reject(err); };
process.stdout.once("drain", onDrain);
process.stdout.once("error", onError);
}
});
}Node.js exits on EPIPE for the default process.stdout pipe, so the practical risk is low in production. However if stdout is replaced with a custom Writable (e.g., in tests or future wrappers), the missing reject path causes an unrecoverable hang with no timeout.
There was a problem hiding this comment.
Fixed — both onDrain and onError listeners now remove each other before settling so no leaks occur.
| expect(resolved).toBe(true); | ||
| expect(stdoutChunks).toContain("large payload\n"); | ||
|
|
||
| onceStub.mockRestore(); |
There was a problem hiding this comment.
onceStub.mockRestore() is not in a try/finally block — if any expect(...) assertion above this line throws, the spy on process.stdout.once is never restored. Subsequent tests that call the real once (e.g., the formatResponse ordering test at line 568+) will operate on the still-mocked version and produce spurious failures or false passes. The same issue exists in the test at line ~616.
💡 Suggested fix
Wrap the test body in try/finally, or register cleanup via onTestFinished (Vitest ≥ 1.4):
const onceStub = vi.spyOn(process.stdout, "once").mockImplementation(...);
try {
// ... all assertions ...
} finally {
onceStub.mockRestore();
}Alternatively, promote the spy setup to a nested beforeEach/afterEach pair so Vitest handles teardown automatically on failure.
There was a problem hiding this comment.
Fixed — both drain tests wrap the entire body (including assertions) in try/finally so mockRestore() is guaranteed to run.
🧪 Test Quality Sentinel Report✅ Test Quality Score: 85/100 — Excellent
📊 Metrics & Test Classification (6 tests analyzed)
Go: 0; JavaScript: 6 ( i️ Score breakdown
Total: 85/100 Verdict
|
…ore with try/finally, add EPIPE test Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Done. Here is the summary:
Actions taken: Added EPIPE rejection path with listener cleanup to Hand-off: CI must be re-triggered by a maintainer (close/reopen PR, |
On Linux,
process.stdout.write()on a pipe writes at most ~64 KiB synchronously (OS pipe buffer), buffers the rest in Node.js, and returnsfalse. Any synchronousprocess.stderr.write()that follows (keepalive pings viacore.info) completes immediately — before the buffered stdout tail drains — injecting[info]lines mid-JSON at exactly offset ~65,536. Callers capturing combined output (e.g. the AWF bash tool with2>&1) see corrupted JSON.Changes
mcp_cli_bridge.cjswriteStdoutAndFlush(data)— new helper that registers paireddrainanderrorlisteners (each removing the other before settling), so the Promise rejects cleanly on EPIPE/broken-pipe instead of hanging indefinitelyformatResponse— madeasync; allprocess.stdout.write()calls replaced withawait writeStdoutAndFlush()main()— stops the keepalive timer before writing output, then awaits all output calls; eliminates any possibility of a ping racing the final writemcp_cli_bridge.test.cjsformatResponsetests updated toasync/awaitwriteStdoutAndFlushtests: immediate resolve (no backpressure), drain-wait (buffer full), ordering guarantee (stdout fully drained before anycore.infocall), and EPIPE rejection (stdout emitserrorwhile waiting for drain)onceStub.mockRestore()calls in drain tests wrapped intry/finallyto guarantee cleanup even when assertions throwWorkflow lockfiles
*.lock.ymlfiles regenerated viamake recompile(addsshell(awk)/Bash(awk)to Copilot allowlists); these changes are incidental to the bridge fix