@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.
- package/README.md +2601 -2
- package/RELEASE.json +123 -0
- package/export-page/coi-sw.js +20 -0
- package/export-page/index.html +17 -0
- package/export-page/run.css +10 -0
- package/export-page/run.js +236 -0
- package/guest/arm64/Image +4 -0
- package/guest/arm64/Image-4k +4 -0
- package/guest/arm64/initramfs.cpio.gz +0 -0
- package/guest/arm64/vm.wasm +0 -0
- package/package.json +36 -4
- package/page/sw.js +381 -0
- package/relay/control.ts +139 -0
- package/relay/engine.ts +787 -0
- package/relay/hosted.ts +660 -0
- package/relay/keys.ts +53 -0
- package/relay/local-tls.ts +121 -0
- package/relay/lock.ts +81 -0
- package/relay/logfile.ts +138 -0
- package/relay/main.ts +642 -0
- package/relay/policy.ts +241 -0
- package/relay/relay.ts +266 -0
- package/relay/token.ts +116 -0
- package/relay/v1.ts +107 -0
- package/relay/wisp.ts +615 -0
- package/src/base.js +325 -0
- package/src/boot.js +2037 -0
- package/src/calib-worker.js +40 -0
- package/src/fs/9p/codec.js +303 -0
- package/src/fs/9p/server.js +521 -0
- package/src/fs/errno.js +43 -0
- package/src/fs/graph.js +380 -0
- package/src/fs/overlay.js +1018 -0
- package/src/fs/pack-write.js +165 -0
- package/src/fs/pack.js +427 -0
- package/src/fs/seed.js +22 -0
- package/src/fs/svc-snapshot.js +65 -0
- package/src/fs/upper-pack.js +112 -0
- package/src/host/base-cache.js +195 -0
- package/src/host/base-file.js +328 -0
- package/src/host/base-io.js +93 -0
- package/src/host/boot-events.js +209 -0
- package/src/host/chan-service.js +300 -0
- package/src/host/chan-services.js +689 -0
- package/src/host/chan.js +655 -0
- package/src/host/ckpt-file.js +395 -0
- package/src/host/ckpt-io.js +207 -0
- package/src/host/ckpt-point.js +160 -0
- package/src/host/ckpt-save.js +64 -0
- package/src/host/ckpt.js +434 -0
- package/src/host/clock.js +111 -0
- package/src/host/ctl.js +778 -0
- package/src/host/errno.js +18 -0
- package/src/host/fs.js +167 -0
- package/src/host/gcnudge.js +83 -0
- package/src/host/i64shim.js +81 -0
- package/src/host/jit-cache.js +341 -0
- package/src/host/jit-hub-core.js +208 -0
- package/src/host/jit-hub.js +93 -0
- package/src/host/jit-snap.js +226 -0
- package/src/host/jit.js +1903 -0
- package/src/host/layout.js +100 -0
- package/src/host/monitor.js +110 -0
- package/src/host/p9mount.js +27 -0
- package/src/host/ram-mem.js +125 -0
- package/src/host/stdio.js +143 -0
- package/src/host/threads.js +109 -0
- package/src/host/wasi.js +335 -0
- package/src/host/wmd-imports.js +71 -0
- package/src/host/yamux.js +387 -0
- package/src/image-files.js +154 -0
- package/src/net/dhcp-dns.js +126 -0
- package/src/net/net-worker.js +172 -0
- package/src/net/packet.js +227 -0
- package/src/net/peer.js +230 -0
- package/src/net/proto.js +189 -0
- package/src/net/relay-client.js +226 -0
- package/src/net/stack.js +867 -0
- package/src/net/switch.js +160 -0
- package/src/net/wisp-client.js +570 -0
- package/src/net/wisp-transport.js +196 -0
- package/src/net/wisp.js +511 -0
- package/src/registry/client.js +176 -0
- package/src/registry/converter.js +68 -0
- package/src/registry/hub.js +221 -0
- package/src/registry/oci.js +76 -0
- package/src/registry/pack-worker.js +70 -0
- package/src/registry/pull.js +258 -0
- package/src/registry/ref.js +110 -0
- package/src/registry/wasi-mem.js +355 -0
- package/src/worker.js +315 -0
- package/wmdpack.wasm +0 -0
|
@@ -0,0 +1,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
|
+
}
|