From f3c154d0a1ded61966f6ad09e198b96694868f87 Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Thu, 13 Aug 2026 16:40:50 +0200 Subject: [PATCH 1/2] Count what loads per URL, and never below zero `waitForDecodes` asks the page for one number: how many items the default loading manager still has in flight. That number is kept by counting `itemStart` up and `itemEnd` down -- which trusts every loader to call both, and one of them does not. `postprocessing` 6.39's `LUTCubeLoader.load()` calls `itemEnd` on a URL it never called `itemStart` for; 6.36, the version on this branch today, called both. A single counter cannot survive that. It reads 0 at whatever moment exactly one real item is in flight -- the shot going off in the middle of a decode, which is the whole thing this wait exists to prevent -- and -1 the rest of the time, which is a wait that cannot end. Measured on the three-0.181 branch, where 6.39 arrives: `glass-flower` and `nextjs-prism` each sat on the full 300s budget and failed, and the four other `.cube` examples shot early without saying anything. So count per URL and ignore an `itemEnd` for a URL nobody started. No behaviour change here -- 6.36 is balanced -- this is the harness holding its own invariant rather than borrowing a loader's. Co-Authored-By: Claude Opus 5 --- packages/e2e/src/deterministic.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/e2e/src/deterministic.js b/packages/e2e/src/deterministic.js index 781b2952..e5c4cbf6 100644 --- a/packages/e2e/src/deterministic.js +++ b/packages/e2e/src/deterministic.js @@ -19,18 +19,30 @@ if (sayCheeseParam) { // the method rather than the `onLoad` slot, which drei's `useProgress` // overwrites for its own loader UI. // - let inflight = 0; + // Counted per URL, and never below zero, because the pairing is a loader's + // to honour and one of them does not: `postprocessing` 6.39's + // `LUTCubeLoader.load()` calls `itemEnd` on a URL it never called + // `itemStart` for (6.36 did both, and the six examples loading a `.cube` + // are exactly the ones that felt it). A single counter turns that into + // `inflight - 1` for the rest of the page's life -- so it reads 0 at + // whatever moment exactly one real item is in flight, which is the shot + // going off *during* a decode, and reads -1 forever otherwise, which is the + // wait never ending: `glass-flower` and `nextjs-prism` sat there for the + // full 300s budget. An `itemEnd` for a URL nobody started is ignored. + const inflight = new Map(); const itemStart = DefaultLoadingManager.itemStart.bind(DefaultLoadingManager); const itemEnd = DefaultLoadingManager.itemEnd.bind(DefaultLoadingManager); DefaultLoadingManager.itemStart = (url) => { - inflight += 1; + inflight.set(url, (inflight.get(url) ?? 0) + 1); itemStart(url); }; DefaultLoadingManager.itemEnd = (url) => { - inflight -= 1; + const pending = inflight.get(url) ?? 0; + if (pending > 0) inflight.set(url, pending - 1); itemEnd(url); }; - window.__cheeseInflight = () => inflight; + window.__cheeseInflight = () => + [...inflight.values()].reduce((total, pending) => total + pending, 0); // // Seeding fixes the *sequence*; it does not fix who draws from it in what From c7fdb350dadd3dfe17774a208ea4cc08603123f0 Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Thu, 13 Aug 2026 19:59:33 +0200 Subject: [PATCH 2/2] Hold each frame for the physics worker, and the shot for the second take Chromatic flagged basic-ballpit against a baseline nobody touched, twice, while e2e-flaky swore it was stable locally. Both were right: two mechanisms, both invisible on an idle machine, both measured under CPU throttle. @react-three/cannon steps by ping-pong -- each frame posts `step` and *transfers* the position buffers to its worker; until the `frame` reply hands them back, every further step is silently skipped and its 1/60th of simulation dropped, not deferred. The picture is spawn + completed round trips x 1/60, and how many round trips fit into thirty pumped frames is the scheduler's call. basic-ballpit: 30/30 at full speed, 28 under x8 throttle, each side perfectly reproducible -- two stable pictures, chosen by machine speed. So the exchange is counted, per worker, and the pump holds the next frame until the count settles: one step per frame, every machine. terminate() forgives what a dying worker owes, and a step posted to an already-terminated worker is never counted -- posting into a dead worker is a silent void, and counting it held the pump for the full 300s budget. And the remount's flushSync returns when the *DOM* side has committed; the scene lives behind the bridge in r3f's own root, whose render is scheduled, not flushed. trails, throttled: both flushSyncs long returned, and the second take still assembled itself at frames 1-2 of the pump -- worker created, connected and populated across running frames, with the first take's worker, not yet unmounted, stepping in the meantime. So the take announces when it has *finished* mounting (Probe, last in the keyed fragment: by the time its effect runs, every sibling's have), and the shot waits for the announcement. Measured across the twelve cannon examples, full speed and x8, two runs each: one hash per example, 30/30 round trips, racing-game (29 posted, 28 answered before) and pmndrs-vercel and trails (three distinct pictures) included. basic-ballpit holds one hash at x1/x4/x8/x20 -- unchanged from before the fix, so CI converges back to the picture already measured. object-clump still moves under x8 -- a different, pre-existing channel (its seeded initial positions shift with load timing); at full speed its hash is unchanged and stable. clones, springy-boxes and video-cookies unchanged as non-cannon controls; arkanoid stays in EXCEPTIONS. Co-Authored-By: Claude Fable 5 --- packages/e2e/lib/shoot.mjs | 18 ++++++++++ packages/e2e/src/CheesyCanvas.jsx | 41 +++++++++++++++++++++- packages/e2e/src/deterministic.js | 56 +++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/packages/e2e/lib/shoot.mjs b/packages/e2e/lib/shoot.mjs index c9e30039..97389dd0 100644 --- a/packages/e2e/lib/shoot.mjs +++ b/packages/e2e/lib/shoot.mjs @@ -76,7 +76,25 @@ export async function shoot(page, host) { // fixed frame, so that is what gets waited on -- then the same settle the // first wait uses. // + // + // The remount's `flushSync` returns when the *DOM* side has committed; the + // scene lives behind the `` bridge in r3f's own root, whose render + // is scheduled, not flushed. Under load that render -- and the effects + // where a physics world is built, worker and all -- landed *inside* the + // shot, assembling the scene across pumped frames at whichever frame the + // machine chose (`trails`, measured under CPU throttle). So the page says + // when the take has finished mounting -- committed and effects run, see + // `Probe` in `CheesyCanvas` -- and the shot waits for it to say so. + // + const take = await page.evaluate(() => window.__cheeseTake ?? 0); await page.evaluate(() => window.__cheeseRemount?.()); + await page + .waitForFunction((before) => window.__cheeseTake > before, take, { + timeout: 60_000, + }) + .catch(() => { + console.log("The second take never finished mounting, shooting anyway"); + }); await waitForDecodes(page); // in case the second take re-suspended anything await page .waitForFunction( diff --git a/packages/e2e/src/CheesyCanvas.jsx b/packages/e2e/src/CheesyCanvas.jsx index 69c1b99f..11518526 100644 --- a/packages/e2e/src/CheesyCanvas.jsx +++ b/packages/e2e/src/CheesyCanvas.jsx @@ -96,6 +96,19 @@ function SayCheese() { // 158 examples still to be covered will. // if (window.__cheeseShoot === true && frame < FRAMES) { + // + // Not while a physics worker still owes a reply. Cannon steps by + // transferring its buffers to a worker and skips every step until + // they come back, so how many steps land inside our thirty frames + // would otherwise be the machine's choice -- see `deterministic.js`, + // which keeps the count this reads. Skipping the advance (rather + // than blocking) keeps the compositor pixel moving above. + // + if (window.__cheesePhysicsSettled?.() === false) { + raf = requestAnimationFrame(tick); + return; + } + if (frame === 0) started = window.__cheeseRealNow?.() ?? 0; // @@ -197,7 +210,33 @@ export default function CheesyCanvas({ children, frameloop, ...props }) { {sayCheeseParam && } - {children} + + {children} + {sayCheeseParam && } + ); } + +// +// Announces that a take has *finished* mounting -- render committed, effects +// run -- which `flushSync` in `__cheeseRemount` cannot promise: the children +// live behind the `` bridge, in r3f's own root, whose render the DOM +// side schedules rather than flushes. Measured on `trails` under CPU +// throttle: both flushSyncs long returned, and the second take still +// assembled itself at frames 1-2 of the pump -- its physics worker created, +// connected and populated across running frames, at whichever frame the +// machine chose (and the first take's, not yet unmounted, stepping in the +// meantime). The harness waits for this signal before it starts the shot -- +// see `shoot.mjs`. +// +// Last in the fragment, because effects flush in tree order: by the time this +// one runs, every sibling before it has run its own -- for physics, that is +// where the worker is created and every body added. +// +function Probe({ take }) { + useEffect(() => { + window.__cheeseTake = take; + }, [take]); + return null; +} diff --git a/packages/e2e/src/deterministic.js b/packages/e2e/src/deterministic.js index e5c4cbf6..b28967c5 100644 --- a/packages/e2e/src/deterministic.js +++ b/packages/e2e/src/deterministic.js @@ -44,6 +44,62 @@ if (sayCheeseParam) { window.__cheeseInflight = () => [...inflight.values()].reduce((total, pending) => total + pending, 0); + // + // Physics lives in a worker, and a worker keeps its own time. + // + // `@react-three/cannon` steps by ping-pong: each frame the provider posts + // `step` and *transfers* the position buffers to the worker; until the + // worker's `frame` reply hands them back, every further step is silently + // skipped (`byteLength === 0`) and its 1/60th of simulation is dropped, not + // deferred. So the picture is spawn + (completed round trips) x 1/60 -- and + // how many round trips fit into thirty pumped frames is the scheduler's + // call, not ours. Measured on `basic-ballpit`: 30/30 round trips at full + // speed, 28 under an 8x CPU throttle, each with its own perfectly + // reproducible canvas -- two stable pictures, chosen by machine speed. + // Chromatic's runner sits between the two and picks per run; twelve + // examples ride `@react-three/cannon`. + // + // So the round trips are counted -- `step` out, `frame` back, per worker -- + // and the pump (see `SayCheese`) holds the next frame until the count + // settles. Every machine then completes exactly one step per frame. + // `terminate()` forgives what a dying worker still owes: the second take + // unmounts the whole physics provider, and a reply that will never come + // must not hold the pump forever. + // + const owed = new Map(); + const dead = new WeakSet(); + const RealWorker = window.Worker; + window.Worker = class extends RealWorker { + postMessage(message, ...rest) { + // A step posted to a terminated worker is a debt nobody will honour: + // posting is a silent void, so counting it would hold the pump for the + // full 300s budget. It can happen -- `terminate()` runs in one effect's + // cleanup and the `useFrame` unsubscribe in another's. + if (message?.op === "step" && !dead.has(this)) + owed.set(this, (owed.get(this) ?? 0) + 1); + super.postMessage(message, ...rest); + } + get onmessage() { + return super.onmessage; + } + set onmessage(handler) { + super.onmessage = (event) => { + if (event.data?.op === "frame" && owed.get(this)) + owed.set(this, owed.get(this) - 1); + handler(event); + }; + } + terminate() { + dead.add(this); + owed.delete(this); + super.terminate(); + } + }; + window.__cheesePhysicsSettled = () => { + for (const count of owed.values()) if (count > 0) return false; + return true; + }; + // // Seeding fixes the *sequence*; it does not fix who draws from it in what // order. Assets resolve in whatever order the network hands them back, the