mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +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",
|
".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;
|
if (window.__HF_VIRTUAL_TIME__) return;
|
||||||
|
|
||||||
var virtualNowMs = 0;
|
var virtualNowMs = 0;
|
||||||
@@ -109,7 +198,7 @@ const VIRTUAL_TIME_SHIM = String.raw`(function() {
|
|||||||
var originalCancelAnimationFrame = window.cancelAnimationFrame
|
var originalCancelAnimationFrame = window.cancelAnimationFrame
|
||||||
? window.cancelAnimationFrame.bind(window)
|
? window.cancelAnimationFrame.bind(window)
|
||||||
: null;
|
: null;
|
||||||
|
${seededRandomBlock}
|
||||||
function flushAnimationFrame() {
|
function flushAnimationFrame() {
|
||||||
if (!rafQueue.length) return;
|
if (!rafQueue.length) return;
|
||||||
var current = rafQueue.slice();
|
var current = rafQueue.slice();
|
||||||
@@ -180,7 +269,7 @@ const VIRTUAL_TIME_SHIM = String.raw`(function() {
|
|||||||
seekToTime: function(nextTimeMs) {
|
seekToTime: function(nextTimeMs) {
|
||||||
var safeTimeMs = Math.max(0, Number(nextTimeMs) || 0);
|
var safeTimeMs = Math.max(0, Number(nextTimeMs) || 0);
|
||||||
virtualNowMs = safeTimeMs;
|
virtualNowMs = safeTimeMs;
|
||||||
flushAnimationFrame();
|
${seekToTimeReseedCall}flushAnimationFrame();
|
||||||
return virtualNowMs;
|
return virtualNowMs;
|
||||||
},
|
},
|
||||||
getTime: function() {
|
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
|
* Render mode extension -- adds renderSeek() for frame-accurate seeking
|
||||||
|
|||||||
Reference in New Issue
Block a user