Add soak, live-check and solver-only soak tooling
A 900-step soak at lmax 63 on software WebGPU now runs clean with a flat
~4 MB heap. Earlier runs died around 700-800 steps; that was the canvas
resize churn fixed alongside the flicker (no-op setSize calls driven by
colorbar label reflow), not a leak in the solver.
3 changed files+116−0
README.mdmodified+16−0View file
@@ -77,8 +77,24 @@ compute, so this port swaps in:
7777 and solver cross-checks, plus a 100-step stability run.
7878 - `node scripts/longrun-node.ts` — CPU run to t = 100 confirming pattern
7979 saturation.
80+- `node scripts/soak.mjs [steps] [lmax] [backend]` — drive the demo for many
81+ steps, sampling JS heap and catching crashes. A 900-step run at lmax 63 on
82+ software WebGPU (SwiftShader) completes with a flat ~4 MB heap.
8083 - `node scripts/screenshot.mjs out.png [light|dark] [minSteps]` — screenshot
8184 the demo after a number of steps.
85+- `node scripts/check-live.mjs [url]` — smoke-check a deployed URL in a real
86+ browser: load, press Run, confirm the solver advances.
87+- `test.html?soak=<steps>&lmax=<n>` — solver-only soak with no rendering.
88+
89+### A note on canvas resizing
90+
91+Early long runs killed the browser after ~700–800 steps. The cause was the
92+colorbar's min/max labels changing width as their digit count changed, which
93+reflowed the panel, fired the `ResizeObserver`, and called
94+`renderer.setSize()` — reallocating the WebGL drawing buffer. Assigning
95+`canvas.width` also blanks the canvas even when the value is unchanged, so the
96+same bug caused visible flicker. Fixed by giving the colorbar column a fixed
97+width and making `SphereScene.resize()` return early on no-op resizes.
8298
8399 ## Development
84100
scripts/check-live.mjsadded+55−0View file
@@ -0,0 +1,55 @@
1+/**
2+ * Smoke-check a deployed URL in headless Chrome: load it, press Run, and
3+ * confirm the solver actually advances. Usage: node scripts/check-live.mjs [url]
4+ */
5+import puppeteer from 'puppeteer-core';
6+
7+const url = process.argv[2] ?? 'https://concept-collection.github.io/turing-sphere/';
8+const browser = await puppeteer.launch({
9+ executablePath: process.env.CHROME_PATH ?? '/usr/bin/google-chrome',
10+ args: ['--headless=new', '--no-sandbox', '--enable-unsafe-webgpu',
11+ '--use-webgpu-adapter=swiftshader', '--enable-unsafe-swiftshader'],
12+});
13+const page = await browser.newPage();
14+await page.setViewport({ width: 1000, height: 900 });
15+const problems = [];
16+page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
17+page.on('requestfailed', (r) => problems.push(`request failed: ${r.url()}`));
18+page.on('console', (m) => {
19+ if (m.type() === 'error' && !/GL Driver|favicon/.test(m.text())) {
20+ problems.push(`console error: ${m.text()}`);
21+ }
22+});
23+
24+try {
25+ await page.goto(url, { waitUntil: 'load', timeout: 60_000 });
26+ await page.waitForFunction(
27+ () => /grid/.test(document.getElementById('stats')?.textContent ?? ''),
28+ { timeout: 120_000 },
29+ );
30+ console.log('initial:', await page.$eval('#stats', (el) => el.textContent));
31+ await page.click('#runpause');
32+ await page.waitForFunction(
33+ () => {
34+ const m = document.getElementById('stats')?.textContent?.match(/\((\d+) steps\)/);
35+ return m && Number(m[1]) >= 20;
36+ },
37+ { timeout: 180_000 },
38+ );
39+ console.log('running:', await page.$eval('#stats', (el) => el.textContent));
40+ const panels = await page.$$eval('.sphere-box canvas', (els) => els.length);
41+ console.log('sphere canvases:', panels);
42+ if (problems.length) {
43+ console.log('PROBLEMS:');
44+ for (const p of new Set(problems)) console.log(' ' + p);
45+ process.exitCode = 1;
46+ } else {
47+ console.log('LIVE CHECK: PASS');
48+ }
49+} catch (e) {
50+ console.error(`LIVE CHECK FAIL: ${e.message}`);
51+ for (const p of new Set(problems)) console.error(' ' + p);
52+ process.exitCode = 1;
53+} finally {
54+ await browser.close();
55+}
test/test-page.tsmodified+45−0View file
@@ -40,7 +40,52 @@ function relL2(a: ArrayLike<number>, b: ArrayLike<number>): number {
4040 return Math.sqrt(num / Math.max(den, 1e-300));
4141 }
4242
43+/**
44+ * Solver-only soak (no rendering), selected with ?soak=<steps>&lmax=<n>.
45+ * Isolates the GPU transform loop from the three.js renderer.
46+ */
47+async function soak(steps: number, lmax: number): Promise<void> {
48+ const device = await requestShtDevice();
49+ const schnak = models[0];
50+ const { nlat, nphi } = gridForLmax(lmax, schnak.pdeg);
51+ const gpu = await GpuBackend.create(device, { lmax, mmax: lmax, nlat, nphi });
52+ const sim = new Simulation(gpu, schnak, defaultParams(schnak));
53+ await sim.init(5);
54+ log(`soak: ${steps} steps at lmax ${lmax} (grid ${nlat}x${nphi}), solver only`);
55+
56+ const t0 = performance.now();
57+ for (let s = 0; s < steps; s++) {
58+ await sim.step();
59+ if ((s + 1) % 100 === 0) {
60+ let lo = Infinity;
61+ let hi = -Infinity;
62+ for (const v of sim.V[0]) {
63+ if (v < lo) lo = v;
64+ if (v > hi) hi = v;
65+ }
66+ const mem = (performance as Performance & { memory?: { usedJSHeapSize: number } }).memory;
67+ log(
68+ ` step ${s + 1} u in [${lo.toFixed(4)}, ${hi.toFixed(4)}]` +
69+ (mem ? ` heap ${(mem.usedJSHeapSize / 1048576).toFixed(1)} MB` : ''),
70+ );
71+ // yield so the page stays responsive and the runner can poll
72+ await new Promise((r) => setTimeout(r, 0));
73+ }
74+ }
75+ const ms = (performance.now() - t0) / steps;
76+ let finite = true;
77+ for (const v of sim.V[0]) if (!Number.isFinite(v)) finite = false;
78+ check(`soak: ${steps} steps survived`, finite, `${ms.toFixed(1)} ms/step`);
79+ gpu.destroy();
80+ window.__RESULTS__ = { ok: failures === 0, lines };
81+ log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`);
82+}
83+
4384 async function main(): Promise<void> {
85+ const q = new URLSearchParams(location.search);
86+ if (q.has('soak')) {
87+ return soak(Number(q.get('soak')) || 500, Number(q.get('lmax')) || 63);
88+ }
4489 const device = await requestShtDevice();
4590
4691 // --- transform cross-check: GPU vs CPU on a random spectrum ---