@threadedstack/wmd-web 0.0.0-stage → 0.1.13

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/boot.js ADDED
@@ -0,0 +1,2037 @@
1
+ // Page-side entry point of wmd-web: download vm.wasm and the boot files, compile,
2
+ // build the shared memory and the Worker pool, run vm.wasm's _start in a Worker.
3
+ //
4
+ // const vm = await bootWmd({ vmUrl, files: { Image: url, ... }, args: [...] })
5
+ // const { code, timings } = await vm.exited
6
+ //
7
+ // Requires a cross-origin isolated page (SharedArrayBuffer, shared wasm memory).
8
+
9
+ import { absMs, markStart } from './host/clock.js'
10
+ import {
11
+ CTL_EXIT,
12
+ CTL_EXIT_CODE,
13
+ CTL_TRAPS,
14
+ CTL_STALL_DONE,
15
+ CTL_STALL_MS,
16
+ CTL_STALL_TAKEN,
17
+ CTL_STALL_TID,
18
+ CTL_STALL_WAKE,
19
+ F64_STALL_AT,
20
+ F64_STALL_END,
21
+ CTL_LIVE_THREADS,
22
+ CTL_NEXT_TID,
23
+ CTL_POOL_N,
24
+ CTL_CK_ATTACHES,
25
+ CTL_CK_GO,
26
+ CTL_CK_RESTORED,
27
+ CTL_SPAWN_FAIL,
28
+ POOL_MAX,
29
+ SLOT_STATE,
30
+ ST_DEAD,
31
+ ST_IDLE,
32
+ ST_STARTING,
33
+ createControl,
34
+ hostCallCounts,
35
+ slotIndex,
36
+ } from './host/layout.js'
37
+ import { gcNudgeEvery } from './host/gcnudge.js'
38
+ import { createRamMemory, haveMemory64 } from './host/ram-mem.js'
39
+ import { bootFileName, cachedFromTiming, createBootEvents, createWorkerList, readBody, timingCount, vcpusOf } from './host/boot-events.js'
40
+ import { createJitStore, readJitStats, tableMinOf } from './host/jit.js'
41
+ import { createJitCacheHost } from './host/jit-cache.js'
42
+ import { createPanicScanner, createThrottleMeter, createWatchdog, watchdogDefaultMs } from './host/monitor.js'
43
+ import { BOOT_RING, createStdio, stdioReader, stdioText } from './host/stdio.js'
44
+ import {
45
+ CONSOLE_IN_LIMIT, chanCounters, chanView, consoleInputGate, createChanBlock, markGuestGone, markServiceDead, ringBell, ringRead,
46
+ } from './host/chan.js'
47
+ import { ControlClient } from './host/ctl.js'
48
+ import { PAGE as CKPT_PAGE, requestCheckpoint } from './host/ckpt-point.js'
49
+ import {
50
+ CheckpointError, assembleCheckpoint, bootKeyOf, checkCheckpointFits, checkPacksFit, checkVmKeyFits, packDigestsOf, fdTableBytes, parseCheckpoint, restoreFdTable, resumePorts, sha256Hex, validateFdTable,
51
+ } from './host/ckpt.js'
52
+ import { captureJit, jitSnapStats, modulesOf, ownersNeedingHub, restoreStore } from './host/jit-snap.js'
53
+ export { CheckpointError, memoryStore, opfsStore } from './host/ckpt.js'
54
+ export { NetSwitch } from './net/switch.js'
55
+ export { ImageFiles, openImage, parseGroup, parsePasswd, resolveUser } from './image-files.js'
56
+ export { P9_CACHE_DEFAULT, p9InitCmdline, p9MountOptions } from './host/p9mount.js'
57
+
58
+ const PAGE = 65536
59
+ /** A trap with the memory this close to its maximum is reported as out of memory. */
60
+ export const OOM_MARGIN = 64 << 20
61
+ /** vm.wasm's stderr when it grew its memory to the maximum up front (W21, WMD_HEAP_RESERVE). */
62
+ export const HEAP_RESERVE_RE = /vm\.wasm: heap reserve \d+ MiB at /
63
+ /** vm.wasm's stderr when an allocation found the reserve used up (W21). */
64
+ export const HEAP_USED_UP_RE = /vm\.wasm: heap reserve used up \(W21\): out of memory/
65
+ /**
66
+ * vm.wasm's stderr when it stops on a failed allocation, reserve or not (W21 review A3), and the
67
+ * JIT encoder's own line (guest/vm/src/wasm_enc.c enc_alloc), which a vm.wasm built before
68
+ * vm_out_of_memory printed before it aborted (a trap): W29, a trap whose only word was this line.
69
+ */
70
+ export const VM_OOM_RE = /vm\.wasm: out of memory: |wasm_enc: out of memory allocating \d+ bytes/
71
+
72
+ /**
73
+ * Whether vm.wasm's environment asks for W21's heap reserve: WMD_HEAP_RESERVE=1 exactly
74
+ * (guest/vm/src/heap_reserve.c reads it so). bootWmd adds it unless heapReserve is false.
75
+ */
76
+ export const heapReserveAsked = env => env.includes('WMD_HEAP_RESERVE=1')
77
+
78
+ /**
79
+ * Whether a trap is vm.wasm out of memory, and the evidence: vm.wasm's own "heap reserve
80
+ * used up" line, or, when it runs without a reserve, the memory within OOM_MARGIN of its
81
+ * maximum. With a reserve (asked for in vm.wasm's environment, or reported by vm.wasm)
82
+ * the memory is at the maximum from the start, so the size says nothing.
83
+ * Returns null, 'heap reserve used up', 'vm.wasm out of memory' (its own line, no reserve)
84
+ * or 'memory at its maximum'.
85
+ */
86
+ export function isOutOfMemory({ reserve = false, usedUp = false, said = false, memoryBytes, memoryMaxBytes }) {
87
+ if (usedUp) return 'heap reserve used up'
88
+ if (said) return 'vm.wasm out of memory'
89
+ if (!reserve && memoryBytes > memoryMaxBytes - OOM_MARGIN) return 'memory at its maximum'
90
+ return null
91
+ }
92
+ const VM_MIN_PAGES = 6144 // vm.wasm's memory import minimum (384 MiB)
93
+
94
+ const haveJspi = () => typeof WebAssembly.Suspending === 'function' && typeof WebAssembly.promising === 'function'
95
+
96
+ /**
97
+ * Whether JIT module sharing is on by default (jit.share 'auto'): wherever JSPI exists
98
+ * (Playwright's Chromium 153, Firefox 155 and WebKit 26.6 all have it). Firefox needs
99
+ * it: with a compiled copy per Worker it ran out of wasm code space at ~16,200 copies
100
+ * per process (P-B2 adversarial F1). In Chromium and WebKit it was as fast or faster
101
+ * and used no more memory in the measured boots (README "F1").
102
+ */
103
+ export function jitShareDefault() {
104
+ return haveJspi()
105
+ }
106
+
107
+ /** WebKit (Safari, Playwright's WebKit) kills a content process whose memory footprint passes 8 GiB (W34). */
108
+ export const WEBKIT_KILL_MIB = 8192
109
+ /** Footprint the page itself needs besides the machine's memory and the JIT's modules (files, packs, rings), MiB (W34, measured: 0.75 GiB with a 220 MiB pack). */
110
+ const PAGE_BASE_MIB = 768
111
+ /** Footprint WebKit keeps per byte of a live JIT module (W34, measured with the heap reserve on: 2.2 GiB for 560 MiB of modules, about 4x; 2.2x with it off). */
112
+ const MODULE_FOOTPRINT_X = 4
113
+
114
+ export function isWebKit(nav = globalThis.navigator) {
115
+ const ua = nav?.userAgent || ''
116
+ return /AppleWebKit/.test(ua) && !/Chrome|Chromium|Firefox|Android/.test(ua) // desktop Edge says Chrome too; iOS Edge (EdgiOS), Chrome (CriOS) and Firefox (FxiOS) are WebKit
117
+ }
118
+
119
+ export function isFirefox(nav = globalThis.navigator) {
120
+ return /Firefox\//.test(nav?.userAgent || '') && !isWebKit(nav)
121
+ }
122
+
123
+ /**
124
+ * D-511: Firefox keeps all compiled code of a process, wasm and JavaScript, in one 2044 MiB pool
125
+ * (SpiderMonkey MaxCodeBytesPerProcess); our modules make about 2.7x their bytes of it, so new ones
126
+ * stop compiling at 768 MiB live. Every Worker instantiates every module and counts its code, and
127
+ * past 80% of the pool (~614 MiB of modules) each Worker runs back-to-back full GCs that discard its
128
+ * JavaScript code and free nothing (TOO_MUCH_JIT_CODE: 166 a second in a Go start-up at 700 MiB,
129
+ * vCPUs 93-97% in the collector, the channel service starved). A release's modules stay allocated
130
+ * until every Worker drops them, so live plus one released generation must fit: 2 x 320 < 768.
131
+ */
132
+ export const FIREFOX_CODE_BUDGET_MIB = 320
133
+
134
+ /**
135
+ * D-516 (wmn#67): mobile WebKit, the engine of every browser on iPhone and iPad. iOS kills its tab when the tab's memory
136
+ * passes a limit far below a Mac's (about 1.5 GiB, up to about 3 GiB on some phones), and mobile WebKit counts every
137
+ * byte a shared memory or growable SharedArrayBuffer grows by at once, written or not; desktop WebKit counts only the
138
+ * pages written. An iPad in desktop mode says Macintosh; it is told apart by its touch points.
139
+ */
140
+ export function isMobileWebKit(nav = globalThis.navigator) {
141
+ if (!isWebKit(nav)) return false
142
+ const ua = nav?.userAgent || ''
143
+ return /\b(iPhone|iPad|iPod)\b/.test(ua) || (/Macintosh/.test(ua) && (nav?.maxTouchPoints || 0) > 1)
144
+ }
145
+
146
+ /**
147
+ * D-516: whether bootWmd asks for W21's heap reserve when the caller does not say. Not in mobile WebKit: there the
148
+ * reserve (the memory grown to its maximum before boot) costs the whole maximum at once, 2.4 GiB with a 1 GiB guest,
149
+ * and iOS kills the tab while the machine boots. The reserve is for V8's stale memory size (W21); WebKit checks the
150
+ * live size, so it does not need it.
151
+ */
152
+ export function heapReserveDefault(nav = globalThis.navigator) {
153
+ return !isMobileWebKit(nav)
154
+ }
155
+
156
+ /**
157
+ * W34: the bytes of live JIT modules a WebKit page can hold, so that the machine's memory at its
158
+ * maximum + the page's own files + the engine's copies of the modules stay under 5.5 GiB (2.5 GiB
159
+ * below WebKit's kill limit). Firefox: FIREFOX_CODE_BUDGET_MIB (D-511). 0 (no budget) in Chromium,
160
+ * whose wasm code space held 1.7 GB of modules in a Go start-up.
161
+ * At least 128 MiB, so a big memory maximum slows the JIT's reach rather than switching it off.
162
+ */
163
+ export function jitBudgetDefault(maxBytes, nav = globalThis.navigator) {
164
+ if (isFirefox(nav)) return FIREFOX_CODE_BUDGET_MIB * MiB
165
+ if (!isWebKit(nav)) return 0
166
+ const room = (WEBKIT_KILL_MIB - 2560 - PAGE_BASE_MIB) * MiB - maxBytes
167
+ return Math.min(WEBKIT_CODE_BUDGET_MIB * MiB, Math.max(128 * MiB, Math.floor(room / MODULE_FOOTPRINT_X)))
168
+ }
169
+
170
+ /**
171
+ * W32: JavaScriptCore's executable pool (512 MiB) stops taking new code at about 343 MB of
172
+ * "JS JIT generated code"; from then on new blocks run at interpreter speed (b8 in WebKit: 6000
173
+ * rounds took 55-63 s after a build instead of 0.55 s). It holds about 2.1x a module's bytes, so
174
+ * the budget releases every module before 144 MiB of them are live. A release is only a backstop:
175
+ * WebKit gives the code back 30-40 s later, so a workload that keeps reaching it builds slowly
176
+ * (b8 build at 144 MiB without a raised threshold: 80 s instead of 14 s, compute fast).
177
+ */
178
+ export const WEBKIT_CODE_BUDGET_MIB = 144
179
+
180
+ /**
181
+ * W32: runs a block makes in vm.wasm's interpreter before it is compiled, in WebKit (vm.wasm's
182
+ * default is 50, D-274). Code that runs a few hundred times stays interpreted, so a build's
183
+ * modules fit the executable pool: b8 in WebKit at 300: build 17.9-18.7 s (14 s at 50), compute
184
+ * after it 0.53-0.55 s (55-63 s at 50), 117 MiB of modules, 249 MB of code at most; at 200 the
185
+ * code reached 326 MB, at 500 the build took 24.7 s, at 2000 56 s.
186
+ * D-505: at 300 a long Claude Code session still filled the pool (about 345 MB) and ran
187
+ * interpreted after it. With vm.wasm batching blocks above jit_hot again, 1000 is the pick:
188
+ * b8 build 25.5-26.3 s (21.2-22.1 at 300, 40.6 at 3000), `claude -p` 32.9 s (57.3 at 300),
189
+ * 2000 terminal lines 4.2 s (31.7 at 300), 161 MB of code.
190
+ */
191
+ export const WEBKIT_JIT_THRESHOLD = 1000
192
+
193
+ const MiB = 1 << 20
194
+
195
+ /**
196
+ * The default cap on a 9P service's upper (the guest's writes, held in the service
197
+ * Worker's memory): an eighth of the device's memory as navigator.deviceMemory reports it
198
+ * (Chromium: 0.25-8 GiB, rounded), from 256 MiB to 1 GiB; 512 MiB where the browser does
199
+ * not say (Firefox, Safari). Returns {bytes, source}.
200
+ */
201
+ export function upperCapDefault(nav = globalThis.navigator) {
202
+ const gib = nav?.deviceMemory
203
+ if (typeof gib === 'number' && Number.isFinite(gib) && gib > 0) {
204
+ return { bytes: Math.min(1024 * MiB, Math.max(256 * MiB, Math.floor((gib * 1024 * MiB) / 8))), source: `navigator.deviceMemory ${gib} GiB / 8` }
205
+ }
206
+ return { bytes: 512 * MiB, source: 'default (navigator.deviceMemory unknown)' }
207
+ }
208
+
209
+ const fmtMiB = n => (Number.isFinite(n) ? `${Math.round((n / MiB) * 10) / 10} MiB` : 'no cap')
210
+
211
+ // Host-side endings (vm.exited code < 0; guest exit codes are >= 0).
212
+ export const EXIT_STOPPED = -1 // reason 'stopped' (vm.stop()) or 'aborted' (the AbortSignal)
213
+ export const EXIT_NO_PROGRESS = -2 // the progress watchdog fired
214
+ export const EXIT_PANIC = -3 // the console showed "Kernel panic - not syncing"
215
+ export const EXIT_SERVICE_DIED = -4 // the channel service Worker ended while the guest ran (P-B3)
216
+
217
+ /**
218
+ * The most --ram (MiB) a browser boot accepts unless the caller opts in (W28). The desktop engine
219
+ * now boots guests up to 3 GiB, but memory 0 is a shared wasm32 memory of at most 4 GiB that also
220
+ * holds vm.wasm's heap (translator IR, module buffers, ~384 MiB initial): --ram 3072 leaves about
221
+ * 0.6 GiB of heap, and a tab commits the whole 4 GiB (W21 reserve). 1536 leaves 2 GiB or more.
222
+ */
223
+ export const MAX_BROWSER_RAM_MIB = 1536
224
+
225
+ /**
226
+ * --ram's value in MiB; throws an error naming --ram when it is not a whole number, is 0, or when it is
227
+ * above MAX_BROWSER_RAM_MIB and allowLargeRam is not true.
228
+ */
229
+ export function ramMiB(args, allowLargeRam = false) {
230
+ const i = args.indexOf('--ram')
231
+ if (i < 0) return args.includes('--machine') ? 256 : 0
232
+ const v = args[i + 1]
233
+ if (typeof v !== 'string' || !/^[0-9]+$/.test(v)) {
234
+ throw new Error(`--ram ${v === undefined ? '(missing)' : JSON.stringify(v)}: expected a whole number of MiB, e.g. --ram 256`)
235
+ }
236
+ const mib = Number(v)
237
+ if (mib < 1) throw new Error(`--ram ${v}: a guest needs at least 1 MiB, e.g. --ram 256`)
238
+ if (mib > MAX_BROWSER_RAM_MIB && allowLargeRam !== true) {
239
+ throw new Error(
240
+ `--ram ${v}: more than ${MAX_BROWSER_RAM_MIB} MiB is not supported in a browser by default: the shared memory is at most 4 GiB ` +
241
+ `and also holds the emulator's heap, so a bigger guest leaves it too little room. ` +
242
+ `Use ${MAX_BROWSER_RAM_MIB} or less, or pass allowLargeRam: true (bootWmd option; memoryMaxPages' third argument) to accept the risk.`,
243
+ )
244
+ }
245
+ return mib
246
+ }
247
+
248
+ /** Memory 0 of a flat guest besides vm.wasm's own minimum: the boot stand-in for the start of RAM (vm/ram.h VM_RAM_LOW_FLAT). */
249
+ export const FLAT_STAGE_MIB = 512
250
+
251
+ /**
252
+ * Whether a boot with these args keeps guest RAM in a memory64 of its own (host/ram-mem.js): o.flatRam when it
253
+ * is a boolean, else when --ram is above MAX_BROWSER_RAM_MIB, allowLargeRam is not set and the engine has memory64.
254
+ */
255
+ export function wantsFlatRam(args, o = {}) {
256
+ if (o.flatRam === false) return false
257
+ let mib = 0
258
+ try {
259
+ mib = ramMiB(args, true)
260
+ } catch {
261
+ return false // ramMiB reports the bad value
262
+ }
263
+ if (!mib) return false
264
+ if (o.flatRam === true) {
265
+ if (!haveMemory64()) throw new Error('flatRam: this browser has no shared 64-bit memory (WebAssembly memory64)')
266
+ return true
267
+ }
268
+ return mib > MAX_BROWSER_RAM_MIB && o.allowLargeRam !== true && haveMemory64()
269
+ }
270
+
271
+ /** The memory 0 maximum, in pages, of a flat guest: vm.wasm's minimum, the boot stand-in and heap slack. */
272
+ export function flatMaxPages(args, slackMiB = 1536) {
273
+ const stage = Math.min(ramMiB(args, true), FLAT_STAGE_MIB)
274
+ return Math.min(65536, VM_MIN_PAGES + Math.ceil(((stage + slackMiB) * 1024 * 1024) / PAGE))
275
+ }
276
+
277
+ /**
278
+ * vm.wasm's environment: the caller's entries plus WMD_HEAP_RESERVE=1 (W21) unless
279
+ * heapReserve is false or the caller sets WMD_HEAP_RESERVE itself.
280
+ */
281
+ export function vmEnv(env = [], heapReserve = true) {
282
+ const out = [...env]
283
+ if (heapReserve !== false && !out.some(e => e.startsWith('WMD_HEAP_RESERVE='))) out.push('WMD_HEAP_RESERVE=1')
284
+ return out
285
+ }
286
+
287
+ /** The browser's wasm engine: 'webkit' (JavaScriptCore), 'gecko' (SpiderMonkey) or 'blink' (V8, the rest). */
288
+ export function engineOf(nav = globalThis.navigator) {
289
+ if (isWebKit(nav)) return 'webkit'
290
+ return /Firefox\//.test(nav?.userAgent || '') ? 'gecko' : 'blink'
291
+ }
292
+
293
+ /**
294
+ * W30: the most members a trace (D-303) records in each engine (vm.wasm's WMD_TRACE_MAX; 1024
295
+ * natively). A trace is one looping wasm function; the browsers' compilers run a big one slower
296
+ * than the blocks it replaces (Chromium awk 29 s without traces, 37-52 s with traces of up to 1024
297
+ * members, 19-21 s with up to 32; Firefox 39 s against 67-99 s at 1024).
298
+ */
299
+ export const BROWSER_TRACE_MAX = { blink: 32, gecko: 32, webkit: 32 }
300
+
301
+ /**
302
+ * W33: a browser can stop one vCPU's Worker for seconds. When it stops in the vCPU's idle wait
303
+ * with an interrupt due (on the 6.1 guest kernel, RCU's grace-period timer), vm.wasm's rescue
304
+ * thread runs that vCPU after this many ms, so exec on the other vCPUs does not wait for it.
305
+ */
306
+ export const BROWSER_RESCUE_MS = 20
307
+
308
+ // D-539: a module with f64x2.relaxed_madd and f32x4.relaxed_madd, each on one lane: d(a, b, c) and s(a, b, c)
309
+ const FMA_PROBE = new Uint8Array([
310
+ 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
311
+ 0x01, 0x0f, 0x02, 0x60, 0x03, 0x7c, 0x7c, 0x7c, 0x01, 0x7c, 0x60, 0x03, 0x7d, 0x7d, 0x7d, 0x01, 0x7d,
312
+ 0x03, 0x03, 0x02, 0x00, 0x01,
313
+ 0x07, 0x09, 0x02, 0x01, 0x64, 0x00, 0x00, 0x01, 0x73, 0x00, 0x01,
314
+ 0x0a, 0x2b, 0x02,
315
+ 0x14, 0x00, 0x20, 0x00, 0xfd, 0x14, 0x20, 0x01, 0xfd, 0x14, 0x20, 0x02, 0xfd, 0x14, 0xfd, 0x87, 0x02, 0xfd, 0x21, 0x00, 0x0b,
316
+ 0x14, 0x00, 0x20, 0x00, 0xfd, 0x13, 0x20, 0x01, 0xfd, 0x13, 0x20, 0x02, 0xfd, 0x13, 0xfd, 0x85, 0x02, 0xfd, 0x1f, 0x00, 0x0b,
317
+ ])
318
+ let fmaFused = null
319
+
320
+ /**
321
+ * D-539: whether this engine parses relaxed SIMD and its relaxed_madd is fused (one rounding), in f64 and f32:
322
+ * (1 + 2^-30)(1 - 2^-30) - 1 is -2^-60 fused and 0 unfused (2^-12 and -2^-24 for f32). Where it is, vm.wasm
323
+ * gets WMD_FMA_FUSED=1 (browserEnv) and its JIT blocks do a guest FMA with relaxed_madd (D-472); Safari
324
+ * rejects relaxed SIMD, so there it stays the exact plain-f64 FMA (D-535). Asked once per page.
325
+ */
326
+ export function fmaFuses() {
327
+ if (fmaFused !== null) return fmaFused
328
+ fmaFused = false
329
+ try {
330
+ if (WebAssembly.validate(FMA_PROBE)) {
331
+ const { d, s } = new WebAssembly.Instance(new WebAssembly.Module(FMA_PROBE)).exports
332
+ fmaFused = d(1 + 2 ** -30, 1 - 2 ** -30, -1) === -(2 ** -60) &&
333
+ s(Math.fround(1 + 2 ** -12), Math.fround(1 - 2 ** -12), -1) === -(2 ** -24)
334
+ }
335
+ } catch {
336
+ fmaFused = false
337
+ }
338
+ return fmaFused
339
+ }
340
+
341
+ /**
342
+ * W32: the environment vm.wasm starts with in this browser: vmEnv's, plus
343
+ * WMD_JIT_THRESHOLD=WEBKIT_JIT_THRESHOLD in WebKit, W30's WMD_TRACE_MAX for the engine and
344
+ * W33's WMD_RESCUE_MS, D-539's WMD_FMA_FUSED=1 where the engine fuses relaxed_madd (fmaFuses) and, in
345
+ * Chromium, D-542's WMD_RGN_DUP=1 (regions copy sampled-hot compiled blocks of their page; Firefox's go ran
346
+ * slower with it, WebKit did not move), each unless the caller sets it. Kept out of the boot key (bootKeyOf hashes vmEnv's list), so a checkpoint or base
347
+ * snapshot made in one engine restores in another; a restored machine keeps the settings it started with, but
348
+ * its FMA follows the restoring page (machine_fma_env). A checkpoint taken with WMD_FMA_FUSED=1 records
349
+ * fmaFused, and an engine that does not fuse refuses it (checkCheckpointFits), as its blocks may hold
350
+ * relaxed_madd.
351
+ */
352
+ export function browserEnv(env, nav = globalThis.navigator, fused = fmaFuses()) {
353
+ const has = k => env.some(e => e.startsWith(`${k}=`))
354
+ const add = []
355
+ if (isWebKit(nav) && !has('WMD_JIT_THRESHOLD')) add.push(`WMD_JIT_THRESHOLD=${WEBKIT_JIT_THRESHOLD}`)
356
+ if (!has('WMD_TRACE_MAX')) add.push(`WMD_TRACE_MAX=${BROWSER_TRACE_MAX[engineOf(nav)]}`)
357
+ if (!has('WMD_RESCUE_MS')) add.push(`WMD_RESCUE_MS=${BROWSER_RESCUE_MS}`)
358
+ if (!has('WMD_FMA_FUSED') && fused) add.push('WMD_FMA_FUSED=1') // D-539
359
+ if (engineOf(nav) === 'blink' && !has('WMD_RGN_DUP')) add.push('WMD_RGN_DUP=1') // D-542: proven in Chromium only
360
+ return add.length ? [...env, ...add] : env
361
+ }
362
+
363
+ /** Pages the shared memory must be able to grow to for a given --ram (MiB). */
364
+ export function memoryMaxPages(args, slackMiB = 1024, allowLargeRam = false) {
365
+ const ramMiB_ = ramMiB(args, allowLargeRam)
366
+ // RAM + vCPU arena (a few MiB per vCPU) + heap growth slack.
367
+ const pages = VM_MIN_PAGES + Math.ceil((ramMiB_ * 1024 * 1024) / PAGE) + Math.ceil((slackMiB * 1024 * 1024) / PAGE)
368
+ return Math.min(65536, pages)
369
+ }
370
+
371
+ /**
372
+ * Time the throttle detector's calibration loop n times now (in its own Worker) and
373
+ * resolve with the fastest run, in ms. A page can call this while it is in the
374
+ * foreground and pass the result as bootWmd({throttle: {baselineMs}}), so a boot that
375
+ * starts in a background window is still recognised as throttled.
376
+ */
377
+ export function calibrate(n = 3) {
378
+ return new Promise((resolve, reject) => {
379
+ const w = new Worker(new URL('./calib-worker.js', import.meta.url), { type: 'module', name: 'wmd-calib' })
380
+ let best = Infinity
381
+ let got = 0
382
+ w.onmessage = e => {
383
+ best = Math.min(best, e.data.workMs)
384
+ if (++got === n) {
385
+ w.terminate()
386
+ resolve(best)
387
+ }
388
+ }
389
+ w.onerror = err => {
390
+ w.terminate()
391
+ reject(new Error(`calibration Worker failed: ${err.message || err}`))
392
+ }
393
+ w.postMessage({ t: 'once', n })
394
+ })
395
+ }
396
+
397
+ async function fetchShared(url, signal, onBytes, shared = true, onResponse) {
398
+ const r = await fetch(url, { signal, cache: 'no-store' })
399
+ if (!r.ok) throw new Error(`GET ${url}: ${r.status} ${r.statusText}`)
400
+ onResponse?.(r)
401
+ return readBody(r, { shared, onBytes })
402
+ }
403
+
404
+ // W71: a pack the page made in shared memory (src/registry/pull.js) is used in place, not copied
405
+ // (packs are read only; a file in o.files may not be, so files are always copied)
406
+ function packShared(bytes) {
407
+ if (typeof SharedArrayBuffer !== 'undefined' && bytes.buffer instanceof SharedArrayBuffer && bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) return bytes.buffer
408
+ return toShared(bytes)
409
+ }
410
+
411
+ function toShared(bytes) {
412
+ const sab = new SharedArrayBuffer(bytes.byteLength)
413
+ new Uint8Array(sab).set(bytes)
414
+ return sab
415
+ }
416
+
417
+ /**
418
+ * @param {object} o
419
+ * @param {string|Uint8Array|ArrayBuffer} o.vmUrl URL of vm.wasm, or its bytes
420
+ * @param {Record<string,string|Uint8Array>} o.files preopen contents: path → URL or bytes
421
+ * @param {string[]} o.args vm.wasm arguments (after argv[0])
422
+ * @param {string[]} [o.env] "K=V" (the runner passes only WMD_*)
423
+ * @param {boolean} [o.heapReserve] W21 (default true, false in mobile WebKit: heapReserveDefault, D-516): vm.wasm grows its memory to the maximum
424
+ * before any thread starts (WMD_HEAP_RESERVE=1), so memory never grows under running
425
+ * vCPUs. Chromium checks memory.fill/copy against a per-Worker size that lags another
426
+ * Worker's grow, and trapped (README "W21"). An env entry WMD_HEAP_RESERVE=... wins.
427
+ * @param {string} [o.preopen] guest name of the preopen (default "/g")
428
+ * @param {number} [o.pool] Workers started up front (default 4)
429
+ * @param {number} [o.spare] idle Workers kept in reserve (default 2)
430
+ * @param {boolean} [o.allowLargeRam] accept --ram above MAX_BROWSER_RAM_MIB (1536; W28)
431
+ * @param {number} [o.maxPages] shared memory maximum, in 64 KiB pages
432
+ * @param {URL|string} [o.workerUrl]
433
+ * @param {(text:string)=>void} [o.onStdout]
434
+ * @param {(text:string)=>void} [o.onStderr]
435
+ * @param {(ev:object)=>void} [o.onEvent] diagnostics: thread start/end, pool growth, ..., and W24's start-up events
436
+ * ({t:'boot', stage, state, atMs, tookMs?}, {t:'heap'}, {t:'layer'}, {t:'exit'}: src/host/boot-events.js, README "W24")
437
+ * @param {{name:string, re:RegExp}[]} [o.marks] stdout regexes whose first match time is recorded
438
+ * @param {AbortSignal} [o.signal] abort before the Workers start: bootWmd rejects with an
439
+ * AbortError; after: vm.exited gives {code:-1, reason:'aborted'}
440
+ * @param {{compileOnCompile?:'compile'|'validate'|'none', brk?:string, share?:'auto'|boolean, shareWaitMs?:number, maxLiveBytes?:number, codePool?:boolean, cache?:{get:(key:string)=>Promise<Uint8Array|null>, put:(key:string, bytes:Uint8Array)=>Promise<void>}}|false} [o.jit]
441
+ * false: no JIT door (the stubbed jit_* imports return -1 and are counted in hostCalls).
442
+ * JIT door options. share: compile each JIT module once per process and hand the
443
+ * Module to every vCPU Worker through a hub Worker (src/host/jit-hub.js), instead
444
+ * of one compiled copy per Worker. Needs JSPI. 'auto' (default): on where
445
+ * WebAssembly.Suspending exists (jitShareDefault). With sharing, jit_compile
446
+ * always compiles (compileOnCompile applies only without it).
447
+ * cache (W77): {get(key) -> Promise<Uint8Array|null>, put(key, bytes) -> Promise}, a byte store the
448
+ * page keeps across visits (IndexedDB, say): the JIT's translated modules and an index of the
449
+ * guest blocks they hold, so a later boot of the same vm.wasm skips translating them again
450
+ * (host/jit-cache.js). Needs sharing. Counters in timings.jit (cacheHits, cacheMisses,
451
+ * translated, ...) and timings.jitCache; the index is put before vm.exited resolves (up to 10 s).
452
+ * @param {number} [o.stdioRingBytes] stdout/stderr ring per fd (power of two; default
453
+ * BOOT_RING, 32 MiB). If the page falls a whole ring behind, the skipped bytes are
454
+ * replaced in the delivered text by a "[wmd-web: N bytes of stdout were dropped]" line.
455
+ * @param {number} [o.watchdogMs] end the boot (code -2) when the guest writes nothing and
456
+ * spawns no thread for this long after it starts. 0 = off.
457
+ * Default: watchdogDefaultMs(args) (60 s arm64, 300 s x86)
458
+ * @param {{intervalMs?:number, threshold?:number, baselineMs?:number}|false} [o.throttle]
459
+ * the throttle detector (calib-worker.js); false = off
460
+ * @param {object} [o.channels] P-B3: wmd.chan_* over shared-memory rings, served by a
461
+ * service Worker (src/host/chan-service.js). {services: [{port, kind: '9p'|'console'|'control'|'net',
462
+ * layers?: (url|Uint8Array)[] (packs, top first), at?, files?: {path: string|Uint8Array},
463
+ * maxUpperBytes?, yamux?}], ringBytes? (each direction, default 1 MiB), nslots? (8), spinUs? (5000: the
464
+ * service's longest adaptive doorbell spin, W51)}.
465
+ * A file is its bytes or text, or {data, mode?, uid?, gid?}; {dir: true, mode?, uid?, gid?} makes a
466
+ * directory (W65: an image's WORKDIR). A control service with yamux: true is
467
+ * wmd's control session (P-B11): vm.control drives it (src/host/ctl.js).
468
+ * Pass the ports to vm.wasm yourself (--p9 PORT, --console PORT, ...). A port with no service
469
+ * refuses (-111), as nothing listening does natively. collect?: [{port, path}]: files read
470
+ * out of the 9P trees when the machine ends, into timings.chanFiles {"port:path": text|null}.
471
+ * maxUpperBytes?: the cap on each 9P service's upper (bytes of file contents the guest's
472
+ * writes hold in the service Worker) for services that do not set their own; default
473
+ * upperCapDefault() (navigator.deviceMemory); Infinity for none. Reported in
474
+ * timings.chanUppers; when a write hits it the guest gets ENOSPC and onStderr says so.
475
+ * consoleInputBytes?: console input (vm.consoleWrite) queued for the guest at most
476
+ * (default CONSOLE_IN_LIMIT, 4 MiB).
477
+ * The guest should mount the 9P root with p9MountOptions() (cache=mmap): under
478
+ * cache=loose a full upper cannot fail write(2) (src/host/p9mount.js).
479
+ * P-B10: a net service with relay: {url, token?, tokenUrl?} ({port, kind: 'net', relay,
480
+ * config?, maxConns?}) is the guest's network: a network Worker (src/net/net-worker.js) runs
481
+ * a TCP/IP stack for the guest's virtio-net frames and carries its TCP connections and DNS
482
+ * through the relay (web/relay). Pass --net PORT (and --mac) to vm.wasm. vm.net has its
483
+ * stats; timings.net has them at the end.
484
+ * W56: {port, kind: 'net', switch, name, ip?, relay?}: the guest is on an in-page network
485
+ * (new NetSwitch(), src/net/switch.js) with the other guests of the page on the same switch:
486
+ * they reach each other by address and by name (name, name.wmd.internal), with or without a
487
+ * relay; the guest's address is `ip`, else the one its name had on the switch, else the next
488
+ * free one (switch.address(name) tells it before the boot, for the bundle's /etc/hosts).
489
+ * A restored guest must come back with the same address (same name, or the same ip). Without `ip` the
490
+ * address follows the order names first reach the switch: to restore after a page reload, give `ip`.
491
+ * @param {(text:string)=>void} [o.onConsole] text the guest wrote to the console channel (hvc0)
492
+ * @param {boolean} [o.checkpoint] P-B9: the guest gets its checkpoint thread (wmd.ctl_attach) and point import
493
+ * (wmd.point), the boot gets a key (the vm.wasm, kernel, command line, packs it ran), and the handle gets
494
+ * checkpoint(): memory 0, the 9P service's files and fids, the JIT slot list and the fd table, taken while the
495
+ * guest is stopped at its checkpoint point. Needs channels with a 9P service, and the same options to restore.
496
+ * @param {boolean} [o.checkpointHeapTop] P-B9: bound the scan at vm.wasm's vm_heap_top() (default on, W58: false reads all of memory 0)
497
+ * @param {number} [o.checkpointScanLimit] P-B9: bytes of memory 0 a checkpoint reads (default all). Above it pages are taken as zero:
498
+ * only safe with the guest's real heap top (WebKit commits every page a scan reads, W34)
499
+ * @param {object} [o.restore] P-B9: a checkpoint ({header, sections}, from vm.checkpoint() or a store's load())
500
+ * to continue from instead of booting: the same options as the boot that took it (a different vm.wasm, kernel,
501
+ * command line, packs or bundle is refused with a CheckpointError, code 'stale'). The guest comes back as it was;
502
+ * its console and control ports are reset (the init opens the control port again), network flows are gone.
503
+ * @param {boolean} [o.stallHook] P-B8 test hook: vm.stallThread(tid, ms) stops one guest
504
+ * thread's Worker at its next host call (default off; when
505
+ * off the Workers do not look at the stall cells)
506
+ */
507
+ export async function bootWmd(o) {
508
+ if (!globalThis.crossOriginIsolated) {
509
+ throw new Error('page is not cross-origin isolated (needs COOP same-origin + COEP require-corp)')
510
+ }
511
+ const t0 = absMs()
512
+ const timings = { t0 }
513
+ const ev = e => o.onEvent?.(e)
514
+ // W24: the start-up events (host/boot-events.js); be.close() at the end
515
+ const be = createBootEvents({ emit: ev, t0, now: absMs })
516
+ // the emulator's last stderr text, for the error a wait on a dead machine gives
517
+ let errTail = ''
518
+ const emitErr = text => {
519
+ errTail = (errTail + text).slice(-4096)
520
+ o.onStderr?.(text)
521
+ }
522
+ const preopen = o.preopen || '/g'
523
+ const argv = ['vm.wasm', ...o.args]
524
+ const env = vmEnv(o.env, o.heapReserve ?? heapReserveDefault())
525
+ // the default Worker is started from a literal new URL(..., import.meta.url) so bundlers (Vite, webpack)
526
+ // see it and bundle worker.js with its imports; a variable URL is copied as a plain asset (2.15)
527
+ const workerUrl = o.workerUrl
528
+ // The flat layout (host/ram-mem.js): a guest with more RAM than memory 0 can hold gets its RAM in a memory64 of its
529
+ // own. Chosen when --ram is above MAX_BROWSER_RAM_MIB and the engine has memory64 (o.flatRam forces it on or off);
530
+ // without it such a --ram is the error it was.
531
+ const flatRam = wantsFlatRam(o.args, o)
532
+ if (!flatRam) ramMiB(o.args, o.allowLargeRam) // a bad or too big --ram is a clear error before anything is downloaded
533
+ const maximum = o.maxPages || (flatRam ? flatMaxPages(o.args) : memoryMaxPages(o.args, 1024, o.allowLargeRam))
534
+ if (flatRam) {
535
+ if (o.restore || o.restoreLazy) throw new Error(`--ram ${ramMiB(o.args, true)}: a guest with more than ${MAX_BROWSER_RAM_MIB} MiB cannot start from a checkpoint yet`)
536
+ env.push('WMD_RAM_FLAT=1')
537
+ }
538
+ const ramMem = flatRam ? createRamMemory(ramMiB(o.args, true)) : null
539
+ // P-B9: checkpoints. restoreIn: the checkpoint's parts (parseCheckpoint refuses a malformed one).
540
+ // A flat guest takes none yet: a checkpoint holds memory 0 only.
541
+ const ckptOn = !flatRam && (!!o.checkpoint || !!o.restore || !!o.restoreLazy)
542
+ let restoreIn = o.restore ? parseCheckpoint(o.restore) : null
543
+ // P-B6: a base snapshot that is still downloading ({header: Promise, ckpt: Promise, abort()}, src/base.js openBase()). The
544
+ // boot downloads its own files meanwhile; the snapshot's header is checked against this boot's vm.wasm and boot key as soon
545
+ // as each is known. With o.restoreFallback a snapshot that is refused (other vm.wasm or boot key, cut, damaged, absent)
546
+ // does not fail the boot: it goes on cold, and timings.baseFallback says why.
547
+ let lazyIn = o.restoreLazy || null
548
+ let baseLazy = lazyIn // kept until the restore has succeeded (accept) or failed (reject)
549
+ // The handle must not keep the checkpoint it came from alive (about 500 MB): vm.restore() builds the next machine from
550
+ // `o`, so `o` carries no restore and no assets. (A caller that holds the checkpoint object holds it for its own reasons.)
551
+ const adoptIn = o._adopt || null // W57: the replaced machine's JIT hub, kept running by vm.restore() on the same handle
552
+ // W59: the network's identity across vm.restore(): the machine's instance id (one per bootWmd handle chain; the relay
553
+ // replaces this instance's old session) and its placeholder map (names the guest looked up -> 198.18.0.0/15 addresses).
554
+ // The checkpoint's own map comes first; the handle's names it does not conflict with are kept too (never recycled).
555
+ const netIn = o._net || null
556
+ const netInstance = netIn?.instance || Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('')
557
+ const netNames = new Map()
558
+ const netIps = new Set()
559
+ for (const [n, ip] of [...(restoreIn?.names || []), ...(netIn?.names || [])]) {
560
+ if (netNames.has(n) || netIps.has(ip)) continue
561
+ netNames.set(n, ip)
562
+ netIps.add(ip)
563
+ }
564
+ let netStatus = null
565
+ const netStatusCbs = new Set()
566
+ const netNameWaiters = new Map()
567
+ o = { ...o, restore: undefined, restoreLazy: undefined, assets: o.assets && undefined, _assets: o.assets, _adopt: undefined, _net: undefined }
568
+ let heldAssets = null // what vm.restore() hands the next boot: bytes, Module, boot files, packs, keys
569
+ let restoring = false
570
+ let ck = null // the checkpoint being taken (declared early: finish() looks at it)
571
+ const ckptT = {}
572
+ let vmKey = null
573
+ let bootKey = null
574
+ let bootDesc = null
575
+ let packDigests = null
576
+ // W24: where the restored snapshot came from ('opfs' | 'network' for a base snapshot, 'memory' for vm.restore()), and its size
577
+ let baseSource = null
578
+ let baseTotal = null
579
+ let baseBytes = null
580
+
581
+ // Abort: listen from the start. Before the Workers exist an abort rejects every await
582
+ // below at once (raceAbort); afterwards it ends the machine (finish).
583
+ const signal = o.signal
584
+ let onAbortAfterStart = null
585
+ let rejectAbort
586
+ const abortP = new Promise((_, rej) => (rejectAbort = rej))
587
+ abortP.catch(() => {})
588
+ const abortError = () => new DOMException('boot aborted', 'AbortError')
589
+ const onAbort = () => (onAbortAfterStart ? onAbortAfterStart() : rejectAbort(abortError()))
590
+ if (signal?.aborted) throw abortError()
591
+ signal?.addEventListener('abort', onAbort)
592
+ const raceAbort = p => {
593
+ p.catch(() => {}) // once aborted nobody awaits p; its own failure must not go unhandled
594
+ return Promise.race([p, abortP])
595
+ }
596
+
597
+ // 1. Downloads (in parallel). vm.wasm is compiled from its bytes so the compile
598
+ // time is measured on its own.
599
+ const names = Object.keys(o.files)
600
+ // P-B9: o.assets is what an earlier boot of this handle already had (vm.restore passes it): vm.wasm's bytes and compiled
601
+ // Module, the boot files and the packs, and the boot's keys. Nothing is downloaded, hashed or compiled again, and the
602
+ // old machine is not stopped until the new one's inputs are in hand.
603
+ const A = o._assets || null
604
+ o._assets = undefined // the handle keeps no reference of its own to the previous boot's assets
605
+ // W24: one download per file, with its bytes as they come. A file the caller passed as bytes, or one a restore reuses
606
+ // (o.assets), is reported once, done and cached.
607
+ const download = (file, url, shared, extra = {}) => {
608
+ const kind = `download:${file}`
609
+ const base = { t: 'boot', stage: 'download', file, ...extra }
610
+ let finalUrl = url
611
+ const before = timingCount(url)
612
+ const ts = absMs()
613
+ return fetchShared(url, o.signal, (bytes, total) => be.progress(kind, { ...base, state: 'progress', bytes, total, cached: null }), shared, r => (finalUrl = r.url || url)).then(async buf => {
614
+ const tookMs = Math.round(absMs() - ts)
615
+ // the browser adds a response's Resource Timing entry a little after its body is read (Chromium: a small file was
616
+ // read before its entry existed): wait for it, at most 200 ms
617
+ for (let i = 0; i < 20 && timingCount(url) <= before && !o.signal?.aborted; i++) await new Promise(r => setTimeout(r, 10))
618
+ const n = buf.byteLength
619
+ be.now(kind, { ...base, state: 'done', bytes: n, total: n, cached: cachedFromTiming(url, { bytes: n, finalUrl, before }), tookMs })
620
+ return buf
621
+ })
622
+ }
623
+ // reused: vm.restore() hands this boot the last one's bytes (nothing fetched: cached); bytes the caller passed came from
624
+ // wherever the caller got them, which wmd-web cannot see (cached null, given true)
625
+ const given = (file, buf, extra = {}) => {
626
+ be.now(`download:${file}`, { t: 'boot', stage: 'download', file, ...extra, state: 'done', bytes: buf.byteLength, total: buf.byteLength, cached: A ? true : null, ...(A ? {} : { given: true }), tookMs: 0 })
627
+ return buf
628
+ }
629
+ let lastDownloadAt = t0
630
+ const downloaded = x => ((lastDownloadAt = Math.max(lastDownloadAt, absMs())), x)
631
+ const vmP = A
632
+ ? Promise.resolve(given('vm.wasm', A.vmBytes))
633
+ : o.vmUrl instanceof Uint8Array || o.vmUrl instanceof ArrayBuffer
634
+ ? Promise.resolve(given('vm.wasm', o.vmUrl instanceof ArrayBuffer ? o.vmUrl : o.vmUrl.slice().buffer)) // 2.7: bytes the page already checked
635
+ : download('vm.wasm', o.vmUrl, false).then(b => {
636
+ timings.vmDownloadMs = absMs() - t0
637
+ return downloaded(b)
638
+ })
639
+ const fileName = n => bootFileName(n, o.args, preopen)
640
+ const filesP = A
641
+ ? Promise.resolve(A.files.map(f => given(fileName(f.path), f.buffer)))
642
+ : Promise.all(names.map(n => (typeof o.files[n] === 'string' ? download(fileName(n), o.files[n], true) : Promise.resolve(given(fileName(n), toShared(o.files[n])))))).then(bufs => {
643
+ timings.filesDownloadMs = absMs() - t0
644
+ return downloaded(bufs)
645
+ })
646
+ filesP.catch(() => {}) // awaited below; must not go unhandled if vm.wasm fails first
647
+ // P-B3: the channel services' packs (fetched in parallel with the rest); pack:<n> numbers them over all services
648
+ const chanSvcs = o.channels?.services || []
649
+ let packN = 0
650
+ const packIds = chanSvcs.map(svc => (svc.layers || []).map(() => packN++))
651
+ const layersP = A
652
+ ? Promise.resolve(A.chanLayers.map((ls, i) => ls.map((b, j) => given(`pack:${packIds[i][j]}`, b, { port: chanSvcs[i].port }))))
653
+ : Promise.all(
654
+ chanSvcs.map((svc, i) =>
655
+ Promise.all((svc.layers || []).map((l, j) => (typeof l === 'string' ? download(`pack:${packIds[i][j]}`, l, true, { port: svc.port }) : Promise.resolve(given(`pack:${packIds[i][j]}`, packShared(l), { port: svc.port }))))),
656
+ ),
657
+ ).then(downloaded)
658
+ layersP.catch(() => {})
659
+ let module, files, chanLayers, tableMin, vmBytes
660
+ function baseRefused(err) {
661
+ const lazy = lazyIn || baseLazy
662
+ lazyIn = null
663
+ baseLazy = null
664
+ try {
665
+ lazy?.abort?.(err)
666
+ } catch {}
667
+ if (!o.restoreFallback) throw err
668
+ timings.baseFallback = { code: err && err.code ? err.code : 'error', message: err && err.message ? err.message : String(err) }
669
+ // W24: the restore stage ends here, unused; the kernel stage follows
670
+ be.now('restore', { t: 'boot', stage: 'restore', state: 'done', used: false, fallback: timings.baseFallback.code, bytes: baseBytes, total: baseTotal, source: baseSource })
671
+ emitErr(`\n[wmd-web] base snapshot not used (${timings.baseFallback.code}): ${timings.baseFallback.message}\n[wmd-web] booting the kernel instead\n`)
672
+ }
673
+ try {
674
+ vmBytes = await raceAbort(vmP)
675
+ // the vm key first (1.4 MB of hashing): a snapshot of another vm.wasm is refused from its header before anything else is spent on it
676
+ if (ckptOn) vmKey = A ? A.keys.vmKey : await raceAbort(sha256Hex(new Uint8Array(vmBytes)))
677
+ if (lazyIn) {
678
+ try {
679
+ const h = await raceAbort(lazyIn.header)
680
+ checkVmKeyFits(h, vmKey)
681
+ lazyIn.proceed?.() // the reader decodes only from here: a refused header costs what was queued, not the file
682
+ } catch (err) {
683
+ if (signal?.aborted) throw err
684
+ baseRefused(err)
685
+ }
686
+ }
687
+ const tc = absMs()
688
+ be.now('compile', { t: 'boot', stage: 'compile', state: 'start', moduleBytes: vmBytes.byteLength, reused: !!A })
689
+ module = A ? A.module : await raceAbort(WebAssembly.compile(vmBytes))
690
+ timings.compileMs = absMs() - tc
691
+ be.now('compile', { t: 'boot', stage: 'compile', state: 'done', moduleBytes: vmBytes.byteLength, reused: !!A, tookMs: Math.round(timings.compileMs) })
692
+ tableMin = tableMinOf(vmBytes) ?? undefined
693
+ timings.tableMin = tableMin
694
+ const bufs = await raceAbort(filesP)
695
+ timings.downloadMs = Math.max(timings.vmDownloadMs || 0, timings.filesDownloadMs || 0)
696
+ timings.fileBytes = bufs.reduce((n, b) => n + b.byteLength, 0) + vmBytes.byteLength
697
+ files = names.map((n, i) => ({ path: n, buffer: bufs[i] }))
698
+ chanLayers = await raceAbort(layersP)
699
+ timings.layerBytes = chanLayers.flat().reduce((n, b) => n + b.byteLength, 0)
700
+ timings.allDownloadMs = lastDownloadAt - t0 // vm.wasm, the boot files and the packs all in
701
+ be.now('download', { t: 'boot', stage: 'download', state: 'done', fileBytes: timings.fileBytes, layerBytes: timings.layerBytes, tookMs: Math.round(timings.allDownloadMs) })
702
+ if (ckptOn) {
703
+ // the boot's identity: a checkpoint restores only into the same one (research/10 R11)
704
+ const tk = absMs()
705
+ let packs
706
+ if (A) {
707
+ bootKey = A.keys.bootKey
708
+ bootDesc = A.keys.bootDesc
709
+ packs = A.keys.packs
710
+ } else {
711
+ // the boot key samples each pack (head and tail); packs is the full digest of every layer (a SHA-256 tree over 8 MiB pieces), recorded in
712
+ // every checkpoint this boot takes and checked against a base snapshot's before it is used
713
+ const [k, pk] = await raceAbort(Promise.all([bootKeyOf({ vmKey, argv, env, preopen, files, services: chanSvcs.map((s, i) => ({ ...s, layers: chanLayers[i] })), maxPages: maximum }), packDigestsOf(chanLayers)]))
714
+ bootKey = k.key
715
+ bootDesc = k.desc
716
+ packs = pk
717
+ }
718
+ ckptT.keyMs = absMs() - tk
719
+ heldAssets = { vmBytes, module, tableMin, files, chanLayers, keys: { vmKey, bootKey, bootDesc, packs } }
720
+ packDigests = packs
721
+ if (lazyIn) {
722
+ const tw = absMs()
723
+ const info = lazyIn.info
724
+ const restoreProgress = () => {
725
+ if (!info) return
726
+ const att = info.attempts?.[info.attempts.length - 1]
727
+ if (!att) return
728
+ baseSource = att.source
729
+ baseTotal = att.size ?? info.entry?.bytes ?? null
730
+ be.progress('restore', { t: 'boot', stage: 'restore', state: 'progress', bytes: info.netBytes, total: baseTotal, source: baseSource })
731
+ }
732
+ restoreProgress()
733
+ const restoreTimer = setInterval(restoreProgress, 250)
734
+ try {
735
+ const box = await raceAbort(lazyIn.ckpt).finally(() => {
736
+ clearInterval(restoreTimer)
737
+ restoreProgress()
738
+ baseBytes = info?.netBytes ?? null
739
+ })
740
+ const got = typeof box.take === 'function' ? box.take() : box // a box hands the snapshot over once: the opener keeps no reference to its 0.5 GB
741
+ ckptT.baseWaitMs = absMs() - tw // the time the boot waited for the snapshot after everything else was in
742
+ const parsed = parseCheckpoint(got)
743
+ checkCheckpointFits(parsed.header, { vmKey, bootKey, bootDesc, maxPages: maximum, hasJit: o.jit !== false, fmaFused: fmaFuses() })
744
+ checkPacksFit(parsed.header.packs, packDigests)
745
+ restoreIn = parsed
746
+ lazyIn = null // baseLazy stays: accept() when the restore has succeeded, reject() if it fails
747
+ } catch (err) {
748
+ if (signal?.aborted) throw err
749
+ baseRefused(err)
750
+ }
751
+ }
752
+ if (restoreIn) checkCheckpointFits(restoreIn.header, { vmKey, bootKey, bootDesc, maxPages: maximum, hasJit: o.jit !== false, fmaFused: fmaFuses() })
753
+ }
754
+ if (signal?.aborted) throw abortError()
755
+ } catch (err) {
756
+ signal?.removeEventListener('abort', onAbort)
757
+ be.close()
758
+ // whatever ended the boot (the caller's signal, a failed download, a compile error): the snapshot download, the decoded
759
+ // snapshot and the pending cache copy are dropped here, not left for the garbage collector
760
+ const lazy = lazyIn || baseLazy
761
+ lazyIn = null
762
+ baseLazy = null
763
+ try {
764
+ lazy?.abort?.(err)
765
+ } catch {}
766
+ throw signal?.aborted ? abortError() : err
767
+ }
768
+
769
+ // 2. Shared memory and control block.
770
+ const tMem = absMs()
771
+ let memory = new WebAssembly.Memory({ initial: VM_MIN_PAGES, maximum, shared: true })
772
+ const memoryInitialBytes = memory.buffer.byteLength
773
+ const ctlSab = createControl()
774
+ const ctl = new Int32Array(ctlSab)
775
+ if (restoreIn) {
776
+ // The fd table now; the memory pages when the main thread has made its instance (restoreMemory, from the
777
+ // 'resume-instantiated' message): vm.wasm's start function must see an untouched memory (worker.js).
778
+ restoreFdTable(ctl, restoreIn.ctl, files.length)
779
+ ckptT.pagesRestored = restoreIn.index.length
780
+ }
781
+ function restoreMemory() {
782
+ try {
783
+ restoreMemoryNow()
784
+ // the snapshot restored: its cache copy may be committed now (not before: a snapshot that fails to restore must not stay cached)
785
+ const lazy = baseLazy
786
+ baseLazy = null
787
+ lazy?.accept?.()
788
+ } catch (err) {
789
+ const lazy = baseLazy
790
+ baseLazy = null
791
+ try {
792
+ lazy?.reject?.(err) // the cache entry this snapshot came from is removed: the next load downloads again
793
+ } catch {}
794
+ // the main thread waits for this (worker.js): tell it, instead of leaving it to its 120 s timeout
795
+ Atomics.store(ctl, CTL_CK_RESTORED, 2)
796
+ Atomics.notify(ctl, CTL_CK_RESTORED)
797
+ o.onStderr?.(`\n[wmd-web] restoring the checkpoint's memory failed: ${err && err.message ? err.message : err}\n`)
798
+ finish(4, `restore failed: ${err && err.message ? err.message : err}`)
799
+ }
800
+ }
801
+ function restoreMemoryNow() {
802
+ const tm = absMs()
803
+ const have = memory.buffer.byteLength / CKPT_PAGE
804
+ if (restoreIn.header.memPages > have) memory.grow(restoreIn.header.memPages - have)
805
+ const u8 = new Uint8Array(memory.buffer)
806
+ const kept = new Uint8Array(restoreIn.header.memPages)
807
+ let k = 0
808
+ for (const c of restoreIn.memChunks) {
809
+ for (let off = 0; off < c.length; off += CKPT_PAGE) {
810
+ const p = restoreIn.index[k++]
811
+ u8.set(c.subarray(off, off + CKPT_PAGE), p * CKPT_PAGE)
812
+ kept[p] = 1
813
+ }
814
+ }
815
+ // pages the instance's start function wrote that were zero at the checkpoint (the runner's zero_absent): the static region
816
+ let zeroed = 0
817
+ const w = new Int32Array(memory.buffer, 0, Math.min(restoreIn.header.memPages, VM_MIN_PAGES) * (CKPT_PAGE / 4))
818
+ for (let p = 0; p * (CKPT_PAGE / 4) < w.length; p++) {
819
+ if (kept[p]) continue
820
+ const lo = p * (CKPT_PAGE / 4)
821
+ let nz = false
822
+ for (let i = lo; i < lo + CKPT_PAGE / 4; i += 8) {
823
+ 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) {
824
+ nz = true
825
+ break
826
+ }
827
+ }
828
+ if (nz) {
829
+ w.fill(0, lo, lo + CKPT_PAGE / 4)
830
+ zeroed++
831
+ }
832
+ }
833
+ ckptT.instantiatedMs = tm - t0 // boot start to the main thread's instance made (key, download, replay, Worker start included)
834
+ ckptT.memoryMs = absMs() - tm
835
+ ckptT.zeroedAfterInstantiate = zeroed
836
+ ckptT.pagesWrittenAt = absMs() - t0
837
+ Atomics.store(ctl, CTL_CK_RESTORED, 1)
838
+ Atomics.notify(ctl, CTL_CK_RESTORED)
839
+ // everything of the checkpoint has been copied into this machine (memory, fd table, the 9P state and the JIT store went
840
+ // earlier): do not pin it for the machine's life
841
+ if (baseSource === null) {
842
+ baseSource = 'memory' // vm.restore() or bootWmd({restore}): the checkpoint was in the page's memory
843
+ baseBytes = baseTotal = restoreIn.memChunks.reduce((n, c) => n + c.length, 0)
844
+ }
845
+ be.now('restore', { t: 'boot', stage: 'restore', state: 'done', used: true, bytes: baseBytes, total: baseTotal, source: baseSource, tookMs: Math.round(ckptT.pagesWrittenAt) })
846
+ restoreIn.memChunks = null
847
+ restoreIn.index = null
848
+ restoreIn.jit = null
849
+ restoreIn.svc = null
850
+ restoreIn.ctl = null
851
+ }
852
+ // o.jit === false: no JIT door; vm.wasm gets the stubbed jit_* imports (-1, counted
853
+ // in the host-call counters), as before P-B2. Otherwise the door (src/host/jit.js).
854
+ // The log is reclaimed while sharing. A Worker that cannot receive a Module from the hub ('messageerror':
855
+ // Firefox past its per-process wasm code limit), or that starts after the log reused the bytes of early
856
+ // Modules, compiles its copy from bytes the hub keeps (W31b), so every browser reclaims. o.jitReclaim overrides.
857
+ const jitStore = o.jit === false ? null : restoreIn?.jit ? restoreStore(restoreIn.jit, { reclaim: o.jitReclaim ?? true }) : createJitStore({ reclaim: o.jitReclaim ?? true })
858
+ const stdio = createStdio(o.stdioRingBytes ?? BOOT_RING)
859
+ let memTook = absMs() - tMem // the memory, the control block, the JIT store and the stdio rings (the channel block is added below)
860
+ const out = stdioReader(stdio)
861
+ const outText = stdioText(out)
862
+ timings.stdioRingBytes = out.ringBytes
863
+ // W77: the cache is the page's object (functions): it stays here, the Workers get ports
864
+ const { cache: jitCacheStore, ...jitRest } = o.jit || {}
865
+ const jitOpts = { compileOnCompile: 'validate', ...jitRest }
866
+ // W34: WebKit keeps every live module until a flush and kills the page at 8 GiB. Past the budget the
867
+ // door answers JIT_OVER_BUDGET and the guest releases every module (one flush) and compiles again.
868
+ if (jitOpts.maxLiveBytes === undefined) jitOpts.maxLiveBytes = jitBudgetDefault(maximum * 65536)
869
+ timings.jitMaxLiveBytes = jitOpts.maxLiveBytes
870
+ const share = !jitStore ? false : jitOpts.share === undefined || jitOpts.share === 'auto' ? jitShareDefault() : !!jitOpts.share
871
+ if (share && !haveJspi()) {
872
+ signal?.removeEventListener('abort', onAbort)
873
+ be.close()
874
+ throw new Error('jit.share needs JSPI (WebAssembly.Suspending / promising)')
875
+ }
876
+ timings.jitShare = share
877
+ // W77: the JIT's module cache (host/jit-cache.js): the index is read before any Worker starts
878
+ let jitCache = null
879
+ let knownSab = null
880
+ if (jitStore && jitCacheStore) {
881
+ if (!share) {
882
+ timings.jitCache = { on: false, why: 'the JIT cache needs module sharing (JSPI)' }
883
+ } else {
884
+ const tag = vmKey ?? (await raceAbort(sha256Hex(new Uint8Array(vmBytes))))
885
+ jitCache = createJitCacheHost({ cache: jitCacheStore, tag, log: s => emitErr(`\n[${s}]\n`) })
886
+ knownSab = await raceAbort(jitCache.load())
887
+ timings.jitCache = { on: true, ...jitCache.stats }
888
+ }
889
+ }
890
+
891
+ // P-B3: the channel block and its service Worker.
892
+ let chanSab = null
893
+ let chanSvc = null
894
+ let chanDied = null
895
+ // P-B10: the network Worker, when a net service has a relay
896
+ let netWorker = null
897
+ let netStats = null
898
+ let netMember = null // W56: this guest on an in-page switch (switch.js attach())
899
+ const netWaiters = new Map()
900
+ let chanLock = null
901
+ let chanStats = null
902
+ let chanBytesSeen = 0
903
+ let controlUp = false
904
+ let collectDone = null
905
+ const upperWaiters = new Map() // W62: export-upper id -> {res, rej}
906
+ // P-B11: wmd's control session (a control service with yamux: true), driven from the page
907
+ const ctlService = chanSvcs.find(s => (s.kind === 'control' || s.kind === 3) && s.yamux)
908
+ const control = ctlService
909
+ ? new ControlClient({
910
+ post: m => {
911
+ if (!chanSvc || done || chanDied) {
912
+ if (m.op === 'open') queueMicrotask(() => control.onEvents([{ ev: 'closed', k: m.k, why: 'the machine has ended' }]))
913
+ return
914
+ }
915
+ if (m.bytes) {
916
+ const b = m.bytes instanceof Uint8Array ? m.bytes.slice() : new Uint8Array(m.bytes)
917
+ chanSvc.postMessage({ ...m, bytes: b }, [b.buffer])
918
+ } else chanSvc.postMessage(m)
919
+ },
920
+ })
921
+ : null
922
+ // W24: the Workers stage. Every Worker started before the vCPU (the channel service, the network Worker, the JIT hub,
923
+ // the first pool Workers and the main one) ready = the stage is done; later changes are progress events.
924
+ const wl = createWorkerList(be.at)
925
+ const vcpus = vcpusOf(o.args)
926
+ let workersDone = false
927
+ let workersStarted = false // set once the main Worker has been started: before that the list is not whole
928
+ let tWorkers = null
929
+ const workersEv = () => {
930
+ const e = { t: 'boot', stage: 'workers', workers: wl.list(), vcpus, jitShared: share }
931
+ if (!workersDone && workersStarted && wl.allReady()) {
932
+ workersDone = true
933
+ be.now('workers', { ...e, state: 'done', tookMs: Math.round(absMs() - tWorkers) })
934
+ } else be.progress('workers', { ...e, state: 'progress' })
935
+ }
936
+ const workerState = (id, role, state) => {
937
+ if (tWorkers === null) tWorkers = absMs()
938
+ wl.set(id, role, state)
939
+ workersEv()
940
+ }
941
+ const consoleDec = new TextDecoder()
942
+ const consoleBuf = new Uint8Array(1 << 20)
943
+ timings.consoleBytes = 0
944
+ let consoleGate = null
945
+ let consoleFull = false
946
+ if (o.channels) {
947
+ // the cap on each 9P upper (F2: none before, so a guest could grow the Worker without bound)
948
+ const capDefault = o.channels.maxUpperBytes !== undefined ? { bytes: o.channels.maxUpperBytes, source: 'channels.maxUpperBytes' } : upperCapDefault()
949
+ timings.chanUppers = chanSvcs
950
+ .filter(s => s.kind === '9p' || s.kind === 2)
951
+ .map(s => (s.maxUpperBytes !== undefined ? { port: s.port, maxUpperBytes: s.maxUpperBytes, source: 'service maxUpperBytes' } : { port: s.port, maxUpperBytes: capDefault.bytes, source: capDefault.source }))
952
+ try {
953
+ const tc = absMs()
954
+ chanSab = createChanBlock({
955
+ ports: chanSvcs.map(s => ({ port: s.port, kind: s.kind })),
956
+ nslots: o.channels.nslots ?? 8,
957
+ ringBytes: o.channels.ringBytes ?? 1 << 20,
958
+ })
959
+ memTook += absMs() - tc
960
+ // W56: on an in-page switch the guest gets its address there (the DHCP lease) and a port for its peers;
961
+ // taken here, before any Worker exists, so a refused name (taken, invalid) leaves nothing behind
962
+ const sw = chanSvcs.find(s => (s.kind === 'net' || s.kind === 1) && s.switch)
963
+ if (sw) netMember = sw.switch.attach({ name: sw.name, ip: sw.ip })
964
+ } catch (err) {
965
+ signal?.removeEventListener('abort', onAbort)
966
+ be.close()
967
+ throw err // (no Worker exists yet)
968
+ }
969
+ const lockName = `wmd-chan-${Math.random().toString(36).slice(2)}-${Date.now()}`
970
+ chanSvc = new Worker(new URL('./host/chan-service.js', import.meta.url), { type: 'module', name: 'wmd-chan' })
971
+ workerState('chan-service', 'chan-service', 'starting')
972
+ chanSvc.onmessage = e => {
973
+ const m = e.data
974
+ if (m.t === 'stats') {
975
+ chanStats = m.stats
976
+ layerEvents(m.stats)
977
+ }
978
+ else if (m.t === 'upper-full') {
979
+ emitErr(
980
+ `\n[wmd-web] the guest's files on 9P port ${m.port} are full: the upper holds ${fmtMiB(m.used)} of its ${fmtMiB(m.max)} cap ` +
981
+ `(channels.maxUpperBytes); the guest's writes get ENOSPC (No space left on device)\n`,
982
+ )
983
+ ev({ t: 'chan', ev: 'upper-full', port: m.port, used: m.used, max: m.max })
984
+ }
985
+ else if (m.t === 'snap') ckptSvc(m)
986
+ else if (m.t === 'collected') collectDone?.(m.files)
987
+ else if (m.t === 'upper') {
988
+ const w = upperWaiters.get(m.id)
989
+ upperWaiters.delete(m.id)
990
+ if (w && m.error) w.rej(new Error(`exportUpper: ${m.error}`))
991
+ else if (w) w.res({ bytes: new Uint8Array(m.bytes.buffer || m.bytes), entries: m.entries, files: m.files, whiteouts: m.whiteouts, opaque: m.opaque, dataBytes: m.dataBytes, ms: m.ms })
992
+ }
993
+ else if (m.t === 'ctl') {
994
+ if (m.evs.some(e => e.ev === 'data')) watchdog.progress()
995
+ if (!controlUp && vcpuAt !== null && m.evs.some(e => e.ev === 'session' && e.up)) {
996
+ controlUp = true // W24: the first time the guest's control session came up
997
+ be.now('control', { t: 'boot', stage: 'control', state: 'done', tookMs: Math.round(absMs() - vcpuAt) })
998
+ }
999
+ control?.onEvents(m.evs)
1000
+ }
1001
+ else if (m.t === 'error') serviceDied(`the channel service failed: ${m.message.split('\n')[0]}`)
1002
+ else if (m.t === 'locked' && navigator.locks?.request) {
1003
+ // Granted only when the service Worker has ended, however it ended.
1004
+ chanLock = new AbortController()
1005
+ navigator.locks
1006
+ .request(lockName, { signal: chanLock.signal }, () => serviceDied('the channel service Worker ended'))
1007
+ .catch(() => {})
1008
+ } else if (m.t === 'ready') {
1009
+ ev({ t: 'chan', ev: 'ready' })
1010
+ workerState('chan-service', 'chan-service', 'ready')
1011
+ }
1012
+ }
1013
+ chanSvc.onerror = err => {
1014
+ if (!done) workerState('chan-service', 'chan-service', 'failed')
1015
+ serviceDied(`the channel service Worker failed: ${err.message || err}`)
1016
+ }
1017
+ const netSvc = chanSvcs.find(s => (s.kind === 'net' || s.kind === 1) && (s.relay || s.switch))
1018
+ let netPort = null
1019
+ if (netSvc) {
1020
+ const mc = new MessageChannel()
1021
+ netPort = mc.port1
1022
+ netWorker = new Worker(new URL('./net/net-worker.js', import.meta.url), { type: 'module', name: 'wmd-net' })
1023
+ workerState('net', 'net', 'starting')
1024
+ netWorker.onmessage = e => {
1025
+ const m = e.data
1026
+ if (m.t === 'ready') workerState('net', 'net', 'ready')
1027
+ else if (m.t === 'stats') {
1028
+ netStats = m.stats
1029
+ const w = netWaiters.get(m.id)
1030
+ if (w) {
1031
+ netWaiters.delete(m.id)
1032
+ w(m.stats)
1033
+ }
1034
+ } else if (m.t === 'log') emitErr(`[wmd-web] ${m.line}\n`)
1035
+ else if (m.t === 'name') {
1036
+ if (!netNames.has(m.name) && !netIps.has(m.ip)) {
1037
+ netNames.set(m.name, m.ip)
1038
+ netIps.add(m.ip)
1039
+ }
1040
+ } else if (m.t === 'status') {
1041
+ netStatus = m.status
1042
+ for (const cb of netStatusCbs) {
1043
+ try {
1044
+ cb(m.status)
1045
+ } catch {}
1046
+ }
1047
+ } else if (m.t === 'names') {
1048
+ const w = netNameWaiters.get(m.id)
1049
+ if (w) {
1050
+ netNameWaiters.delete(m.id)
1051
+ w(m.names)
1052
+ }
1053
+ }
1054
+ }
1055
+ netWorker.onerror = err => {
1056
+ if (!done) workerState('net', 'net', 'failed')
1057
+ emitErr(`\n[wmd-web] the network Worker failed: ${err.message || err}\n`)
1058
+ }
1059
+ const r = netSvc.relay
1060
+ const relay = r ? { url: r.url, protocol: r.protocol, token: r.token, key: r.key, tokenUrl: r.tokenUrl && new URL(r.tokenUrl, location.href).href } : null
1061
+ const config = netMember ? { ...netSvc.config, ip: netMember.ip } : netSvc.config
1062
+ const transfer = netMember ? [mc.port2, netMember.port] : [mc.port2]
1063
+ netWorker.postMessage({ t: 'init', port: mc.port2, relay, instance: netInstance, names: [...netNames], switchPort: netMember?.port, config, maxConns: netSvc.maxConns }, transfer)
1064
+ }
1065
+ chanSvc.postMessage({
1066
+ t: 'init',
1067
+ sab: chanSab,
1068
+ lockName,
1069
+ spinUs: o.channels.spinUs,
1070
+ consoleInputBytes: o.channels.consoleInputBytes ?? CONSOLE_IN_LIMIT,
1071
+ services: chanSvcs.map((s, i) => ({
1072
+ port: s.port,
1073
+ kind: s.kind,
1074
+ at: s.at,
1075
+ files: s.files,
1076
+ yamux: !!s.yamux,
1077
+ maxUpperBytes: timings.chanUppers.find(u => u.port === s.port)?.maxUpperBytes,
1078
+ layers: chanLayers[i],
1079
+ restore: restoreIn?.svc[s.port], // P-B9: the 9P tree and fids of the checkpoint (copied: the checkpoint stays usable)
1080
+ })),
1081
+ netPort,
1082
+ }, netPort ? [netPort] : [])
1083
+ consoleGate = consoleInputGate(chanSab, o.channels.consoleInputBytes ?? CONSOLE_IN_LIMIT)
1084
+ }
1085
+ be.now('memory', { t: 'boot', stage: 'memory', state: 'done', initialBytes: memoryInitialBytes, maxBytes: maximum * PAGE, ringBytes: stdio.byteLength + (chanSab ? chanSab.byteLength : 0), tookMs: Math.round(memTook) })
1086
+ // W24: each 9P service's writable layer (its upper), from the service's stats (every 250 ms while they change); an
1087
+ // event when it is first seen and on each change of 1 MiB or more
1088
+ const layerSeen = new Map()
1089
+ function layerEvents(st) {
1090
+ for (const [port, u] of Object.entries(st?.uppers || {})) {
1091
+ const last = layerSeen.get(port)
1092
+ if (last !== undefined && Math.abs(u.used - last) < 1 << 20) continue
1093
+ layerSeen.set(port, u.used)
1094
+ be.progress(`layer:${port}`, { t: 'layer', port: Number(port), usedBytes: u.used, capBytes: u.max, kind: 'memory' })
1095
+ }
1096
+ }
1097
+ function serviceDied(why) {
1098
+ if (done || chanDied) return
1099
+ chanDied = why
1100
+ markServiceDead(chanSab)
1101
+ emitErr(`\n[wmd-web] ${why}: every channel is closed\n`)
1102
+ control?.fail(`${why}: every channel is closed${errTail.trim() ? `; the emulator's last output:\n${errTail.trim().split('\n').slice(-12).join('\n')}` : ''}`)
1103
+ ev({ t: 'chan', ev: 'service-died', why })
1104
+ failUpperWaiters(why)
1105
+ }
1106
+ function failUpperWaiters(why) {
1107
+ for (const w of upperWaiters.values()) w.rej(new Error(`exportUpper: ${why}`))
1108
+ upperWaiters.clear()
1109
+ }
1110
+ const chanV = chanSab && chanView(chanSab)
1111
+ function drainConsole() {
1112
+ if (!chanV) return
1113
+ const v = chanV
1114
+ let text = ''
1115
+ for (;;) {
1116
+ const n = ringRead(v, v.pipe, consoleBuf)
1117
+ if (!n) break
1118
+ timings.consoleBytes += n
1119
+ text += consoleDec.decode(consoleBuf.subarray(0, n), { stream: true })
1120
+ }
1121
+ if (text) {
1122
+ ringBell(v) // the service may be waiting for room in the pipe
1123
+ watchdog.progress()
1124
+ o.onConsole?.(text)
1125
+ }
1126
+ // channel traffic is progress too (a guest busy on 9P may print nothing for a while)
1127
+ const c = v.f64
1128
+ let bytes = 0
1129
+ for (let s = 0; s < v.nslots; s++) bytes += c[s * 4] + c[s * 4 + 1]
1130
+ if (bytes !== chanBytesSeen) {
1131
+ chanBytesSeen = bytes
1132
+ watchdog.progress()
1133
+ }
1134
+ }
1135
+ let hub = share ? new Worker(new URL('./host/jit-hub.js', import.meta.url), { type: 'module', name: 'wmd-jit-hub' }) : null
1136
+ if (hub && jitStore) hub.postMessage({ t: 'store', recs: jitStore.recs, ctl: jitStore.ctl }) // the hub marks records delivered (W31)
1137
+ // P-B9: a checkpoint needs the bytes of modules the log released; the hub keeps them (up to this much)
1138
+ if (hub && ckptOn) hub.postMessage({ t: 'config', bytesMax: o.ckptHubBytes ?? 1 << 30 })
1139
+ let hubUp = false
1140
+ let replayDone = null // P-B9/W57: the hub's answer to a restore's replay
1141
+ // W57: vm.restore() keeps this machine's hub past its end, for the next machine's hub to take its Modules from
1142
+ let keepHub = false
1143
+ let keptHub = null
1144
+ if (hub) {
1145
+ // W24: the hub's first answer (to a stats request) says its script runs
1146
+ workerState('jit-hub', 'jit-hub', 'starting')
1147
+ hub.onmessage = e => {
1148
+ if (e.data.t === 'bytes-of') ckptOn && ckptHub(e.data)
1149
+ else if (e.data.t === 'replayed') replayDone?.(e.data)
1150
+ else if (e.data.t === 'stats' && !hubUp && !done) {
1151
+ hubUp = true
1152
+ workerState('jit-hub', 'jit-hub', 'ready')
1153
+ }
1154
+ }
1155
+ hub.onerror = () => !done && workerState('jit-hub', 'jit-hub', 'failed')
1156
+ hub.postMessage({ t: 'stats' })
1157
+ }
1158
+ /** A port to the hub for one Worker (null when not sharing). */
1159
+ const hubPort = () => {
1160
+ if (!hub) return null
1161
+ const ch = new MessageChannel()
1162
+ hub.postMessage({ t: 'port', port: ch.port1 }, [ch.port1])
1163
+ return ch.port2
1164
+ }
1165
+
1166
+ // 3. Workers.
1167
+ const workers = []
1168
+ const stdoutLines = { buf: '' }
1169
+ const marks = (o.marks || []).map(m => ({ ...m, hit: false }))
1170
+ let done = false
1171
+ const jitCachePorts = [] // W77: the page's ends of the Workers' cache ports
1172
+ let resolveExit
1173
+ const exited = new Promise(r => (resolveExit = r))
1174
+ let vcpuAt = null // W24: when vm.wasm's _start began (the main Worker's instance made)
1175
+ let kernelDone = false
1176
+ let lastHeap = -1
1177
+ function heapEvent() {
1178
+ const bytes = memory ? memory.buffer.byteLength : 0
1179
+ if (bytes === lastHeap) return
1180
+ lastHeap = bytes
1181
+ be.progress('heap', { t: 'heap', bytes, maxBytes: maximum * PAGE })
1182
+ }
1183
+ const spare = o.spare ?? 2
1184
+
1185
+ // vm.wasm's own lines about its heap (W21's reserve, branch w21-trap): with the reserve
1186
+ // the memory is at its maximum from the start, so only vm.wasm can say it ran out.
1187
+ const heap = { reserve: false, usedUp: false, said: false, carry: '' }
1188
+ const watchHeap = text => {
1189
+ const t = heap.carry + text
1190
+ if (HEAP_RESERVE_RE.test(t)) heap.reserve = true
1191
+ if (HEAP_USED_UP_RE.test(t)) heap.usedUp = true
1192
+ if (VM_OOM_RE.test(t)) heap.said = true
1193
+ heap.carry = t.slice(-160)
1194
+ }
1195
+
1196
+ function pump() {
1197
+ const got = outText.drain() // text, with a marker line wherever bytes were skipped
1198
+ if (got[1] || got[2]) watchdog.progress()
1199
+ if (timings.firstByteMs === undefined && out.firstStdoutAt()) timings.firstByteMs = out.firstStdoutAt() - t0
1200
+ if (!kernelDone && vcpuAt !== null && out.firstStdoutAt()) {
1201
+ kernelDone = true // W24: the kernel stage ends at the first console byte (a restored machine has no kernel stage)
1202
+ if (!restoreIn) be.now('kernel', { t: 'boot', stage: 'kernel', state: 'done', tookMs: Math.round(Math.max(0, out.firstStdoutAt() - vcpuAt)) })
1203
+ }
1204
+ if (got[1]) onStdoutText(got[1])
1205
+ if (got[2]) {
1206
+ watchHeap(got[2])
1207
+ emitErr(got[2])
1208
+ }
1209
+ drainConsole()
1210
+ }
1211
+ const pumpTimer = setInterval(pump, 16)
1212
+
1213
+ let lateTrap = null
1214
+ function finish(code, reason) {
1215
+ if (done) return
1216
+ done = true
1217
+ if (baseLazy) {
1218
+ // the machine ended before its restore succeeded (stopped, failed to start): the snapshot's cache copy is not committed
1219
+ const lazy = baseLazy
1220
+ baseLazy = null
1221
+ try {
1222
+ lazy.abort?.(new Error(`the machine ended (${reason}) before the base snapshot was restored`))
1223
+ } catch {}
1224
+ }
1225
+ if (ck) ckptFail(new CheckpointError('ended', `the machine ended (${reason}) before the checkpoint was complete`)) // also stops its poll timer
1226
+ timings.totalMs = absMs() - t0
1227
+ timings.spawnFailures = Atomics.load(ctl, CTL_SPAWN_FAIL)
1228
+ timings.threadsSpawned = Atomics.load(ctl, CTL_NEXT_TID) - 1
1229
+ timings.jit = jitStore ? readJitStats(jitStore) : null
1230
+ timings.memoryBytes = memory.buffer.byteLength
1231
+ timings.memoryMaxBytes = maximum * PAGE
1232
+ const trapped = String(reason).startsWith('trap')
1233
+ if (trapped) {
1234
+ // stop every guest thread now: after a trap the guest's memory may hold garbage
1235
+ // (the others also stop at their next host call, worker.js guard)
1236
+ for (const w of workers) w.terminate()
1237
+ }
1238
+ if (trapped || reason === 'exit') pump() // vm.wasm's last stderr lines (the heap reserve's "used up")
1239
+ // W21 review A3: vm.wasm now stops itself on a failed allocation (exit 4 after its "out
1240
+ // of memory" line) instead of trapping; that end is reported the same way.
1241
+ // Only exit status 4, the one vm_out_of_memory uses: a user-mode guest can print the same
1242
+ // lines on its own stderr (the joint W21/W23 review's oomspoof: exit 1 was taken for OOM).
1243
+ if (trapped || (reason === 'exit' && code === 4 && (heap.usedUp || heap.said))) {
1244
+ // A trap because vm.wasm ran out of room for its own heap: its malloc failed and it
1245
+ // aborted (P-B11 problem 1, review finding 4). With W21's heap reserve vm.wasm says
1246
+ // so on stderr, and the memory is at its maximum whatever happens, so the size
1247
+ // only counts without a reserve.
1248
+ const oom = isOutOfMemory({ ...heap, reserve: heap.reserve || heapReserveAsked(env), memoryBytes: timings.memoryBytes, memoryMaxBytes: timings.memoryMaxBytes })
1249
+ if (oom) {
1250
+ if (!trapped) for (const w of workers) w.terminate()
1251
+ timings.oom = oom
1252
+ timings.trap = reason
1253
+ reason =
1254
+ `out of memory: the emulator used ${fmtMiB(timings.memoryBytes)} of its ${fmtMiB(timings.memoryMaxBytes)} memory maximum and stopped ` +
1255
+ `(${reason}); give it more room above the guest's RAM (bootWmd maxPages, memoryMaxPages(args, slackMiB))`
1256
+ emitErr(`\n[wmd-web] ${reason}\n`)
1257
+ }
1258
+ }
1259
+ let collected = Promise.resolve()
1260
+ if (chanSab) {
1261
+ markGuestGone(chanSab)
1262
+ chanLock?.abort() // before the service ends, so its end is not taken for a death
1263
+ chanSvc?.postMessage({ t: 'stats' })
1264
+ netWorker?.postMessage({ t: 'stats', id: 'final' })
1265
+ // files the caller wants out of the 9P trees (bundle/exitcode, a runc log, ...)
1266
+ const want = o.channels.collect || []
1267
+ if (want.length && chanSvc && !chanDied) {
1268
+ collected = new Promise(res => {
1269
+ collectDone = files => {
1270
+ timings.chanFiles = files
1271
+ res()
1272
+ }
1273
+ setTimeout(res, 2000)
1274
+ })
1275
+ chanSvc.postMessage({ t: 'collect', files: want })
1276
+ }
1277
+ }
1278
+ // Give threads a moment to finish their last writes, drain, then stop every Worker.
1279
+ const settle = new Promise(r => setTimeout(r, 50))
1280
+ Promise.all([settle, collected]).then(async () => {
1281
+ for (const w of workers) w.terminate()
1282
+ // W77: the notes the Workers sent are in; put the index (bounded wait) before the end is reported
1283
+ if (jitCache) {
1284
+ await jitCache.save()
1285
+ for (const p of jitCachePorts) p.close()
1286
+ timings.jitCache = { on: true, ...jitCache.stats }
1287
+ }
1288
+ if (keepHub && hub) keptHub = hub
1289
+ else hub?.terminate()
1290
+ chanSvc?.terminate()
1291
+ failUpperWaiters('the machine has ended (its files ended with it)')
1292
+ netWorker?.terminate()
1293
+ netMember?.detach() // W56: its peers' streams to it fail; the name keeps its address
1294
+ if (netWorker) timings.net = netStats
1295
+ if (chanSab) timings.chan = { ...chanCounters(chanSab), service: chanStats, died: chanDied }
1296
+ if (control) {
1297
+ control.fail(`the machine ended (${reason}, exit code ${code}) before the control channel was ready${errTail.trim() ? `; the emulator's last output:\n${errTail.trim().split('\n').slice(-12).join('\n')}` : ''}`)
1298
+ // every stream still open ends (a process gets exit 125), as when the engine's
1299
+ // control connection breaks
1300
+ const evs = [...control.streams.keys()].map(k => ({ ev: 'closed', k, why: `the machine ended (${reason})` }))
1301
+ evs.push({ ev: 'session', up: false, epoch: control.session.epoch, why: 'the machine ended' })
1302
+ control.onEvents(evs)
1303
+ }
1304
+ clearInterval(pumpTimer)
1305
+ pump()
1306
+ timings.outputDropped = { stdout: out.dropped[1], stderr: out.dropped[2] }
1307
+ timings.outputDropMarkers = { stdout: outText.skips[1], stderr: outText.skips[2] }
1308
+ timings.hostCalls = hostCallCounts(ctl)
1309
+ timings.throttle = throttle ? throttle.state() : null
1310
+ // A kernel panic the guest turns into a clean exit (PSCI reset -> 0) is a failure.
1311
+ if (panic.line && (reason === 'exit' || reason === 'kernel panic')) {
1312
+ timings.guestExitCode = reason === 'exit' ? code : null
1313
+ code = EXIT_PANIC
1314
+ reason = `kernel panic: ${panic.line.replace(/^\[[^\]]*\]\s*/, '')}`
1315
+ } else if (chanDied && reason === 'exit') {
1316
+ // The guest powers off when its 9P or net channel closes (chan_pump.c host_gone),
1317
+ // with exit 0: a host failure must not look like a clean run.
1318
+ timings.guestExitCode = code
1319
+ code = EXIT_SERVICE_DIED
1320
+ reason = `channel service died: ${chanDied}`
1321
+ }
1322
+ // W21 review A2: a trap counted up to here (the 50 ms settle) turns an exit 0 into 4. A
1323
+ // trap later than that is not seen; the joint W21/W23 review found none in 15 proof boots,
1324
+ // and user mode now drains its threads before vm.wasm exits (main_wasm.c).
1325
+ timings.traps = Atomics.load(ctl, CTL_TRAPS)
1326
+ if (timings.traps && reason === 'exit' && code === 0) {
1327
+ timings.guestExitCode = code
1328
+ code = 4
1329
+ reason = lateTrap || `trap: a thread trapped after the exit (${timings.traps})`
1330
+ }
1331
+ // W24: every Worker has ended; the end, by number
1332
+ wl.endAll()
1333
+ be.now('workers', { t: 'boot', stage: 'workers', state: 'progress', workers: wl.list(), vcpus, jitShared: share })
1334
+ be.now('exit', { t: 'exit', code, reason, oom: !!timings.oom })
1335
+ be.close()
1336
+ release()
1337
+ resolveExit({ code, reason, timings })
1338
+ })
1339
+ }
1340
+
1341
+ // Drop everything that holds the shared memory, the file buffers or a Worker, so a
1342
+ // finished boot can be collected even while the page keeps its handle (vm.memory
1343
+ // stays readable for post-mortem until vm.dispose()).
1344
+ function release() {
1345
+ clearInterval(tickTimer)
1346
+ stopGcNudge()
1347
+ clearTimeout(panicTimer)
1348
+ signal?.removeEventListener('abort', onAbort)
1349
+ for (const w of workers) w.onmessage = w.onerror = null
1350
+ workers.length = 0
1351
+ calib?.terminate()
1352
+ if (calib) calib.onmessage = null
1353
+ calib = null
1354
+ hub = null // terminated in finish()
1355
+ if (chanSvc) chanSvc.onmessage = chanSvc.onerror = null
1356
+ chanSvc = null
1357
+ if (netWorker) netWorker.onmessage = netWorker.onerror = null
1358
+ netWorker = null
1359
+ netMember?.detach()
1360
+ module = files = null
1361
+ }
1362
+
1363
+ function onStdoutText(text) {
1364
+ o.onStdout?.(text)
1365
+ if (panic.push(text)) onPanic()
1366
+ if (!marks.length) return
1367
+ stdoutLines.buf += text
1368
+ let nl
1369
+ while ((nl = stdoutLines.buf.indexOf('\n')) >= 0) {
1370
+ const line = stdoutLines.buf.slice(0, nl).replace(/\r$/, '') // the x86 8250 console sends CRLF
1371
+ stdoutLines.buf = stdoutLines.buf.slice(nl + 1)
1372
+ for (const m of marks) {
1373
+ if (!m.hit && m.re.test(line)) {
1374
+ m.hit = true
1375
+ timings[m.name] = absMs() - t0
1376
+ }
1377
+ }
1378
+ }
1379
+ }
1380
+
1381
+ function onMessage(e) {
1382
+ const m = e.data
1383
+ switch (m.t) {
1384
+ case 'ready':
1385
+ loaded.add(m.role === 'main' ? 'main' : m.slot)
1386
+ if (!done) workerState(wid(m.role === 'main' ? 'main' : m.slot), m.role === 'main' ? 'vcpu' : 'pool', 'ready')
1387
+ if (m.role === 'main') {
1388
+ timings.instantiatedMs = m.at - t0
1389
+ watchdog.start()
1390
+ ev({ t: 'phase', phase: 'running' })
1391
+ vcpuAt = m.at
1392
+ if (!restoreIn) be.now('kernel', { t: 'boot', stage: 'kernel', state: 'start' })
1393
+ heapEvent()
1394
+ }
1395
+ break
1396
+ case 'need':
1397
+ topUp(m.why)
1398
+ break
1399
+ case 'thread':
1400
+ watchdog.progress()
1401
+ ev(m)
1402
+ if (!done && wl.tid(wid(m.slot), m.ev === 'start' ? m.tid : null)) workersEv()
1403
+ break
1404
+ case 'exit':
1405
+ finish(m.code, 'exit')
1406
+ break
1407
+ case 'resume-instantiated':
1408
+ restoreMemory()
1409
+ break
1410
+ case 'ckpt-point':
1411
+ case 'ckpt-mem':
1412
+ case 'ckpt-failed':
1413
+ case 'ckpt-mem-error':
1414
+ ckptEvent(m)
1415
+ break
1416
+ case 'trap':
1417
+ emitErr(`\n[wmd-web] thread ${m.tid} trapped: ${m.message}\n${m.stack}\n`)
1418
+ // W21 review A2: a thread that traps after another called proc_exit (the trap lost
1419
+ // the race for CTL_EXIT) still fails the run; finish() turns an exit 0 into 4
1420
+ if (done) lateTrap ??= `trap: ${m.message} (thread ${m.tid}, after the exit)`
1421
+ finish(4, `trap: ${m.message}`)
1422
+ break
1423
+ }
1424
+ }
1425
+
1426
+ // P-B7: a Worker whose script failed to load (an error before its 'ready') is made again,
1427
+ // up to LOAD_TRIES times, after a delay that differs per Worker. WebKit, offline behind the
1428
+ // pack host's service worker, failed the module imports of all but one of several Workers
1429
+ // started at once from one script (README P-B7, problem 1); started 30 ms apart they load.
1430
+ const LOAD_TRIES = 3
1431
+ const loaded = new Set() // 'main' and pool slots that posted 'ready'
1432
+ const retryDelay = (key, attempt) => 40 * attempt * (1 + (key === 'main' ? 0 : key + 1))
1433
+ const wid = key => (key === 'main' ? 'vcpu-0' : `pool-${key}`)
1434
+ function startWorker(key, name, role, slot, attempt, onFail) {
1435
+ const w = workerUrl
1436
+ ? new Worker(workerUrl, { type: 'module', name })
1437
+ : new Worker(new URL('./worker.js', import.meta.url), { type: 'module', name })
1438
+ workerState(wid(key), key === 'main' ? 'vcpu' : 'pool', 'starting')
1439
+ w.onmessage = onMessage
1440
+ w.onerror = err => {
1441
+ if (!done && !loaded.has(key) && attempt < LOAD_TRIES) {
1442
+ err.preventDefault?.()
1443
+ w.terminate()
1444
+ const i = workers.indexOf(w)
1445
+ if (i >= 0) workers.splice(i, 1)
1446
+ ev({ t: 'pool', ev: 'worker-retry', key, attempt })
1447
+ timings.workerLoadRetries = (timings.workerLoadRetries || 0) + 1
1448
+ setTimeout(() => !done && startWorker(key, name, role, slot, attempt + 1, onFail), retryDelay(key, attempt))
1449
+ return
1450
+ }
1451
+ if (!done) workerState(wid(key), key === 'main' ? 'vcpu' : 'pool', 'failed')
1452
+ onFail(err)
1453
+ }
1454
+ postInit(w, role, slot)
1455
+ workers.push(w)
1456
+ return w
1457
+ }
1458
+
1459
+ function addPoolWorker() {
1460
+ const slot = Atomics.load(ctl, CTL_POOL_N)
1461
+ if (slot >= POOL_MAX) return false
1462
+ Atomics.store(ctl, slotIndex(slot, SLOT_STATE), ST_STARTING)
1463
+ Atomics.store(ctl, CTL_POOL_N, slot + 1)
1464
+ startWorker(slot, `wmd-pool-${slot}`, 'pool', slot, 1, err => {
1465
+ Atomics.store(ctl, slotIndex(slot, SLOT_STATE), ST_DEAD)
1466
+ emitErr(`\n[wmd-web] pool worker ${slot} failed to load: ${err.message || err}\n`)
1467
+ ev({ t: 'pool', ev: 'worker-error', slot })
1468
+ })
1469
+ return true
1470
+ }
1471
+
1472
+ function topUp(why) {
1473
+ if (done) return
1474
+ const n = Atomics.load(ctl, CTL_POOL_N)
1475
+ let idleOrStarting = 0
1476
+ for (let i = 0; i < n; i++) {
1477
+ const s = Atomics.load(ctl, slotIndex(i, SLOT_STATE))
1478
+ if (s === ST_IDLE || s === ST_STARTING) idleOrStarting++
1479
+ }
1480
+ let added = 0
1481
+ while (idleOrStarting + added < spare + (why === 'spawned' ? 0 : 1)) {
1482
+ if (!addPoolWorker()) {
1483
+ emitErr(`\n[wmd-web] thread pool exhausted: ${POOL_MAX} Workers in use (${why})\n`)
1484
+ ev({ t: 'pool', ev: 'exhausted' })
1485
+ return
1486
+ }
1487
+ added++
1488
+ }
1489
+ if (why === 'exhausted') {
1490
+ emitErr(`\n[wmd-web] thread-spawn found no idle Worker in time; pthread_create failed (EAGAIN)\n`)
1491
+ ev({ t: 'pool', ev: 'spawn-failed' })
1492
+ }
1493
+ if (added) ev({ t: 'pool', ev: 'grow', added, total: Atomics.load(ctl, CTL_POOL_N), why })
1494
+ }
1495
+
1496
+ function postInit(w, role, slot) {
1497
+ const msg = initMsg(role, slot)
1498
+ msg.jitPort = hubPort()
1499
+ const transfer = msg.jitPort ? [msg.jitPort] : []
1500
+ if (jitCache) {
1501
+ msg.jitCache = { sab: knownSab, port: jitCachePort() }
1502
+ transfer.push(msg.jitCache.port)
1503
+ }
1504
+ w.postMessage(msg, transfer)
1505
+ }
1506
+
1507
+ /** W77: a Worker's port to the JIT cache: module reads ({t:'get'}) and notes ({t:'note'}). */
1508
+ function jitCachePort() {
1509
+ const ch = new MessageChannel()
1510
+ const p = ch.port1
1511
+ p.onmessage = e => {
1512
+ const m = e.data
1513
+ if (m.t === 'get') {
1514
+ jitCache.get(m.hl, m.hh, m.len).then(bytes => {
1515
+ try {
1516
+ p.postMessage({ id: m.id, bytes }, bytes ? [bytes.buffer] : [])
1517
+ } catch {}
1518
+ })
1519
+ } else if (m.t === 'note') jitCache.note(m)
1520
+ }
1521
+ jitCachePorts.push(p)
1522
+ return ch.port2
1523
+ }
1524
+
1525
+ function initMsg(role, slot) {
1526
+ return {
1527
+ t: 'init',
1528
+ role,
1529
+ slot,
1530
+ module,
1531
+ tableMin,
1532
+ memory,
1533
+ ram: ramMem,
1534
+ ctlSab,
1535
+ files,
1536
+ preopen,
1537
+ args: argv,
1538
+ env: browserEnv(env),
1539
+ jit: jitStore,
1540
+ jitOpts,
1541
+ stdio,
1542
+ chan: chanSab,
1543
+ stallHook: !!o.stallHook,
1544
+ ckpt: ckptOn,
1545
+ ckptScanLimit: o.checkpointScanLimit,
1546
+ ckptVerifyBound: !!o.checkpointVerifyBound,
1547
+ ckptUseHeapTop: o.checkpointHeapTop !== false, // W58: reading untouched pages commits them in WebKit
1548
+ resume: role === 'main' && restoreIn ? { ports: resumePorts(o.args), stats: o.args.includes('--stats') } : null,
1549
+ }
1550
+ }
1551
+
1552
+ // Monitors: kernel panic, progress watchdog, throttling, stub host-call counters.
1553
+ const panic = createPanicScanner()
1554
+ let panicTimer = 0
1555
+ function onPanic() {
1556
+ ev({ t: 'panic', line: panic.line })
1557
+ emitErr(`\n[wmd-web] kernel panic on the console: ${panic.line}\n`)
1558
+ // CONFIG_PANIC_TIMEOUT=-1 reboots at once (exit 0); a kernel that hangs instead is
1559
+ // stopped after a few seconds, once the panic trace has been printed.
1560
+ panicTimer = setTimeout(() => finish(EXIT_PANIC, 'kernel panic'), 5000)
1561
+ }
1562
+ const TICK_MS = 250
1563
+ const watchdogMs = o.watchdogMs ?? watchdogDefaultMs(o.args)
1564
+ const watchdog = createWatchdog({ limitMs: watchdogMs, tickMs: TICK_MS })
1565
+ const throttleOpts = o.throttle === false ? null : o.throttle || {}
1566
+ const throttle = throttleOpts && createThrottleMeter({ threshold: throttleOpts.threshold ?? 2, baselineMs: throttleOpts.baselineMs })
1567
+ let calib = null
1568
+ if (throttle) {
1569
+ calib = new Worker(new URL('./calib-worker.js', import.meta.url), { type: 'module', name: 'wmd-calib' })
1570
+ calib.onmessage = e => {
1571
+ if (done || e.data.t !== 'calib') return
1572
+ const s = throttle.sample(e.data.workMs)
1573
+ if (s.changed) {
1574
+ const { changed, ...st } = s
1575
+ ev({ t: 'throttle', ...st, waitLateMs: e.data.waitLateMs })
1576
+ if (st.throttled)
1577
+ emitErr(`\n[wmd-web] the guest is running about ${st.ratio}x slower than this browser can run it (throttled: background window or tab?)\n`)
1578
+ }
1579
+ }
1580
+ calib.postMessage({ t: 'start', intervalMs: throttleOpts.intervalMs ?? 2000 })
1581
+ }
1582
+ let lastCalls = ''
1583
+ function onTick() {
1584
+ if (done) return
1585
+ if (vcpuAt !== null) heapEvent()
1586
+ const calls = hostCallCounts(ctl)
1587
+ const key = JSON.stringify(calls)
1588
+ if (key !== lastCalls) {
1589
+ lastCalls = key
1590
+ ev({ t: 'host-calls', counts: calls }) // at most once per tick, however often the guest calls
1591
+ }
1592
+ if (watchdog.check(throttle && throttle.state().throttled ? throttle.state().ratio : 1)) {
1593
+ const s = Math.round(watchdog.idleMs() / 1000)
1594
+ emitErr(`\n[wmd-web] guest made no progress: no console output and no new thread for ${s} s after the vCPU started; stopping\n`)
1595
+ finish(EXIT_NO_PROGRESS, `no progress: the guest wrote nothing for ${s} s`)
1596
+ }
1597
+ }
1598
+ const tickTimer = setInterval(onTick, TICK_MS)
1599
+ // D-419: the page holds the guest's memory too, so WebKit would collect its garbage late
1600
+ const stopGcNudge = gcNudgeEvery()
1601
+
1602
+ if (restoreIn?.jit && hub) {
1603
+ // the slot list's modules, compiled again by the hub before any vCPU starts. Not here: a Module the page compiles
1604
+ // keeps its code until the page collects its garbage, and Firefox's per-process code limit then fills with the
1605
+ // code of machines already gone (W57); the hub's code goes with its Worker when the machine ends.
1606
+ const tr = absMs()
1607
+ const mods = modulesOf(restoreIn.jit).map(m => ({ owner: m.owner, bytes: m.bytes.slice() }))
1608
+ if (adoptIn) {
1609
+ // the old machine's hub (still running) posts its Modules and their bytes to this one, Worker to Worker; the
1610
+ // replay takes each whose bytes are the same instead of compiling them again
1611
+ const ch = new MessageChannel()
1612
+ adoptIn.postMessage({ t: 'handoff', port: ch.port1 }, [ch.port1])
1613
+ hub.postMessage({ t: 'adopt', port: ch.port2 }, [ch.port2])
1614
+ }
1615
+ const r = await Promise.race([
1616
+ new Promise(res => {
1617
+ replayDone = res
1618
+ hub.postMessage({ t: 'replay', id: 1, gen: restoreIn.jit.gen, mods }, mods.map(m => m.bytes.buffer))
1619
+ }),
1620
+ exited.then(() => ({ ended: true })), // the hub ends with the machine: the handle is returned, already ended
1621
+ ])
1622
+ replayDone = null
1623
+ if (r.error && !done) throw new Error(`restore: the JIT hub could not compile the checkpoint's modules: ${r.error}`)
1624
+ ckptT.replayModules = r.n ?? 0
1625
+ ckptT.replayReused = r.reused ?? 0
1626
+ ckptT.replayMs = absMs() - tr
1627
+ }
1628
+ if (restoreIn) timings.restore = ckptT
1629
+
1630
+ const poolSize = o.pool ?? 4
1631
+ for (let i = 0; i < poolSize; i++) addPoolWorker()
1632
+
1633
+ markStart(ctlSab)
1634
+ timings.startMs = absMs() - t0
1635
+ startWorker('main', 'wmd-main', 'main', -1, 1, err => {
1636
+ emitErr(`\n[wmd-web] main worker failed: ${err.message || err}\n`)
1637
+ finish(4, 'main worker error')
1638
+ })
1639
+ workersStarted = true
1640
+
1641
+ onAbortAfterStart = () => finish(EXIT_STOPPED, 'aborted')
1642
+ if (signal?.aborted) onAbortAfterStart()
1643
+
1644
+
1645
+ // ---- P-B9: checkpoints -------------------------------------------------------------------
1646
+ //
1647
+ // vm.checkpoint(): the page stores REQ_CKPT in the guest's host_req word (the address the guest's ctl thread
1648
+ // gave wmd.ctl_attach). The guest stops its vCPUs at a block boundary, drains the file share, stops its
1649
+ // service threads and calls wmd.point on vCPU 0 (host/ckpt-point.js), which tells the page and copies memory
1650
+ // 0's non-zero pages. At that same moment the page asks the channel service Worker for the 9P state and
1651
+ // the JIT hub for the modules' bytes the log released, and reads the JIT slot list and the fd table (nothing
1652
+ // else runs). When all four are in, the page lets vCPU 0 go (CTL_CK_GO) and the guest continues.
1653
+ const gnow = () => performance.timeOrigin + performance.now()
1654
+ const hdrNs = (hdr, off) => Number(new DataView(hdr.buffer, hdr.byteOffset, hdr.byteLength).getBigUint64(off, true)) / 1e6
1655
+
1656
+ function ckptFail(err) {
1657
+ const c = ck
1658
+ if (!c) return
1659
+ ck = null
1660
+ clearTimeout(c.timer)
1661
+ clearInterval(c.poll)
1662
+ if (c.atPoint && !c.went) {
1663
+ Atomics.store(ctl, CTL_CK_GO, 2) // the guest's vCPU 0 is waiting for us: tell it the checkpoint failed
1664
+ Atomics.notify(ctl, CTL_CK_GO)
1665
+ }
1666
+ c.reject(err)
1667
+ }
1668
+
1669
+ function ckptEvent(m) {
1670
+ const c = ck
1671
+ if (!c) return // a point nobody asked for (or one that timed out): nothing to do
1672
+ if (m.t === 'ckpt-failed') return ckptFail(new CheckpointError('point', `the guest could not take the checkpoint: ${m.why} (the machine goes on)`))
1673
+ if (m.t === 'ckpt-mem-error') return ckptFail(new CheckpointError('memory', `copying the guest's memory failed: ${m.message}`))
1674
+ if (m.t === 'ckpt-point') {
1675
+ c.atPoint = true
1676
+ c.hdr = m.hdr
1677
+ c.pointAt = m.at
1678
+ if (chanSvc) chanSvc.postMessage({ t: 'snap' })
1679
+ else c.svc = {}
1680
+ try {
1681
+ c.ctlBytes = fdTableBytes(ctl)
1682
+ if (!jitStore) c.jitDone = true
1683
+ else {
1684
+ const need = ownersNeedingHub(jitStore)
1685
+ if (!need.length) {
1686
+ const t = absMs()
1687
+ c.jitSnap = captureJit(jitStore, new Map())
1688
+ c.jitMs = absMs() - t
1689
+ c.jitDone = true
1690
+ } else if (!hub) {
1691
+ throw new CheckpointError('jit', `${need.length} module records were released from the module log and there is no hub to ask`)
1692
+ } else {
1693
+ c.jitAskedAt = absMs()
1694
+ hub.postMessage({ t: 'bytes-of', id: 1, owners: need })
1695
+ }
1696
+ }
1697
+ } catch (err) {
1698
+ return ckptFail(err)
1699
+ }
1700
+ } else if (m.t === 'ckpt-mem') {
1701
+ c.mem = m
1702
+ }
1703
+ ckptMaybeGo()
1704
+ }
1705
+
1706
+ function ckptSvc(m) {
1707
+ const c = ck
1708
+ if (!c || !c.atPoint) return
1709
+ if (m.error) return ckptFail(new CheckpointError('service', `the file service could not save its state: ${m.error}`))
1710
+ for (const d of Object.values(m.ports)) d.segments = d.segments.map(b => new Uint8Array(b))
1711
+ c.svc = m.ports
1712
+ c.svcMs = m.ms
1713
+ ckptMaybeGo()
1714
+ }
1715
+
1716
+ function ckptHub(m) {
1717
+ const c = ck
1718
+ if (!c || !c.atPoint) return
1719
+ try {
1720
+ if (m.missing.length) {
1721
+ throw new CheckpointError('jit', `the JIT hub no longer holds the bytes of ${m.missing.length} released module records (first: ${m.missing[0]}); raise bootWmd({ckptHubBytes})`)
1722
+ }
1723
+ c.jitSnap = captureJit(jitStore, new Map(m.got))
1724
+ c.jitMs = absMs() - c.jitAskedAt
1725
+ c.jitDone = true
1726
+ } catch (err) {
1727
+ return ckptFail(err)
1728
+ }
1729
+ ckptMaybeGo()
1730
+ }
1731
+
1732
+ function ckptMaybeGo() {
1733
+ const c = ck
1734
+ if (!c || c.went || !c.mem || !c.svc || !c.jitDone) return
1735
+ c.went = true
1736
+ c.goAt = gnow()
1737
+ Atomics.store(ctl, CTL_CK_GO, 1)
1738
+ Atomics.notify(ctl, CTL_CK_GO, 1)
1739
+ // the guest runs again when its ctl thread attaches once more (the end of the pause)
1740
+ c.poll = setInterval(() => {
1741
+ if (Atomics.load(ctl, CTL_CK_ATTACHES) > c.attachesBefore || gnow() - c.goAt > 15000) {
1742
+ clearInterval(c.poll)
1743
+ c.resumeAt = gnow()
1744
+ ckptFinish(c)
1745
+ }
1746
+ }, 2)
1747
+ }
1748
+
1749
+ /** W59: the network Worker's whole placeholder map (a superset of what the guest has seen), else the page's copy. */
1750
+ function netNameList() {
1751
+ if (!netWorker || netSvcRelayProtocol() !== 'wisp') return Promise.resolve([...netNames])
1752
+ return new Promise(res => {
1753
+ const id = Math.random()
1754
+ const t = setTimeout(() => netNameWaiters.delete(id) && res([...netNames]), 2000)
1755
+ netNameWaiters.set(id, names => {
1756
+ clearTimeout(t)
1757
+ for (const [n, ip] of names) {
1758
+ if (netNames.has(n) || netIps.has(ip)) continue
1759
+ netNames.set(n, ip)
1760
+ netIps.add(ip)
1761
+ }
1762
+ res([...netNames])
1763
+ })
1764
+ netWorker.postMessage({ t: 'names', id })
1765
+ })
1766
+ }
1767
+ function netSvcRelayProtocol() {
1768
+ return chanSvcs?.find(s => (s.kind === 'net' || s.kind === 1) && s.relay)?.relay?.protocol || ''
1769
+ }
1770
+
1771
+ function ckptFinish(c) {
1772
+ if (ck !== c) return
1773
+ ck = null
1774
+ clearTimeout(c.timer)
1775
+ netNameList().then(names => ckptAssemble(c, names))
1776
+ }
1777
+
1778
+ function ckptAssemble(c, names) {
1779
+ try {
1780
+ const t = absMs()
1781
+ const stats = {
1782
+ stopLatencyMs: c.pointAt - c.t0, // request to the vCPUs stopped and the point entered (includes the guest's world stop, share drain and service stop)
1783
+ worldStopMs: hdrNs(c.hdr, 312 - 0),
1784
+ p9DrainMs: hdrNs(c.hdr, 320),
1785
+ svcStopMs: hdrNs(c.hdr, 328),
1786
+ scanMs: c.mem.scanMs,
1787
+ copyMs: c.mem.copyMs,
1788
+ memWaitMs: c.goAt - c.pointAt, // point entered to released: scan, copy, the page's collection of the rest
1789
+ svcMs: c.svcMs,
1790
+ jitMs: c.jitMs ?? 0,
1791
+ resumeMs: c.resumeAt - c.goAt, // released to the guest's ctl thread attached again
1792
+ nonzeroPages: c.mem.nonzero,
1793
+ memPages: c.mem.pages,
1794
+ scannedPages: c.mem.scannedPages,
1795
+ scanLimitBytes: c.mem.limitBytes,
1796
+ heapTopPage: c.mem.heapTopRet ? Math.ceil(c.mem.heapTopRet / CKPT_PAGE) : null,
1797
+ pagesAtOrAboveHeapTop: c.mem.heapTopRet ? Array.from(c.mem.index).filter(p => p >= Math.ceil(c.mem.heapTopRet / CKPT_PAGE)).length : null, // non-zero pages the checkpoint holds from the heap top up: 0 proves the heap-top bound
1798
+ aboveNonzeroPages: c.mem.aboveNonzero,
1799
+ brkLive: c.mem.brkLive,
1800
+ endLive: c.mem.endLive,
1801
+ heapTopRet: c.mem.heapTopRet,
1802
+ }
1803
+ // the guest's view of the pause: stopping the world, draining the share, stopping its threads, then everything from the point to its restart
1804
+ stats.pauseMs = stats.worldStopMs + stats.p9DrainMs + stats.svcStopMs + (c.resumeAt - c.pointAt)
1805
+ const meta = {
1806
+ label: c.label || '',
1807
+ vmKey,
1808
+ bootKey,
1809
+ bootDesc,
1810
+ packs: packDigests,
1811
+ ports: resumePorts(o.args),
1812
+ args: o.args,
1813
+ jitShare: share,
1814
+ fmaFused: env.includes('WMD_FMA_FUSED=1') || (!env.some(e => e.startsWith('WMD_FMA_FUSED=')) && fmaFuses()), // D-539: as browserEnv; its blocks may hold relaxed_madd
1815
+ stats,
1816
+ }
1817
+ const parts = { meta, hdr: c.hdr, mem: c.mem, jit: c.jitSnap ? { snap: c.jitSnap } : null, svc: c.svc, ctl: c.ctlBytes, names }
1818
+ const ckpt = assembleCheckpoint(parts)
1819
+ ckpt.header.stats.assembleMs = absMs() - t
1820
+ ckpt.header.svcStats = Object.fromEntries(Object.entries(c.svc).map(([port, d]) => [port, { bytes: d.bytes, fids: d.fids }]))
1821
+ if (c.jitSnap) ckpt.header.jitStats = jitSnapStats(c.jitSnap)
1822
+ c.resolve(ckpt)
1823
+ } catch (err) {
1824
+ c.reject(err)
1825
+ }
1826
+ }
1827
+
1828
+ /**
1829
+ * P-B9: take a checkpoint. Resolves with {header, sections}: the guest's memory pages, the JIT slot list, the 9P
1830
+ * service's state and the fd table, as captured at one instant while the guest was stopped. header.stats has the
1831
+ * times. It stays usable (restore it again as often as wanted) until the caller drops it; to survive a reload save
1832
+ * it with a store (opfsStore().save(name, ckpt)).
1833
+ */
1834
+ function checkpoint({ label = '', timeoutMs = 60_000 } = {}) {
1835
+ if (!ckptOn) return Promise.reject(new Error('this machine was booted without checkpoint support: bootWmd({checkpoint: true})'))
1836
+ if (restoring) return Promise.reject(new CheckpointError('busy', 'a restore is running: this machine is about to end, and a checkpoint taken now would be lost'))
1837
+ if (done || chanDied) return Promise.reject(new Error('the machine has ended'))
1838
+ if (ck) return Promise.reject(new Error('a checkpoint is already being taken'))
1839
+ return new Promise((resolve, reject) => {
1840
+ const c = (ck = { label, t0: gnow(), resolve, reject, attachesBefore: Atomics.load(ctl, CTL_CK_ATTACHES), timer: null })
1841
+ c.timer = setTimeout(() => ckptFail(new CheckpointError('timeout', `the guest did not reach its checkpoint point in ${timeoutMs} ms`)), timeoutMs)
1842
+ if (!requestCheckpoint(memory, ctl)) ckptFail(new CheckpointError('not-ready', 'the guest has not started its checkpoint thread yet (it does that once the kernel has booted); try again after the control session is up'))
1843
+ })
1844
+ }
1845
+
1846
+ /**
1847
+ * P-B9: end this machine and continue from a checkpoint in a new one (same page, same options). Resolves with the
1848
+ * new machine's handle. The old machine's Workers are gone when this returns; anything that was running in it (a
1849
+ * block, a process the guest started after the checkpoint) is gone with them.
1850
+ */
1851
+ async function restore(ckpt, over = {}) {
1852
+ if (restoring) throw new CheckpointError('busy', 'a restore is already running on this machine: wait for it (it returns the new machine)')
1853
+ restoring = true
1854
+ try {
1855
+ // everything that can fail without harming the running machine comes first: the checkpoint parses and is this boot's
1856
+ const parsed = parseCheckpoint(ckpt)
1857
+ checkCheckpointFits(parsed.header, { vmKey, bootKey, bootDesc, maxPages: maximum, hasJit: o.jit !== false, fmaFused: fmaFuses() })
1858
+ if (!heldAssets) throw new CheckpointError('not-ready', 'this machine was booted without checkpoint support: it has no kept boot files to restore with')
1859
+ validateFdTable(parsed.ctl, heldAssets.files.length) // the fd table is checked against this boot's files now, not after the old machine is gone
1860
+ // the boot's own bytes (vm.wasm and its compiled Module, the kernel, the packs, the keys) are reused: nothing is
1861
+ // downloaded or hashed again, so the running machine is stopped only when the next one's inputs are in hand. After a
1862
+ // page reload (bootWmd({restore})) they are fetched, as for any boot.
1863
+ const reuse = !['vmUrl', 'files', 'channels', 'args', 'env', 'maxPages'].some(k => k in over)
1864
+ const assets = reuse ? heldAssets : null
1865
+ if (ck) ckptFail(new CheckpointError('restore', 'a restore began: the checkpoint in progress was abandoned'))
1866
+ // W57: this machine's hub outlives it until the next one's hub has taken the Modules it can reuse (the same
1867
+ // code: Firefox would otherwise hold two copies of the guest's code, and two guests' restores filled its 2 GiB)
1868
+ keepHub = !!(hub && parsed.jit)
1869
+ let oldHub = null
1870
+ try {
1871
+ finish(EXIT_STOPPED, 'stopped')
1872
+ await exited
1873
+ memory = null
1874
+ oldHub = keptHub
1875
+ keptHub = null
1876
+ const next = await bootWmd({ ...o, ...over, checkpoint: true, restore: ckpt, assets, _adopt: oldHub, _net: { instance: netInstance, names: [...netNames] } })
1877
+ heldAssets = null // the new machine holds them
1878
+ return next
1879
+ } finally {
1880
+ keepHub = false
1881
+ ;(oldHub || keptHub)?.terminate() // the replay is over (or never came): the new hub holds what it took
1882
+ keptHub = null
1883
+ }
1884
+ } finally {
1885
+ restoring = false
1886
+ }
1887
+ }
1888
+
1889
+ return {
1890
+ exited,
1891
+ timings,
1892
+ /** The shared memory, for post-mortem reads; null after dispose(). */
1893
+ get memory() {
1894
+ return memory
1895
+ },
1896
+ stop: () => finish(EXIT_STOPPED, 'stopped'),
1897
+ checkpoint,
1898
+ restore,
1899
+ /** P-B9: this boot's identity keys ({vmKey, bootKey}; null without checkpoint support). */
1900
+ keys: () => (ckptOn ? { vmKey, bootKey } : null),
1901
+ /**
1902
+ * P-B11: wmd's control session (src/host/ctl.js ControlClient), when a channel service
1903
+ * {kind: 'control', yamux: true} was given: run / startExec / attach / kill / procs /
1904
+ * simple over the guest init's yamux server. null otherwise.
1905
+ */
1906
+ control,
1907
+ /**
1908
+ * Bytes (or text) for the guest's console channel (hvc0); queued until it is open.
1909
+ * Returns how many bytes were taken: at most consoleSpace(), so the queue for a guest
1910
+ * that does not read stays under channels.consoleInputBytes (4 MiB). The rest is not
1911
+ * sent; write it again once consoleSpace() > 0. 0 without channels or after the end.
1912
+ */
1913
+ consoleWrite: data => {
1914
+ if (!chanSvc || !consoleGate || done || chanDied) return 0
1915
+ const all = typeof data === 'string' ? new TextEncoder().encode(data) : new Uint8Array(data)
1916
+ const n = consoleGate.take(all.length)
1917
+ if (n < all.length) {
1918
+ if (!consoleFull) {
1919
+ consoleFull = true
1920
+ emitErr(
1921
+ `\n[wmd-web] console input is full: the guest has not read the ${fmtMiB(consoleGate.limit)} already queued ` +
1922
+ `(channels.consoleInputBytes); vm.consoleWrite took ${n} of ${all.length} bytes\n`,
1923
+ )
1924
+ ev({ t: 'chan', ev: 'console-full', took: n, of: all.length })
1925
+ }
1926
+ } else consoleFull = false
1927
+ if (!n) return 0
1928
+ const bytes = all.slice(0, n) // our own copy: the caller's buffer is not transferred
1929
+ chanSvc.postMessage({ t: 'console', bytes }, [bytes.buffer])
1930
+ return n
1931
+ },
1932
+ /** Bytes vm.consoleWrite can take now (0 without channels or after the end). */
1933
+ consoleSpace: () => (consoleGate && chanSvc && !done && !chanDied ? consoleGate.space() : 0),
1934
+ /**
1935
+ * P-B10: the guest network (null without a net service with a relay or a switch): stats() asks the
1936
+ * network Worker for its stack and relay stats; dropRelay() drops the relay WebSocket
1937
+ * as a network failure would (test hook).
1938
+ */
1939
+ net: netWorker
1940
+ ? {
1941
+ stats: () =>
1942
+ new Promise(res => {
1943
+ if (!netWorker) return res(netStats)
1944
+ const id = Math.random()
1945
+ netWaiters.set(id, res)
1946
+ netWorker.postMessage({ t: 'stats', id })
1947
+ setTimeout(() => netWaiters.delete(id) && res(netStats), 2000)
1948
+ }),
1949
+ dropRelay: () => netWorker?.postMessage({ t: 'drop-relay' }),
1950
+ /** W59: the relay's state for the page ({state, relay, reason, words}); returns a function that stops the calls */
1951
+ onStatus: cb => {
1952
+ netStatusCbs.add(cb)
1953
+ return () => netStatusCbs.delete(cb)
1954
+ },
1955
+ /** W59: the last status (null before the first) */
1956
+ status: () => netStatus,
1957
+ /** W56: the guest's name and address on its switch (null without one) */
1958
+ member: netMember ? { name: netMember.name, ip: netMember.ip } : null,
1959
+ }
1960
+ : null,
1961
+ /**
1962
+ * W62: the guest's writable layer on 9P service `port` (the files it made, changed and removed
1963
+ * under the service's `at`) as one pack: Promise<{bytes (Uint8Array, a pack), entries, files,
1964
+ * whiteouts, opaque, dataBytes, ms}>. Booted again with `layers: [bytes, ...the image's layers]`
1965
+ * the guest finds the tree it left (a build committed once, cached between visits). The tree
1966
+ * is read as it is at that moment: have the guest finish its writes (`sync`) first. Rejects
1967
+ * after the machine has ended, as the service's tree ends with it.
1968
+ */
1969
+ exportUpper: port =>
1970
+ new Promise((res, rej) => {
1971
+ if (!chanSvc || done || chanDied) return rej(new Error('exportUpper: the machine has ended (its files ended with it)'))
1972
+ const svc = chanSvcs.find(s => s.port === port && (s.kind === '9p' || s.kind === 2))
1973
+ if (!svc) return rej(new Error(`exportUpper: no 9P service on port ${port}`))
1974
+ const id = Math.random()
1975
+ upperWaiters.set(id, { res, rej })
1976
+ chanSvc.postMessage({ t: 'export-upper', id, port })
1977
+ }),
1978
+ /** P-B3 channel counters, and the service's latest stats (refreshed every 250 ms). */
1979
+ chanStats: () => (chanSab ? { ...chanCounters(chanSab), service: chanStats, died: chanDied } : null),
1980
+ /**
1981
+ * Test hooks for the channel service: 'terminate' (the page terminates it), 'die' (it
1982
+ * calls self.close(): only its lock tells the page), 'crash' (an exception in its loop),
1983
+ * ['stall', ms] (busy for ms).
1984
+ */
1985
+ chanTest: (what, arg) => {
1986
+ if (!chanSvc) return false
1987
+ if (what === 'terminate') {
1988
+ chanSvc.terminate()
1989
+ serviceDied('the channel service Worker was terminated')
1990
+ } else chanSvc.postMessage({ t: what, ms: arg })
1991
+ return true
1992
+ },
1993
+ /**
1994
+ * P-B8 test hook (needs bootWmd({stallHook: true})): the Worker running guest thread `tid`
1995
+ * (1 = _start, vCPU 0) blocks at its next host call for `ms`, as if the browser had stopped
1996
+ * it. One stall at a time: false if the hook is off, the machine has ended, or a stall is
1997
+ * still armed. vm.stallThread(0) ends a running stall early. vm.stats().stall reports it.
1998
+ */
1999
+ stallThread: (tid, ms = 0) => {
2000
+ if (!o.stallHook || done) return false
2001
+ if (!tid) {
2002
+ Atomics.store(ctl, CTL_STALL_TID, 0)
2003
+ Atomics.add(ctl, CTL_STALL_WAKE, 1)
2004
+ Atomics.notify(ctl, CTL_STALL_WAKE)
2005
+ return true
2006
+ }
2007
+ Atomics.store(ctl, CTL_STALL_MS, Math.max(0, Math.min(2 ** 31 - 1, Math.round(ms))))
2008
+ return Atomics.compareExchange(ctl, CTL_STALL_TID, 0, tid) === 0
2009
+ },
2010
+ /** Stop if running and drop the last reference this handle holds (the memory). */
2011
+ dispose: () => {
2012
+ finish(EXIT_STOPPED, 'stopped')
2013
+ memory = null
2014
+ },
2015
+ stats: () => ({
2016
+ exited: Atomics.load(ctl, CTL_EXIT),
2017
+ exitCode: Atomics.load(ctl, CTL_EXIT_CODE),
2018
+ liveThreads: Atomics.load(ctl, CTL_LIVE_THREADS),
2019
+ poolWorkers: Atomics.load(ctl, CTL_POOL_N),
2020
+ spawnFailures: Atomics.load(ctl, CTL_SPAWN_FAIL),
2021
+ memoryPages: memory ? memory.buffer.byteLength / PAGE : null,
2022
+ jit: jitStore ? readJitStats(jitStore) : null,
2023
+ hostCalls: hostCallCounts(ctl),
2024
+ throttle: throttle ? throttle.state() : null,
2025
+ noProgressMs: done ? null : Math.round(watchdog.idleMs()),
2026
+ stall: o.stallHook
2027
+ ? {
2028
+ armed: Atomics.load(ctl, CTL_STALL_TID),
2029
+ taken: Atomics.load(ctl, CTL_STALL_TAKEN),
2030
+ done: Atomics.load(ctl, CTL_STALL_DONE),
2031
+ at: new Float64Array(ctlSab)[F64_STALL_AT / 8],
2032
+ end: new Float64Array(ctlSab)[F64_STALL_END / 8],
2033
+ }
2034
+ : null,
2035
+ }),
2036
+ }
2037
+ }