Fix the command line's file writing, and how it gets updated
Three things the first real run turned up.
h5wasm's node build is compiled with NODERAWFS: its filesystem *is* the real
one, so the scratch path that means an in-memory file in the browser meant a
file in the root directory instead, and every encode failed with a wall of
HDF5 diagnostics. Nothing wrong was uploaded -- reading the file back threw
before any upload could start -- but nothing right was either. Scratch files
now go to a real temporary directory, tagged with the process id so two fills
on one machine cannot collide, and are removed however the encode ends.
Every file recorded `runtime: browser-webgpu`, which is false for a file the
command line writes. The runtime travels with the adapter now, so a file says
what actually produced it.
npx keys its install directory on the whole spec string, so the same URL keeps
running whatever it first installed, however often the file behind it has
changed -- confirmed against a server that mimics Pages' caching, where
neither a second run nor --prefer-online picked up new content, and a changed
query string did. The command the page offers therefore carries the build it
belongs to, and both bundles now know which build they are.
Also: the headless check ran to t = 5 and 10, which is three and a half
minutes on the software rasterizer CI uses, and it died on puppeteer's
180-second cap on a single protocol call -- which is what a waitForFunction
is, however generous its own timeout. Half the end times and a raised cap:
under two minutes, and no longer a race against a timer that has nothing to
do with what is being tested.
11 changed files+121−20
README.mdmodified+10−0View file
@@ -148,6 +148,16 @@ writes `~/.config/turing-surface-cache/key`), or passed as `--key`, though the
148148 environment is preferable: a key on the command line is visible to every user
149149 on the machine through `ps`, while another process's environment is not.
150150
151+One consequence of installing from a URL is worth knowing. npx keys its
152+install directory on the whole spec string it was given, so a URL that never
153+changes keeps running whatever it first installed, however often the file
154+behind it has been replaced — and neither `--prefer-online` nor a changed
155+version in the manifest makes any difference, since nothing remote is
156+consulted once that directory exists. The command the page offers therefore
157+carries the build it belongs to (`fill.tgz?v=<commit>`), which makes every
158+deployment a new spec and so a fresh install. The bare URL above is right the
159+first time and stale ever after; `--help` says which build is running.
160+
151161 The walk, the runs and the uploads are the page's own — the same modules under
152162 [`src/cache/`](src/cache/), driven by console output instead of a status bar
153163 (see [`src/cli/fill.ts`](src/cli/fill.ts)). What differs is the WebGPU: node
scripts/check-app.mjsmodified+20−9View file
@@ -25,8 +25,11 @@ import puppeteer from 'puppeteer-core';
2525
2626 const DIST = new URL('../dist/', import.meta.url).pathname;
2727 const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' };
28-const T_END = '5';
29-const T_END_LONG = '10';
28+// Short enough that the whole check is a couple of minutes on the software
29+// rasterizer CI runs it on, long enough that the run passes through T_END on
30+// its way to T_END_LONG (which is what the warm start needs).
31+const T_END = '2';
32+const T_END_LONG = '4';
3033
3134 const server = createServer(async (req, res) => {
3235 try {
@@ -46,6 +49,10 @@ const browser = await puppeteer.launch({
4649 executablePath: process.env.CHROME_PATH ?? '/usr/bin/google-chrome',
4750 args: ['--headless=new', '--no-sandbox', '--enable-unsafe-webgpu',
4851 '--use-webgpu-adapter=swiftshader', '--enable-unsafe-swiftshader'],
52+ // waitForFunction is one CDP call that lasts as long as the wait does, so
53+ // the default 180 s cap on a call, not the waits' own timeouts, is what a
54+ // slow run trips over.
55+ protocolTimeout: 900_000,
4956 });
5057
5158 const problems = [];
@@ -136,7 +143,9 @@ try {
136143 const e1 = await errOf(page1);
137144 if (e1) problems.push(`miss: err: ${e1}`);
138145 if (!/computed locally/.test(s1)) problems.push(`miss: unexpected status: ${s1}`);
139- if (!/t = 5\b/.test(s1)) problems.push(`miss: did not stop at t = 5: ${s1}`);
146+ if (!new RegExp(`t = ${T_END}\\b`).test(s1)) {
147+ problems.push(`miss: did not stop at t = ${T_END}: ${s1}`);
148+ }
140149
141150 const fileName = await page1.$eval('#download', (a) => a.download);
142151 const b64 = await page1.$eval('#download', async (a) => {
@@ -167,8 +176,8 @@ f = h5py.File('${h5Path}', 'r')
167176 spec = json.loads(f.attrs['spec_json'])
168177 assert f.attrs['app'] == 'turing-surface-cache', f.attrs['app']
169178 assert int(f.attrs['format_version']) == 1
170-assert spec['tEnd'] == 5 and spec['model'] == 'schnakenberg', spec
171-assert int(f['spec'].attrs['steps']) == round(5 / spec['params']['dt'])
179+assert spec['tEnd'] == ${T_END} and spec['model'] == 'schnakenberg', spec
180+assert int(f['spec'].attrs['steps']) == round(${T_END} / spec['params']['dt'])
172181 nlm = (spec['lmax'] + 1) * (spec['lmax'] + 2) // 2
173182 for g in ('geometry/Gx', 'geometry/Gy', 'geometry/Gz', 'initial/U', 'initial/V', 'final/U', 'final/V'):
174183 d = f[g]
@@ -212,7 +221,7 @@ print('h5py check ok; species', list(f.attrs['species']), '; adapter:', f.attrs.
212221 await page2.close();
213222
214223 // ---- pass 4: warm start from the shorter cached run ----------------------
215- // Only the t = 5 file is in the "cache"; asking for t = 10 must resume from
224+ // Only the T_END file is in the "cache"; asking for T_END_LONG must resume from
216225 // it and compute just the remainder.
217226 const page3 = await browser.newPage();
218227 watch(page3, 'warm:');
@@ -234,9 +243,11 @@ print('h5py check ok; species', list(f.attrs['species']), '; adapter:', f.attrs.
234243 console.log('pass 4 status:', s3);
235244 const e3 = await errOf(page3);
236245 if (e3) problems.push(`warm: err: ${e3}`);
237- if (!/t = 10\b/.test(s3)) problems.push(`warm: did not stop at t = 10: ${s3}`);
238- if (!/resumed from cached t = 5\b/.test(s3)) {
239- problems.push(`warm: expected a resume from t = 5: ${s3}`);
246+ if (!new RegExp(`t = ${T_END_LONG}\\b`).test(s3)) {
247+ problems.push(`warm: did not stop at t = ${T_END_LONG}: ${s3}`);
248+ }
249+ if (!new RegExp(`resumed from cached t = ${T_END}\\b`).test(s3)) {
250+ problems.push(`warm: expected a resume from t = ${T_END}: ${s3}`);
240251 }
241252 const warmFile = await page3.$eval('#download', (a) => a.download);
242253 if (warmFile === fileName || !/^[0-9a-f]{64}\.h5$/.test(warmFile)) {
scripts/check-live.mjsmodified+3−0View file
@@ -10,6 +10,9 @@ const browser = await puppeteer.launch({
1010 executablePath: process.env.CHROME_PATH ?? '/usr/bin/google-chrome',
1111 args: ['--headless=new', '--no-sandbox', '--enable-unsafe-webgpu',
1212 '--use-webgpu-adapter=swiftshader', '--enable-unsafe-swiftshader'],
13+ // A wait is one CDP call lasting as long as the wait, so the default 180 s
14+ // cap on a call is what a slow run trips over first.
15+ protocolTimeout: 900_000,
1316 });
1417 const page = await browser.newPage();
1518 await page.setViewport({ width: 1100, height: 900 });
scripts/screenshot.mjsmodified+3−0View file
@@ -30,6 +30,9 @@ const browser = await puppeteer.launch({
3030 executablePath: process.env.CHROME_PATH ?? '/usr/bin/google-chrome',
3131 args: ['--headless=new', '--no-sandbox', '--enable-unsafe-webgpu',
3232 '--use-webgpu-adapter=swiftshader', '--enable-unsafe-swiftshader'],
33+ // A wait is one CDP call lasting as long as the wait, so the default 180 s
34+ // cap on a call is what a slow run trips over first.
35+ protocolTimeout: 900_000,
3336 });
3437 const page = await browser.newPage();
3538 await page.setViewport({ width: 1100, height: 900 });
src/cache/fillWalk.tsmodified+2−0View file
@@ -40,6 +40,7 @@ export interface FillOptions {
4040 targets: AutoTarget[];
4141 solver: SolverSession;
4242 adapter: string;
43+ runtime: string;
4344 apiKey(): string;
4445 /**
4546 * Take the selection to this target and hand back the spec to compute. The
@@ -70,6 +71,7 @@ export async function fillWalk(opts: FillOptions): Promise<FillCounts> {
7071 solver: opts.solver,
7172 spec,
7273 adapter: opts.adapter,
74+ runtime: opts.runtime,
7375 apiKey: opts.apiKey,
7476 events: ev,
7577 });
src/cache/h5file.tsmodified+37−6View file
@@ -38,6 +38,8 @@ export interface CacheFileData {
3838 final: Record<string, Float32Array>;
3939 /** Provenance: which GPU computed it. */
4040 adapter: string;
41+ /** And what was driving it — a browser, or the command line's Dawn. */
42+ runtime: string;
4143 }
4244
4345 interface H5Module {
@@ -70,6 +72,31 @@ interface EmFS {
7072
7173 let scratchCounter = 0;
7274
75+/**
76+ * Where the scratch file that h5wasm reads or writes lives.
77+ *
78+ * In the browser it lives in h5wasm's own in-memory filesystem, where any
79+ * absolute path will do and nothing touches a disk. The node build is
80+ * compiled with NODERAWFS, which is to say its filesystem *is* the real one:
81+ * the same path would name a file in the root directory, which fails with a
82+ * wall of HDF5 diagnostics rather than an error anyone could act on. A real
83+ * temporary directory is therefore used there, and the command line replaces
84+ * this default with the platform's own (src/cli/fill.ts).
85+ */
86+let scratchDir = __NODE_BUILD__ ? '/tmp/' : '/';
87+
88+/** Set the directory for those scratch files; node only. */
89+export function setScratchDir(dir: string): void {
90+ scratchDir = dir.endsWith('/') ? dir : `${dir}/`;
91+}
92+
93+/** Two fills on one machine share that real directory; a page's filesystem is
94+ * its own, so there is nothing to distinguish there. */
95+const scratchTag = __NODE_BUILD__ ? `${process.pid}-` : '';
96+
97+const scratchPath = (what: string): string =>
98+ `${scratchDir}turing-surface-cache-${scratchTag}${what}-${scratchCounter++}.h5`;
99+
73100 async function withH5<T>(fn: (h5: H5Module, fs: EmFS) => T | Promise<T>): Promise<T> {
74101 // Two builds of the same library: the browser one carries the wasm inside
75102 // the bundle, the node one reads it off disk. __NODE_BUILD__ is a build-time
@@ -102,7 +129,7 @@ const coeffsOf = (group: H5Obj, groupName: string, name: string, nlm: number): F
102129 /** Serialize one solution to HDF5 bytes. */
103130 export function encodeCacheFile(data: CacheFileData): Promise<Uint8Array> {
104131 return withH5((h5, FS) => {
105- const path = `/encode-${scratchCounter++}.h5`;
132+ const path = scratchPath('encode');
106133 const file = new h5.File(path, 'w');
107134 try {
108135 const { spec, grid } = data;
@@ -117,7 +144,7 @@ export function encodeCacheFile(data: CacheFileData): Promise<Uint8Array> {
117144 file.create_group('backend');
118145 const backend = groupOf(file, 'backend');
119146 backend.create_attribute('adapter', data.adapter);
120- backend.create_attribute('runtime', 'browser-webgpu');
147+ backend.create_attribute('runtime', data.runtime);
121148 backend.create_attribute('precision', 'fp32');
122149
123150 file.create_group('spec');
@@ -169,9 +196,13 @@ export function encodeCacheFile(data: CacheFileData): Promise<Uint8Array> {
169196 } finally {
170197 file.close();
171198 }
172- const bytes = FS.readFile(path);
173- FS.unlink(path);
174- return bytes;
199+ try {
200+ return FS.readFile(path);
201+ } finally {
202+ // Under NODERAWFS this is a real file in a real temporary directory, so
203+ // it is removed on the way out however this ends.
204+ FS.unlink(path);
205+ }
175206 });
176207 }
177208
@@ -198,7 +229,7 @@ export function decodeCacheFile(
198229 expectSpecies: string[],
199230 ): Promise<DecodedCacheFile> {
200231 return withH5((h5, FS) => {
201- const path = `/decode-${scratchCounter++}.h5`;
232+ const path = scratchPath('decode');
202233 FS.writeFile(path, bytes);
203234 const file = new h5.File(path, 'r');
204235 try {
src/cache/runSpec.tsmodified+4−1View file
@@ -101,6 +101,8 @@ export interface RunOptions {
101101 spec: CacheSpec;
102102 /** Which GPU computed it; recorded in every file it writes. */
103103 adapter: string;
104+ /** What is driving that GPU: 'browser-webgpu', or the command line's Dawn. */
105+ runtime: string;
104106 /** Read at every upload point, so a key entered mid-run still contributes. */
105107 apiKey(): string;
106108 events?: RunEvents;
@@ -116,7 +118,7 @@ const stateIsFinite = (state: Record<string, Float32Array>): boolean =>
116118
117119 /** Run the solver to the spec's end time, contributing everything it passes. */
118120 export async function runSpec(opts: RunOptions): Promise<RunOutcome> {
119- const { solver, spec, adapter, apiKey } = opts;
121+ const { solver, spec, adapter, runtime, apiKey } = opts;
120122 const ev = opts.events ?? {};
121123 const cancelled = (): boolean => ev.cancelled?.() ?? false;
122124 const session = solver.live;
@@ -194,6 +196,7 @@ export async function runSpec(opts: RunOptions): Promise<RunOutcome> {
194196 initial,
195197 final: state,
196198 adapter,
199+ runtime,
197200 });
198201 const uploadedTimes: number[] = [];
199202 const uploadErrors: string[] = [];
src/cli/fill.tsmodified+12−2View file
@@ -7,6 +7,7 @@
77 * of a browser's WebGPU, a key from the environment instead of localStorage,
88 * and lines of text instead of a status bar.
99 */
10+import { tmpdir } from 'node:os';
1011 import { requestShtDevice, describeAdapter } from '../sht/sht.ts';
1112 import { mModelByKey } from '../mgpu/registry.ts';
1213 import { formatFailure } from '../mgpu/errors.ts';
@@ -21,6 +22,7 @@ import { autoOrder, specForTarget, type AutoTarget } from '../cache/autoWalk.ts'
2122 import { headCached, lookupFor, verifyApiKey } from '../cache/client.ts';
2223 import { SolverSession } from '../cache/solver.ts';
2324 import { fillWalk } from '../cache/fillWalk.ts';
25+import { setScratchDir } from '../cache/h5file.ts';
2426 import { stepsFor } from '../cache/spec.ts';
2527 import type { RunSummary } from '../cache/runSpec.ts';
2628 import { installWebGpu, errMsg, isSoftwareAdapter, NO_ADAPTER_HINT } from './webgpu.ts';
@@ -44,7 +46,11 @@ Options
4446 a short run hashes to its own honest cache entry)
4547
4648 An upload key is required: the walk exists to contribute. Solutions are read
47-by everyone and written only by key holders.`;
49+by everyone and written only by key holders.
50+
51+This is build ${__BUILD_ID__}. npx keys its install directory on the whole
52+URL it was given, so a newer build comes from the command the page offers,
53+whose URL carries the build it belongs to.`;
4854
4955 interface Options {
5056 command: 'fill' | 'login' | 'help';
@@ -191,7 +197,7 @@ async function fill(opts: Options, targets: AutoTarget[], apiKey: string): Promi
191197 throw new Error(`${errMsg(e)}\n${NO_ADAPTER_HINT}`);
192198 });
193199 const adapter = await describeAdapter(device);
194- say(`${runtime} · ${adapter}`);
200+ say(`build ${__BUILD_ID__} · ${runtime} · ${adapter}`);
195201 say(`uploads enabled (key ${maskKey(apiKey)})`);
196202 if (isSoftwareAdapter(adapter)) {
197203 say('');
@@ -226,6 +232,7 @@ async function fill(opts: Options, targets: AutoTarget[], apiKey: string): Promi
226232 targets,
227233 solver,
228234 adapter,
235+ runtime,
229236 apiKey: () => apiKey,
230237 beforeTarget: (target) => {
231238 index++;
@@ -300,6 +307,9 @@ async function fill(opts: Options, targets: AutoTarget[], apiKey: string): Promi
300307
301308 // ---------------------------------------------------------------- main
302309 async function main(): Promise<void> {
310+ // h5wasm's node build writes through to the real filesystem, so its scratch
311+ // files need a real directory to live in (src/cache/h5file.ts).
312+ setScratchDir(tmpdir());
303313 const opts = parseArgs(process.argv.slice(2));
304314 if (opts.command === 'help') {
305315 say(HELP);
src/main.tsmodified+6−1View file
@@ -898,6 +898,7 @@ async function computeLocally(spec: CacheSpec, gen: number): Promise<RunOutcome>
898898 solver,
899899 spec,
900900 adapter: adapterName,
901+ runtime: 'browser-webgpu',
901902 apiKey: () => elApiKey.value.trim(),
902903 events: runEvents(() => gen),
903904 });
@@ -963,6 +964,7 @@ async function autoRun(): Promise<void> {
963964 targets: autoOrder(),
964965 solver,
965966 adapter: adapterName,
967+ runtime: 'browser-webgpu',
966968 apiKey: () => elApiKey.value.trim(),
967969 beforeTarget(target) {
968970 setSelection(target);
@@ -1056,7 +1058,10 @@ function updateUploadNote(): void {
10561058 * that has one, which is what the password field exists to prevent.
10571059 */
10581060 function fillCommand(key: string): string {
1059- const url = new URL('fill.tgz', location.href).href;
1061+ // The build id is not decoration: npx keys its install directory on the whole
1062+ // spec string, so a URL that never changes keeps running whatever it first
1063+ // installed. This one changes with every deployment.
1064+ const url = new URL(`fill.tgz?v=${__BUILD_ID__}`, location.href).href;
10601065 return `TURING_SURFACE_CACHE_KEY=${key} npx ${url}`;
10611066 }
10621067
src/raw.d.tsmodified+3−0View file
@@ -6,6 +6,9 @@
66 */
77 declare const __NODE_BUILD__: boolean;
88
9+/** The commit this was built from, or 'dev'. Defined by both vite configs. */
10+declare const __BUILD_ID__: string;
11+
912 /** Vite's `?raw` suffix imports a file's text. Used to load .m model sources. */
1013 declare module '*?raw' {
1114 const source: string;
vite.config.tsmodified+21−1View file
@@ -1,7 +1,27 @@
11 import { defineConfig } from 'vite';
2+import { execFileSync } from 'node:child_process';
23 import { realpathSync } from 'node:fs';
34 import { resolve } from 'node:path';
45
6+/**
7+ * Which build this is. It ends up in the URL the page hands out for the
8+ * command line (src/main.ts), because npx keys its install directory on the
9+ * whole spec string: the same URL keeps running whatever it first installed,
10+ * however many times the file behind it has changed. A URL carrying the
11+ * commit is a new spec, and so a fresh install.
12+ */
13+function buildId(): string {
14+ if (process.env.GITHUB_SHA) return process.env.GITHUB_SHA.slice(0, 7);
15+ try {
16+ return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
17+ cwd: import.meta.dirname,
18+ encoding: 'utf8',
19+ }).trim();
20+ } catch {
21+ return 'dev';
22+ }
23+}
24+
525 // numbl is a local `file:` dependency, so node_modules/numbl is a symlink to
626 // the sibling checkout. Its package `exports` map only publishes the runtime
727 // entry points, not the compiler internals we need (parser + JIT lowering), so
@@ -19,7 +39,7 @@ export default defineConfig({
1939 base: './',
2040 // The page is the browser build; the command line's bundle sets this true
2141 // (vite.cli.config.ts). See src/cache/h5file.ts.
22- define: { __NODE_BUILD__: 'false' },
42+ define: { __NODE_BUILD__: 'false', __BUILD_ID__: JSON.stringify(buildId()) },
2343 resolve: {
2444 alias: { 'numbl-src': numblSrc },
2545 },