@threadedstack/wmd-web 0.0.0-stage → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +2601 -2
  2. package/RELEASE.json +123 -0
  3. package/export-page/coi-sw.js +20 -0
  4. package/export-page/index.html +17 -0
  5. package/export-page/run.css +10 -0
  6. package/export-page/run.js +236 -0
  7. package/guest/arm64/Image +4 -0
  8. package/guest/arm64/Image-4k +4 -0
  9. package/guest/arm64/initramfs.cpio.gz +0 -0
  10. package/guest/arm64/vm.wasm +0 -0
  11. package/package.json +36 -4
  12. package/page/sw.js +381 -0
  13. package/relay/control.ts +139 -0
  14. package/relay/engine.ts +787 -0
  15. package/relay/hosted.ts +660 -0
  16. package/relay/keys.ts +53 -0
  17. package/relay/local-tls.ts +121 -0
  18. package/relay/lock.ts +81 -0
  19. package/relay/logfile.ts +138 -0
  20. package/relay/main.ts +642 -0
  21. package/relay/policy.ts +241 -0
  22. package/relay/relay.ts +266 -0
  23. package/relay/token.ts +116 -0
  24. package/relay/v1.ts +107 -0
  25. package/relay/wisp.ts +615 -0
  26. package/src/base.js +325 -0
  27. package/src/boot.js +2037 -0
  28. package/src/calib-worker.js +40 -0
  29. package/src/fs/9p/codec.js +303 -0
  30. package/src/fs/9p/server.js +521 -0
  31. package/src/fs/errno.js +43 -0
  32. package/src/fs/graph.js +380 -0
  33. package/src/fs/overlay.js +1018 -0
  34. package/src/fs/pack-write.js +165 -0
  35. package/src/fs/pack.js +427 -0
  36. package/src/fs/seed.js +22 -0
  37. package/src/fs/svc-snapshot.js +65 -0
  38. package/src/fs/upper-pack.js +112 -0
  39. package/src/host/base-cache.js +195 -0
  40. package/src/host/base-file.js +328 -0
  41. package/src/host/base-io.js +93 -0
  42. package/src/host/boot-events.js +209 -0
  43. package/src/host/chan-service.js +300 -0
  44. package/src/host/chan-services.js +689 -0
  45. package/src/host/chan.js +655 -0
  46. package/src/host/ckpt-file.js +395 -0
  47. package/src/host/ckpt-io.js +207 -0
  48. package/src/host/ckpt-point.js +160 -0
  49. package/src/host/ckpt-save.js +64 -0
  50. package/src/host/ckpt.js +434 -0
  51. package/src/host/clock.js +111 -0
  52. package/src/host/ctl.js +778 -0
  53. package/src/host/errno.js +18 -0
  54. package/src/host/fs.js +167 -0
  55. package/src/host/gcnudge.js +83 -0
  56. package/src/host/i64shim.js +81 -0
  57. package/src/host/jit-cache.js +341 -0
  58. package/src/host/jit-hub-core.js +208 -0
  59. package/src/host/jit-hub.js +93 -0
  60. package/src/host/jit-snap.js +226 -0
  61. package/src/host/jit.js +1903 -0
  62. package/src/host/layout.js +100 -0
  63. package/src/host/monitor.js +110 -0
  64. package/src/host/p9mount.js +27 -0
  65. package/src/host/ram-mem.js +125 -0
  66. package/src/host/stdio.js +143 -0
  67. package/src/host/threads.js +109 -0
  68. package/src/host/wasi.js +335 -0
  69. package/src/host/wmd-imports.js +71 -0
  70. package/src/host/yamux.js +387 -0
  71. package/src/image-files.js +154 -0
  72. package/src/net/dhcp-dns.js +126 -0
  73. package/src/net/net-worker.js +172 -0
  74. package/src/net/packet.js +227 -0
  75. package/src/net/peer.js +230 -0
  76. package/src/net/proto.js +189 -0
  77. package/src/net/relay-client.js +226 -0
  78. package/src/net/stack.js +867 -0
  79. package/src/net/switch.js +160 -0
  80. package/src/net/wisp-client.js +570 -0
  81. package/src/net/wisp-transport.js +196 -0
  82. package/src/net/wisp.js +511 -0
  83. package/src/registry/client.js +176 -0
  84. package/src/registry/converter.js +68 -0
  85. package/src/registry/hub.js +221 -0
  86. package/src/registry/oci.js +76 -0
  87. package/src/registry/pack-worker.js +70 -0
  88. package/src/registry/pull.js +258 -0
  89. package/src/registry/ref.js +110 -0
  90. package/src/registry/wasi-mem.js +355 -0
  91. package/src/worker.js +315 -0
  92. package/wmdpack.wasm +0 -0
@@ -0,0 +1,160 @@
1
+ // P-B9: the guest's checkpoint point, the host half that runs inside the vCPU 0 Worker.
2
+ //
3
+ // The guest (machine.c machine_point) stops every other vCPU at a block boundary, drains the
4
+ // file share, stops its service threads and then calls the import wmd.point(kind, hdr, len) on
5
+ // vCPU 0, with nothing else touching memory 0. This module is that import. While it runs the
6
+ // machine is frozen, so it
7
+ // 1. tells the page the point began ({t:'ckpt-point', hdr}): the page then asks the channel
8
+ // service Worker for its state and the JIT hub for module bytes, at this same instant;
9
+ // 2. copies every non-zero 64 KiB page of memory 0 ({t:'ckpt-mem'}: an index of page numbers
10
+ // and the page bytes in chunks), the way the runner's snapshot keeps "every non-zero page";
11
+ // 3. waits for the page's go (CTL_CK_GO), then returns 0 and the guest carries on.
12
+ // A point that failed in the guest (a vCPU did not stop, the share did not drain: hdr.status != 0)
13
+ // is reported as {t:'ckpt-failed'} and returns 0 at once; the guest has restarted its vCPUs.
14
+ //
15
+ // wmd.ctl_attach(word_addr) is called by the guest's ctl thread when it starts, and again after
16
+ // every checkpoint when the vCPUs run again. The page writes a request kind into the word at
17
+ // word_addr (guest memory) and notifies it; the ctl thread hands it to vCPU 0.
18
+
19
+ import { CTL_CK_ATTACHES, CTL_CK_GO, CTL_CK_HOSTREQ, CTL_CK_POINTS } from './layout.js'
20
+
21
+ export const PAGE = 65536
22
+ /** struct vm_snapshot_hdr (guest/vm/include/vm/snapshot.h), 336 bytes: field offsets read here. */
23
+ export const HDR_LEN = 336
24
+ export const HDR_KIND = 296
25
+ export const HDR_STATUS = 300
26
+ export const VM_POINT_CKPT = 2
27
+ export const POINT_STATUS = { 0: 'ok', 1: 'a vCPU did not reach a block boundary in time', 2: 'the file share did not drain in time' }
28
+ /** The page asks the guest for a checkpoint by storing this in the word. */
29
+ export const REQ_CKPT = VM_POINT_CKPT
30
+
31
+ /** Pages per chunk of the memory section (32 MiB). */
32
+ export const CHUNK_PAGES = 512
33
+
34
+ /**
35
+ * Non-zero pages of memory: {index: Uint32Array of page numbers, chunks: ArrayBuffer[] (pages
36
+ * back to back, CHUNK_PAGES each), pages (total), nonzero, scanMs, copyMs}. Reads the memory
37
+ * without atomics: the machine is frozen.
38
+ */
39
+ export function scanMemory(memory, { now = () => performance.now(), chunkPages = CHUNK_PAGES, limitBytes = Infinity, verifyAbove = false } = {}) {
40
+ const buf = memory.buffer
41
+ // limitBytes: pages from here up are taken to be zero and are not read. Reading an untouched page of a shared
42
+ // memory commits it in WebKit (W34: a full walk of a 3968 MiB memory added about 2.2 GB of footprint), so a caller
43
+ // that knows where the guest's heap ends should say so; the page count of the checkpoint stays the memory's.
44
+ const total = buf.byteLength / PAGE
45
+ const pages = Math.min(total, Math.ceil(limitBytes / PAGE))
46
+ const w = new Int32Array(buf)
47
+ const u8 = new Uint8Array(buf)
48
+ const per = PAGE / 4
49
+ const t0 = now()
50
+ let idx = new Uint32Array(Math.min(pages, 1 << 14))
51
+ let n = 0
52
+ for (let p = 0; p < pages; p++) {
53
+ const lo = p * per
54
+ const hi = lo + per
55
+ let nz = false
56
+ for (let i = lo; i < hi; i += 8) {
57
+ if ((w[i] | w[i + 1] | w[i + 2] | w[i + 3] | w[i + 4] | w[i + 5] | w[i + 6] | w[i + 7]) !== 0) {
58
+ nz = true
59
+ break
60
+ }
61
+ }
62
+ if (nz) {
63
+ if (n === idx.length) {
64
+ const g = new Uint32Array(idx.length * 2)
65
+ g.set(idx)
66
+ idx = g
67
+ }
68
+ idx[n++] = p
69
+ }
70
+ }
71
+ // diagnostics (verifyAbove): non-zero pages above the bound, which would be lost. Reads them (commits them in WebKit).
72
+ let aboveNonzero = 0
73
+ if (verifyAbove) {
74
+ for (let p = pages; p < total; p++) {
75
+ const lo = p * per
76
+ for (let i = lo; i < lo + per; i += 8) {
77
+ if ((w[i] | w[i + 1] | w[i + 2] | w[i + 3] | w[i + 4] | w[i + 5] | w[i + 6] | w[i + 7]) !== 0) {
78
+ aboveNonzero++
79
+ break
80
+ }
81
+ }
82
+ }
83
+ }
84
+ const t1 = now()
85
+ const chunks = []
86
+ for (let k = 0; k < n; k += chunkPages) {
87
+ const m = Math.min(chunkPages, n - k)
88
+ const c = new Uint8Array(m * PAGE)
89
+ for (let j = 0; j < m; j++) c.set(u8.subarray(idx[k + j] * PAGE, (idx[k + j] + 1) * PAGE), j * PAGE)
90
+ chunks.push(c.buffer)
91
+ }
92
+ return { index: idx.slice(0, n), chunks, pages: total, scannedPages: pages, aboveNonzero, nonzero: n, scanMs: t1 - t0, copyMs: now() - t1 }
93
+ }
94
+
95
+ /**
96
+ * The imports. memory: the machine's shared memory; ctl: the control block's Int32Array; post:
97
+ * message to the page; goTimeoutMs: how long vCPU 0 waits for the page (a page that is gone
98
+ * makes the point fail: the guest goes on, as it does for any failed point).
99
+ */
100
+ export function createCkptImports({ memory, ctl, post, scanLimit = null, verifyBound = false, useHeapTop = false, heapTop = () => null, goTimeoutMs = 60_000, now = () => performance.timeOrigin + performance.now() }) {
101
+ return {
102
+ ctl_attach(wordAddr) {
103
+ Atomics.store(ctl, CTL_CK_HOSTREQ, wordAddr >>> 0)
104
+ Atomics.add(ctl, CTL_CK_ATTACHES, 1)
105
+ return 0
106
+ },
107
+ point(kind, hp, hl) {
108
+ if (kind !== VM_POINT_CKPT) return -22
109
+ Atomics.add(ctl, CTL_CK_POINTS, 1)
110
+ const at = now()
111
+ const len = Math.min(hl >>> 0, HDR_LEN)
112
+ const hdr = new Uint8Array(memory.buffer, hp >>> 0, len).slice()
113
+ const status = len >= HDR_STATUS + 4 ? new DataView(hdr.buffer).getUint32(HDR_STATUS, true) : 0
114
+ if (status !== 0) {
115
+ post({ t: 'ckpt-failed', status, why: POINT_STATUS[status] || `status ${status}`, hdr })
116
+ return 0
117
+ }
118
+ Atomics.store(ctl, CTL_CK_GO, 0)
119
+ post({ t: 'ckpt-point', hdr, at })
120
+ let cap
121
+ try {
122
+ // the bound: the caller's, else the end of memory 0 in use (vm.wasm's vm_heap_top), else all of it
123
+ // W58: on by default (boot.js); the heap's break only grows (sbrk refuses a negative increment), and a WebKit run with
124
+ // verifyBound found no non-zero page above it
125
+ const top = scanLimit ?? (useHeapTop ? heapTop() : null)
126
+ const limit = typeof top === 'number' && top > 0 ? Math.min(top, memory.buffer.byteLength) : Infinity
127
+ cap = scanMemory(memory, { now, limitBytes: limit, verifyAbove: verifyBound })
128
+ cap.limitBytes = Number.isFinite(limit) ? limit : null
129
+ // the live g_rsv {brk, end} of vm.wasm 50c6495e / 3aef93fc (linear address 4251160), next to what vm_heap_top() says
130
+ try {
131
+ const g = new BigUint64Array(memory.buffer, 4251160, 2)
132
+ cap.brkLive = Number(g[0])
133
+ cap.endLive = Number(g[1])
134
+ } catch {}
135
+ cap.heapTopRet = heapTop()
136
+ } catch (err) {
137
+ post({ t: 'ckpt-mem-error', message: String(err && err.message ? err.message : err) })
138
+ Atomics.wait(ctl, CTL_CK_GO, 0, goTimeoutMs)
139
+ Atomics.store(ctl, CTL_CK_GO, 0)
140
+ return -12
141
+ }
142
+ post({ t: 'ckpt-mem', index: cap.index, chunks: cap.chunks, pages: cap.pages, scannedPages: cap.scannedPages, aboveNonzero: cap.aboveNonzero, brkLive: cap.brkLive, endLive: cap.endLive, heapTopRet: cap.heapTopRet, limitBytes: cap.limitBytes, nonzero: cap.nonzero, scanMs: cap.scanMs, copyMs: cap.copyMs, doneAt: now() }, [cap.index.buffer, ...cap.chunks])
143
+ const r = Atomics.wait(ctl, CTL_CK_GO, 0, goTimeoutMs)
144
+ const go = Atomics.load(ctl, CTL_CK_GO)
145
+ Atomics.store(ctl, CTL_CK_GO, 0)
146
+ if (r === 'timed-out' || go !== 1) return -1
147
+ return 0
148
+ },
149
+ }
150
+ }
151
+
152
+ /** Asks the guest for a checkpoint: false when its ctl thread has not attached (no checkpoint support, or not booted far enough). */
153
+ export function requestCheckpoint(memory, ctl) {
154
+ const addr = Atomics.load(ctl, CTL_CK_HOSTREQ)
155
+ if (!addr) return false
156
+ const w = new Int32Array(memory.buffer, addr & ~3, 1)
157
+ Atomics.store(w, 0, REQ_CKPT)
158
+ Atomics.notify(w, 0, 1)
159
+ return true
160
+ }
@@ -0,0 +1,64 @@
1
+ // P-B9: the two halves of the OPFS save's pull protocol, apart from the Worker and the page so tests can drive them.
2
+ //
3
+ // worker side createSaver(post): start(m) maps the save's section sizes into lazy chunks; each lazy chunk asks the page for
4
+ // its bytes ({need:true, saveId, si, ci}) when the writer reaches it and is dropped once written.
5
+ // chunk(m) delivers the page's answer: bytes, or {error} (the page could not copy the chunk: out of memory).
6
+ // page side answerNeed(ck, need, send): copies exactly the chunk asked for and sends {op:'chunk', saveId, si, ci, buf},
7
+ // or {op:'chunk', ..., error} when the copy failed, so the save fails instead of waiting for ever.
8
+ //
9
+ // One save at a time: `saving` is what refuses a second ("busy"); done() clears it, so a failed save does not block the next.
10
+ import { CheckpointError } from './ckpt-file.js'
11
+
12
+ export function createSaver(post) {
13
+ let saving = null // {id, waits: Map key -> {resolve, reject}}
14
+ return {
15
+ get busy() {
16
+ return !!saving
17
+ },
18
+ /** Maps m.sections ({name, lens}) into lazy chunks. `saving` is set only after the sections were validated and mapped. */
19
+ start(m) {
20
+ if (saving) throw new CheckpointError('busy', 'a checkpoint save is already running in this browser (saves are one at a time)')
21
+ if (!Array.isArray(m.sections) || !m.sections.every(s => s && typeof s.name === 'string' && Array.isArray(s.lens) && s.lens.every(n => Number.isInteger(n) && n >= 0))) {
22
+ throw new CheckpointError('malformed', 'the save request has no valid section list')
23
+ }
24
+ const waits = new Map()
25
+ const sections = m.sections.map((s, si) => ({
26
+ name: s.name,
27
+ chunks: s.lens.map((len, ci) => {
28
+ const f = () =>
29
+ new Promise((resolve, reject) => {
30
+ waits.set(`${si}:${ci}`, { resolve, reject })
31
+ post({ need: true, saveId: m.id, si, ci })
32
+ })
33
+ f.size = len
34
+ Object.defineProperty(f, 'length', { value: len })
35
+ return f
36
+ }),
37
+ }))
38
+ saving = { id: m.id, waits }
39
+ return sections
40
+ },
41
+ /** The page's answer to a need. */
42
+ chunk(m) {
43
+ const w = saving && saving.id === m.saveId ? saving.waits.get(`${m.si}:${m.ci}`) : null
44
+ if (!w) return false
45
+ saving.waits.delete(`${m.si}:${m.ci}`)
46
+ if (m.error) w.reject(new CheckpointError('io', `the page could not copy checkpoint chunk ${m.si}:${m.ci} (${m.error})`))
47
+ else w.resolve(new Uint8Array(m.buf))
48
+ return true
49
+ },
50
+ /** The save ended, however it ended. */
51
+ done() {
52
+ saving = null
53
+ },
54
+ }
55
+ }
56
+
57
+ export function answerNeed(ck, need, send) {
58
+ try {
59
+ const buf = ck.sections[need.si].chunks[need.ci].slice().buffer
60
+ send({ op: 'chunk', saveId: need.saveId, si: need.si, ci: need.ci, buf }, [buf])
61
+ } catch (e) {
62
+ send({ op: 'chunk', saveId: need.saveId, si: need.si, ci: need.ci, error: String((e && e.message) || e) })
63
+ }
64
+ }
@@ -0,0 +1,434 @@
1
+ // P-B9: checkpoints, the page's side: what a checkpoint is made of, the keys that say which
2
+ // machine it belongs to, and the store (OPFS, through ckpt-io.js, or memory).
3
+ //
4
+ // A checkpoint is {header, sections}. The header is JSON (ckpt-file.js adds the section table when
5
+ // it is written); sections are named lists of byte chunks:
6
+ // mem-index Uint32 page numbers of the non-zero 64 KiB pages of memory 0
7
+ // mem those pages' bytes, back to back (32 MiB chunks)
8
+ // jit-recs the JIT door's slot records (jit-snap.js), jit-log: the modules' bytes
9
+ // svc-<port> the 9P service's state for that port (fs/svc-snapshot.js)
10
+ // ctl the control block's WASI fd table (Int32 cells)
11
+ // Taken by vm.checkpoint() (boot.js), restored by bootWmd({restore}).
12
+ import { FORMAT_VERSION, CheckpointError, concat, memoryFile, readHeader, readSection, sectionBytes, sha256Hex, writeCheckpoint } from './ckpt-file.js'
13
+ import { answerNeed } from './ckpt-save.js'
14
+ import { PAGE } from './ckpt-point.js'
15
+ import { validateSnap, widenRecs } from './jit-snap.js'
16
+ import { FD_BASE, FD_FILE, FD_INTS, FD_KIND, FD_MAX } from './layout.js'
17
+
18
+ export { CheckpointError, FORMAT_VERSION, sha256Hex }
19
+
20
+ const b64 = u8 => {
21
+ let s = ''
22
+ for (let i = 0; i < u8.length; i += 8192) s += String.fromCharCode.apply(null, u8.subarray(i, i + 8192))
23
+ return btoa(s)
24
+ }
25
+ export const fromB64 = s => Uint8Array.from(atob(s), c => c.charCodeAt(0))
26
+
27
+ /** A fresh (non-shared) copy: SubtleCrypto refuses views of shared memory. */
28
+ const plain = u8 => (u8.buffer instanceof SharedArrayBuffer ? u8.slice() : u8)
29
+
30
+ /** SHA-256 of a pack or file that may be large: its size, and (when large) only its first 64 KiB and last 4 MiB. */
31
+ export async function sampleKey(u8, { head = 64 << 10, tail = 4 << 20 } = {}) {
32
+ if (u8.length <= head + tail) return `${u8.length}:${await sha256Hex(plain(u8))}`
33
+ const a = await sha256Hex(plain(u8.subarray(0, head)))
34
+ const b = await sha256Hex(plain(u8.subarray(u8.length - tail)))
35
+ return `${u8.length}:${a}:${b}`
36
+ }
37
+
38
+ /** Full digest of a pack: its size and the SHA-256 of the SHA-256s of its 8 MiB pieces (pieces are copied out of shared memory one at a time). */
39
+ export async function treeKey(u8, piece = 8 << 20) {
40
+ const parts = []
41
+ for (let o = 0; o < u8.length; o += piece) parts.push(await sha256Hex(plain(u8.subarray(o, Math.min(u8.length, o + piece)))))
42
+ return `${u8.length}:${await sha256Hex(new TextEncoder().encode(parts.join('')))}`
43
+ }
44
+
45
+ /** [[treeKey of each layer] per service], from the fetched layers (ArrayBuffers) as boot.js holds them. */
46
+ export async function packDigestsOf(chanLayers) {
47
+ return Promise.all(chanLayers.map(ls => Promise.all(ls.map(l => treeKey(new Uint8Array(l))))))
48
+ }
49
+
50
+ /** A snapshot's pack digests must equal this boot's: the boot key only samples each pack, this covers every byte. */
51
+ export function checkPacksFit(theirs, mine) {
52
+ if (!mine) return
53
+ if (!Array.isArray(theirs)) throw new CheckpointError('stale', 'this snapshot does not record the full digests of its image layers (taken by an older build): it cannot be checked against the packs this page loaded; take a new one')
54
+ for (let i = 0; i < Math.max(theirs.length, mine.length); i++) {
55
+ const a = theirs[i] || []
56
+ const b = mine[i] || []
57
+ for (let j = 0; j < Math.max(a.length, b.length); j++) {
58
+ if (a[j] !== b[j]) throw new CheckpointError('stale', `this snapshot was taken over a different image layer: service ${i} layer ${j} is ${String(a[j]).slice(0, 30)} there and ${String(b[j]).slice(0, 30)} here (same boot key, other bytes: a rebuilt pack?)`)
59
+ }
60
+ }
61
+ }
62
+
63
+ /**
64
+ * The key of a boot: which vm.wasm, which kernel and initramfs, which command line and
65
+ * environment, which packs and bundle files. A checkpoint restores only into a boot with the
66
+ * same key (research/10 R11).
67
+ */
68
+ export async function bootKeyOf({ vmKey, argv, env, preopen, files, services, maxPages }) {
69
+ const fileKeys = {}
70
+ for (const f of files) fileKeys[f.path] = await sampleKey(new Uint8Array(f.buffer), { head: 1 << 30, tail: 1 << 30 }) // the kernel and initramfs in full (a few MB: cheap)
71
+ const svc = []
72
+ for (const s of services) {
73
+ const layers = []
74
+ for (const l of s.layers || []) layers.push(await sampleKey(new Uint8Array(l)))
75
+ const bundle = {}
76
+ for (const [p, f] of Object.entries(s.files || {})) {
77
+ const d = f && typeof f === 'object' && 'data' in f ? f.data : f
78
+ const u8 = typeof d === 'string' ? new TextEncoder().encode(d) : new Uint8Array(d)
79
+ bundle[p] = await sampleKey(u8)
80
+ }
81
+ // W56: a guest on an in-page switch has a network, and its address is in its memory (a restore must give it the same one)
82
+ const sw = s.switch ? { ip: s.switch.address(s.name, s.ip) } : {}
83
+ svc.push({ port: s.port, kind: s.kind, at: s.at || '', yamux: !!s.yamux, net: !!(s.relay || s.switch), ...sw, layers, bundle })
84
+ }
85
+ const desc = { vmKey, argv, env, preopen, files: fileKeys, services: svc, maxPages }
86
+ return { key: await sha256Hex(new TextEncoder().encode(JSON.stringify(desc))), desc }
87
+ }
88
+
89
+ /** The ports a resume writes for the guest (vm_resume_args), from the command line. */
90
+ export function resumePorts(args) {
91
+ const get = name => {
92
+ const i = args.indexOf(name)
93
+ return i >= 0 && /^[0-9]+$/.test(args[i + 1] ?? '') ? Number(args[i + 1]) : 0
94
+ }
95
+ return { net: get('--net'), p9: get('--p9'), console: get('--console'), control: get('--control'), stats: args.includes('--stats') }
96
+ }
97
+
98
+ /** Parts packed into chunks of at most `limit` bytes; a single part over the limit is split too (a 74 MB JIT log is three chunks). */
99
+ export const concatChunks = (parts, limit) => {
100
+ const out = []
101
+ let cur = []
102
+ let n = 0
103
+ const flush = () => {
104
+ if (cur.length) out.push(concat(cur))
105
+ cur = []
106
+ n = 0
107
+ }
108
+ for (const p of parts) {
109
+ if (p.length > limit) {
110
+ flush()
111
+ for (let o = 0; o < p.length; o += limit) out.push(p.subarray(o, Math.min(p.length, o + limit)))
112
+ continue
113
+ }
114
+ if (cur.length && n + p.length > limit) flush()
115
+ cur.push(p)
116
+ n += p.length
117
+ }
118
+ flush()
119
+ return out.length ? out : [new Uint8Array(0)]
120
+ }
121
+
122
+ /** The control block's fd table cells as bytes. */
123
+ export function fdTableBytes(ctl) {
124
+ const cells = new Int32Array(FD_MAX * FD_INTS)
125
+ for (let i = 0; i < cells.length; i++) cells[i] = Atomics.load(ctl, FD_BASE + i)
126
+ return new Uint8Array(cells.buffer)
127
+ }
128
+
129
+ /** Writes the saved fd table into a new control block. */
130
+ export function validateFdTable(bytes, nfiles = Infinity) {
131
+ if (bytes.byteLength % 4) throw new CheckpointError('malformed', `the checkpoint's fd table is ${bytes.byteLength} bytes, not a whole number of cells`)
132
+ const cells = new Int32Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)) // aligned copy
133
+ if (cells.length !== FD_MAX * FD_INTS) throw new CheckpointError('malformed', `the checkpoint's fd table has ${cells.length} cells; this build has ${FD_MAX * FD_INTS}`)
134
+ for (let fd = 0; fd < FD_MAX; fd++) {
135
+ const kind = cells[fd * FD_INTS + FD_KIND]
136
+ if (kind !== 0 && kind !== 1) throw new CheckpointError('malformed', `fd ${fd + 4} has kind ${kind} in the checkpoint (0 or 1 only)`)
137
+ if (kind === 1 && !(cells[fd * FD_INTS + FD_FILE] >= 0 && cells[fd * FD_INTS + FD_FILE] < nfiles)) throw new CheckpointError('malformed', `fd ${fd + 4} refers to boot file ${cells[fd * FD_INTS + FD_FILE]} and this boot has ${nfiles}`)
138
+ }
139
+ return cells
140
+ }
141
+
142
+ export function restoreFdTable(ctl, bytes, nfiles = Infinity) {
143
+ const cells = validateFdTable(bytes, nfiles)
144
+ for (let i = 0; i < cells.length; i++) Atomics.store(ctl, FD_BASE + i, cells[i])
145
+ }
146
+
147
+ /**
148
+ * Assembles a checkpoint from what the capture collected.
149
+ * @param {object} p {meta, hdr (Uint8Array, the guest's 336 bytes), mem: {index, chunks (ArrayBuffer[]), pages, nonzero},
150
+ * jit: {snap (jit-snap capture)}|null, svc: {port: {segments}}, ctl (bytes), names?: [[name, ip]] (W59: the network's
151
+ * placeholder map, an optional section `net-names`; FORMAT_VERSION is unchanged and images without it still load)}
152
+ */
153
+ export function assembleCheckpoint(p) {
154
+ const sections = []
155
+ sections.push({ name: 'mem-index', chunks: [new Uint8Array(p.mem.index.buffer, p.mem.index.byteOffset, p.mem.index.byteLength)] })
156
+ sections.push({ name: 'mem', chunks: p.mem.chunks.map(c => new Uint8Array(c)) })
157
+ const header = {
158
+ ...p.meta,
159
+ format: FORMAT_VERSION,
160
+ createdAt: new Date().toISOString(),
161
+ memPages: p.mem.pages,
162
+ memNonzero: p.mem.nonzero,
163
+ pageSize: PAGE,
164
+ guestHdr: b64(p.hdr),
165
+ svcPorts: Object.keys(p.svc).map(Number),
166
+ }
167
+ if (p.jit) {
168
+ sections.push({ name: 'jit-recs', chunks: [new Uint8Array(p.jit.snap.recs.buffer, p.jit.snap.recs.byteOffset, p.jit.snap.recs.byteLength)] })
169
+ sections.push({ name: 'jit-log', chunks: concatChunks([p.jit.snap.log], 32 << 20) })
170
+ header.jit = { gen: p.jit.snap.gen, nslots: p.jit.snap.nslots, slot0: p.jit.snap.slot0, nrec: p.jit.snap.nrec }
171
+ }
172
+ for (const [port, d] of Object.entries(p.svc)) sections.push({ name: `svc-${port}`, chunks: concatChunks(d.segments, 16 << 20) })
173
+ sections.push({ name: 'ctl', chunks: [p.ctl] })
174
+ if (p.names?.length) sections.push({ name: 'net-names', chunks: [new TextEncoder().encode(JSON.stringify(p.names))] })
175
+ header.sizes = Object.fromEntries(sections.map(s => [s.name, s.chunks.reduce((n, c) => n + c.length, 0)]))
176
+ header.bytes = Object.values(header.sizes).reduce((a, b) => a + b, 0)
177
+ return { header, sections }
178
+ }
179
+
180
+ /** The typed pieces boot.js restores from. Throws CheckpointError for a checkpoint that cannot be used. */
181
+ export function parseCheckpoint(ck) {
182
+ const h = ck.header
183
+ const get = name => {
184
+ const s = ck.sections.find(x => x.name === name)
185
+ if (!s) throw new CheckpointError('malformed', `checkpoint has no section ${name}`)
186
+ return s.chunks
187
+ }
188
+ const idxBytes = concat(get('mem-index'))
189
+ if (idxBytes.length % 4) throw new CheckpointError('malformed', 'mem-index is not a list of 32-bit page numbers')
190
+ const index = new Uint32Array(idxBytes.buffer.slice(idxBytes.byteOffset, idxBytes.byteOffset + idxBytes.length))
191
+ const memChunks = get('mem')
192
+ const have = memChunks.reduce((n, c) => n + c.length, 0)
193
+ if (have !== index.length * PAGE) throw new CheckpointError('malformed', `memory section holds ${have} bytes for ${index.length} pages`)
194
+ memChunks.forEach((c, i) => {
195
+ if (c.length % PAGE) throw new CheckpointError('malformed', `memory chunk ${i} is ${c.length} bytes, not a whole number of ${PAGE}-byte pages`)
196
+ })
197
+ for (let i = 0; i < index.length; i++) {
198
+ if (index[i] >= h.memPages) throw new CheckpointError('malformed', `page ${index[i]} is past the ${h.memPages} pages of memory`)
199
+ if (i && index[i] <= index[i - 1]) throw new CheckpointError('malformed', `the page list is not strictly increasing at entry ${i} (page ${index[i]} after ${index[i - 1]}): a duplicate or unordered page`)
200
+ }
201
+ let jit = null
202
+ if (h.jit) {
203
+ const recs = concat(get('jit-recs'))
204
+ const log = concat(get('jit-log'))
205
+ if (recs.length % 4) throw new CheckpointError('malformed', 'the JIT record section is not whole 32-bit cells')
206
+ jit = { gen: h.jit.gen, nslots: h.jit.nslots, slot0: h.jit.slot0, nrec: h.jit.nrec, recs: widenRecs(new Int32Array(recs.buffer.slice(recs.byteOffset, recs.byteOffset + recs.length)), h.jit.nrec), log }
207
+ try {
208
+ validateSnap(jit)
209
+ } catch (e) {
210
+ throw new CheckpointError('malformed', e.message)
211
+ }
212
+ }
213
+ const svc = {}
214
+ for (const port of h.svcPorts || []) svc[port] = concat(get(`svc-${port}`))
215
+ const ctlBytes = concat(get('ctl'))
216
+ return { header: h, index, memChunks, jit, svc, ctl: new Uint8Array(ctlBytes), names: parseNames(ck) }
217
+ }
218
+
219
+ // no leading zeros: "198.18.07.1" and "198.18.7.1" are one address, and the map keeps each address once
220
+ const PLACEHOLDER = /^198\.1[89]\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)$/
221
+
222
+ /** W59: the optional `net-names` section: [[name, ip]] with ip in 198.18.0.0/15, each name and ip once. */
223
+ function parseNames(ck) {
224
+ const s = ck.sections.find(x => x.name === 'net-names')
225
+ if (!s) return []
226
+ let v
227
+ try {
228
+ v = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(concat(s.chunks)))
229
+ } catch (e) {
230
+ throw new CheckpointError('malformed', `the net-names section is not JSON: ${e.message}`)
231
+ }
232
+ if (!Array.isArray(v) || v.length > 65536) throw new CheckpointError('malformed', 'the net-names section is not a list of at most 65536 names')
233
+ const names = new Set()
234
+ const ips = new Set()
235
+ for (const e of v) {
236
+ if (!Array.isArray(e) || e.length !== 2 || typeof e[0] !== 'string' || !e[0] || e[0].length > 253 || typeof e[1] !== 'string' || !PLACEHOLDER.test(e[1])) {
237
+ throw new CheckpointError('malformed', `the net-names section has a bad entry: ${JSON.stringify(e)?.slice(0, 80)}`)
238
+ }
239
+ if (names.has(e[0]) || ips.has(e[1])) throw new CheckpointError('malformed', `the net-names section lists ${e[0]} or ${e[1]} twice`)
240
+ names.add(e[0])
241
+ ips.add(e[1])
242
+ }
243
+ return v
244
+ }
245
+
246
+ /** Throws a CheckpointError naming what differs, if `h` does not belong to this boot. */
247
+ /** What differs between two boot descriptions (bootKeyOf's desc), for the message of a refused restore. */
248
+ export function bootDescDiff(a, b) {
249
+ if (!a || !b) return ''
250
+ const out = []
251
+ const walk = (x, y, path) => {
252
+ if (JSON.stringify(x) === JSON.stringify(y)) return
253
+ if (x && y && typeof x === 'object' && typeof y === 'object') {
254
+ for (const k of new Set([...Object.keys(x), ...Object.keys(y)])) walk(x[k], y[k], `${path}.${k}`)
255
+ } else out.push(`${path}: ${JSON.stringify(x)?.slice(0, 60)} -> ${JSON.stringify(y)?.slice(0, 60)}`)
256
+ }
257
+ walk(a, b, 'boot')
258
+ return out.slice(0, 6).join('; ')
259
+ }
260
+
261
+ /** The part of the fit check that needs only vm.wasm's key: a snapshot from another vm.wasm is refused before anything else is read of it. */
262
+ export function checkVmKeyFits(h, vmKey) {
263
+ if (h.format !== FORMAT_VERSION) throw new CheckpointError('version', `checkpoint format ${h.format}; this build restores format ${FORMAT_VERSION}`)
264
+ if (h.vmKey !== vmKey) {
265
+ throw new CheckpointError('stale', `this checkpoint was taken with a different vm.wasm (${String(h.vmKey).slice(0, 12)}, this boot has ${String(vmKey).slice(0, 12)}): its memory image is the old emulator's and cannot be restored; take a new one`)
266
+ }
267
+ }
268
+
269
+ export function checkCheckpointFits(h, { vmKey, bootKey, bootDesc, maxPages, hasJit, fmaFused = true }) {
270
+ if (h.format !== FORMAT_VERSION) throw new CheckpointError('version', `checkpoint format ${h.format}; this build restores format ${FORMAT_VERSION}`)
271
+ if (h.vmKey !== vmKey) {
272
+ throw new CheckpointError('stale', `this checkpoint was taken with a different vm.wasm (${String(h.vmKey).slice(0, 12)}, this boot has ${String(vmKey).slice(0, 12)}): its memory image is the old emulator's and cannot be restored; take a new one`)
273
+ }
274
+ if (h.bootKey !== bootKey) {
275
+ const diff = bootDescDiff(h.bootDesc, bootDesc)
276
+ throw new CheckpointError('stale', `this checkpoint was taken by a boot with a different kernel, initramfs, command line, environment, image layers or bundle files than this one; restore it only into the same configuration${diff ? ` (differs: ${diff})` : ''}`)
277
+ }
278
+ if (h.memPages > maxPages) throw new CheckpointError('stale', `this checkpoint's memory is ${h.memPages} pages and this boot's maximum is ${maxPages}`)
279
+ if (h.jit && !hasJit) throw new CheckpointError('stale', 'this checkpoint carries JIT state and this boot has the JIT door off (jit: false)')
280
+ if (!h.jit && hasJit) throw new CheckpointError('stale', 'this checkpoint was taken without the JIT door and this boot has it on')
281
+ // D-539: its JIT blocks may use relaxed_madd, which this engine does not fuse or parse
282
+ if (h.fmaFused && h.jit && !fmaFused) throw new CheckpointError('stale', "this checkpoint was taken in a browser whose wasm engine fuses relaxed_madd, and its compiled code uses it; this browser's engine does not (Safari): restore it in Chromium or Firefox, or take a new one here")
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // The stores
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /** OPFS, through the I/O Worker. Methods return promises. */
290
+ export function opfsStore({ workerUrl } = {}) {
291
+ let w = null
292
+ let next = 1
293
+ const pending = new Map()
294
+ let saveQueue = Promise.resolve() // saves run one at a time (the Worker refuses a second, and two would pick one version number)
295
+ const activeSaves = new Map() // save id -> checkpoint being streamed
296
+ const call = (msg, transfer = [], id = next++) => {
297
+ if (!w) {
298
+ // a literal URL when no workerUrl is given, so bundlers bundle the Worker (2.15)
299
+ w = workerUrl
300
+ ? new Worker(workerUrl, { type: 'module', name: 'wmd-ckpt-io' })
301
+ : new Worker(new URL('./ckpt-io.js', import.meta.url), { type: 'module', name: 'wmd-ckpt-io' })
302
+ w.onmessage = e => {
303
+ if (e.data.need) {
304
+ // the save is ready for a chunk: copy exactly that one (the in-memory checkpoint stays usable) and send it
305
+ const ck = activeSaves.get(e.data.saveId)
306
+ if (ck) answerNeed(ck, e.data, (m, t) => w.postMessage(m, t || [])) // a failed copy (out of memory) answers with an error, so the save fails
307
+ return
308
+ }
309
+ const p = pending.get(e.data.id)
310
+ if (!p) return
311
+ pending.delete(e.data.id)
312
+ if (e.data.ok) p.resolve(e.data)
313
+ else p.reject(new CheckpointError(e.data.code || 'io', e.data.error))
314
+ }
315
+ w.onerror = err => {
316
+ for (const p of pending.values()) p.reject(new CheckpointError('io', `the checkpoint I/O Worker failed: ${err.message || err}`))
317
+ pending.clear()
318
+ }
319
+ }
320
+ return new Promise((resolve, reject) => {
321
+ pending.set(id, { resolve, reject })
322
+ w.postMessage({ ...msg, id }, transfer)
323
+ })
324
+ }
325
+ return {
326
+ kind: 'opfs',
327
+ /**
328
+ * Writes a checkpoint. The Worker pulls it one chunk (at most 32 MiB) at a time, each copied on request so the
329
+ * in-memory checkpoint stays usable: the page never holds a second copy of the whole file, and the Worker holds at
330
+ * most one chunk. Saves are queued, one at a time. Resolves {bytes, ms, shaMs, version, leftover}.
331
+ */
332
+ save(name, ck) {
333
+ const run = saveQueue.catch(() => {}).then(async () => {
334
+ const id = next++
335
+ activeSaves.set(id, ck)
336
+ try {
337
+ const sections = ck.sections.map(s => ({ name: s.name, lens: s.chunks.map(c => c.length) }))
338
+ const r = await call({ op: 'save', name, header: ck.header, sections }, [], id)
339
+ return { bytes: r.bytes, ms: r.ms, shaMs: r.shaMs, version: r.version, leftover: r.leftover }
340
+ } finally {
341
+ activeSaves.delete(id)
342
+ }
343
+ })
344
+ saveQueue = run
345
+ return run
346
+ },
347
+ async info(name) {
348
+ const r = await call({ op: 'info', name })
349
+ return { header: r.header, size: r.size, version: r.version, skipped: r.skipped }
350
+ },
351
+ /** Reads and verifies a checkpoint into memory: {header, sections, ms}. */
352
+ async load(name, { verify = true } = {}) {
353
+ // one op: a save that lands between an info and a load could make them name different versions
354
+ const r = await call({ op: 'load', name, sections: null, verify })
355
+ return { header: r.header, sections: r.header.sections.map(s => ({ name: s.name, chunks: r.sections[s.name].map(b => new Uint8Array(b)) })), ms: r.ms, version: r.version, skipped: r.skipped }
356
+ },
357
+ async verify(name) {
358
+ return call({ op: 'verify', name })
359
+ },
360
+ async list() {
361
+ return (await call({ op: 'list' })).items
362
+ },
363
+ async delete(name) {
364
+ await call({ op: 'delete', name })
365
+ },
366
+ async usage() {
367
+ const r = await call({ op: 'usage' })
368
+ return { usage: r.usage, quota: r.quota }
369
+ },
370
+ /** Test hook: how many MB one OPFS file can take here before the browser refuses. */
371
+ async probe(maxMB = 1024) {
372
+ const r = await call({ op: 'probe', maxMB })
373
+ return { writtenMB: r.writtenMB, error: r.error }
374
+ },
375
+ /** Test hooks: damage a stored checkpoint (flip a byte; cut the file short). */
376
+ async poke(name, at, xor = 0xff) {
377
+ await call({ op: 'poke', name, at, xor })
378
+ },
379
+ async cut(name, size) {
380
+ await call({ op: 'cut', name, size })
381
+ },
382
+ close() {
383
+ w?.terminate()
384
+ w = null
385
+ // nothing will answer the calls still waiting for the Worker, or the save that was pulling chunks from it
386
+ for (const p of pending.values()) p.reject(new CheckpointError('io', 'the checkpoint store was closed'))
387
+ pending.clear()
388
+ activeSaves.clear()
389
+ },
390
+ }
391
+ }
392
+
393
+ /** The same interface over memory files (tests; a page without OPFS). */
394
+ export function memoryStore() {
395
+ const files = new Map()
396
+ return {
397
+ kind: 'memory',
398
+ files,
399
+ async save(name, ck) {
400
+ const f = memoryFile()
401
+ const r = await writeCheckpoint(f, ck.header, ck.sections)
402
+ files.set(name, f)
403
+ return { bytes: r.bytes, ms: r.ms, shaMs: r.shaMs }
404
+ },
405
+ async info(name) {
406
+ const f = files.get(name)
407
+ if (!f) throw new CheckpointError('missing', `no checkpoint named ${JSON.stringify(name)}`)
408
+ return { header: readHeader(f), size: f.getSize() }
409
+ },
410
+ async load(name, { verify = true } = {}) {
411
+ const f = files.get(name)
412
+ if (!f) throw new CheckpointError('missing', `no checkpoint named ${JSON.stringify(name)}`)
413
+ const header = readHeader(f)
414
+ const sections = []
415
+ for (const s of header.sections) sections.push({ name: s.name, chunks: await readSection(f, header, s.name, { verify }) })
416
+ return { header, sections, ms: 0 }
417
+ },
418
+ async verify(name) {
419
+ await this.load(name, { verify: true })
420
+ },
421
+ async list() {
422
+ return [...files].map(([name, f]) => ({ name, size: f.getSize(), modified: 0 }))
423
+ },
424
+ async delete(name) {
425
+ if (!files.delete(name)) throw new CheckpointError('missing', `no checkpoint named ${JSON.stringify(name)}`)
426
+ },
427
+ async usage() {
428
+ return { usage: [...files.values()].reduce((n, f) => n + f.getSize(), 0), quota: Infinity }
429
+ },
430
+ close() {},
431
+ }
432
+ }
433
+
434
+ export { sectionBytes }