Skip to content

Commit 7b09a69

Browse files
test(capture): pin the rasterization loop to one render at a time
The serialization fix shipped without a regression test on the grounds that native heap corruption is not unit-testable. The corruption is not, but the property that prevents it is: `sharp` is mocked to record how many renders are in flight, and a six-SVG batch must never reach two. A deliberately slow caption stub makes overlapping renders the faster path, so a future refactor that "optimises" the loop back to `Promise.all` fails here instead of aborting in production. Also covered: `sharp.concurrency(1)` is applied — serializing the loop while leaving libvips' pool at the host core count still fans one render across every core — and an unrasterizable SVG is skipped without breaking serialization for its siblings. Verified as a real guard: reverting only contentExtractor.ts to origin/main fails 7 of the 22 cases in this file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3880043 commit 7b09a69

1 file changed

Lines changed: 146 additions & 1 deletion

File tree

packages/cli/src/capture/contentExtractor.test.ts

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,48 @@ import {
88
type VisionCaptionOutcome,
99
} from "./contentExtractor.js";
1010

11-
const { generateContentMock, clientOptions } = vi.hoisted(() => ({
11+
const { generateContentMock, clientOptions, sharpState } = vi.hoisted(() => ({
1212
generateContentMock: vi.fn(),
1313
// How the SDK client was constructed is the whole difference between the Vertex and API-key
1414
// paths, so the Vertex cases assert on it rather than on the request.
1515
clientOptions: [] as Record<string, unknown>[],
16+
// A native abort inside libvips cannot be caught, so the only defence is never running two
17+
// renders at once. That is a property of the loop, and this records it.
18+
sharpState: {
19+
inFlight: 0,
20+
maxInFlight: 0,
21+
concurrencyCalls: [] as number[],
22+
renders: [] as string[],
23+
},
1624
}));
1725

26+
vi.mock("sharp", () => {
27+
const pipeline = (filePath: string) => {
28+
const chain = {
29+
resize: () => chain,
30+
flatten: () => chain,
31+
png: () => chain,
32+
toBuffer: async () => {
33+
sharpState.inFlight += 1;
34+
sharpState.maxInFlight = Math.max(sharpState.maxInFlight, sharpState.inFlight);
35+
sharpState.renders.push(filePath);
36+
// Yield, so an overlapping caller would be observed rather than serialized by luck.
37+
await new Promise((resolve) => setTimeout(resolve, 5));
38+
sharpState.inFlight -= 1;
39+
return Buffer.from([0x89, 0x50, 0x4e, 0x47]);
40+
},
41+
};
42+
return chain;
43+
};
44+
const sharp = Object.assign(pipeline, {
45+
concurrency: (value: number) => {
46+
sharpState.concurrencyCalls.push(value);
47+
return value;
48+
},
49+
});
50+
return { default: sharp };
51+
});
52+
1853
vi.mock("@google/genai", () => ({
1954
GoogleGenAI: class {
2055
models = { generateContent: generateContentMock };
@@ -574,3 +609,113 @@ describe("captionImagesWithGemini — Vertex AI provider", () => {
574609
expect(generateContentMock).not.toHaveBeenCalled();
575610
});
576611
});
612+
613+
describe("captionImagesWithGemini — SVG rasterization", () => {
614+
const dirs: string[] = [];
615+
616+
beforeEach(() => {
617+
sharpState.inFlight = 0;
618+
sharpState.maxInFlight = 0;
619+
sharpState.concurrencyCalls.length = 0;
620+
sharpState.renders.length = 0;
621+
clientOptions.length = 0;
622+
});
623+
624+
afterEach(() => {
625+
generateContentMock.mockReset();
626+
vi.unstubAllGlobals();
627+
vi.unstubAllEnvs();
628+
for (const d of dirs) rmSync(d, { recursive: true, force: true });
629+
dirs.length = 0;
630+
});
631+
632+
function makeProjectWithSvgs(count: number): string {
633+
const dir = mkdtempSync(join(tmpdir(), "hf-svg-"));
634+
mkdirSync(join(dir, "assets", "svgs"), { recursive: true });
635+
for (let i = 0; i < count; i++) {
636+
writeFileSync(
637+
join(dir, "assets", "svgs", `logo-${i}.svg`),
638+
'<svg xmlns="http://www.w3.org/2000/svg"><path fill="#000" d="M0 0h8v8H0z"/></svg>',
639+
);
640+
}
641+
return dir;
642+
}
643+
644+
it("never runs two libvips renders at once", async () => {
645+
// Production aborted here with `free(): unaligned chunk detected in tcache 2` (SIGABRT),
646+
// twice in fourteen days, losing the whole capture. A native abort cannot be caught by the
647+
// surrounding try/catch, so the concurrency has to be absent rather than handled — which
648+
// makes "one at a time" the assertion, not "errors are reported".
649+
const dir = makeProjectWithSvgs(6);
650+
dirs.push(dir);
651+
vi.stubEnv("OPENROUTER_API_KEY", "or-key");
652+
vi.stubGlobal(
653+
"fetch",
654+
vi.fn(async () => {
655+
// Slower than a render, so overlapping renders would be the easy way to go faster —
656+
// the point is that the loop does not take it.
657+
await new Promise((resolve) => setTimeout(resolve, 15));
658+
return new Response(
659+
JSON.stringify({ choices: [{ message: { content: "A dark glyph." } }] }),
660+
{
661+
status: 200,
662+
headers: { "content-type": "application/json" },
663+
},
664+
);
665+
}),
666+
);
667+
668+
const captions = await captionImagesWithGemini(dir, () => {}, []);
669+
670+
expect(sharpState.renders).toHaveLength(6);
671+
expect(sharpState.maxInFlight).toBe(1);
672+
expect(Object.keys(captions)).toHaveLength(6);
673+
});
674+
675+
it("bounds libvips' own worker pool as well", async () => {
676+
// Left at its default the pool sizes itself to the host's core count, so serializing the
677+
// loop alone still leaves one render fanning out across every core.
678+
const dir = makeProjectWithSvgs(1);
679+
dirs.push(dir);
680+
vi.stubEnv("OPENROUTER_API_KEY", "or-key");
681+
vi.stubGlobal(
682+
"fetch",
683+
vi.fn(
684+
async () =>
685+
new Response(JSON.stringify({ choices: [{ message: { content: "A glyph." } }] }), {
686+
status: 200,
687+
headers: { "content-type": "application/json" },
688+
}),
689+
),
690+
);
691+
692+
await captionImagesWithGemini(dir, () => {}, []);
693+
694+
expect(sharpState.concurrencyCalls).toEqual([1]);
695+
});
696+
697+
it("keeps captioning the rest when one SVG cannot be rasterized", async () => {
698+
const dir = makeProjectWithSvgs(3);
699+
dirs.push(dir);
700+
vi.stubEnv("OPENROUTER_API_KEY", "or-key");
701+
vi.stubGlobal(
702+
"fetch",
703+
vi.fn(
704+
async () =>
705+
new Response(JSON.stringify({ choices: [{ message: { content: "A glyph." } }] }), {
706+
status: 200,
707+
headers: { "content-type": "application/json" },
708+
}),
709+
),
710+
);
711+
// One file is unreadable, which is how an exotic SVG behaves through sharp.
712+
rmSync(join(dir, "assets", "svgs", "logo-1.svg"));
713+
mkdirSync(join(dir, "assets", "svgs", "logo-1.svg"));
714+
715+
const warnings: string[] = [];
716+
const captions = await captionImagesWithGemini(dir, () => {}, warnings);
717+
718+
expect(Object.keys(captions).sort()).toEqual(["svgs/logo-0.svg", "svgs/logo-2.svg"]);
719+
expect(sharpState.maxInFlight).toBe(1);
720+
});
721+
});

0 commit comments

Comments
 (0)