@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,112 @@
1
+ // W62: the writable layer of an Overlay as one pack layer, what `docker commit` keeps: every
2
+ // file the guest made or changed (contents, mode, owner, times, xattrs, devices, links), a
3
+ // whiteout for every lower name it removed, opaque for a directory made over a removed lower
4
+ // one. Booted again with this pack on top of the same image layers (bootWmd's layers, top
5
+ // first), the guest sees the tree it left.
6
+ //
7
+ // The Overlay keeps lower and upper in one inode tree, so the layer is found by walking the
8
+ // directories the guest has read (kids loaded; an unread directory has no changes below it):
9
+ // an inode with `upper` set is written whole, a directory that holds a change is written so
10
+ // the readers can reach it (a pack lists only entries under directory entries of its own).
11
+ // A directory that moved (its lower contents now show under another name: rename of a lower
12
+ // directory) is written opaque with everything under it, since a pack has no redirects.
13
+ //
14
+ // A file the guest still has mapped writable may have changes in the guest's page cache
15
+ // that 9P has not seen: run `sync` in the guest first (cache=mmap writes the rest at once).
16
+
17
+ import { S_IFMT, S_IFREG } from './pack.js'
18
+ import { writePack } from './pack-write.js'
19
+
20
+ /**
21
+ * The upper of fs below `at` ('' the root; 'rootfs' for the guest init's layout, where the
22
+ * layers show) as pack bytes, and what it holds.
23
+ * @returns {{bytes: Uint8Array, entries: number, files: number, whiteouts: number, opaque: number, dataBytes: number}}
24
+ */
25
+ export function exportUpper(fs, { at = '' } = {}) {
26
+ const top = at ? fs.resolve(at, false) : fs.root
27
+ if (!top.isDir()) throw new Error(`exportUpper: ${at} is not a directory`)
28
+ const out = []
29
+ const links = new Map() // inode -> link id, for a regular file with more than one name written
30
+ let nextLink = 1
31
+ const stats = { files: 0, whiteouts: 0, opaque: 0, dataBytes: 0 }
32
+
33
+ const entryOf = (n, path, opaque) => {
34
+ const e = {
35
+ path, mode: n.mode, uid: n.uid, gid: n.gid, rdev: n.rdev,
36
+ atime: n.atime, mtime: n.mtime, ctime: n.ctime, target: n.target || '',
37
+ xattrs: n.xattrs && n.xattrs.size ? n.xattrs : null, opaque: !!opaque,
38
+ size: 0, link: 0,
39
+ }
40
+ if ((n.mode & S_IFMT) === S_IFREG) {
41
+ e.size = fs.size(n)
42
+ if (n.nlink > 1) {
43
+ let id = links.get(n)
44
+ if (id) e.link = id
45
+ else {
46
+ links.set(n, (id = nextLink++))
47
+ e.link = id
48
+ e.data = (off, len) => fs.read(n, off, len)
49
+ stats.dataBytes += e.size
50
+ }
51
+ } else {
52
+ e.data = (off, len) => fs.read(n, off, len)
53
+ stats.dataBytes += e.size
54
+ }
55
+ stats.files++
56
+ }
57
+ if (opaque) stats.opaque++
58
+ return e
59
+ }
60
+
61
+ // Whether a lower layer shows something at path (a whiteout there hides it).
62
+ const lowerHas = path => {
63
+ for (const p of fs.layers) {
64
+ const i = p.index(path)
65
+ if (i >= 0) return !p.entry(i).whiteout
66
+ }
67
+ // (an entry hidden by a parent's whiteout or opaque directory counts too: asked only of a
68
+ // new directory, it makes it opaque, which hides nothing the guest sees)
69
+ return false
70
+ }
71
+
72
+ // Writes what changed in directory n at path; whole: everything under it (a moved
73
+ // directory's subtree). Returns whether anything was written.
74
+ const visit = (n, path, whole) => {
75
+ let wrote = false
76
+ if (n.isDir()) {
77
+ const moved = !whole && n.lowerDirs && n.lowerDirs.some(d => d.path !== path)
78
+ const allIn = whole || moved
79
+ const kids = allIn ? fs.list(n) : n.kids ? [...n.kids] : []
80
+ const before = out.length
81
+ if (!allIn && n.whiteouts) {
82
+ for (const name of n.whiteouts) {
83
+ if (n.kids && n.kids.has(name)) continue
84
+ out.push({ path: join(path, name), whiteout: true })
85
+ stats.whiteouts++
86
+ }
87
+ }
88
+ for (const [name, c] of kids) visit(c, join(path, name), allIn)
89
+ const inside = out.length > before
90
+ // a new directory at a lower directory's name hides it (#insert set opaque for one made
91
+ // there; one renamed there from the upper did not, and in a pack it would merge)
92
+ const opaque = allIn || n.opaque || (n.upper && n.lowerDirs && n.lowerDirs.length === 0 && path !== '' && lowerHas(path))
93
+ if (whole || n.upper || inside || opaque) {
94
+ out.push(entryOf(n, path, allIn || opaque))
95
+ wrote = true
96
+ }
97
+ return wrote || inside
98
+ }
99
+ if (whole || n.upper) {
100
+ out.push(entryOf(n, path, false))
101
+ return true
102
+ }
103
+ return false
104
+ }
105
+
106
+ // the root is written whenever anything under it is (with its current attributes)
107
+ visit(top, '', false)
108
+ const bytes = writePack(out)
109
+ return { bytes, entries: out.length, ...stats }
110
+ }
111
+
112
+ const join = (dir, name) => (dir === '' ? name : `${dir}/${name}`)
@@ -0,0 +1,195 @@
1
+ // P-B6: where a downloaded base snapshot is kept so the next page load does not fetch it again.
2
+ //
3
+ // opfsBaseCache() OPFS directory `wmd-base`, one file per snapshot (the file as downloaded: codec and all), written by
4
+ // base-io.js; read back with getFile().stream(). The file is valid only once its magic is in (written last).
5
+ // memoryBaseCache() the same interface over memory (tests, a page without OPFS)
6
+ //
7
+ // open(name) -> {stream, size} | null null when there is no complete file (a partial one is deleted)
8
+ // writer(name) -> {write(u8), commit(), abort()} the tee of a download: pieces in file order; write() applies backpressure
9
+ // A cache is an optimisation: every failure is the caller's to log and ignore (the snapshot is verified again on every read).
10
+ import { BASE_MAGIC } from './base-file.js'
11
+
12
+ const MAGIC = new TextEncoder().encode(BASE_MAGIC)
13
+ const FLUSH = 4 << 20
14
+ const MAX_INFLIGHT = 64 << 20
15
+
16
+ /** Wraps a raw `put(at, u8)` (async) into the writer: zeroes the first 8 bytes until commit, batches, bounds what is in flight. */
17
+ export function makeWriter({ put, commit, abort }) {
18
+ let at = 0
19
+ let pending = []
20
+ let pendingBytes = 0
21
+ let inflight = 0
22
+ const waiters = []
23
+ let failed = null
24
+ let first = true
25
+ const ship = async () => {
26
+ if (!pendingBytes) return
27
+ const buf = new Uint8Array(pendingBytes)
28
+ let o = 0
29
+ for (const p of pending) (buf.set(p, o), (o += p.length))
30
+ pending = []
31
+ pendingBytes = 0
32
+ if (first) {
33
+ if (buf.length < 8 || MAGIC.some((b, i) => buf[i] !== b)) throw new Error('not a base snapshot: it is not cached')
34
+ buf.fill(0, 0, 8) // the magic goes in at commit
35
+ first = false
36
+ }
37
+ const here = at
38
+ const n = buf.length // read before put(): the buffer is transferred and its length reads 0 afterwards
39
+ at += n
40
+ inflight += n
41
+ put(here, buf).then(
42
+ () => {
43
+ inflight -= n
44
+ waiters.splice(0).forEach(w => w())
45
+ },
46
+ e => {
47
+ failed = e
48
+ inflight -= n
49
+ waiters.splice(0).forEach(w => w())
50
+ },
51
+ )
52
+ while (inflight > MAX_INFLIGHT && !failed) await new Promise(r => waiters.push(r))
53
+ }
54
+ const settle = async () => {
55
+ await ship()
56
+ while (inflight > 0 && !failed) await new Promise(r => waiters.push(r))
57
+ if (failed) throw failed
58
+ }
59
+ return {
60
+ async write(u8) {
61
+ if (failed) throw failed
62
+ pending.push(u8)
63
+ pendingBytes += u8.length
64
+ if (pendingBytes >= FLUSH) await ship()
65
+ },
66
+ async commit() {
67
+ await settle()
68
+ await commit(at)
69
+ return { bytes: at }
70
+ },
71
+ async abort() {
72
+ pending = []
73
+ await abort().catch(() => {})
74
+ },
75
+ get bytes() {
76
+ return at + pendingBytes
77
+ },
78
+ }
79
+ }
80
+
81
+ export function opfsBaseCache({ workerUrl, dirName = 'wmd-base' } = {}) {
82
+ let w = null
83
+ let next = 1
84
+ const pending = new Map()
85
+ const call = (msg, transfer = []) => {
86
+ if (!w) {
87
+ // a literal URL when no workerUrl is given, so bundlers bundle the Worker (2.15)
88
+ w = workerUrl
89
+ ? new Worker(workerUrl, { type: 'module', name: 'wmd-base-io' })
90
+ : new Worker(new URL('./base-io.js', import.meta.url), { type: 'module', name: 'wmd-base-io' })
91
+ w.onmessage = e => {
92
+ const p = pending.get(e.data.id)
93
+ if (!p) return
94
+ pending.delete(e.data.id)
95
+ if (e.data.ok) p.resolve(e.data)
96
+ else p.reject(Object.assign(new Error(e.data.error), { code: e.data.code }, e.data.code === 'quota' ? { name: 'QuotaExceededError' } : {}))
97
+ }
98
+ w.onerror = err => {
99
+ for (const p of pending.values()) p.reject(new Error(`the cache Worker failed: ${err.message || err}`))
100
+ pending.clear()
101
+ }
102
+ }
103
+ return new Promise((resolve, reject) => {
104
+ const id = next++
105
+ pending.set(id, { resolve, reject })
106
+ w.postMessage({ ...msg, id }, transfer)
107
+ })
108
+ }
109
+ const dir = async () => (await navigator.storage.getDirectory()).getDirectoryHandle(dirName, { create: true })
110
+ return {
111
+ kind: 'opfs',
112
+ async open(name) {
113
+ let file
114
+ try {
115
+ file = await (await (await dir()).getFileHandle(name)).getFile()
116
+ } catch (e) {
117
+ if (e.name === 'NotFoundError') return null
118
+ throw e
119
+ }
120
+ const head = new Uint8Array(await file.slice(0, 8).arrayBuffer())
121
+ if (file.size < 16 || MAGIC.some((b, i) => head[i] !== b)) {
122
+ await call({ op: 'delete', name }).catch(() => {}) // an unfinished copy: not usable, not kept
123
+ return null
124
+ }
125
+ return { stream: file.stream(), size: file.size }
126
+ },
127
+ async writer(name) {
128
+ await call({ op: 'open', name })
129
+ return makeWriter({
130
+ put: (at, buf) => call({ op: 'write', name, at, buf: buf.buffer }, [buf.buffer]),
131
+ commit: () => call({ op: 'commit', name, magic: MAGIC.slice().buffer }, []),
132
+ abort: () => call({ op: 'abort', name }),
133
+ })
134
+ },
135
+ async list() {
136
+ return (await call({ op: 'list' })).items
137
+ },
138
+ async delete(name) {
139
+ await call({ op: 'delete', name })
140
+ },
141
+ async clear() {
142
+ await call({ op: 'clear' })
143
+ },
144
+ close() {
145
+ w?.terminate()
146
+ w = null
147
+ },
148
+ }
149
+ }
150
+
151
+ export function memoryBaseCache() {
152
+ const files = new Map() // name -> {buf: Uint8Array, done}
153
+ return {
154
+ kind: 'memory',
155
+ files,
156
+ async open(name) {
157
+ const f = files.get(name)
158
+ if (!f || !f.done) {
159
+ files.delete(name)
160
+ return null
161
+ }
162
+ const b = f.buf
163
+ return { stream: new ReadableStream({ start(c) { c.enqueue(b.slice()); c.close() } }), size: b.length }
164
+ },
165
+ async writer(name) {
166
+ const f = { buf: new Uint8Array(0), done: false }
167
+ files.set(name, f)
168
+ return makeWriter({
169
+ put: async (at, u8) => {
170
+ if (at + u8.length > f.buf.length) {
171
+ const nb = new Uint8Array(at + u8.length)
172
+ nb.set(f.buf)
173
+ f.buf = nb
174
+ }
175
+ f.buf.set(u8, at)
176
+ },
177
+ commit: async () => {
178
+ f.buf.set(MAGIC, 0)
179
+ f.done = true
180
+ },
181
+ abort: async () => void files.delete(name),
182
+ })
183
+ },
184
+ async list() {
185
+ return [...files].map(([name, f]) => ({ name, size: f.buf.length }))
186
+ },
187
+ async delete(name) {
188
+ files.delete(name)
189
+ },
190
+ async clear() {
191
+ files.clear()
192
+ },
193
+ close() {},
194
+ }
195
+ }
@@ -0,0 +1,328 @@
1
+ // P-B6: the base snapshot file, a checkpoint made to be downloaded (a static file next to an image's packs).
2
+ //
3
+ // [0, 8) magic "WMDBASE1" (an OPFS copy is written with these 8 bytes zero and the magic put in last)
4
+ // [8, 12) u32 LE format version
5
+ // [12, 16) u32 LE length n of the header JSON
6
+ // [16, 16+n) header JSON (see below), then its SHA-256 (32 bytes)
7
+ // then the chunks, back to back, in the order of header.sections, each `clen` bytes (the codec's output)
8
+ //
9
+ // header JSON: the checkpoint's header (vmKey, bootKey, bootDesc, memPages, jit, svcPorts, stats, ...) plus
10
+ // {base: 1, image, codec: 'none'|'gzip', createdAt, dataLen,
11
+ // sections: [{name, chunks: [{len, clen, sha256}]}]}
12
+ // `len` is the raw size of a chunk, `clen` the size in the file, `sha256` the digest of the RAW bytes: a reader checks the
13
+ // header against its own SHA-256 (and, when it has it, the manifest's), then every chunk after decoding. A file cut short is
14
+ // refused as soon as the stream ends early (dataLen is in the header), a flipped byte by its chunk's digest or the decoder.
15
+ //
16
+ // The reader works on a stream, so a page decodes and verifies while the rest downloads; chunks are small (8 MiB by default)
17
+ // so decoding overlaps the transfer. codec 'gzip' uses DecompressionStream (all three browsers); a host that serves the file
18
+ // brotli-compressed (Content-Encoding: br) uses codec 'none': the browser decodes that layer and the digests still apply.
19
+ import { CheckpointError, concat, sha256Hex, sha256Sync } from './ckpt-file.js'
20
+
21
+ export const BASE_MAGIC = 'WMDBASE1'
22
+ export const BASE_VERSION = 1
23
+ export const BASE_MAX_HEADER = 4 << 20
24
+ export const BASE_CHUNK = 8 << 20
25
+ const enc = new TextEncoder()
26
+ const dec = new TextDecoder()
27
+ const hex = u8 => [...u8].map(b => b.toString(16).padStart(2, '0')).join('')
28
+
29
+ /** A checkpoint's chunks split at `limit` bytes (a multiple of the 64 KiB page, so memory chunks stay whole pages). */
30
+ function split(c, limit) {
31
+ if (c.length <= limit) return [c]
32
+ const out = []
33
+ for (let o = 0; o < c.length; o += limit) out.push(c.subarray(o, Math.min(c.length, o + limit)))
34
+ return out
35
+ }
36
+
37
+ /**
38
+ * Encodes a checkpoint ({header, sections}) as a base file. `compress(u8) -> Promise<Uint8Array>` is the codec's encoder
39
+ * (omit with codec 'none'). Resolves {head, chunks, header, bytes}: write `head` then every `chunks[i]` in order.
40
+ */
41
+ export async function encodeBase(ckpt, { image, codec = 'none', compress = async x => x, chunkBytes = BASE_CHUNK, now = () => new Date().toISOString() } = {}) {
42
+ if (chunkBytes % (64 << 10)) throw new Error('chunkBytes must be a multiple of 64 KiB')
43
+ const chunks = []
44
+ const sections = []
45
+ let dataLen = 0
46
+ for (const s of ckpt.sections) {
47
+ const entries = []
48
+ for (const c0 of s.chunks) {
49
+ for (const c of split(c0, chunkBytes)) {
50
+ const z = codec === 'none' ? c : await compress(c)
51
+ entries.push({ len: c.length, clen: z.length, sha256: await sha256Hex(c.buffer instanceof SharedArrayBuffer ? c.slice() : c) })
52
+ chunks.push(z)
53
+ dataLen += z.length
54
+ }
55
+ }
56
+ sections.push({ name: s.name, chunks: entries })
57
+ }
58
+ const { sections: _drop, fileLen: _fl, ...rest } = ckpt.header
59
+ const header = { ...rest, base: 1, image, codec, createdAt: now(), dataLen, sections }
60
+ const json = enc.encode(JSON.stringify(header))
61
+ const head = new Uint8Array(16 + json.length + 32)
62
+ head.set(enc.encode(BASE_MAGIC), 0)
63
+ const dv = new DataView(head.buffer)
64
+ dv.setUint32(8, BASE_VERSION, true)
65
+ dv.setUint32(12, json.length, true)
66
+ head.set(json, 16)
67
+ head.set(sha256Sync(json), 16 + json.length)
68
+ return { head, chunks, header, bytes: head.length + dataLen, headerSha256: hex(sha256Sync(json)) }
69
+ }
70
+
71
+ /**
72
+ * A pull queue over a ReadableStream: a background pump keeps reading (up to `cap` bytes queued) so the network stays busy
73
+ * while the consumer decodes. read(n) gives exactly n bytes; feed(n, sink) passes n bytes on in pieces without a copy.
74
+ */
75
+ export class ByteQueue {
76
+ constructor(stream, { cap = 16 << 20, onBytes = null, signal = null } = {}) {
77
+ this.reader = stream.getReader()
78
+ this.q = [] // Uint8Array pieces
79
+ this.queued = 0
80
+ this.total = 0 // bytes received from the stream
81
+ this.ended = false
82
+ this.error = null
83
+ this.cap = cap
84
+ this.onBytes = onBytes
85
+ this.wake = null // resolves a waiting consumer
86
+ this.room = null // resolves a waiting pump
87
+ this.waitMs = 0
88
+ this.signal = signal
89
+ if (signal) {
90
+ // an abort stops the pump and wakes a reader waiting for bytes: it fails at once instead of decoding on
91
+ const stop = () => {
92
+ this.cancelled = true
93
+ this.error = this.error || signal.reason || new DOMException('aborted', 'AbortError')
94
+ this.ended = true
95
+ this.reader.cancel(signal.reason).catch(() => {})
96
+ this.room?.()
97
+ this.notify()
98
+ }
99
+ if (signal.aborted) stop()
100
+ else signal.addEventListener('abort', stop, { once: true })
101
+ }
102
+ this.pumping = this.pump()
103
+ }
104
+
105
+ notify() {
106
+ const w = this.wake
107
+ this.wake = null
108
+ w?.()
109
+ }
110
+
111
+ async pump() {
112
+ try {
113
+ for (;;) {
114
+ while (this.queued >= this.cap && !this.cancelled) await new Promise(r => (this.room = r))
115
+ if (this.cancelled) return
116
+ const { done, value } = await this.reader.read()
117
+ if (done) break
118
+ if (!value || !value.length) continue
119
+ this.total += value.length
120
+ if (this.onBytes) await this.onBytes(value)
121
+ this.q.push(value)
122
+ this.queued += value.length
123
+ this.notify()
124
+ }
125
+ } catch (e) {
126
+ this.error = e
127
+ }
128
+ this.ended = true
129
+ this.notify()
130
+ }
131
+
132
+ async need(n) {
133
+ while (this.queued < n) {
134
+ if (this.error || this.ended) return false
135
+ const t = performance.now()
136
+ await new Promise(r => (this.wake = r))
137
+ this.waitMs += performance.now() - t
138
+ }
139
+ return true
140
+ }
141
+
142
+ /** Sets an error message for a stream that stopped (called by the reader with what it was reading). */
143
+ short(what, want) {
144
+ const got = this.queued
145
+ const why = this.error ? `the download failed (${this.error.name || 'Error'}: ${this.error.message || this.error})` : 'the download ended'
146
+ return new CheckpointError('truncated', `the base snapshot is cut short: ${why} after ${this.total} bytes, while reading ${what} (wanted ${want} more bytes, ${got} left)`)
147
+ }
148
+
149
+ take(n) {
150
+ const out = new Uint8Array(n)
151
+ this.feedSync(n, (p, o) => out.set(p, o))
152
+ return out
153
+ }
154
+
155
+ feedSync(n, sink) {
156
+ let done = 0
157
+ while (done < n) {
158
+ const p = this.q[0]
159
+ const k = Math.min(p.length, n - done)
160
+ sink(k === p.length ? p : p.subarray(0, k), done)
161
+ done += k
162
+ if (k === p.length) this.q.shift()
163
+ else this.q[0] = p.subarray(k)
164
+ this.queued -= k
165
+ }
166
+ if (this.room && this.queued < this.cap) {
167
+ const r = this.room
168
+ this.room = null
169
+ r()
170
+ }
171
+ }
172
+
173
+ async read(n, what) {
174
+ if (!(await this.need(n))) throw this.short(what, n)
175
+ return this.take(n)
176
+ }
177
+
178
+ /** Passes n bytes on in pieces as they arrive (a piece is a view of what the network gave: do not keep it). */
179
+ async feed(n, what, sink) {
180
+ let left = n
181
+ while (left > 0) {
182
+ if (!this.queued && !(await this.need(1))) throw this.short(what, left)
183
+ const k = Math.min(this.queued, left)
184
+ const pieces = []
185
+ this.feedSync(k, p => pieces.push(p))
186
+ for (const p of pieces) await sink(p)
187
+ left -= k
188
+ }
189
+ }
190
+
191
+ /** True when the stream ended cleanly with nothing left over; false when bytes remain; throws if the stream failed. */
192
+ async atEnd() {
193
+ while (!this.queued && !this.ended) await new Promise(r => (this.wake = r))
194
+ if (this.queued) return false
195
+ if (this.error) throw new CheckpointError('truncated', `the download failed at its very end (${this.error.name || 'Error'}: ${this.error.message || this.error}) after ${this.total} bytes`)
196
+ return true
197
+ }
198
+
199
+ async cancel(reason) {
200
+ this.cancelled = true
201
+ this.room?.()
202
+ try {
203
+ await this.reader.cancel(reason)
204
+ } catch {}
205
+ }
206
+ }
207
+
208
+ /** Decodes one chunk: `clen` compressed bytes from the queue into `len` raw bytes. */
209
+ async function decodeChunk(q, codec, len, clen, what) {
210
+ if (codec === 'none') {
211
+ if (clen !== len) throw new CheckpointError('malformed', `${what}: codec none but clen ${clen} != len ${len}`)
212
+ return q.read(len, what)
213
+ }
214
+ if (codec !== 'gzip') throw new CheckpointError('malformed', `${what}: unknown codec ${JSON.stringify(codec)}`)
215
+ const ds = new DecompressionStream('gzip')
216
+ const w = ds.writable.getWriter()
217
+ const r = ds.readable.getReader()
218
+ const out = new Uint8Array(len)
219
+ let got = 0
220
+ const drain = (async () => {
221
+ for (;;) {
222
+ const { done, value } = await r.read()
223
+ if (done) break
224
+ if (got + value.length > len) throw new CheckpointError('corrupt', `${what} expands past its ${len} bytes: the file is damaged`)
225
+ out.set(value, got)
226
+ got += value.length
227
+ }
228
+ if (got !== len) throw new CheckpointError('corrupt', `${what} decodes to ${got} bytes, the header says ${len}: the file is damaged`)
229
+ })()
230
+ drain.catch(() => {})
231
+ try {
232
+ await q.feed(clen, what, p => w.write(p))
233
+ await w.close()
234
+ await drain
235
+ } catch (e) {
236
+ w.abort().catch(() => {})
237
+ r.cancel().catch(() => {})
238
+ drain.catch(() => {})
239
+ if (e instanceof CheckpointError) throw e
240
+ throw new CheckpointError('corrupt', `${what} does not decode (${e && e.name}: ${e && e.message}): the file is damaged`)
241
+ }
242
+ return out
243
+ }
244
+
245
+ /**
246
+ * Reads a base file from a stream: {header, sections: [{name, chunks}], stats}. header is the checkpoint's header
247
+ * (restorable: bootWmd takes {header, sections}). Everything is verified: the header's SHA-256 (and `expect.headerSha256`
248
+ * when given), every chunk's, the length of the stream. `onHeader(header)` runs as soon as the header is in (before any
249
+ * chunk is read): it may throw to refuse the file (a different vm.wasm, boot key), and the download is cancelled.
250
+ */
251
+ export async function readBase(stream, { expect = {}, onHeader = null, onBytes = null, onProgress = null, cap, signal = null } = {}) {
252
+ const q = new ByteQueue(stream, { cap, onBytes, signal })
253
+ const stats = { decodeMs: 0, digestMs: 0, chunks: 0, bytes: 0 }
254
+ try {
255
+ const top = await q.read(16, 'the file header')
256
+ const magic = dec.decode(top.subarray(0, 8))
257
+ if (magic !== BASE_MAGIC) {
258
+ const head = [...top.subarray(0, 8)].every(b => b === 0)
259
+ if (head) throw new CheckpointError('incomplete', 'the base snapshot was never finished (its first bytes are zero: a copy whose write did not complete)')
260
+ throw new CheckpointError('foreign', `not a wmd-web base snapshot (magic ${JSON.stringify(magic.replace(/[^\x20-\x7e]/g, '?'))})`)
261
+ }
262
+ const dv = new DataView(top.buffer)
263
+ const version = dv.getUint32(8, true)
264
+ if (version !== BASE_VERSION) throw new CheckpointError('version', `base snapshot format ${version}; this build reads format ${BASE_VERSION}`)
265
+ const n = dv.getUint32(12, true)
266
+ if (n < 2 || n > BASE_MAX_HEADER) throw new CheckpointError('malformed', `base snapshot header length ${n} is not possible`)
267
+ const rest = await q.read(n + 32, 'the header')
268
+ const json = rest.subarray(0, n)
269
+ const got = sha256Sync(json)
270
+ const want = rest.subarray(n)
271
+ if (want.some((b, i) => b !== got[i])) throw new CheckpointError('corrupt', 'the base snapshot header does not match its SHA-256: the section table or a count in it is damaged')
272
+ if (expect.headerSha256 && hex(got) !== expect.headerSha256) {
273
+ throw new CheckpointError('corrupt', `the base snapshot is not the file the manifest names (header SHA-256 ${hex(got).slice(0, 12)}, the manifest says ${expect.headerSha256.slice(0, 12)})`)
274
+ }
275
+ let header
276
+ try {
277
+ header = JSON.parse(dec.decode(json))
278
+ } catch (e) {
279
+ throw new CheckpointError('malformed', `base snapshot header is not JSON: ${e.message}`)
280
+ }
281
+ if (header.base !== 1 || !Array.isArray(header.sections) || typeof header.dataLen !== 'number') throw new CheckpointError('malformed', 'base snapshot header is missing its section table')
282
+ let sum = 0
283
+ for (const s of header.sections) for (const c of s.chunks) {
284
+ if (!(Number.isInteger(c.len) && c.len >= 0 && Number.isInteger(c.clen) && c.clen >= 0 && typeof c.sha256 === 'string')) throw new CheckpointError('malformed', `section ${s.name} has a bad chunk entry`)
285
+ // a raw chunk is read whole from the queue: one longer than the queue's read-ahead cap could never be satisfied (it would park until a false stall)
286
+ if (header.codec === 'none' && c.clen > q.cap) throw new CheckpointError('malformed', `section ${s.name} has a raw chunk of ${c.clen} bytes; this reader takes at most ${q.cap} (re-pack with smaller chunks)`)
287
+ sum += c.clen
288
+ }
289
+ if (sum !== header.dataLen) throw new CheckpointError('malformed', `base snapshot header: its chunks add up to ${sum} bytes and dataLen says ${header.dataLen}`)
290
+ stats.fileBytes = 16 + n + 32 + header.dataLen
291
+ stats.headerAt = performance.now()
292
+ if (onHeader) await onHeader(header)
293
+ const sections = []
294
+ for (const s of header.sections) {
295
+ const chunks = []
296
+ for (let i = 0; i < s.chunks.length; i++) {
297
+ const c = s.chunks[i]
298
+ const what = `section ${s.name}, chunk ${i}`
299
+ const t0 = performance.now()
300
+ const raw = await decodeChunk(q, header.codec, c.len, c.clen, what)
301
+ const t1 = performance.now()
302
+ const sha = await sha256Hex(raw)
303
+ stats.decodeMs += t1 - t0 // includes the time spent waiting for the network
304
+ stats.digestMs += performance.now() - t1
305
+ if (sha !== c.sha256) throw new CheckpointError('corrupt', `base snapshot ${what} does not match its SHA-256: the file is damaged`)
306
+ chunks.push(raw)
307
+ stats.chunks++
308
+ stats.bytes += raw.length
309
+ onProgress?.(stats)
310
+ }
311
+ sections.push({ name: s.name, chunks })
312
+ }
313
+ if (!(await q.atEnd())) throw new CheckpointError('malformed', 'the base snapshot has bytes after its last chunk')
314
+ stats.netBytes = q.total
315
+ stats.netWaitMs = q.waitMs
316
+ return { header, sections, stats }
317
+ } catch (e) {
318
+ await q.cancel(e)
319
+ if (signal?.aborted) {
320
+ const r = signal.reason
321
+ throw r instanceof CheckpointError ? r : new CheckpointError('aborted', `the base snapshot read was stopped (${(r && r.message) || 'aborted'}) after ${q.total} bytes`)
322
+ }
323
+ throw e
324
+ }
325
+ }
326
+
327
+ /** The pieces of a base file as one byte array (tests, small files). */
328
+ export const baseBytes = ({ head, chunks }) => concat([head, ...chunks])