Skip to content

Commit a04817c

Browse files
robobunalii
andauthored
deflake hot.test.ts: fix duplicate error handling and buffer management (#28202)
The three sourcemap tests (generation, loading, loading with large files) used a `continue outer` pattern in their error-handling loops. When a duplicate/stale error was encountered, `continue outer` jumped back to reading the next stderr chunk, but any remaining lines from `str.split("\n")` that had not yet been processed were discarded because `str` was set to `""` inside the inner loop. This lost data and could cause the test to hang waiting for output that was already consumed and thrown away. Additionally, the bundler processes in the sourcemap loading tests used `stdout: "inherit"` / `stderr: "inherit"`, which could cause pipe buffer backpressure blocking the bundler. Fixes both issues with a shared `driveErrorReloadCycle` helper that: - Preserves unprocessed lines when encountering duplicate errors (uses `lines.pop()` for trailing partial lines and re-buffers remaining lines via `lines.slice(i + 1)`) - Pipes bundler stdout/stderr to `ignore` to avoid pipe buffer backpressure - Races `bundler.exited` against the reload driver for early-exit detection in the two bundler-based tests --- Verification: Format and Lint JavaScript CI checks pass. Buildkite build #39875 still compiling at time of review. Only `test/cli/hot/hot.test.ts` changed (test-only deflake, no production code). Jarred's CHANGES_REQUESTED about the nonce mechanism was addressed in commit 50f3f4e (nonce removed). No TODO/FIXME/HACK in added lines. The driveErrorReloadCycle helper correctly preserves partial line fragments and remaining unprocessed lines, prevents double onReload calls via the triggered flag, and uses Buffer.alloc per test/CLAUDE.md convention. --------- Co-authored-by: Alistair Smith <hi@alistair.sh>
1 parent af24e28 commit a04817c

File tree

1 file changed

+154
-141
lines changed

1 file changed

+154
-141
lines changed

test/cli/hot/hot.test.ts

Lines changed: 154 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,84 @@ import { join } from "path";
77
const timeout = isDebug ? Infinity : 10_000;
88
const longTimeout = isDebug ? Infinity : 30_000;
99

10+
/**
11+
* Helper to parse stderr from a --hot process that throws errors.
12+
* Drives the reload cycle: reads error lines from stderr, verifies them,
13+
* and calls onReload to trigger the next file change.
14+
*
15+
* This fixes the original `continue outer` pattern which discarded any
16+
* remaining buffered lines from the current chunk when a duplicate error
17+
* was encountered, potentially losing data and causing test hangs.
18+
*/
19+
async function driveErrorReloadCycle(
20+
runner: ReturnType<typeof spawn>,
21+
opts: {
22+
targetCount: number;
23+
onReload: (counter: number) => void;
24+
verifyLine?: (errorLine: string, nextLine: string | undefined, counter: number) => void;
25+
},
26+
): Promise<number> {
27+
const { targetCount, onReload, verifyLine } = opts;
28+
let reloadCounter = 0;
29+
let str = "";
30+
31+
for await (const chunk of runner.stderr) {
32+
str += new TextDecoder().decode(chunk);
33+
// Need at least one error line followed by a newline, then another line followed by a newline
34+
if (!/error: .*[0-9]\n.*?\n/g.test(str)) continue;
35+
36+
const lines = str.split("\n");
37+
// Preserve trailing partial line for the next chunk
38+
str = lines.pop() ?? "";
39+
let triggered = false;
40+
41+
for (let i = 0; i < lines.length; i++) {
42+
const line = lines[i];
43+
if (!line.includes("error:")) continue;
44+
45+
if (reloadCounter >= targetCount) {
46+
runner.kill();
47+
return reloadCounter;
48+
}
49+
50+
// If we see the previous error repeated, the pending reload hasn't
51+
// taken effect yet. Re-save the file and put remaining unprocessed
52+
// lines back into the buffer so they aren't lost.
53+
if (line.includes(`error: ${reloadCounter - 1}`)) {
54+
const remaining = lines.slice(i + 1).join("\n");
55+
if (remaining) {
56+
str = `${remaining}\n${str}`;
57+
}
58+
onReload(reloadCounter);
59+
triggered = false; // onReload already called; skip post-loop call
60+
break;
61+
}
62+
63+
expect(line).toContain(`error: ${reloadCounter}`);
64+
65+
const nextLine = lines[i + 1];
66+
if (verifyLine) {
67+
verifyLine(line, nextLine, reloadCounter);
68+
i++; // Skip the next line (stack trace)
69+
}
70+
71+
reloadCounter++;
72+
triggered = true;
73+
74+
if (reloadCounter >= targetCount) {
75+
runner.kill();
76+
return reloadCounter;
77+
}
78+
}
79+
80+
if (triggered) {
81+
onReload(reloadCounter);
82+
}
83+
}
84+
85+
return reloadCounter;
86+
}
87+
1088
let hotRunnerRoot: string = "",
1189
cwd = "";
1290
beforeEach(() => {
@@ -414,7 +492,8 @@ it(
414492
timeout,
415493
);
416494

417-
const comment_spam = ("//" + "B".repeat(2000) + "\n").repeat(1000);
495+
const comment_line = "//" + Buffer.alloc(2000, "B").toString() + "\n";
496+
const comment_spam = Buffer.alloc(comment_line.length * 1000, comment_line).toString();
418497
it(
419498
"should work with sourcemap generation",
420499
async () => {
@@ -432,50 +511,24 @@ throw new Error('0');`,
432511
stderr: "pipe",
433512
stdin: "ignore",
434513
});
435-
let reloadCounter = 0;
436-
function onReload() {
437-
writeFileSync(
438-
hotRunnerRoot,
439-
`// source content
514+
const reloadCounter = await driveErrorReloadCycle(runner, {
515+
targetCount: 50,
516+
onReload: counter => {
517+
writeFileSync(
518+
hotRunnerRoot,
519+
`// source content
440520
${comment_spam}
441-
${" ".repeat(reloadCounter * 2)}throw new Error(${reloadCounter});`,
442-
);
443-
}
444-
let str = "";
445-
outer: for await (const chunk of runner.stderr) {
446-
str += new TextDecoder().decode(chunk);
447-
var any = false;
448-
if (!/error: .*[0-9]\n.*?\n/g.test(str)) continue;
449-
450-
let it = str.split("\n");
451-
let line;
452-
while ((line = it.shift())) {
453-
if (!line.includes("error:")) continue;
454-
str = "";
455-
456-
if (reloadCounter === 50) {
457-
runner.kill();
458-
break;
459-
}
460-
461-
if (line.includes(`error: ${reloadCounter - 1}`)) {
462-
onReload(); // re-save file to prevent deadlock
463-
continue outer;
464-
}
465-
expect(line).toContain(`error: ${reloadCounter}`);
466-
reloadCounter++;
467-
468-
let next = it.shift()!;
469-
if (!next) throw new Error(line);
470-
const match = next.match(/\s*at.*?:1003:(\d+)$/);
471-
if (!match) throw new Error("invalid string: " + next);
521+
${Buffer.alloc(counter * 2, " ").toString()}throw new Error(${counter});`,
522+
);
523+
},
524+
verifyLine: (errorLine, nextLine, counter) => {
525+
if (!nextLine) throw new Error(errorLine);
526+
const match = nextLine.match(/\s*at.*?:1003:(\d+)$/);
527+
if (!match) throw new Error("invalid string: " + nextLine);
472528
const col = match[1];
473-
expect(Number(col)).toBe(1 + "throw new ".length + (reloadCounter - 1) * 2);
474-
any = true;
475-
}
476-
477-
if (any) await onReload();
478-
}
529+
expect(Number(col)).toBe(1 + "throw new ".length + counter * 2);
530+
},
531+
});
479532
await runner.exited;
480533
expect(reloadCounter).toBe(50);
481534
},
@@ -498,8 +551,8 @@ throw new Error('0');`,
498551
cmd: [bunExe(), "build", "--watch", bundleIn, "--target=bun", "--sourcemap=inline", "--outfile", hotRunnerRoot],
499552
env: bunEnv,
500553
cwd,
501-
stdout: "inherit",
502-
stderr: "inherit",
554+
stdout: "ignore",
555+
stderr: "ignore",
503556
stdin: "ignore",
504557
});
505558
waitForFileToExist(hotRunnerRoot, 20);
@@ -511,57 +564,42 @@ throw new Error('0');`,
511564
stderr: "pipe",
512565
stdin: "ignore",
513566
});
514-
let reloadCounter = 0;
515-
function onReload() {
516-
writeFileSync(
517-
bundleIn,
518-
`// source content
567+
let done = false;
568+
const reloadCounter = await Promise.race([
569+
driveErrorReloadCycle(runner, {
570+
targetCount: 50,
571+
onReload: counter => {
572+
writeFileSync(
573+
bundleIn,
574+
`// source content
519575
// etc etc
520576
// etc etc
521-
${" ".repeat(reloadCounter * 2)}throw new Error(${reloadCounter});`,
522-
);
523-
}
524-
let str = "";
525-
outer: for await (const chunk of runner.stderr) {
526-
const s = new TextDecoder().decode(chunk);
527-
str += s;
528-
var any = false;
529-
if (!/error: .*[0-9]\n.*?\n/g.test(str)) continue;
530-
531-
let it = str.split("\n");
532-
let line;
533-
while ((line = it.shift())) {
534-
if (!line.includes("error:")) continue;
535-
str = "";
536-
537-
if (reloadCounter === 50) {
538-
runner.kill();
539-
break;
540-
}
541-
542-
if (line.includes(`error: ${reloadCounter - 1}`)) {
543-
onReload(); // re-save file to prevent deadlock
544-
continue outer;
545-
}
546-
expect(line).toContain(`error: ${reloadCounter}`);
547-
reloadCounter++;
548-
549-
let next = it.shift()!;
550-
expect(next).toInclude("bundle_in.ts");
551-
const col = next.match(/\s*at.*?:4:(\d+)$/)![1];
552-
expect(Number(col)).toBe(1 + "throw ".length + (reloadCounter - 1) * 2);
553-
any = true;
554-
}
555-
556-
if (any) await onReload();
557-
}
577+
${Buffer.alloc(counter * 2, " ").toString()}throw new Error(${counter});`,
578+
);
579+
},
580+
verifyLine: (_errorLine, nextLine, counter) => {
581+
if (!nextLine) throw new Error(_errorLine);
582+
expect(nextLine).toInclude("bundle_in.ts");
583+
const match = nextLine.match(/\s*at.*?:4:(\d+)$/);
584+
if (!match) throw new Error("invalid stack trace: " + nextLine);
585+
const col = match[1];
586+
expect(Number(col)).toBe(1 + "throw ".length + counter * 2);
587+
},
588+
}).finally(() => {
589+
done = true;
590+
}),
591+
bundler.exited.then(code => {
592+
if (!done) throw new Error(`bundler exited early with code ${code}`);
593+
return -1; // Ignored — race already resolved
594+
}),
595+
]);
558596
expect(reloadCounter).toBe(50);
559597
bundler.kill();
560598
},
561599
timeout,
562600
);
563601

564-
const long_comment = "BBBB".repeat(100000);
602+
const long_comment = Buffer.alloc(400000, "BBBB").toString();
565603

566604
it(
567605
"should work with sourcemap loading with large files",
@@ -604,68 +642,43 @@ throw new Error('0');`,
604642
],
605643
env: bunEnv,
606644
cwd,
607-
stdout: "inherit",
645+
stdout: "ignore",
608646
stderr: "pipe",
609647
stdin: "ignore",
610648
});
611-
let reloadCounter = 0;
612-
function onReload() {
613-
writeFileSync(
614-
bundleIn,
615-
`// ${long_comment}
649+
let done2 = false;
650+
const reloadCounter = await Promise.race([
651+
driveErrorReloadCycle(runner, {
652+
targetCount: 50,
653+
onReload: counter => {
654+
writeFileSync(
655+
bundleIn,
656+
`// ${long_comment}
616657
console.error("RSS: %s", process.memoryUsage().rss);
617658
//
618-
${" ".repeat(reloadCounter * 2)}throw new Error(${reloadCounter});`,
619-
);
620-
}
621-
let str = "";
622-
let sampleMemory10: number | undefined;
623-
let sampleMemory100: number | undefined;
624-
outer: for await (const chunk of runner.stderr) {
625-
str += new TextDecoder().decode(chunk);
626-
var any = false;
627-
if (!/error: .*[0-9]\n.*?\n/g.test(str)) continue;
628-
629-
let it = str.split("\n");
630-
let line;
631-
while ((line = it.shift())) {
632-
if (!line.includes("error:")) continue;
633-
let rssMatch = str.match(/RSS: (\d+(\.\d+)?)\n/);
634-
let rss;
635-
if (rssMatch) rss = Number(rssMatch[1]);
636-
str = "";
637-
638-
if (reloadCounter == 10) {
639-
sampleMemory10 = rss;
640-
}
641-
642-
if (reloadCounter >= 50) {
643-
sampleMemory100 = rss;
644-
runner.kill();
645-
break;
646-
}
647-
648-
if (line.includes(`error: ${reloadCounter - 1}`)) {
649-
onReload(); // re-save file to prevent deadlock
650-
continue outer;
651-
}
652-
expect(line).toContain(`error: ${reloadCounter}`);
653-
654-
reloadCounter++;
655-
let next = it.shift()!;
656-
expect(next).toInclude("bundle_in.ts");
657-
const col = next.match(/\s*at.*?:4:(\d+)$/)![1];
658-
expect(Number(col)).toBe(1 + "throw ".length + (reloadCounter - 1) * 2);
659-
any = true;
660-
}
661-
662-
if (any) await onReload();
663-
}
659+
${Buffer.alloc(counter * 2, " ").toString()}throw new Error(${counter});`,
660+
);
661+
},
662+
verifyLine: (_errorLine, nextLine, counter) => {
663+
if (!nextLine) throw new Error(_errorLine);
664+
expect(nextLine).toInclude("bundle_in.ts");
665+
const match = nextLine.match(/\s*at.*?:4:(\d+)$/);
666+
if (!match) throw new Error("invalid stack trace: " + nextLine);
667+
const col = match[1];
668+
expect(Number(col)).toBe(1 + "throw ".length + counter * 2);
669+
},
670+
}).finally(() => {
671+
done2 = true;
672+
}),
673+
bundler.exited.then(code => {
674+
if (!done2) throw new Error(`bundler exited early with code ${code}`);
675+
return -1; // Ignored — race already resolved
676+
}),
677+
]);
664678
expect(reloadCounter).toBe(50);
665679
bundler.kill();
666680
await runner.exited;
667681
// TODO: bun has a memory leak when --hot is used on very large files
668-
// console.log({ sampleMemory10, sampleMemory100 });
669682
},
670683
longTimeout,
671684
);

0 commit comments

Comments
 (0)