Skip to content

Commit e6f2240

Browse files
cliffhallclaude
andcommitted
fix(scripts): read gh replies over 1 MiB in release:notes and the sweeps
run() in scripts/release-notes.mjs called spawnSync with Node's default 1 MiB maxBuffer, so the src/ tree query at v2.2.0's merge commit (1,087,532 bytes) aborted the run with ENOBUFS. Pass a 256 MiB maxBuffer. scripts/lib/sweep.mjs's gh() gets the same: the Dependabot alert listing is about 7 KB per alert, so ~150 open alerts would overflow it. Tests drive the real spawnSync with a >1 MiB reply, and check a genuine spawn error is still rethrown. Closes #5339 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com>
1 parent 2016f16 commit e6f2240

4 files changed

Lines changed: 79 additions & 1 deletion

File tree

‎scripts/lib/sweep.mjs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ export const AUTOMATION_LOGIN = "github-actions";
4848
/** Labels on every sweep issue. `chore` and `dependencies` need write access. */
4949
export const SWEEP_LABELS = ["v2", "chore", "dependencies"];
5050

51+
/**
52+
* The most stdout `gh()` reads from one call. Node's default is 1 MiB, and
53+
* past it `spawnSync` throws ENOBUFS. The alert listing is the reply that can
54+
* cross it: each Dependabot alert is about 7 KB of JSON, so some 150 open
55+
* alerts overflow the default and abort the sweep (#5339).
56+
*/
57+
export const GH_MAX_BUFFER = 256 * 1024 * 1024;
58+
5159
/** The two labels the trust rule reads; `v2` is applied by an issue form. */
5260
const TRUST_LABELS = ["chore", "dependencies"];
5361

@@ -223,6 +231,7 @@ export function isDryRun(argv = process.argv.slice(2)) {
223231
export function gh(spawn, args, input) {
224232
const result = spawn("gh", args, {
225233
encoding: "utf8",
234+
maxBuffer: GH_MAX_BUFFER,
226235
...(input === undefined ? {} : { input }),
227236
});
228237
if (result.error) throw result.error;

‎scripts/lib/sweep.test.mjs‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55

66
import { test } from "node:test";
77
import assert from "node:assert/strict";
8+
import { spawnSync } from "node:child_process";
89
import {
10+
GH_MAX_BUFFER,
911
SWEEP_LABELS,
1012
cell,
1113
currentMilestone,
@@ -176,6 +178,34 @@ test("gh throws on a spawn error, and passes stdin only when given", () => {
176178
assert.equal(calls[1].input, "body");
177179
});
178180

181+
// The real spawnSync, with "gh" swapped for a node that writes `bytes` of
182+
// stdout, so the options gh() passes are the ones that decide the outcome.
183+
const bigReply = (bytes) => (_cmd, _args, opts) =>
184+
spawnSync(
185+
process.execPath,
186+
["-e", `process.stdout.write("x".repeat(${bytes}))`],
187+
opts,
188+
);
189+
190+
test("gh reads a reply over Node's 1 MiB default without ENOBUFS (#5339)", () => {
191+
const bytes = 2 * 1024 * 1024;
192+
assert.ok(GH_MAX_BUFFER > bytes);
193+
const result = gh(bigReply(bytes), ["api", "x"]);
194+
assert.equal(result.status, 0);
195+
assert.equal(result.stdout.length, bytes);
196+
});
197+
198+
test("gh still rethrows a genuine spawn error from the real spawnSync", () => {
199+
assert.throws(
200+
() =>
201+
gh(
202+
(_cmd, args, opts) => spawnSync("/nonexistent/gh-5339", args, opts),
203+
["api", "x"],
204+
),
205+
(err) => err.code === "ENOENT",
206+
);
207+
});
208+
179209
test("currentMilestone reads open milestones and fails loudly", () => {
180210
const { spawn, calls } = fakeSpawn(() => ({
181211
status: 0,

‎scripts/release-notes.mjs‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ import { parseArgs } from "node:util";
6868
import { readProject } from "./prepare-python-release.mjs";
6969

7070
export const REPO = "modelcontextprotocol/servers";
71+
72+
// The most stdout `run()` reads from one call. Node's default is 1 MiB, and
73+
// past it `spawnSync` throws ENOBUFS before any notes are produced: the tree
74+
// under `src/` at v2.2.0's merge commit came back at 1,087,532 bytes (#5339),
75+
// and it only grows. 256 MiB is far past any reply this script reads while
76+
// still bounding a runaway one.
77+
export const MAX_BUFFER = 256 * 1024 * 1024;
7178
const [OWNER, NAME] = REPO.split("/");
7279

7380
// What `collaborators/{user}/permission` reports in its `permission` field.
@@ -183,7 +190,11 @@ export function parseNotesArgs(argv) {
183190
* @param {string} [input]
184191
*/
185192
function run(spawn, cmd, args, input) {
186-
const result = spawn(cmd, args, { encoding: "utf8", input });
193+
const result = spawn(cmd, args, {
194+
encoding: "utf8",
195+
input,
196+
maxBuffer: MAX_BUFFER,
197+
});
187198
if (result.error) {
188199
throw result.error;
189200
}

‎scripts/release-notes.test.mjs‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,19 @@
1010
// `npm run test:scripts`.
1111

1212
import assert from "node:assert/strict";
13+
import { spawnSync } from "node:child_process";
1314
import { test } from "node:test";
1415
import {
1516
ADVISORY_LEAD_IN,
17+
MAX_BUFFER,
1618
MIXED_LEAD_IN,
1719
REPO,
1820
THANKS_LEAD_IN,
1921
assembleNotes,
2022
bucket,
2123
closingKeywordIssues,
2224
dirsTouched,
25+
filesOf,
2326
formatKnownIssues,
2427
formatPackages,
2528
formatThanks,
@@ -282,6 +285,31 @@ test("bucket: one, several and no servers; unpublished and empty fall to Reposit
282285
);
283286
});
284287

288+
test("a gh reply over Node's 1 MiB default gets through without ENOBUFS (#5339)", () => {
289+
// The real spawnSync, with "gh" swapped for a node that prints 2 MiB of
290+
// file names, so the options run() passes are what decide the outcome.
291+
const lines = 128 * 1024;
292+
const spawn = (_cmd, _args, opts) =>
293+
spawnSync(
294+
process.execPath,
295+
["-e", `process.stdout.write("src/everything/f.ts\\n".repeat(${lines}))`],
296+
opts,
297+
);
298+
assert.ok(MAX_BUFFER > lines * 20);
299+
const files = filesOf(spawn, 1);
300+
assert.equal(files.length, lines);
301+
assert.equal(files[0], "src/everything/f.ts");
302+
});
303+
304+
test("a genuine spawn error is still rethrown", () => {
305+
const spawn = (_cmd, args, opts) =>
306+
spawnSync("/nonexistent/gh-5339", args, opts);
307+
assert.throws(
308+
() => filesOf(spawn, 1),
309+
(err) => err.code === "ENOENT",
310+
);
311+
});
312+
285313
test("closingKeywordIssues reads every closing keyword, never a cross-repo ref", () => {
286314
const body = [
287315
"Closes #1",

0 commit comments

Comments
 (0)