@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
@@ -0,0 +1,93 @@
1
+ // P-B6: the base snapshot cache's Worker: one OPFS file per base snapshot in the directory `wmd-base`, written through
2
+ // FileSystemSyncAccessHandle (the one OPFS write API all three browsers give a Worker). The page reads a cached file with
3
+ // getFile().stream() (base-cache.js).
4
+ //
5
+ // in: {id, op:'open', name} -> {} creates (or empties) the file; an earlier open handle of this Worker is closed
6
+ // {id, op:'write', name, at, buf} -> {} (buf is transferred)
7
+ // {id, op:'commit', name, magic} -> {} flush, then the 8 magic bytes at 0 (the file is valid only from here)
8
+ // {id, op:'abort', name} -> {} close and delete
9
+ // {id, op:'list'} -> {items:[{name,size}]}
10
+ // {id, op:'delete', name} | {id, op:'clear'}
11
+ // out: {id, ok:true, ...} or {id, ok:false, code, error}
12
+ const DIR = 'wmd-base'
13
+ const handles = new Map()
14
+ async function dir() {
15
+ return (await navigator.storage.getDirectory()).getDirectoryHandle(DIR, { create: true })
16
+ }
17
+ const check = n => {
18
+ if (!/^[A-Za-z0-9._-]{1,120}$/.test(n) || n.startsWith('.')) throw Object.assign(new Error(`bad cache file name ${JSON.stringify(n)}`), { code: 'malformed' })
19
+ return n
20
+ }
21
+ function writeAll(h, u8, at) {
22
+ let done = 0
23
+ while (done < u8.length) {
24
+ const n = h.write(done ? u8.subarray(done) : u8, { at: at + done })
25
+ if (!(n > 0)) throw Object.assign(new Error(`write stopped at ${at + done} (storage full?)`), { code: 'quota' })
26
+ done += n
27
+ }
28
+ }
29
+ async function handle(m) {
30
+ switch (m.op) {
31
+ case 'open': {
32
+ const name = check(m.name)
33
+ handles.get(name)?.close()
34
+ handles.delete(name)
35
+ const h = await (await (await dir()).getFileHandle(name, { create: true })).createSyncAccessHandle()
36
+ h.truncate(0)
37
+ handles.set(name, h)
38
+ return {}
39
+ }
40
+ case 'write': {
41
+ const h = handles.get(check(m.name))
42
+ if (!h) throw Object.assign(new Error(`cache file ${m.name} is not open`), { code: 'io' })
43
+ writeAll(h, new Uint8Array(m.buf), m.at)
44
+ return {}
45
+ }
46
+ case 'commit': {
47
+ const name = check(m.name)
48
+ const h = handles.get(name)
49
+ if (!h) throw Object.assign(new Error(`cache file ${name} is not open`), { code: 'io' })
50
+ h.flush()
51
+ writeAll(h, new Uint8Array(m.magic), 0)
52
+ h.flush()
53
+ h.close()
54
+ handles.delete(name)
55
+ return {}
56
+ }
57
+ case 'abort': {
58
+ const name = check(m.name)
59
+ try {
60
+ handles.get(name)?.close()
61
+ } catch {}
62
+ handles.delete(name)
63
+ await (await dir()).removeEntry(name).catch(() => {})
64
+ return {}
65
+ }
66
+ case 'list': {
67
+ const d = await dir()
68
+ const items = []
69
+ for await (const [n, fh] of d.entries()) items.push({ name: n, size: (await fh.getFile()).size })
70
+ return { items }
71
+ }
72
+ case 'delete':
73
+ for (const [n, h] of handles) if (n === m.name) (h.close(), handles.delete(n))
74
+ await (await dir()).removeEntry(check(m.name)).catch(() => {})
75
+ return {}
76
+ case 'clear': {
77
+ for (const h of handles.values()) try { h.close() } catch {}
78
+ handles.clear()
79
+ await (await navigator.storage.getDirectory()).removeEntry(DIR, { recursive: true }).catch(() => {})
80
+ return {}
81
+ }
82
+ default:
83
+ throw new Error(`base-io: unknown op ${m.op}`)
84
+ }
85
+ }
86
+ self.onmessage = async e => {
87
+ const m = e.data
88
+ try {
89
+ self.postMessage({ id: m.id, ok: true, ...(await handle(m)) })
90
+ } catch (err) {
91
+ self.postMessage({ id: m.id, ok: false, code: err.code || (err.name === 'QuotaExceededError' ? 'quota' : 'io'), error: err && err.message ? `${err.name}: ${err.message}` : String(err) })
92
+ }
93
+ }
@@ -0,0 +1,209 @@
1
+ // W24: the start-up events bootWmd's onEvent gets for wmn's engine start-up screen (wmn D-211, ROADMAP 7.14).
2
+ //
3
+ // {t:'boot', stage:'download', state:'progress'|'done', file, bytes, total, cached, atMs, tookMs?} per file
4
+ // {t:'boot', stage:'download', state:'done', fileBytes, layerBytes, atMs, tookMs} all files in
5
+ // {t:'boot', stage:'compile', state:'start'|'done', moduleBytes, reused, atMs, tookMs?}
6
+ // {t:'boot', stage:'memory', state:'done', initialBytes, maxBytes, ringBytes, atMs, tookMs}
7
+ // {t:'boot', stage:'workers', state:'progress'|'done', workers:[{id, role, state, atMs, tid?}], vcpus, jitShared, atMs, tookMs?}
8
+ // {t:'boot', stage:'kernel', state:'start'|'done', atMs, tookMs?}
9
+ // {t:'boot', stage:'restore', state:'progress'|'done', bytes, total, source, atMs, tookMs?}
10
+ // {t:'boot', stage:'control', state:'done', atMs, tookMs}
11
+ // {t:'heap', bytes, maxBytes, atMs}
12
+ // {t:'layer', port, usedBytes, capBytes, kind, atMs}
13
+ // {t:'exit', code, reason, oom, atMs}
14
+ //
15
+ // atMs: milliseconds from bootWmd's t0, on the page's clock; tookMs: the stage's own duration. Sizes are bytes.
16
+ // Progress-like events (download and restore progress, workers progress, heap, layer) are sent at most `perSec` times a
17
+ // second per kind (a download's kind is its file, a layer's its port): the latest one waits and goes out when the
18
+ // interval allows, and a 'start' or 'done' of the same kind replaces a waiting one. Every other event goes out at once.
19
+
20
+ export function createBootEvents({ emit, t0, now, perSec = 4 }) {
21
+ const gap = 1000 / perSec
22
+ const kinds = new Map() // kind -> {last, pending, timer}
23
+ let closed = false
24
+ const at = () => Math.round(now() - t0)
25
+
26
+ function send(e) {
27
+ if (closed) return
28
+ try {
29
+ emit(e)
30
+ } catch {} // a caller's handler that throws must not break the boot
31
+ }
32
+ function flush(k) {
33
+ const s = kinds.get(k)
34
+ s.timer = 0
35
+ if (!s.pending) return
36
+ const e = s.pending
37
+ s.pending = null
38
+ s.last = now()
39
+ send(e)
40
+ }
41
+ /** A progress-like event: throttled per kind. */
42
+ function progress(kind, e) {
43
+ if (closed) return
44
+ let s = kinds.get(kind)
45
+ if (!s) kinds.set(kind, (s = { last: -Infinity, pending: null, timer: 0 }))
46
+ const ev = { ...e, atMs: at() }
47
+ const wait = s.last + gap - now()
48
+ if (wait <= 0 && !s.timer) {
49
+ s.last = now()
50
+ send(ev)
51
+ return
52
+ }
53
+ s.pending = ev
54
+ if (!s.timer) s.timer = setTimeout(() => flush(kind), Math.max(0, wait))
55
+ }
56
+ /** An event that always goes out now; a waiting progress event of the same kind is dropped (this one supersedes it). */
57
+ function now_(kind, e) {
58
+ if (closed) return
59
+ const s = kinds.get(kind)
60
+ if (s) {
61
+ clearTimeout(s.timer)
62
+ s.timer = 0
63
+ s.pending = null
64
+ }
65
+ send({ ...e, atMs: at() })
66
+ }
67
+ /** Stop: waiting events are dropped, nothing more is sent. */
68
+ function close() {
69
+ for (const s of kinds.values()) clearTimeout(s.timer)
70
+ kinds.clear()
71
+ closed = true
72
+ }
73
+ return { progress, now: now_, close, at }
74
+ }
75
+
76
+ /**
77
+ * The name a boot file gets in download events: "kernel" or "initramfs" when vm.wasm's --kernel or --initrd names it (as
78
+ * `${preopen}/${path}`), otherwise its preopen path.
79
+ */
80
+ export function bootFileName(path, args, preopen) {
81
+ const full = `${preopen.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
82
+ for (let i = 0; i + 1 < args.length; i++) {
83
+ if (args[i + 1] !== full) continue
84
+ if (args[i] === '--kernel') return 'kernel'
85
+ if (args[i] === '--initrd') return 'initramfs'
86
+ }
87
+ return path
88
+ }
89
+
90
+ /** vCPUs vm.wasm runs: its --cpus (default 1), clamped to 1..64 as vm.wasm does (main_wasm.c, VM_MAX_VCPUS). */
91
+ export function vcpusOf(args) {
92
+ let n = 1
93
+ for (let i = 0; i + 1 < args.length; i++) if (args[i] === '--cpus') n = parseInt(args[i + 1], 10) || 0
94
+ return Math.max(1, Math.min(64, n))
95
+ }
96
+
97
+ /**
98
+ * Whether a response came without the network, from the page's Resource Timing entry for `url`: true when the body arrived
99
+ * with nothing transferred (a service worker's Cache Storage; bootWmd's fetches bypass the HTTP cache), false when bytes
100
+ * were transferred, null when the browser has no entry to say (a full timing buffer, a browser that does not record it).
101
+ * Firefox and WebKit report a service worker's cached response with decodedBodySize 0 too (W24 probe, page/w24/), as every
102
+ * browser does for a cross-origin file without Timing-Allow-Origin: so a zero-size entry counts as cached only when the
103
+ * response was same-origin (`finalUrl`, after redirects) and `bytes` were read.
104
+ */
105
+ export function cachedFromTiming(url, { bytes = 0, finalUrl = url, before = 0 } = {}, perf = globalThis.performance) {
106
+ let list
107
+ let sameOrigin = false
108
+ try {
109
+ const base = globalThis.location?.href
110
+ list = perf?.getEntriesByName?.(new URL(url, base).href, 'resource')
111
+ sameOrigin = !!globalThis.location && new URL(finalUrl || url, base).origin === globalThis.location.origin
112
+ } catch {
113
+ return null
114
+ }
115
+ // `before`: the entries for this URL before this fetch (timingCount): an earlier boot's entry is not this one's
116
+ const e = list && list.length > before ? list[list.length - 1] : null
117
+ if (!e || typeof e.transferSize !== 'number') return null
118
+ if (e.transferSize > 0) return false
119
+ if (e.decodedBodySize > 0) return true
120
+ return sameOrigin && bytes > 0 ? true : null
121
+ }
122
+
123
+ /** How many Resource Timing entries the page holds for `url` now (cachedFromTiming's `before`). */
124
+ export function timingCount(url, perf = globalThis.performance) {
125
+ try {
126
+ return perf?.getEntriesByName?.(new URL(url, globalThis.location?.href).href, 'resource')?.length ?? 0
127
+ } catch {
128
+ return 0
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Read a response's body, calling onBytes(received, total) as it comes. total is the Content-Length when the body is not
134
+ * content-encoded (an encoded length is not the decoded size), else null. Returns a SharedArrayBuffer (shared) or an
135
+ * ArrayBuffer of the body.
136
+ */
137
+ export async function readBody(r, { shared, onBytes }) {
138
+ const enc = r.headers.get('content-encoding')
139
+ const lenHdr = r.headers.get('content-length')
140
+ const len = lenHdr === null || lenHdr.trim() === '' ? NaN : Number(lenHdr)
141
+ const total = (!enc || enc === 'identity') && Number.isSafeInteger(len) && len >= 0 ? len : null
142
+ if (!r.body || !r.body.getReader) {
143
+ const ab = await r.arrayBuffer()
144
+ onBytes?.(ab.byteLength, total)
145
+ return shared ? toSab(new Uint8Array(ab)) : ab
146
+ }
147
+ const rd = r.body.getReader()
148
+ const parts = []
149
+ let n = 0
150
+ onBytes?.(0, total)
151
+ for (;;) {
152
+ const { done, value } = await rd.read()
153
+ if (done) break
154
+ parts.push(value)
155
+ n += value.byteLength
156
+ onBytes?.(n, total)
157
+ }
158
+ const buf = shared ? new SharedArrayBuffer(n) : new ArrayBuffer(n)
159
+ const u8 = new Uint8Array(buf)
160
+ let off = 0
161
+ for (const p of parts) {
162
+ u8.set(p, off)
163
+ off += p.byteLength
164
+ }
165
+ return buf
166
+ }
167
+
168
+ function toSab(u8) {
169
+ const sab = new SharedArrayBuffer(u8.byteLength)
170
+ new Uint8Array(sab).set(u8)
171
+ return sab
172
+ }
173
+
174
+ /**
175
+ * The Workers list of the 'workers' stage. roles: 'vcpu' (the main Worker: vCPU 0), 'pool' (a thread-pool Worker, which
176
+ * runs the guest's other threads: further vCPUs and vm.wasm's own threads; tid is the guest thread it runs now), 'jit-hub',
177
+ * 'chan-service', 'net'. onChange(list, allReady) after each change.
178
+ */
179
+ export function createWorkerList(at) {
180
+ const ws = new Map()
181
+ return {
182
+ set(id, role, state, extra) {
183
+ const w = ws.get(id) || { id, role }
184
+ const next = { ...w, ...extra, role, state, atMs: at() }
185
+ if (next.tid === undefined) delete next.tid
186
+ ws.set(id, next)
187
+ },
188
+ tid(id, tid) {
189
+ const w = ws.get(id)
190
+ if (!w) return false
191
+ const next = { ...w }
192
+ if (tid == null) delete next.tid
193
+ else next.tid = tid
194
+ ws.set(id, next)
195
+ return true
196
+ },
197
+ endAll() {
198
+ for (const [id, w] of ws) {
199
+ if (w.state === 'failed' || w.state === 'ended') continue
200
+ const next = { ...w, state: 'ended', atMs: at() }
201
+ delete next.tid
202
+ ws.set(id, next)
203
+ }
204
+ },
205
+ list: () => [...ws.values()].map(w => ({ ...w })),
206
+ allReady: () => ws.size > 0 && [...ws.values()].every(w => w.state === 'ready'),
207
+ has: id => ws.has(id),
208
+ }
209
+ }
@@ -0,0 +1,300 @@
1
+ // P-B3: the channel service Worker. Serves every channel of one machine (ChanHost in
2
+ // chan-services.js) off the page's main thread.
3
+ //
4
+ // It never blocks: it waits on the chan block's doorbell with Atomics.waitAsync, so its
5
+ // event loop stays free for the page's messages (console input, stats requests). After
6
+ // any work it spins on the doorbell for 200 us before going back to waitAsync: a request
7
+ // that arrives within that time is served without a wakeup through the event loop. W51:
8
+ // while it is serving 9P requests the spin may be longer, up to spinUs (5 ms), adapting as the
9
+ // vm's halt poll (D-307) does: a doorbell that came within spinUs after the spin gave up
10
+ // doubles the window (a longer spin would have caught it), a longer wait halves it, so a
11
+ // guest whose 9P traffic stops costs no spinning. Other traffic keeps the 200 us spin.
12
+ // Safari wakes a parked waiter 1-2 ms late when other threads are busy, which was most of
13
+ // its 9P round trip (P-B3: 1075 us p50 with a 200 us spin, 220 us with 5 ms). A spin never
14
+ // runs past the 10 ms yield, so messages wait at most about 10 ms as before.
15
+ //
16
+ // Death: the Worker holds a Web Lock for its whole life. The page asks for the same lock;
17
+ // the browser grants it when this Worker ends for any reason (terminated, closed,
18
+ // crashed), and the page then marks the service dead in the chan block (markServiceDead),
19
+ // which wakes every guest thread waiting on a channel. An exception inside the loop marks
20
+ // it dead from here first.
21
+ //
22
+ // Messages in: {t:'init', sab, services:[{port, kind, layers:[SharedArrayBuffer|ArrayBuffer],
23
+ // at, files, maxUpperBytes, yamux}], lockName, spinUs, consoleInputBytes}
24
+ // (init's netPort: P-B10, a MessagePort to the network Worker, src/net/net-worker.js)
25
+ // {t:'ctl', op, k, bytes?, n?} P-B11: a control-session stream op (ChanHost.controlInput)
26
+ // {t:'console', bytes} page -> guest console bytes (held up to
27
+ // consoleInputBytes; boot.js's gate keeps under it)
28
+ // {t:'stats'} reply {t:'stats', stats}
29
+ // {t:'collect', files:[{port, path}]} reply {t:'collected', files: {"port:path": text|null}}
30
+ // {t:'export-upper', id, port} W62: reply {t:'upper', id, bytes, entries, files, whiteouts,
31
+ // opaque, dataBytes, ms} (the pack, transferred) or {t:'upper', id, error}
32
+ // test hooks: {t:'die'} (self.close() at once), {t:'crash'} (throw in the loop),
33
+ // {t:'stall', ms} (busy for ms inside the loop)
34
+ // Messages out: {t:'ready'}, {t:'locked'}, {t:'stats', stats}, {t:'console-bytes', n},
35
+ // {t:'ctl', evs:[...]} (P-B11: control session events, ChanHost.takeCtlEvents),
36
+ // {t:'error', message}, {t:'upper-full', port, used, max} (a 9P write was
37
+ // refused with ENOSPC for maxUpperBytes; at most one per port every 10 s)
38
+
39
+ import { H_DOORBELL, H_SVC, SVC_RUNNING, markServiceDead } from './chan.js'
40
+ import { ChanHost } from './chan-services.js'
41
+ import { Pack, bytesSource } from '../fs/pack.js'
42
+ import { Overlay } from '../fs/overlay.js'
43
+ import { seedEntry } from '../fs/seed.js'
44
+ import { exportUpper } from '../fs/upper-pack.js'
45
+
46
+ let host = null
47
+ let sab = null
48
+ const svcAt = {} // port -> where its layers show (W62: the upper is exported from there)
49
+ let crash = false
50
+ let stallMs = 0
51
+ let spinUs = 5000 // longest spin (W51)
52
+ const SPIN_BASE_US = 200 // the spin after any work
53
+ let spinCur = 0 // W51: the window after 9P work, us (adapts between 0 and spinUs)
54
+ const spin = { hits: 0, sleeps: 0 }
55
+ let lastP9 = 0
56
+ let lastP9At = -Infinity
57
+
58
+ const nowMs = () => performance.now()
59
+
60
+ self.onmessage = e => {
61
+ const m = e.data
62
+ try {
63
+ switch (m.t) {
64
+ case 'init':
65
+ init(m)
66
+ break
67
+ case 'console':
68
+ host?.consoleInput(new Uint8Array(m.bytes).slice())
69
+ kick()
70
+ break
71
+ case 'ctl':
72
+ if (host) {
73
+ host.controlInput(m)
74
+ postCtl()
75
+ kick()
76
+ }
77
+ break
78
+ case 'stats':
79
+ self.postMessage({ t: 'stats', stats: host ? withSpin(host.snapshot()) : null, id: m.id })
80
+ break
81
+ case 'snap': {
82
+ // P-B9: the 9P trees and fid tables as bytes (the guest is stopped at its checkpoint point)
83
+ const t0 = nowMs()
84
+ try {
85
+ const ports = host.snapshotState()
86
+ const out = {}
87
+ const transfer = []
88
+ for (const [port, d] of Object.entries(ports)) {
89
+ const segments = d.segments.map(u => u.buffer)
90
+ out[port] = { segments, bytes: d.bytes, fids: d.fids, inodes: d.inodes }
91
+ transfer.push(...segments)
92
+ }
93
+ self.postMessage({ t: 'snap', ports: out, ms: nowMs() - t0 }, transfer)
94
+ } catch (err) {
95
+ self.postMessage({ t: 'snap', error: err && err.message ? `${err.name}: ${err.message}` : String(err) })
96
+ }
97
+ break
98
+ }
99
+ case 'export-upper': {
100
+ const t0 = nowMs()
101
+ try {
102
+ if (!host) throw new Error('the channel service has not started')
103
+ const r = exportUpper(host.fsFor(m.port), { at: svcAt[m.port] || '' })
104
+ self.postMessage({ t: 'upper', id: m.id, ...r, ms: nowMs() - t0 }, [r.bytes.buffer])
105
+ } catch (err) {
106
+ self.postMessage({ t: 'upper', id: m.id, error: err && err.message ? `${err.name}: ${err.message}` : String(err) })
107
+ }
108
+ break
109
+ }
110
+ case 'collect': {
111
+ // files out of the 9P trees, e.g. bundle/exitcode once the guest has ended
112
+ const files = {}
113
+ for (const f of m.files || []) {
114
+ try {
115
+ files[`${f.port}:${f.path}`] = new TextDecoder().decode(host.fsFor(f.port).readFile(f.path))
116
+ } catch (e) {
117
+ files[`${f.port}:${f.path}`] = null
118
+ }
119
+ }
120
+ self.postMessage({ t: 'collected', files })
121
+ break
122
+ }
123
+ case 'die':
124
+ self.close() // ends at once, mid-whatever; the lock tells the page
125
+ break
126
+ case 'crash':
127
+ crash = true
128
+ kick()
129
+ break
130
+ case 'stall':
131
+ stallMs = m.ms
132
+ kick()
133
+ break
134
+ }
135
+ } catch (err) {
136
+ fail(err)
137
+ }
138
+ }
139
+
140
+ /** Control events for the page (data bytes transferred). */
141
+ function postCtl() {
142
+ const evs = host?.takeCtlEvents()
143
+ if (!evs) return
144
+ const transfer = []
145
+ for (const e of evs) if (e.bytes) transfer.push(e.bytes.buffer)
146
+ self.postMessage({ t: 'ctl', evs }, transfer)
147
+ }
148
+
149
+ function kick() {
150
+ if (!sab) return
151
+ const i32 = new Int32Array(sab)
152
+ Atomics.add(i32, H_DOORBELL, 1)
153
+ Atomics.notify(i32, H_DOORBELL)
154
+ }
155
+
156
+ function fail(err) {
157
+ if (sab) markServiceDead(sab)
158
+ // Firefox and WebKit stacks do not include the message
159
+ self.postMessage({ t: 'error', message: err && err.message !== undefined ? `${err.name || 'Error'}: ${err.message}\n${err.stack || ''}` : String(err) })
160
+ self.close()
161
+ }
162
+
163
+ const FULL_NOTE_MS = 10_000
164
+
165
+ function buildFs(svc) {
166
+ const layers = (svc.layers || []).map((b, i) => {
167
+ const u8 = new Uint8Array(b)
168
+ try {
169
+ return new Pack(bytesSource(u8), `layer ${i}`)
170
+ } catch (e) {
171
+ // what arrived, so a bad transfer can be told from a bad file
172
+ const show = a => Array.from(a, x => x.toString(16).padStart(2, '0')).join('')
173
+ let nonzero = 0
174
+ for (let k = 0; k < u8.length; k += 4096) if (u8[k]) nonzero++
175
+ throw new Error(`${e.message} (${u8.length} bytes${b instanceof SharedArrayBuffer ? ', shared' : ''}; first 8 ${show(u8.subarray(0, 8))}, last 8 ${show(u8.subarray(-8))}; ${nonzero} of ${Math.ceil(u8.length / 4096)} pages start non-zero)`)
176
+ }
177
+ })
178
+ let noted = -Infinity
179
+ const onFull = f => {
180
+ const t = nowMs()
181
+ if (t - noted < FULL_NOTE_MS) return
182
+ noted = t
183
+ self.postMessage({ t: 'upper-full', port: svc.port, used: f.used, max: f.maxUpperBytes, hits: f.fullHits })
184
+ }
185
+ const fs = new Overlay({ layers, at: svc.at || '', maxUpperBytes: svc.maxUpperBytes ?? Infinity, onFull })
186
+ // a restored tree comes whole from the checkpoint (ChanHost restore), the bundle files included
187
+ for (const [path, f] of Object.entries(svc.restore ? {} : svc.files || {})) {
188
+ seedEntry(fs, path, f)
189
+ }
190
+ return fs
191
+ }
192
+
193
+ function init(m) {
194
+ sab = m.sab
195
+ spinUs = m.spinUs ?? spinUs
196
+ const fs = {}
197
+ for (const svc of m.services || []) {
198
+ if (svc.kind !== '9p' && svc.kind !== 2) continue
199
+ fs[svc.port] = buildFs(svc)
200
+ svcAt[svc.port] = svc.at || ''
201
+ }
202
+ const yamuxPorts = (m.services || []).filter(s => (s.kind === 'control' || s.kind === 3) && s.yamux).map(s => s.port)
203
+ let netPost = null
204
+ if (m.netPort) {
205
+ // P-B10: the net channel's frames go to and come from the network Worker
206
+ const p = m.netPort
207
+ netPost = (msg, transfer) => p.postMessage(msg, transfer || [])
208
+ p.onmessage = e => {
209
+ try {
210
+ host?.netMessage(e.data)
211
+ kick()
212
+ } catch (err) {
213
+ fail(err)
214
+ }
215
+ }
216
+ }
217
+ const restore = {}
218
+ for (const svc of m.services || []) if (svc.restore) restore[svc.port] = new Uint8Array(svc.restore)
219
+ host = new ChanHost(sab, { fs, consoleInputBytes: m.consoleInputBytes, yamuxPorts, netPost, restore })
220
+ if (m.lockName && self.navigator?.locks?.request) {
221
+ self.navigator.locks.request(m.lockName, () => {
222
+ self.postMessage({ t: 'locked' })
223
+ return new Promise(() => {}) // held until this Worker ends
224
+ })
225
+ }
226
+ Atomics.store(new Int32Array(sab), H_SVC, SVC_RUNNING)
227
+ self.postMessage({ t: 'ready' })
228
+ setInterval(() => host && !host.stopped && maybePostStats(), 250) // stats while idle (and keeps a Bun Worker alive)
229
+ loop().catch(fail)
230
+ }
231
+
232
+ let lastStats = ''
233
+ const withSpin = s => ({ ...s, spin: { ...spin, curUs: spinCur } })
234
+ let lastStatsAt = 0
235
+ function maybePostStats() {
236
+ const t = nowMs()
237
+ if (t - lastStatsAt < 250) return
238
+ lastStatsAt = t
239
+ const s = host.snapshot()
240
+ const key = JSON.stringify(s) // the spin counters alone do not make news
241
+ if (key === lastStats) return
242
+ lastStats = key
243
+ self.postMessage({ t: 'stats', stats: withSpin(s) })
244
+ }
245
+
246
+ // A macrotask without setTimeout's clamping, to let messages in during long busy runs.
247
+ const yieldChan = new MessageChannel()
248
+ const yieldQ = []
249
+ yieldChan.port1.onmessage = () => yieldQ.shift()?.()
250
+ const yieldTask = () => new Promise(r => (yieldQ.push(r), yieldChan.port2.postMessage(0)))
251
+
252
+ async function loop() {
253
+ const i32 = new Int32Array(sab)
254
+ let lastYield = nowMs()
255
+ for (;;) {
256
+ if (crash) throw new Error('chan service: crash requested (test hook)')
257
+ if (stallMs) {
258
+ const end = nowMs() + stallMs
259
+ stallMs = 0
260
+ while (nowMs() < end) {}
261
+ }
262
+ const bell = Atomics.load(i32, H_DOORBELL)
263
+ const work = host.step()
264
+ postCtl()
265
+ if (host.stopped) return
266
+ const t = nowMs()
267
+ if (t - lastYield > 10) {
268
+ maybePostStats()
269
+ await yieldTask()
270
+ lastYield = nowMs()
271
+ continue
272
+ }
273
+ if (work) continue
274
+ // spin on the doorbell for a little while: the next request often comes at once
275
+ // 9P work within the last 2 spinUs: the guest's next request is likely on its way (the
276
+ // doorbell also rings for other things, such as the guest reading a reply)
277
+ const p9 = host.p9Requests()
278
+ if (p9 !== lastP9) (lastP9 = p9), (lastP9At = t)
279
+ const p9Served = t - lastP9At < (2 * spinUs) / 1000
280
+ const spinWin = Math.min(spinUs, p9Served ? Math.max(spinCur, SPIN_BASE_US) : SPIN_BASE_US)
281
+ const capped = lastYield + 10 < t + spinWin / 1000 // the yield, not the window, ends this spin
282
+ const spinEnd = capped ? lastYield + 10 : t + spinWin / 1000
283
+ if (Atomics.load(i32, H_DOORBELL) !== bell) continue // rang during the step: not a spin hit
284
+ while (Atomics.load(i32, H_DOORBELL) === bell && nowMs() < spinEnd) {}
285
+ if (Atomics.load(i32, H_DOORBELL) !== bell) {
286
+ spin.hits++
287
+ continue
288
+ }
289
+ maybePostStats()
290
+ const w = Atomics.waitAsync(i32, H_DOORBELL, bell, 500)
291
+ if (w.async) await w.value
292
+ lastYield = nowMs()
293
+ if (!p9Served || capped) continue
294
+ // adapt (after 9P work only): rung soon after the spin gave up -> longer; quiet -> shorter
295
+ const rung = Atomics.load(i32, H_DOORBELL) !== bell
296
+ if (rung) spin.sleeps++
297
+ if (rung && (lastYield - spinEnd) * 1000 <= spinUs) spinCur = Math.min(spinUs, spinCur ? spinCur * 2 : SPIN_BASE_US * 2)
298
+ else if ((spinCur /= 2) < SPIN_BASE_US) spinCur = 0
299
+ }
300
+ }