@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,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])
|