fix(aws-lambda): resolve Chromium for handlePlan before invoking probe

The producer's probe stage launches Chromium when Plan has to resolve
browser-only data: root duration unknown, unresolved sub-compositions, or
data-hf-auto-start media. Only handleRenderChunk was setting
PRODUCER_HEADLESS_SHELL_PATH, so a cold Plan invocation launched
puppeteer-core with no executablePath and failed before writing plan.tar.gz.

Mirror the renderChunk env-var guard inside handlePlan so the bundled
Sparticuz binary gets resolved on the first Plan invocation and reused on
warm starts. The skipChromeResolution dep stays honored for SAM-local RIE
smokes.

A warm Lambda environment can mask this only if it previously served a
renderChunk from another execution and left the env var sticky. Within a
single Step Functions execution, Plan still runs before RenderChunks.

A new dispatch test exercises the guard path by pre-seeding
PRODUCER_HEADLESS_SHELL_PATH and asserting Plan does not overwrite it.
This commit is contained in:
Kiyeon Jeon
2026-05-24 16:22:31 +09:00
parent 90bf485db8
commit bc20b87b9a
2 changed files with 79 additions and 0 deletions
+69
View File
@@ -209,6 +209,75 @@ describe("handler dispatch", () => {
).toBe(true);
});
it("plan honors a pre-set PRODUCER_HEADLESS_SHELL_PATH instead of re-resolving Chrome", async () => {
// Mirrors the renderChunk env-var guard — when a caller (e.g. SAM-local
// RIE smoke) seeds the path, handlePlan must not overwrite it.
const tmpRoot = makeTmpRoot();
const s3 = new FakeS3Client();
s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar());
const planMock = mock(
async (_projectDir: string, _config: unknown, planDir: string): Promise<PlanResult> => {
mkdirSync(planDir, { recursive: true });
writeFileSync(join(planDir, "plan.json"), JSON.stringify({ planHash: "fakehash" }));
mkdirSync(join(planDir, "meta"), { recursive: true });
writeFileSync(join(planDir, "meta", "chunks.json"), "[]");
return {
planDir,
planHash: "fakehash",
chunkCount: 1,
totalFrames: 30,
fps: 30 as const,
width: 1920,
height: 1080,
format: "mp4" as const,
ffmpegVersion: "6.0",
producerVersion: "0.0.0-test",
};
},
);
const renderChunkMock = mock(async () => {
throw new Error("should not be called");
});
const assembleMock = mock(async () => {
throw new Error("should not be called");
});
const event: PlanEvent = {
Action: "plan",
ProjectS3Uri: "s3://bucket/project.tar.gz",
PlanOutputS3Prefix: "s3://bucket/renders/abc/",
Config: { fps: 30, width: 1920, height: 1080, format: "mp4" },
};
const sentinel = "/tmp/test-chrome-sentinel";
const prev = process.env.PRODUCER_HEADLESS_SHELL_PATH;
process.env.PRODUCER_HEADLESS_SHELL_PATH = sentinel;
try {
// Note: no skipChromeResolution flag — the guard must short-circuit
// because PRODUCER_HEADLESS_SHELL_PATH is already set.
await handler(event, {
s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
primitives: {
plan: planMock as unknown as typeof import("@hyperframes/producer/distributed").plan,
renderChunk:
renderChunkMock as unknown as typeof import("@hyperframes/producer/distributed").renderChunk,
assemble:
assembleMock as unknown as typeof import("@hyperframes/producer/distributed").assemble,
},
tmpRoot,
});
expect(process.env.PRODUCER_HEADLESS_SHELL_PATH).toBe(sentinel);
expect(planMock).toHaveBeenCalledTimes(1);
} finally {
if (prev === undefined) {
delete process.env.PRODUCER_HEADLESS_SHELL_PATH;
} else {
process.env.PRODUCER_HEADLESS_SHELL_PATH = prev;
}
}
});
it("routes Action='renderChunk' to the renderChunk primitive", async () => {
const tmpRoot = makeTmpRoot();
const s3 = new FakeS3Client();
+10
View File
@@ -229,6 +229,16 @@ async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise<PlanLam
const s3 = deps?.s3 ?? getS3Client();
const primitive = deps?.primitives?.plan ?? plan;
// The producer's probe stage launches Chromium whenever the composition
// needs a runtime duration probe or has unresolved sub-compositions, so
// plan has to resolve Chrome the same way renderChunk does. Without this
// the probe throws "An `executablePath` or `channel` must be specified
// for `puppeteer-core`" the moment runProbeStage calls puppeteer.launch.
if (!deps?.skipChromeResolution && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
const chromePath = await resolveChromeExecutablePath();
process.env.PRODUCER_HEADLESS_SHELL_PATH = chromePath;
}
const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-lambda-plan-"));
// We use `.tar.gz` (not `.zip`) as the project archive's on-the-wire
// format because Lambda's Amazon Linux base image ships GNU `tar` but