@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.
- package/README.md +2601 -2
- package/RELEASE.json +123 -0
- package/export-page/coi-sw.js +20 -0
- package/export-page/index.html +17 -0
- package/export-page/run.css +10 -0
- package/export-page/run.js +236 -0
- package/guest/arm64/Image +4 -0
- package/guest/arm64/Image-4k +4 -0
- package/guest/arm64/initramfs.cpio.gz +0 -0
- package/guest/arm64/vm.wasm +0 -0
- package/package.json +36 -4
- package/page/sw.js +381 -0
- package/relay/control.ts +139 -0
- package/relay/engine.ts +787 -0
- package/relay/hosted.ts +660 -0
- package/relay/keys.ts +53 -0
- package/relay/local-tls.ts +121 -0
- package/relay/lock.ts +81 -0
- package/relay/logfile.ts +138 -0
- package/relay/main.ts +642 -0
- package/relay/policy.ts +241 -0
- package/relay/relay.ts +266 -0
- package/relay/token.ts +116 -0
- package/relay/v1.ts +107 -0
- package/relay/wisp.ts +615 -0
- package/src/base.js +325 -0
- package/src/boot.js +2037 -0
- package/src/calib-worker.js +40 -0
- package/src/fs/9p/codec.js +303 -0
- package/src/fs/9p/server.js +521 -0
- package/src/fs/errno.js +43 -0
- package/src/fs/graph.js +380 -0
- package/src/fs/overlay.js +1018 -0
- package/src/fs/pack-write.js +165 -0
- package/src/fs/pack.js +427 -0
- package/src/fs/seed.js +22 -0
- package/src/fs/svc-snapshot.js +65 -0
- package/src/fs/upper-pack.js +112 -0
- package/src/host/base-cache.js +195 -0
- package/src/host/base-file.js +328 -0
- package/src/host/base-io.js +93 -0
- package/src/host/boot-events.js +209 -0
- package/src/host/chan-service.js +300 -0
- package/src/host/chan-services.js +689 -0
- package/src/host/chan.js +655 -0
- package/src/host/ckpt-file.js +395 -0
- package/src/host/ckpt-io.js +207 -0
- package/src/host/ckpt-point.js +160 -0
- package/src/host/ckpt-save.js +64 -0
- package/src/host/ckpt.js +434 -0
- package/src/host/clock.js +111 -0
- package/src/host/ctl.js +778 -0
- package/src/host/errno.js +18 -0
- package/src/host/fs.js +167 -0
- package/src/host/gcnudge.js +83 -0
- package/src/host/i64shim.js +81 -0
- package/src/host/jit-cache.js +341 -0
- package/src/host/jit-hub-core.js +208 -0
- package/src/host/jit-hub.js +93 -0
- package/src/host/jit-snap.js +226 -0
- package/src/host/jit.js +1903 -0
- package/src/host/layout.js +100 -0
- package/src/host/monitor.js +110 -0
- package/src/host/p9mount.js +27 -0
- package/src/host/ram-mem.js +125 -0
- package/src/host/stdio.js +143 -0
- package/src/host/threads.js +109 -0
- package/src/host/wasi.js +335 -0
- package/src/host/wmd-imports.js +71 -0
- package/src/host/yamux.js +387 -0
- package/src/image-files.js +154 -0
- package/src/net/dhcp-dns.js +126 -0
- package/src/net/net-worker.js +172 -0
- package/src/net/packet.js +227 -0
- package/src/net/peer.js +230 -0
- package/src/net/proto.js +189 -0
- package/src/net/relay-client.js +226 -0
- package/src/net/stack.js +867 -0
- package/src/net/switch.js +160 -0
- package/src/net/wisp-client.js +570 -0
- package/src/net/wisp-transport.js +196 -0
- package/src/net/wisp.js +511 -0
- package/src/registry/client.js +176 -0
- package/src/registry/converter.js +68 -0
- package/src/registry/hub.js +221 -0
- package/src/registry/oci.js +76 -0
- package/src/registry/pack-worker.js +70 -0
- package/src/registry/pull.js +258 -0
- package/src/registry/ref.js +110 -0
- package/src/registry/wasi-mem.js +355 -0
- package/src/worker.js +315 -0
- 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
|
+
}
|
package/src/host/ckpt.js
ADDED
|
@@ -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 }
|