feat(producer): seedable Math.random / crypto.getRandomValues shim, gated

Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.2 (Math.random row) and §17.2
(gating table).

The existing `VIRTUAL_TIME_SHIM` freezes Date.now / performance.now / rAF
on a render seek but leaves `Math.random` and `crypto.getRandomValues` as
native non-deterministic. Compositions that paint stochastic visuals
through these APIs produce different pixels on distributed retries.

This change adds `buildVirtualTimeShim({ seedRandomFromFrame: boolean })`.
Default `false` returns a string byte-identical to today's
`VIRTUAL_TIME_SHIM` (pinned by a new unit test). When `true`, the script
additionally:

  - Installs a Mulberry32 PRNG with a single uint32 state
  - Reseeds the state from the current virtual time on every
    `seekToTime(ms)` call (Knuth multiplicative hash + golden-ratio offset)
  - Replaces `Math.random` with the PRNG output
  - Replaces `crypto.getRandomValues` to fill the buffer from the PRNG

`VIRTUAL_TIME_SHIM` (the const consumed by `renderOrchestrator` +
`probeStage`) is now `buildVirtualTimeShim({ seedRandomFromFrame: false })`
— in-process behavior unchanged, producer regression baselines unaffected.

Phase 3 distributed primitives will pass `true` when building the chunk
worker's file-server scripts.

10 new unit tests at packages/producer/src/services/
fileServer-seededRandom.test.ts use node:vm to evaluate the shim in
isolated contexts and pin both branches:

  - default emits no RNG override and leaves Math.random native
  - locked emits the seeded block, produces identical sequences across
    fresh VMs at the same time, and yields different sequences for
    different times

This is part of a stack of 10 PRs; this is PR 4 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
James
2026-05-13 04:36:11 +00:00
co-authored by Claude Opus 4.7
parent d54ad9ca19
commit 8bac7ba0d1
2 changed files with 285 additions and 3 deletions
@@ -0,0 +1,185 @@
/**
* Tests for the `seedRandomFromFrame` gate on `buildVirtualTimeShim`.
*
* 1. Backwards compatibility — `buildVirtualTimeShim({
* seedRandomFromFrame: false })` returns a string byte-identical to the
* legacy `VIRTUAL_TIME_SHIM`. Existing in-process callers see no
* difference.
*
* 2. Distributed determinism — with `seedRandomFromFrame: true`, the shim's
* `seekToTime(ms)` reseeds a Mulberry32 PRNG keyed by the virtual time
* and replaces `Math.random` / `crypto.getRandomValues` with that PRNG.
* `seekToTime(N)` → N `Math.random()` calls is a deterministic
* sequence; reseeking to the same time restarts the sequence.
*
* The shim is executed inside `node:vm` with a synthetic `window`/`Math` so
* tests don't need real Chrome.
*/
import { describe, expect, it } from "bun:test";
import { Script, createContext, type Context } from "node:vm";
import { buildVirtualTimeShim, VIRTUAL_TIME_SHIM } from "./fileServer.js";
/**
* Build a fresh VM context with its own globals (its own Math, its own
* crypto, …) and run the shim inside it. Each context's Math is independent,
* so we can run two shims back-to-back and have their `Math.random` overrides
* not clobber each other.
*
* The shim is a browser-style IIFE that touches `window.*`. We set the VM's
* `window` to its own globalThis so `window.setTimeout = ...` mutates the VM
* globals (matching browser semantics) and our test code can read
* `window.__HF_VIRTUAL_TIME__` afterward.
*/
function makeShimContext(): {
context: Context;
run: <T = unknown>(code: string) => T;
} {
const context = createContext({});
const bootstrap = `
globalThis.window = globalThis;
globalThis.setTimeout = (cb, ms) => 0;
globalThis.clearTimeout = (id) => undefined;
globalThis.setInterval = (cb, ms) => 0;
globalThis.clearInterval = (id) => undefined;
globalThis.performance = { now: () => 0 };
globalThis.requestAnimationFrame = undefined;
globalThis.cancelAnimationFrame = undefined;
// Provide a synthetic crypto.getRandomValues so the shim can detect it
// and replace it. Default is a no-op that returns the buffer unchanged.
globalThis.crypto = { getRandomValues: (arr) => arr };
`;
new Script(bootstrap).runInContext(context);
const run = <T>(code: string): T => new Script(code).runInContext(context) as T;
return { context, run };
}
function runShim(shimSource: string): {
run: <T = unknown>(code: string) => T;
} {
const ctx = makeShimContext();
ctx.run(shimSource);
return ctx;
}
describe("buildVirtualTimeShim — backwards compatibility", () => {
it("default (seedRandomFromFrame: false) is byte-identical to VIRTUAL_TIME_SHIM", () => {
// The const that existing call sites import:
// renderOrchestrator.ts: preHeadScripts: [VIRTUAL_TIME_SHIM]
// probeStage.ts: preHeadScripts: [VIRTUAL_TIME_SHIM]
// Must continue to emit the same script.
const built = buildVirtualTimeShim({ seedRandomFromFrame: false });
expect(built).toBe(VIRTUAL_TIME_SHIM);
});
it("default shim does not mention any seeded-RNG identifiers", () => {
const shim = buildVirtualTimeShim({ seedRandomFromFrame: false });
expect(shim).not.toContain("mulberry32");
expect(shim).not.toContain("reseedRngFromTime");
expect(shim).not.toContain("__seededGetRandomValues");
expect(shim).not.toContain("Math.random = ");
});
it("default shim leaves Math.random pointing at the VM's native function", () => {
const shim = buildVirtualTimeShim({ seedRandomFromFrame: false });
const { run } = runShim(shim);
// toString() of native Math.random is `function random() { [native code] }`
const isNative = run<boolean>(`/\\[native code\\]/.test(Math.random.toString())`);
expect(isNative).toBe(true);
});
});
describe("buildVirtualTimeShim — seedRandomFromFrame: true", () => {
it("emits the seeded-RNG block", () => {
const shim = buildVirtualTimeShim({ seedRandomFromFrame: true });
expect(shim).toContain("mulberry32");
expect(shim).toContain("reseedRngFromTime");
expect(shim).toContain("__seededGetRandomValues");
});
it("replaces Math.random with a non-native PRNG", () => {
const shim = buildVirtualTimeShim({ seedRandomFromFrame: true });
const { run } = runShim(shim);
const isNative = run<boolean>(`/\\[native code\\]/.test(Math.random.toString())`);
expect(isNative).toBe(false);
});
it("produces identical Math.random sequences across two fresh VMs at the same time", () => {
const shim = buildVirtualTimeShim({ seedRandomFromFrame: true });
const drawSequence = `(() => {
window.__HF_VIRTUAL_TIME__.seekToTime(1234);
const seq = [];
for (let i = 0; i < 16; i++) seq.push(Math.random());
return seq;
})()`;
const seqA = runShim(shim).run<number[]>(drawSequence);
const seqB = runShim(shim).run<number[]>(drawSequence);
expect(seqA).toEqual(seqB);
// Sanity: the sequence isn't degenerate.
expect(new Set(seqA).size).toBeGreaterThan(8);
for (const v of seqA) {
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(1);
}
});
it("re-seeking to the same time produces the same Math.random sequence", () => {
// The core determinism contract for retries: same (planDir, chunkIndex) →
// same frame N → same seekToTime(t_N) → same Math.random outputs.
const shim = buildVirtualTimeShim({ seedRandomFromFrame: true });
const { run } = runShim(shim);
const observed = run<{ first: number[]; second: number[] }>(`(() => {
window.__HF_VIRTUAL_TIME__.seekToTime(42);
const first = [Math.random(), Math.random(), Math.random()];
window.__HF_VIRTUAL_TIME__.seekToTime(999);
Math.random(); Math.random();
window.__HF_VIRTUAL_TIME__.seekToTime(42);
const second = [Math.random(), Math.random(), Math.random()];
return { first, second };
})()`);
expect(observed.second).toEqual(observed.first);
});
it("different times produce different Math.random sequences", () => {
const shim = buildVirtualTimeShim({ seedRandomFromFrame: true });
const { run } = runShim(shim);
const observed = run<{ t0: number[]; t1: number[] }>(`(() => {
window.__HF_VIRTUAL_TIME__.seekToTime(0);
const t0 = [Math.random(), Math.random(), Math.random()];
window.__HF_VIRTUAL_TIME__.seekToTime(1);
const t1 = [Math.random(), Math.random(), Math.random()];
return { t0, t1 };
})()`);
expect(observed.t1).not.toEqual(observed.t0);
});
it("seeded crypto.getRandomValues writes deterministic bytes", () => {
const shim = buildVirtualTimeShim({ seedRandomFromFrame: true });
const drawBytes = `(() => {
window.__HF_VIRTUAL_TIME__.seekToTime(7);
const buf = new Uint8Array(64);
window.crypto.getRandomValues(buf);
return Array.from(buf);
})()`;
const a = runShim(shim).run<number[]>(drawBytes);
const b = runShim(shim).run<number[]>(drawBytes);
expect(a).toEqual(b);
expect(a.some((v) => v !== 0)).toBe(true);
});
it("seeded crypto.getRandomValues handles odd byte lengths", () => {
const shim = buildVirtualTimeShim({ seedRandomFromFrame: true });
const { run } = runShim(shim);
const lengths = run<number[]>(`(() => {
window.__HF_VIRTUAL_TIME__.seekToTime(3);
const out = [];
for (const len of [1, 2, 3, 4, 5, 7, 31, 33]) {
const buf = new Uint8Array(len);
window.crypto.getRandomValues(buf);
out.push(buf.byteLength);
}
return out;
})()`);
expect(lengths).toEqual([1, 2, 3, 4, 5, 7, 31, 33]);
});
});
+100 -3
View File
@@ -92,7 +92,96 @@ const MIME_TYPES: Record<string, string> = {
".otf": "font/otf",
};
const VIRTUAL_TIME_SHIM = String.raw`(function() {
/**
* Options for {@link buildVirtualTimeShim}.
*/
export interface VirtualTimeShimOptions {
/**
* When `true`, the shim additionally replaces `Math.random` and
* `crypto.getRandomValues` with a Mulberry32-seeded PRNG keyed by the
* current frame's virtual time. Compositions that call `Math.random()`
* during render then produce byte-identical pixels across machines and
* across replays of the same `(planDir, chunkIndex)` pair.
*
* Default `false`: leaves `Math.random` / `crypto.getRandomValues` native,
* preserving the in-process renderer's non-deterministic behavior for
* compositions that rely on it.
*/
seedRandomFromFrame: boolean;
}
/**
* Build the page-side virtual-time shim script.
*
* The shim freezes `Date.now`, `performance.now`, and the rAF/setTimeout
* pipeline so a render seek can deterministically advance the page's
* notion of "now". The renderer issues `__HF_VIRTUAL_TIME__.seekToTime(ms)`
* before every frame capture; everything timing-related on the page sees
* exactly `ms` until the next seek.
*
* When `options.seedRandomFromFrame` is `true`, the returned script also
* installs a seeded `Math.random` / `crypto.getRandomValues` keyed by the
* current virtual time — so compositions with stochastic visuals retry
* identically. When `false`, the shim emits no random-override code; the
* page's native `Math.random` is left alone (the in-process default).
*/
export function buildVirtualTimeShim(options: VirtualTimeShimOptions): string {
const seedRandomFromFrame = options.seedRandomFromFrame === true;
// The seeded-RNG block is gated at build time so the unlocked shim is
// byte-identical to the pre-flag form. Producer regression baselines
// compare on rendered pixels — but the file-server unit tests in
// `fileServer.test.ts` also string-match `VIRTUAL_TIME_SHIM`, and we want
// those matches to remain stable.
const seededRandomBlock = seedRandomFromFrame
? String.raw`
// Seeded Math.random / crypto.getRandomValues, keyed by virtual time.
// Mulberry32 — single uint32 state, deterministic, fast.
var rngState = 0;
function mulberry32() {
rngState |= 0; rngState = (rngState + 0x6D2B79F5) | 0;
var t = rngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
function reseedRngFromTime(ms) {
var ms32 = Math.max(0, Math.floor(Number(ms) || 0)) | 0;
// Knuth's multiplicative hash + golden-ratio offset — gives a well-
// distributed seed even for frame 0 (otherwise rngState=0 degenerates
// the PRNG's first few outputs).
rngState = (Math.imul(ms32, -1640531527) + 0x9E3779B9) | 0;
}
reseedRngFromTime(0);
try {
Math.random = function() { return mulberry32(); };
} catch (e) {}
if (window.crypto && typeof window.crypto.getRandomValues === "function") {
try {
var __seededGetRandomValues = function(arr) {
if (!arr || typeof arr.byteLength !== "number" || !arr.buffer) return arr;
var byteLen = arr.byteLength;
if (byteLen <= 0) return arr;
var view = new DataView(arr.buffer, arr.byteOffset, byteLen);
var i = 0;
for (; i + 4 <= byteLen; i += 4) {
var word = ((mulberry32() * 4294967296) >>> 0);
view.setUint32(i, word, true);
}
for (; i < byteLen; i++) {
view.setUint8(i, (mulberry32() * 256) | 0);
}
return arr;
};
window.crypto.getRandomValues = __seededGetRandomValues;
} catch (e) {}
}
`
: "";
// The seekToTime hook reseeds when seeding is on; under seedRandomFromFrame=false
// we emit no extra call so the function body is byte-identical to the
// unseeded shim.
const seekToTimeReseedCall = seedRandomFromFrame ? "reseedRngFromTime(safeTimeMs);\n " : "";
return String.raw`(function() {
if (window.__HF_VIRTUAL_TIME__) return;
var virtualNowMs = 0;
@@ -109,7 +198,7 @@ const VIRTUAL_TIME_SHIM = String.raw`(function() {
var originalCancelAnimationFrame = window.cancelAnimationFrame
? window.cancelAnimationFrame.bind(window)
: null;
${seededRandomBlock}
function flushAnimationFrame() {
if (!rafQueue.length) return;
var current = rafQueue.slice();
@@ -180,7 +269,7 @@ const VIRTUAL_TIME_SHIM = String.raw`(function() {
seekToTime: function(nextTimeMs) {
var safeTimeMs = Math.max(0, Number(nextTimeMs) || 0);
virtualNowMs = safeTimeMs;
flushAnimationFrame();
${seekToTimeReseedCall}flushAnimationFrame();
return virtualNowMs;
},
getTime: function() {
@@ -188,6 +277,14 @@ const VIRTUAL_TIME_SHIM = String.raw`(function() {
},
};
})();`;
}
/**
* Default in-process virtual-time shim — `seedRandomFromFrame: false`.
* Existing call sites (`renderOrchestrator`, `probeStage`) import this
* constant. Distributed callers build their own with seeding enabled.
*/
const VIRTUAL_TIME_SHIM = buildVirtualTimeShim({ seedRandomFromFrame: false });
/**
* Render mode extension -- adds renderSeek() for frame-accurate seeking