diff --git a/packages/producer/src/services/render/observability.ts b/packages/producer/src/services/render/observability.ts index 0996f6000..d802ae12f 100644 --- a/packages/producer/src/services/render/observability.ts +++ b/packages/producer/src/services/render/observability.ts @@ -71,7 +71,11 @@ export interface RenderCaptureObservability { /** Worker count the resolver would have used absent the inversion; undefined if it never fired. */ dePreInversionWorkers?: number; /** - * Rough element count of the compiled composition (`countElementTags`). + * Element count for the short-comp band gate (`resolveCompositionElementCount`): + * the LIVE DOM size from the already-running probe session when one is + * initialized, falling back to a static scan of the compiled HTML + * (`countElementTags`) otherwise. Live is authoritative — a static scan + * cannot see elements a composition's own script creates at runtime. * * Emitted on every render, not just inverted ones — this is the variable the * short-comp inversion band is gated on, and the fleet distribution of it is diff --git a/packages/producer/src/services/renderOrchestrator.test.ts b/packages/producer/src/services/renderOrchestrator.test.ts index 6f3a198e5..fe83d25f7 100644 --- a/packages/producer/src/services/renderOrchestrator.test.ts +++ b/packages/producer/src/services/renderOrchestrator.test.ts @@ -37,6 +37,7 @@ import { countElementTags, envInt, mergeWorkerInitObservability, + resolveCompositionElementCount, resolveDeShortBand, shouldPreferParallelDrawElement, shouldPreferSingleWorkerDrawElement, @@ -1896,6 +1897,46 @@ describe("shouldPreferSingleWorkerDrawElement (DE priority inversion)", () => { }); }); + describe("resolveCompositionElementCount", () => { + // Review finding (R3): a static scan of SOURCE markup cannot see DOM a + // composition's own script creates at runtime (document.createElement) — + // an unbounded undercount no regex can close. style-10-prod's real + // per-transcript-word caption generator is exactly this shape: 2 source + // tags, thousands of live nodes after init. These pin the fix: prefer the + // live count from an initialized probe session, static scan only as + // fallback. + it("uses the live DOM count from an initialized probe session, ignoring the (much smaller) source scan", async () => { + const session = { isInitialized: true, page: { evaluate: async () => 40001 } }; + expect(await resolveCompositionElementCount(session, "
")).toBe(40001); + }); + + it("falls back to the static scan when there is no probe session", async () => { + expect(await resolveCompositionElementCount(null, "
")).toBe(2); + }); + + it("falls back to the static scan when the probe session is not yet initialized", async () => { + const session = { isInitialized: false, page: { evaluate: async () => 999 } }; + expect(await resolveCompositionElementCount(session, "
")).toBe(1); + }); + + it("falls back to the static scan when page.evaluate throws (detached frame, mid-navigation)", async () => { + const session = { + isInitialized: true, + page: { + evaluate: async () => { + throw new Error("Execution context was destroyed"); + }, + }, + }; + expect(await resolveCompositionElementCount(session, "
")).toBe(2); + }); + + it("falls back to the static scan when evaluate resolves a non-finite value", async () => { + const session = { isInitialized: true, page: { evaluate: async () => Number.NaN } }; + expect(await resolveCompositionElementCount(session, "
")).toBe(1); + }); + }); + describe("envInt", () => { afterEach(() => { delete process.env.HF_TEST_ENV_INT; diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 03fe724e4..6b88a9b29 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -1270,9 +1270,13 @@ export function envInt(name: string, fallback: number): number { * literal closing marker (``), so * ordinary JS comparisons and divisions don't qualify — verified by test. * - * These semantics are FROZEN while the short-band baseline is being read — - * the fleet distribution recorded by the baseline release must be measured - * by the same counter that later gates routing, or the baseline is invalid. + * FALLBACK ONLY as of the live-DOM fix below — a string scan of the SOURCE + * markup cannot see elements a composition's own script creates at runtime + * (`document.createElement`), which is an unbounded undercount no regex can + * close: `style-10-prod`'s per-transcript-word caption generator measures 2 + * source tags against thousands of live nodes after init (review finding). + * `resolveCompositionElementCount` prefers the initialized probe session's + * live count and uses this only when no such session exists. */ export function countElementTags(html: string): number { const matches = html.match( @@ -1281,6 +1285,41 @@ export function countElementTags(html: string): number { return matches === null ? 0 : matches.length; } +/** + * Element count for the short-comp band's gate — prefers the LIVE DOM of the + * probe session that's already running for every render at this point in the + * pipeline (its Chrome is reused for capture on the common single-worker + * path, so this costs one extra CDP round-trip, not a browser launch) over + * the static `countElementTags` scan of `compiled.html`. The live count is + * the only one that sees runtime-generated DOM — a composition whose script + * builds its own elements after load (the caption-word-span pattern above) + * is otherwise measured as near-empty regardless of how the static scanner + * is tuned, admitting an arbitrarily large live DOM below the routing + * ceiling (review finding, R3). + * + * These semantics are FROZEN while the short-band baseline is being read — + * the fleet distribution recorded by the baseline release must be measured + * by the same resolver that later gates routing, or the baseline is invalid. + */ +export async function resolveCompositionElementCount( + probeSession: Pick | null, + html: string, +): Promise { + if (probeSession?.isInitialized) { + try { + const liveCount = await probeSession.page.evaluate( + () => document.querySelectorAll("*").length, + ); + if (typeof liveCount === "number" && Number.isFinite(liveCount)) return liveCount; + } catch { + // Probe page evaluate can fail (navigation mid-flight, detached frame, + // page crash) — fall through to the static scan rather than block the + // render on a routing-gate measurement. + } + } + return countElementTags(html); +} + /** * Max-merge init telemetry across per-worker capture perf summaries — the * success-path channel for PARALLEL renders, whose worker console buffers @@ -2539,7 +2578,10 @@ async function executeRenderPipeline(input: { // 900 floor stands, unchanged. const deShortBandMinFrames = envInt("HF_DE_SHORT_MIN_FRAMES", 250); const deShortBandMaxElements = envInt("HF_DE_SHORT_MAX_ELEMENTS", 2500); - const compositionElementCount = countElementTags(compiled.html); + const compositionElementCount = await resolveCompositionElementCount( + probeSession, + compiled.html, + ); // HF_DE_SHORT_MAX_ELEMENTS=0 is the documented kill switch (symmetric // with HF_DE_SHORT_MIN_FRAMES=0, which disables via the predicate's own // minFrames > 0 guard). Gated explicitly here too — without it, a fired