oxedyne/daimond/dev/verify_wasmpanic.mjs
5.2 KiB, 1 run
created by r2519314175:801, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | // verify_wasmpanic.mjs — a Rust panic must say where it happened. |
| 2 | // |
| 3 | // WHY. There was no panic hook in this app at all, and a wasm panic without one |
| 4 | // is the most opaque failure it can produce: |
| 5 | // |
| 6 | // - the browser reports a bare `"Script error."` with no file, line or message |
| 7 | // - the `Promise` the call was made through NEVER SETTLES, so the caller's |
| 8 | // `.then` and `.catch` both stay silent for ever |
| 9 | // - the module is poisoned, so everything after it fails differently |
| 10 | // |
| 11 | // An iPhone looped on exactly that for four sessions. The durable trail showed |
| 12 | // `unlocked`, then `Script error.`, then a step that had started and reported |
| 13 | // neither success nor failure, and then the tab dying. Four diagnoses were made |
| 14 | // from reading code and all four were wrong, because the one component that |
| 15 | // knew what had happened had nowhere to say it. |
| 16 | // |
| 17 | // So: a hook, and this file, which requires the hook to be REAL rather than |
| 18 | // present. A diagnostic nobody has watched work is a diagnostic whose silence |
| 19 | // means nothing. |
| 20 | // |
| 21 | // WHAT IS LOCKED DOWN. |
| 22 | // |
| 23 | // A. A panic reaches the durable trail, with the FILE AND LINE in it. |
| 24 | // B. It also reaches the console, for anyone who has one. |
| 25 | // C. It survives a reload, because a panicking tab is a tab about to go away |
| 26 | // and a message that dies with it is no message at all. |
| 27 | // D. The hook is installed before the app can run anything — it is the first |
| 28 | // thing after `init()`, not somewhere later that a panic could beat. |
| 29 | // E. Installing twice is harmless. |
| 30 | // |
| 31 | // node dev/verify_wasmpanic.mjs |
| 32 | // |
| 33 | // Needs dev/serve.mjs. No gateway, no model. |
| 34 | |
| 35 | import fs from 'node:fs'; |
| 36 | import path from 'node:path'; |
| 37 | import { fileURLToPath } from 'node:url'; |
| 38 | import { open, scratch } from './harness.mjs'; |
| 39 | |
| 40 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 41 | const ROOT = path.join(HERE, '..'); |
| 42 | |
| 43 | const out = []; |
| 44 | let bad = 0; |
| 45 | const check = (ok, what, detail) => { |
| 46 | out.push(`${ok ? 'PASS' : 'FAIL'} ${what}${detail != null ? ' — ' + detail : ''}`); |
| 47 | if (!ok) bad++; |
| 48 | return ok; |
| 49 | }; |
| 50 | |
| 51 | // ── D. Installed FIRST, read from the source ──────────────────────── |
| 52 | // |
| 53 | // Ordering is the whole value of a panic hook: one installed after the thing |
| 54 | // that panics reports nothing. Checked in the file rather than at runtime, |
| 55 | // because a runtime check can only see the order that happened to occur. |
| 56 | { |
| 57 | const src = fs.readFileSync(path.join(ROOT, 'www', 'js', 'daimond.js'), 'utf8'); |
| 58 | const at = src.indexOf('install_panic_hook()'); |
| 59 | const init = src.indexOf('await init();'); |
| 60 | const ready = src.indexOf('__DAIMOND_READY = true'); |
| 61 | check(at > 0 && init > 0 && at > init && at < ready, |
| 62 | 'the hook is installed immediately after the wasm module, before anything uses it', |
| 63 | `init@${init} hook@${at} ready@${ready}`); |
| 64 | } |
| 65 | |
| 66 | const PROFILE = scratch('pw', 'wasmpanic-' + process.pid); |
| 67 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 68 | const s = await open({ name: 'wasmpanic', connect: false, profile: PROFILE }); |
| 69 | const p = s.page; |
| 70 | |
| 71 | // ── A + B. It reports, with a location ────────────────────────────── |
| 72 | const r = await p.evaluate(async () => { |
| 73 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 74 | // Twice, which also covers E: the second install must not throw and the |
| 75 | // second panic must still report. |
| 76 | try { m.install_panic_hook(); } catch (e) { /* already */ } |
| 77 | try { m.panic_on_purpose(); } catch (e) { /* the wasm trap, expected */ } |
| 78 | await new Promise((r) => setTimeout(r, 300)); |
| 79 | return { |
| 80 | rows: (window.DaimondTrail.rows() || []).filter((x) => /panic/i.test(x.w)), |
| 81 | text: window.DaimondTrail.text(), |
| 82 | }; |
| 83 | }); |
| 84 | |
| 85 | check(r.rows.length > 0, 'a panic reaches the durable trail', `${r.rows.length} row(s)`); |
| 86 | const said = (r.rows[0] || {}).d || ''; |
| 87 | check(/entry\.rs:\d+/.test(said), |
| 88 | 'and NAMES THE FILE AND THE LINE — which is the whole point', |
| 89 | JSON.stringify(said.slice(0, 90))); |
| 90 | check(/panic_on_purpose/.test(said), |
| 91 | 'and carries the panic message, not just its location'); |
| 92 | |
| 93 | const consoled = s.logs.some((l) => /panic_on_purpose/.test(l)); |
| 94 | check(consoled, 'and the console gets it too, for anyone who has one'); |
| 95 | |
| 96 | // ── C. It survives the reload ─────────────────────────────────────── |
| 97 | { |
| 98 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 99 | await p.waitForTimeout(1200); |
| 100 | const after = await p.evaluate(() => { |
| 101 | try { return (window.DaimondTrail.rows() || []).filter((x) => /panic/i.test(x.w)).length; } |
| 102 | catch (e) { return -1; } |
| 103 | }); |
| 104 | check(after > 0, |
| 105 | 'and it is still there after a reload — a panicking tab is one about to go away', |
| 106 | String(after)); |
| 107 | } |
| 108 | |
| 109 | // ── The trail says nothing it should not ──────────────────────────── |
| 110 | check(!/[A-Za-z0-9_-]{32,}/.test(r.text), |
| 111 | 'the trail carries nothing that looks like a key or a token', |
| 112 | JSON.stringify((r.text.match(/[A-Za-z0-9_-]{32,}/) || [])[0] || 'none')); |
| 113 | |
| 114 | await s.close(); |
| 115 | try { fs.rmSync(PROFILE, { recursive: true, force: true }); } catch (e) { /* gone */ } |
| 116 | |
| 117 | console.log(out.join('\n')); |
| 118 | const total = out.filter((l) => /^(PASS|FAIL)/.test(l)).length; |
| 119 | console.log(bad === 0 ? `\nALL ${total} CHECKS PASSED` : `\n${bad} of ${total} FAILED`); |
| 120 | process.exit(bad === 0 ? 0 : 1); |