@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
package/src/base.js ADDED
@@ -0,0 +1,325 @@
1
+ // P-B6: base snapshots, the page's side. openBase() starts fetching (or reading from the OPFS cache) an image's base
2
+ // snapshot at once and returns the lazy restore bootWmd takes as `restoreLazy`: the boot downloads the kernel and packs
3
+ // meanwhile, checks the snapshot's header against its own vm.wasm and boot key as soon as it has them (a different one is
4
+ // refused, the download cancelled, and the boot goes on cold with the reason in timings.baseFallback), and restores from the
5
+ // snapshot once it is whole and verified.
6
+ //
7
+ // const lazy = openBase({ manifestUrl: '/fixtures/images/python/base.json', cache: opfsBaseCache(), vmKey: vmKeyOf('/vm.wasm'), log })
8
+ // const vm = await bootWmd({ ...options, checkpoint: true, restoreLazy: lazy, restoreFallback: true })
9
+ // vm.timings.restore ? 'restored from the base snapshot' : vm.timings.baseFallback // {code, message}
10
+ //
11
+ // The manifest (base.json next to the packs): {version: 1, image, entries: [{file, vmKey, bootKey, headerSha256, bytes, codec,
12
+ // createdAt}]}. With `vmKey` the entry is chosen by vm.wasm key (the last entry of that build); a manifest with none for this
13
+ // build is refused at once, without downloading the file. A cache entry is named after the header's SHA-256, so a new
14
+ // snapshot never reads an old copy; the manifest's headerSha256 pins the header, the header pins every chunk and the packs.
15
+ //
16
+ // Lifecycle (what bootWmd does with the lazy object):
17
+ // header resolves when the snapshot's header is in; the reader then WAITS for proceed() (or abort) before it decodes
18
+ // a chunk, so a header the boot refuses (another vm.wasm) costs only what the pump had queued (a few MB)
19
+ // proceed() the vm key matched: the reader may go on
20
+ // ckpt resolves with a box {take(), drop(), stats}: take() hands the snapshot over once
21
+ // accept() the restore SUCCEEDED: the cache copy (if this was a download) is committed
22
+ // abort(reason) the boot does not want it (any exit before accept): the download stops, the box is dropped, the pending
23
+ // cache copy is discarded; an entry read from the cache is deleted when the reason is a refusal of its content
24
+ // reject(err) the restore failed after the snapshot was taken over: abort, and the cache entry is removed whatever its source
25
+ // A download that stops making progress for `stallMs`, or runs longer than `maxMs`, is aborted with code 'stalled'.
26
+ import { opfsBaseCache } from './host/base-cache.js'
27
+ import { readBase } from './host/base-file.js'
28
+ import { CheckpointError, sha256Hex } from './host/ckpt.js'
29
+
30
+ export { opfsBaseCache, memoryBaseCache } from './host/base-cache.js'
31
+ export { readBase, encodeBase } from './host/base-file.js'
32
+
33
+ /** A cache file name: the image with the characters a file name may not have replaced (no '-' in it), '-', the header hash. */
34
+ export const safeImage = image => String(image || 'base').replace(/[^A-Za-z0-9_.]/g, '_')
35
+ const cacheName = (image, entry) => `${safeImage(image)}-${entry.headerSha256.slice(0, 24)}.wmdbase`
36
+ const REFUSALS = ['stale', 'version', 'malformed', 'corrupt', 'foreign', 'incomplete']
37
+
38
+ /** The SHA-256 of a vm.wasm URL's bytes (what bootWmd's vmKey is), for openBase's `vmKey`. */
39
+ export async function vmKeyOf(url, fetchFn = (...a) => fetch(...a)) {
40
+ const r = await fetchFn(url)
41
+ if (!r.ok) throw new Error(`GET ${url}: ${r.status}`)
42
+ return sha256Hex(new Uint8Array(await r.arrayBuffer()))
43
+ }
44
+
45
+ /**
46
+ * @param {object} o {manifestUrl, image?, cache?, query? (appended to the file's URL: test hooks), log?, fetchFn?, vmKey? (a key or a promise of one),
47
+ * pick? (entries -> entry), stallMs? (20000), maxMs? (600000), gate? (true: the reader waits for proceed() after the header)}
48
+ */
49
+ export function openBase({ manifestUrl, image, cache = null, query = '', log = () => {}, fetchFn = (...a) => fetch(...a), vmKey = null, pick = es => es.at(-1), stallMs = 20_000, maxMs = 600_000, gate = true } = {}) {
50
+ const ac = new AbortController()
51
+ const info = { source: null, manifestMs: null, cacheWrite: null, attempts: [], netBytes: 0, stalled: null }
52
+ let resolveHeader
53
+ let rejectHeader
54
+ const header = new Promise((res, rej) => ((resolveHeader = res), (rejectHeader = rej)))
55
+ header.catch(() => {})
56
+ let headerSettled = false
57
+ const t0 = performance.now()
58
+ let lastProgress = t0
59
+ let gateWaiting = false
60
+ let openGate
61
+ const gateP = gate ? new Promise(r => (openGate = r)) : Promise.resolve()
62
+ let pending = null // {tee, name, image}: a verified download whose cache copy waits for accept()
63
+ let box = null
64
+ let aborted = false
65
+ let cur = { name: null, source: null }
66
+ let committed = null // the cache file name once accept() wrote it
67
+ const touch = () => (lastProgress = performance.now())
68
+
69
+ const reasonOf = () => {
70
+ const r = ac.signal.reason
71
+ return r instanceof CheckpointError ? r : new CheckpointError('aborted', `the base snapshot was abandoned (${(r && r.message) || 'aborted'})`)
72
+ }
73
+ const fail = err => {
74
+ if (ac.signal.aborted) return
75
+ if (err.code === 'stalled') info.stalled = err.message
76
+ ac.abort(err)
77
+ openGate?.()
78
+ }
79
+ // no bytes for stallMs (while the reader is not waiting for the boot), or more than maxMs in all: refuse, so the boot goes on cold
80
+ const dog = setInterval(() => {
81
+ const now = performance.now()
82
+ if (now - t0 > maxMs) fail(new CheckpointError('stalled', `the base snapshot did not finish in ${Math.round(maxMs / 1000)} s (${info.netBytes} bytes received): giving up on it`))
83
+ else if (!gateWaiting && now - lastProgress > stallMs) fail(new CheckpointError('stalled', `the base snapshot stalled: no bytes for ${Math.round(stallMs / 1000)} s after ${info.netBytes} bytes (the host stopped answering?): giving up on it`))
84
+ }, Math.max(20, Math.min(1000, stallMs / 4)))
85
+ const stopDog = () => clearInterval(dog)
86
+
87
+ async function dropCacheEntry(why) {
88
+ const name = committed || cur.name
89
+ if (!cache || !name) return
90
+ await cache.delete(name).catch(() => {})
91
+ log(`base snapshot: cache entry ${name} removed (${why})`)
92
+ }
93
+
94
+ async function accept() {
95
+ const p = pending
96
+ pending = null
97
+ if (!p || aborted) return
98
+ const tc = performance.now()
99
+ try {
100
+ const w = await p.tee.commit()
101
+ committed = p.name
102
+ info.cacheWrite = { ok: true, bytes: w.bytes, ms: performance.now() - tc }
103
+ pruneOthers(cache, p.image, p.name).catch(() => {})
104
+ } catch (e) {
105
+ p.tee.abort().catch(() => {}) // never leave a half-committed file
106
+ info.cacheWrite = { ok: false, name: e && e.name, error: `${e && e.name}: ${e && e.message}` }
107
+ log(`base snapshot: the cache write did not finish (${e && e.message})`)
108
+ }
109
+ }
110
+
111
+ function abort(reason) {
112
+ if (aborted) return
113
+ aborted = true
114
+ const err = reason instanceof CheckpointError ? reason : reason ? new CheckpointError('aborted', `the base snapshot was abandoned (${reason.message || reason})`) : new CheckpointError('aborted', 'the base snapshot was abandoned')
115
+ if (!ac.signal.aborted) ac.abort(err)
116
+ openGate?.()
117
+ stopDog()
118
+ const p = pending
119
+ pending = null
120
+ p?.tee.abort().catch(() => {})
121
+ box?.drop()
122
+ // a cached file this page refuses on its content (another vm.wasm, a damaged chunk) is removed, so it is not read again
123
+ if (cur.source === 'opfs' && err.code && REFUSALS.includes(err.code)) dropCacheEntry(err.code)
124
+ }
125
+
126
+ async function reject(err) {
127
+ abort(err)
128
+ await dropCacheEntry(`the restore failed: ${(err && err.message) || err}`)
129
+ }
130
+
131
+ async function run() {
132
+ const tm = performance.now()
133
+ let manifest
134
+ try {
135
+ const r = await fetchFn(manifestUrl, { cache: 'no-store', signal: ac.signal })
136
+ if (!r.ok) throw new CheckpointError('unavailable', `no base snapshot: GET ${manifestUrl} answered ${r.status}`)
137
+ manifest = await r.json()
138
+ } catch (e) {
139
+ if (ac.signal.aborted) throw reasonOf()
140
+ if (e instanceof CheckpointError) throw e
141
+ throw new CheckpointError('unavailable', `no base snapshot: ${manifestUrl} could not be read (${e && e.name}: ${e && e.message})`)
142
+ }
143
+ touch()
144
+ info.manifestMs = performance.now() - tm
145
+ let entries = (manifest.entries || []).filter(e => !image || e.image === image || !e.image)
146
+ if (vmKey) {
147
+ let vk = null
148
+ // the page's vm key may be slow or never settle: the wait is bounded (stallMs), ends at once on an abort, and the watchdog does not
149
+ // count it as a download stall (like the gate)
150
+ gateWaiting = true
151
+ let timer
152
+ try {
153
+ vk = await Promise.race([
154
+ vmKey,
155
+ new Promise((_, rej) => {
156
+ timer = setTimeout(() => rej(new CheckpointError('stalled', `the page's vm.wasm key did not arrive in ${Math.round(stallMs / 1000)} s: giving up on the base snapshot`)), stallMs)
157
+ }),
158
+ new Promise((_, rej) => {
159
+ if (ac.signal.aborted) rej(reasonOf())
160
+ else ac.signal.addEventListener('abort', () => rej(reasonOf()), { once: true })
161
+ }),
162
+ ])
163
+ } catch (e) {
164
+ if (e instanceof CheckpointError) throw e
165
+ log(`base snapshot: this page's vm.wasm key is unknown (${e && e.message}); the header decides`)
166
+ } finally {
167
+ clearTimeout(timer)
168
+ gateWaiting = false
169
+ touch()
170
+ }
171
+ if (vk) {
172
+ const mine = entries.filter(e => e.vmKey === vk)
173
+ if (entries.length && !mine.length) {
174
+ throw new CheckpointError('stale', `no base snapshot for this vm.wasm (${vk.slice(0, 12)}): the manifest ${manifestUrl} lists snapshots of vm.wasm ${[...new Set(entries.map(e => String(e.vmKey).slice(0, 12)))].join(', ')} only (its memory image is another emulator's); take a new one`)
175
+ }
176
+ entries = mine
177
+ }
178
+ }
179
+ const entry = entries.length ? pick(entries) : null
180
+ if (!entry || !entry.file || typeof entry.headerSha256 !== 'string') throw new CheckpointError('unavailable', `no base snapshot: the manifest ${manifestUrl} lists none${image ? ` for ${image}` : ''}`)
181
+ info.entry = entry
182
+ const name = cacheName(image || manifest.image, entry)
183
+ const url = new URL(entry.file, new URL(manifestUrl, globalThis.location?.href || 'http://localhost/')).href + query
184
+ const opts = {
185
+ expect: { headerSha256: entry.headerSha256 },
186
+ signal: ac.signal,
187
+ onProgress: touch,
188
+ onHeader: async h => {
189
+ if (!headerSettled) {
190
+ headerSettled = true
191
+ resolveHeader(h)
192
+ }
193
+ if (gate) {
194
+ gateWaiting = true
195
+ await gateP // the boot checks the vm key; a refusal aborts (abort() opens the gate)
196
+ gateWaiting = false
197
+ touch()
198
+ }
199
+ },
200
+ }
201
+ // attempt 1: the cache (if it holds the file); attempt 2 (or 1): the network. A cached file that fails verification is deleted and the network is tried.
202
+ for (const source of cache ? ['opfs', 'network'] : ['network']) {
203
+ let stream = null
204
+ let tee = null
205
+ const att = { source }
206
+ info.attempts.push(att)
207
+ const ta = performance.now()
208
+ info.netBytes = 0
209
+ cur = { name, source }
210
+ try {
211
+ if (source === 'opfs') {
212
+ const c = await cache.open(name).catch(e => {
213
+ att.cacheError = String(e && e.message)
214
+ return null
215
+ })
216
+ if (!c) {
217
+ att.miss = true
218
+ continue
219
+ }
220
+ stream = c.stream
221
+ att.size = c.size
222
+ } else {
223
+ const r = await fetchFn(url, { signal: ac.signal })
224
+ if (!r.ok || !r.body) throw new CheckpointError('unavailable', `the base snapshot ${url} answered ${r.status}`)
225
+ touch()
226
+ att.headers = r.headers.get('content-length')
227
+ att.contentEncoding = r.headers.get('content-encoding')
228
+ if (cache) {
229
+ try {
230
+ tee = await cache.writer(name)
231
+ } catch (e) {
232
+ info.cacheWrite = { ok: false, name: e && e.name, error: `${e && e.name}: ${e && e.message}` }
233
+ log(`base snapshot: not cached (${e && e.message})`)
234
+ }
235
+ }
236
+ stream = r.body
237
+ }
238
+ const res = await readBase(stream, {
239
+ ...opts,
240
+ onBytes: async u8 => {
241
+ info.netBytes += u8.length // live: a page can watch how far the download is
242
+ touch()
243
+ if (!tee) return
244
+ try {
245
+ await tee.write(u8)
246
+ } catch (e) {
247
+ // the cache failed (cleared, full, another tab): the download goes on without it
248
+ info.cacheWrite = { ok: false, name: e && e.name, error: `${e && e.name}: ${e && e.message}` }
249
+ log(`base snapshot: the cache write failed, going on without it (${e && e.message})`)
250
+ tee.abort().catch(() => {})
251
+ tee = null
252
+ }
253
+ },
254
+ })
255
+ att.ms = performance.now() - ta
256
+ info.source = source
257
+ info.stats = res.stats
258
+ info.totalMs = performance.now() - t0
259
+ stopDog()
260
+ // the cache copy is committed only when the restore has succeeded (accept()): a snapshot this page refuses, or fails to restore, is not kept
261
+ if (tee) {
262
+ if (aborted) {
263
+ tee.abort().catch(() => {})
264
+ throw new CheckpointError('aborted', 'the base snapshot was refused before it finished downloading')
265
+ }
266
+ pending = { tee, name, image: image || manifest.image }
267
+ }
268
+ if (aborted) throw reasonOf()
269
+ let held = res
270
+ box = {
271
+ take() {
272
+ const r = held
273
+ held = null
274
+ return r
275
+ },
276
+ drop() {
277
+ held = null
278
+ },
279
+ stats: res.stats,
280
+ }
281
+ return box
282
+ } catch (e) {
283
+ att.error = `${e && e.name}: ${e && e.message}`
284
+ att.code = e && e.code
285
+ if (tee) tee.abort().catch(() => {}) // never leave a partial file that looks whole (its magic is not in)
286
+ if (ac.signal.aborted) throw reasonOf()
287
+ if (source === 'opfs') {
288
+ log(`base snapshot: the cached copy was not usable (${e && e.message}); deleting it and downloading`)
289
+ await cache.delete(name).catch(() => {})
290
+ continue
291
+ }
292
+ throw e
293
+ }
294
+ }
295
+ throw new CheckpointError('unavailable', 'no base snapshot could be read')
296
+ }
297
+
298
+ const ckpt = run().then(
299
+ r => r,
300
+ e => {
301
+ stopDog()
302
+ if (!headerSettled) {
303
+ headerSettled = true
304
+ rejectHeader(e)
305
+ }
306
+ throw e
307
+ },
308
+ )
309
+ ckpt.catch(() => {})
310
+ return {
311
+ header,
312
+ ckpt,
313
+ info,
314
+ /** The vm key matched: the reader may decode (it waits here after the header). */
315
+ proceed: () => openGate?.(),
316
+ accept,
317
+ abort,
318
+ reject,
319
+ }
320
+ }
321
+
322
+ async function pruneOthers(cache, image, keep) {
323
+ const prefix = `${safeImage(image)}-`
324
+ for (const f of await cache.list()) if (f.name !== keep && f.name.startsWith(prefix)) await cache.delete(f.name)
325
+ }