From 1d189aa26b0cb4da9187cca223eb958fd48ba38b Mon Sep 17 00:00:00 2001 From: James Date: Wed, 13 May 2026 00:32:15 +0000 Subject: [PATCH] feat(producer): freezePlan snapshots PRODUCER_RUNTIME_* env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of Phase 2 of the distributed rendering plan (determinism hardening). See DISTRIBUTED-RENDERING-PLAN.md §4.3 (LockedRenderConfig.runtimeEnv) and §5.2 (RENDER_SEEK_MODE row). `fileServer.ts` reads several `PRODUCER_RUNTIME_*` and `PRODUCER_RENDER_*` env vars at module-load time (RENDER_SEEK_MODE, RENDER_SEEK_STEP, RENDER_SEEK_OFFSET_FRACTION, …) and bakes them into the served HTML's RENDER_MODE_SCRIPT. Distributed chunk workers are separate processes that may inherit a different environment, so the plan needs to freeze a snapshot. Adds `snapshotRuntimeEnv(env = process.env)` in packages/producer/src/services/render/stages/freezePlan.ts. Captures keys matching `PRODUCER_RUNTIME_` or `PRODUCER_RENDER_` prefixes into a fresh plain object, ignoring everything else. Phase 3's `renderChunk` will materialize the snapshot back into `process.env` before launching its file server. Also exports `RUNTIME_ENV_SNAPSHOT_PREFIXES` so the chunk-worker side can apply the same prefix filter (asymmetric handling would leak stale controller env into worker behavior). The freezePlan function body remains a skeleton — Phase 3 owns the full implementation. The snapshot helper is exported on its own so this gate's unit test can pin the behavior without depending on the not-yet-written freezePlan body. In-process behavior is unchanged: no in-process caller invokes freezePlan or snapshotRuntimeEnv yet. 9 unit tests at packages/producer/src/services/render/stages/ freezePlan.test.ts cover: prefix matches (both families), non-matching keys ignored, undefined values skipped, fresh-object contract, and default-to-process.env behavior. This is part of a stack of 10 PRs; this is PR 5 of 10. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../render/runtimeEnvSnapshot.test.ts | 101 ++++++++++++++++++ .../src/services/render/runtimeEnvSnapshot.ts | 53 +++++++++ .../src/services/render/stages/freezePlan.ts | 14 ++- 3 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 packages/producer/src/services/render/runtimeEnvSnapshot.test.ts create mode 100644 packages/producer/src/services/render/runtimeEnvSnapshot.ts diff --git a/packages/producer/src/services/render/runtimeEnvSnapshot.test.ts b/packages/producer/src/services/render/runtimeEnvSnapshot.test.ts new file mode 100644 index 000000000..3b69ae6d2 --- /dev/null +++ b/packages/producer/src/services/render/runtimeEnvSnapshot.test.ts @@ -0,0 +1,101 @@ +/** + * Tests for the `snapshotRuntimeEnv` helper that backs the + * `LockedRenderConfig.runtimeEnv` field. + */ + +import { describe, expect, it } from "bun:test"; +import { RUNTIME_ENV_PREFIXES, snapshotRuntimeEnv } from "./runtimeEnvSnapshot.js"; + +describe("snapshotRuntimeEnv", () => { + it("captures PRODUCER_RUNTIME_* keys", () => { + const env = { + PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary", + PRODUCER_RUNTIME_RENDER_SEEK_OFFSET_FRACTION: "0.5", + }; + expect(snapshotRuntimeEnv(env)).toEqual({ + PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary", + PRODUCER_RUNTIME_RENDER_SEEK_OFFSET_FRACTION: "0.5", + }); + }); + + it("captures PRODUCER_RENDER_* keys", () => { + const env = { + PRODUCER_RENDER_SEEK_STEP: "0.008", + }; + expect(snapshotRuntimeEnv(env)).toEqual({ + PRODUCER_RENDER_SEEK_STEP: "0.008", + }); + }); + + it("captures both prefix families in a single snapshot", () => { + const env = { + PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary", + PRODUCER_RENDER_SEEK_STEP: "0.008", + }; + expect(snapshotRuntimeEnv(env)).toEqual({ + PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary", + PRODUCER_RENDER_SEEK_STEP: "0.008", + }); + }); + + it("ignores keys that don't match either prefix", () => { + const env = { + PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary", + HOME: "/home/ci", + PATH: "/usr/bin:/bin", + NODE_ENV: "production", + // Off-by-one prefix variants — must NOT be captured. + PRODUCER_RUNTIM_FOO: "x", + PRODUCER_RENDR_BAR: "y", + PRODUCER_DEBUG_SEEK_DIAGNOSTICS: "true", + }; + const snapshot = snapshotRuntimeEnv(env); + expect(snapshot).toEqual({ + PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary", + }); + expect(Object.keys(snapshot)).toHaveLength(1); + }); + + it("skips keys whose value is undefined", () => { + const env = { + PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary", + PRODUCER_RUNTIME_OTHER: undefined, + }; + expect(snapshotRuntimeEnv(env)).toEqual({ + PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary", + }); + }); + + it("returns an empty object when no keys match", () => { + expect(snapshotRuntimeEnv({ HOME: "/home/ci", PATH: "/usr/bin" })).toEqual({}); + }); + + it("returns a NEW object each call (no live reference to process.env)", () => { + const env = { PRODUCER_RUNTIME_X: "v1" }; + const first = snapshotRuntimeEnv(env); + env.PRODUCER_RUNTIME_X = "v2"; + const second = snapshotRuntimeEnv(env); + expect(first.PRODUCER_RUNTIME_X).toBe("v1"); + expect(second.PRODUCER_RUNTIME_X).toBe("v2"); + expect(first).not.toBe(second); + }); + + it("defaults to process.env when no argument is passed", () => { + const key = `PRODUCER_RUNTIME_FREEZEPLAN_TEST_${Date.now()}`; + const sentinel = "freezeplan-test-value"; + process.env[key] = sentinel; + try { + const snapshot = snapshotRuntimeEnv(); + expect(snapshot[key]).toBe(sentinel); + } finally { + delete process.env[key]; + } + }); + + it("exports the prefix list for chunk-worker materialization", () => { + // Chunk workers must apply the SAME prefix filter when reading the + // snapshot back; asymmetric handling would let stale controller env + // leak into chunk-worker behavior. + expect(RUNTIME_ENV_PREFIXES).toEqual(["PRODUCER_RUNTIME_", "PRODUCER_RENDER_"]); + }); +}); diff --git a/packages/producer/src/services/render/runtimeEnvSnapshot.ts b/packages/producer/src/services/render/runtimeEnvSnapshot.ts new file mode 100644 index 000000000..49394e825 --- /dev/null +++ b/packages/producer/src/services/render/runtimeEnvSnapshot.ts @@ -0,0 +1,53 @@ +/** + * runtimeEnvSnapshot — capture / re-apply the env vars that drive in-page + * render behavior. + * + * `fileServer.ts` reads several `PRODUCER_RUNTIME_*` and `PRODUCER_RENDER_*` + * variables at module-load time and bakes them into the served HTML's + * `RENDER_MODE_SCRIPT`. Distributed chunk workers are separate processes + * that may inherit a different environment, so the plan freezes a snapshot + * of the controller's env. The chunk worker then materializes the snapshot + * back into `process.env` before launching its file server, which keeps the + * served HTML byte-identical to what the controller would have served. + * + * Used by `freezePlan` (capture side) and the chunked render worker + * (re-apply side). Kept here as a standalone utility because it has no + * dependency on the plan-freeze pipeline. + */ + +/** + * Env-var name prefixes captured by {@link snapshotRuntimeEnv}. Exported so + * the chunk-worker side can apply the same filter when materializing a + * snapshot — asymmetric handling would leak stale controller env into + * worker behavior. + */ +export const RUNTIME_ENV_PREFIXES: readonly string[] = [ + "PRODUCER_RUNTIME_", + "PRODUCER_RENDER_", +] as const; + +/** + * Snapshot `process.env` keys that match any of {@link RUNTIME_ENV_PREFIXES} + * into a plain string→string record. Returns a NEW object each call (never a + * live reference to `process.env`) so subsequent mutations of the process + * env do not retroactively change a frozen plan. + * + * Pass an optional `env` for tests that don't want to mutate the real + * process env. The default reads `process.env`. + */ +export function snapshotRuntimeEnv( + env: Record = process.env, +): Record { + const snapshot: Record = {}; + for (const key of Object.keys(env)) { + const matches = RUNTIME_ENV_PREFIXES.some((prefix) => key.startsWith(prefix)); + if (!matches) continue; + const value = env[key]; + // Skip undefined / non-string values. `process.env` only ever returns + // strings, but `Record` lets tests pass an + // env object with explicit `undefined` slots (e.g. after `delete`). + if (typeof value !== "string") continue; + snapshot[key] = value; + } + return snapshot; +} diff --git a/packages/producer/src/services/render/stages/freezePlan.ts b/packages/producer/src/services/render/stages/freezePlan.ts index 7e99627d1..be95ee250 100644 --- a/packages/producer/src/services/render/stages/freezePlan.ts +++ b/packages/producer/src/services/render/stages/freezePlan.ts @@ -89,13 +89,23 @@ export interface FreezePlanResult { planHash: string; } +/** + * Re-export the runtime-env snapshot helper for backward compatibility with + * earlier imports from `./freezePlan`. The implementation lives in + * `../runtimeEnvSnapshot.ts` — chunk workers re-apply the snapshot during + * boot, so it needs to be importable without dragging in the freeze pipeline. + */ +export { snapshotRuntimeEnv, RUNTIME_ENV_PREFIXES } from "../runtimeEnvSnapshot.js"; + /** * Freeze a plan directory: write `meta/*.json` + top-level `plan.json`, then * compute `planHash` over the canonicalized contents. * * Skeleton — body lands when the distributed-render primitives compose the - * stage functions. + * stage functions. The body will resolve `input.encoder.runtimeEnv ||= + * snapshotRuntimeEnv()` so callers can optionally pre-populate the field, + * with the live env as the default. */ export async function freezePlan(_input: FreezePlanInput): Promise { - throw new Error("freezePlan is not implemented yet — see DISTRIBUTED-RENDERING-PLAN.md §11."); + throw new Error("freezePlan is not implemented yet."); }