mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
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:
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user