@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,689 @@
1
+ // P-B3: the host side of the channels: one ChanHost serves every channel of a machine
2
+ // from the service Worker (chan-service.js). It is synchronous and transport-free, so the
3
+ // unit tests drive it directly (step()).
4
+ //
5
+ // Services, by the kind registered for the port (not the kind the guest passed, as in
6
+ // chan.rs, where the port decides who answers):
7
+ // 9p the 9P2000.L server (src/fs/9p/server.js) over an Overlay of packs. One
8
+ // Overlay per port, one NinePServer per open channel (a connection). Frames are
9
+ // 9P's own (size[4] LE). A size < 7 or > MAX_FRAME is a protocol error: the
10
+ // host closes the channel, as wmd's Go server drops the connection.
11
+ // console raw bytes. Guest -> the page through the pipe ring (backpressure, no loss);
12
+ // page -> guest from consoleInput(), at most consoleInputBytes held (the
13
+ // page's consoleInputGate keeps under it; bytes past it are dropped, counted).
14
+ // control a stub: it reads and counts the bytes (and their CRC-32, so a test can check
15
+ // content and order) and answers nothing. On a port listed in `yamuxPorts`
16
+ // (P-B11), the host side of wmd's control session instead: a yamux client
17
+ // (yamux.js) whose streams the page drives through controlInput() and
18
+ // ctlEvents (ControlSession below; the protocol on the streams is ctl.js's).
19
+ // net P-B10: with a network Worker (o.netPost, src/net/net-worker.js), the guest's
20
+ // frame stream (4-byte big-endian length + frame) goes to that Worker, which runs
21
+ // the TCP/IP stack and the relay connection, and its frames come back here for
22
+ // the guest's ring (NetSession). Without one, a stub: frames are counted and
23
+ // dropped, nothing is answered.
24
+ //
25
+ // When the guest shuts its side down (EOF), the host closes its side too once its output
26
+ // is out. Backpressure: a channel whose replies the guest is not reading stops being
27
+ // read once OUT_LIMIT bytes are queued, so its guest->host ring fills and chan_send
28
+ // blocks.
29
+
30
+ import {
31
+ CHAN_CONSOLE, CHAN_CONTROL, CHAN_NET, CHAN_P9, CONSOLE_IN_LIMIT, G2H, H2G, H_CIN_TAKEN, H_CLOSES, H_GONE, KIND_NAMES, R_GONE, R_SHUT,
32
+ RTT_BUCKETS, S_GEN, S_PORT, S_STATE, ST_CLOSING, ST_FREE, ST_OPEN, chanPorts, chanView, histSummary,
33
+ ringAvail, ringKick, ringRead, ringSpace, ringWrite, rttBucket, stageNote,
34
+ } from './chan.js'
35
+ import { NinePServer } from '../fs/9p/server.js'
36
+ import { adoptServer, dumpService, loadService } from '../fs/svc-snapshot.js'
37
+ import { YamuxClient } from './yamux.js'
38
+
39
+ export const MAX_FRAME = (1 << 20) + 64 // the guest stages T-messages in 1 MiB (p9proxy.c P9_MAX_TMSG)
40
+ export const OUT_LIMIT = 4 << 20
41
+ const READ_CHUNK = 1 << 18
42
+ /** Bytes one channel may read in one step(): a guest that keeps its ring full cannot hold
43
+ * the service in one pass (its messages, the other channels wait for the step to end). */
44
+ export const STEP_READ_BUDGET = 1 << 20
45
+
46
+ const CRC_TABLE = (() => {
47
+ const t = new Int32Array(256)
48
+ for (let n = 0; n < 256; n++) {
49
+ let c = n
50
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
51
+ t[n] = c
52
+ }
53
+ return t
54
+ })()
55
+ /** CRC-32 (IEEE, as zlib) of bytes, continuing from crc (0 to start). */
56
+ export function crc32(bytes, crc = 0) {
57
+ let c = ~crc
58
+ for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8)
59
+ return ~c >>> 0
60
+ }
61
+
62
+ /** A queue of byte chunks written into a ring as room appears. */
63
+ class OutQueue {
64
+ constructor() {
65
+ this.chunks = []
66
+ this.off = 0
67
+ this.bytes = 0
68
+ this.taken = 0 // bytes written into the ring, ever
69
+ }
70
+ push(u8) {
71
+ if (!u8.length) return
72
+ this.chunks.push(u8)
73
+ this.bytes += u8.length
74
+ }
75
+ /** Write what fits; returns bytes written. */
76
+ flush(v, r) {
77
+ let total = 0
78
+ while (this.chunks.length) {
79
+ const c = this.chunks[0]
80
+ const n = ringWrite(v, r, c, this.off, c.length - this.off)
81
+ if (n <= 0) break
82
+ total += n
83
+ this.off += n
84
+ this.bytes -= n
85
+ this.taken += n
86
+ if (this.off === c.length) {
87
+ this.chunks.shift()
88
+ this.off = 0
89
+ }
90
+ }
91
+ return total
92
+ }
93
+ }
94
+
95
+ /** 9P: frames from the stream, a reply for each, in order. */
96
+ class P9Session {
97
+ constructor(host, port) {
98
+ this.host = host
99
+ this.server = new NinePServer(host.fsFor(port))
100
+ // P-B9: the first connection after a restore carries the checkpoint's fids (the guest kernel's v9fs holds them)
101
+ const restored = host.restoredServers.get(port)
102
+ if (restored) {
103
+ host.restoredServers.delete(port)
104
+ adoptServer(this.server, restored)
105
+ }
106
+ this.buf = new Uint8Array(1 << 16)
107
+ this.len = 0
108
+ }
109
+ room(sess) {
110
+ // no more requests while the replies already made are not being read
111
+ return sess.out.bytes >= OUT_LIMIT ? 0 : MAX_FRAME - this.len
112
+ }
113
+ data(sess, bytes) {
114
+ if (this.len + bytes.length > this.buf.length) {
115
+ const nb = new Uint8Array(Math.max(this.buf.length * 2, this.len + bytes.length))
116
+ nb.set(this.buf.subarray(0, this.len))
117
+ this.buf = nb
118
+ }
119
+ this.buf.set(bytes, this.len)
120
+ this.len += bytes.length
121
+ this.process(sess)
122
+ }
123
+ /** Answer the whole frames buffered, until OUT_LIMIT bytes of replies wait to be read. */
124
+ process(sess) {
125
+ let at = 0
126
+ while (this.len - at >= 4 && sess.out.bytes < OUT_LIMIT) {
127
+ const b = this.buf
128
+ const size = (b[at] | (b[at + 1] << 8) | (b[at + 2] << 16) | (b[at + 3] << 24)) >>> 0
129
+ if (size < 7 || size > MAX_FRAME) {
130
+ this.host.protocolError(sess, `9p frame size ${size}`)
131
+ this.len = 0
132
+ return
133
+ }
134
+ if (this.len - at < size) break
135
+ const frame = b.slice(at, at + size) // the server may keep parts of it (Twrite data)
136
+ at += size
137
+ const v = this.host.v
138
+ const tag = frame[5] | (frame[6] << 8)
139
+ const ts = v.tagTimes[tag]
140
+ if (ts > 0) stageNote(v.pickup, (performance.timeOrigin + performance.now() - ts) * 1000)
141
+ const t0 = performance.now()
142
+ const reply = this.server.handle(frame)
143
+ this.host.noteP9(frame[4], (performance.now() - t0) * 1000)
144
+ sess.out.push(reply)
145
+ v.replyTimes[tag] = performance.timeOrigin + performance.now() // flushed right after this frame's data()
146
+ }
147
+ if (at) {
148
+ this.buf.copyWithin(0, at, this.len)
149
+ this.len -= at
150
+ }
151
+ }
152
+ pending() {
153
+ return this.len
154
+ }
155
+ }
156
+
157
+ class ConsoleSession {
158
+ constructor(host) {
159
+ this.host = host
160
+ }
161
+ room() {
162
+ return ringSpace(this.host.v, this.host.v.pipe)
163
+ }
164
+ process() {}
165
+ data(_sess, bytes) {
166
+ const n = ringWrite(this.host.v, this.host.v.pipe, bytes)
167
+ if (n !== bytes.length) throw new Error('console pipe overrun') // room() bounds every read
168
+ this.host.consoleBytes(bytes.length)
169
+ }
170
+ pending() {
171
+ return 0
172
+ }
173
+ }
174
+
175
+ /**
176
+ * P-B11: the control port as a yamux session (the guest init is the server). One per
177
+ * open of the port; the page's streams (keys k) live on the host's ctl state, so a
178
+ * session that ends ends them all.
179
+ */
180
+ class ControlSession {
181
+ constructor(host, sess) {
182
+ this.host = host
183
+ const ctl = host.ctl
184
+ ctl.epoch++
185
+ this.epoch = ctl.epoch
186
+ this.yamux = new YamuxClient({
187
+ send: u8 => sess.out.push(u8),
188
+ keepAliveMs: host.ctlKeepAliveMs,
189
+ onClose: why => host.ctlSessionDown(this, why),
190
+ })
191
+ ctl.session = this
192
+ host.ctlEvent({ ev: 'session', up: true, epoch: this.epoch })
193
+ }
194
+ room() {
195
+ return Infinity // bounded by the yamux windows the guest respects, and OUT_LIMIT
196
+ }
197
+ process() {}
198
+ tick() {
199
+ this.yamux.tick()
200
+ }
201
+ data(_sess, bytes) {
202
+ this.yamux.feed(bytes)
203
+ }
204
+ pending() {
205
+ return 0
206
+ }
207
+ end(why) {
208
+ this.yamux.close(why, 0, false)
209
+ }
210
+ }
211
+
212
+ /** Bytes sent to the network Worker and not yet processed by it, at most. */
213
+ export const NET_INFLIGHT = 1 << 20
214
+
215
+ /**
216
+ * P-B10: the net channel with a network Worker. The guest's bytes go to the Worker as they
217
+ * are read (at most NET_INFLIGHT unprocessed); the Worker's frames are queued for the
218
+ * guest's ring, and the Worker hears how much the ring took ('wrote'), which bounds what
219
+ * it sends.
220
+ */
221
+ class NetSession {
222
+ constructor(host, sess) {
223
+ this.host = host
224
+ this.sess = sess
225
+ this.reported = 0
226
+ host.net.session = this
227
+ host.net.opens++
228
+ }
229
+ room() {
230
+ return Math.max(0, NET_INFLIGHT - this.host.net.inflight)
231
+ }
232
+ process() {}
233
+ data(_sess, bytes) {
234
+ const c = bytes.slice()
235
+ this.host.net.inflight += c.length
236
+ this.host.netPost({ t: 'in', buf: c.buffer }, [c.buffer])
237
+ }
238
+ afterPump(sess) {
239
+ const n = sess.out.taken - this.reported
240
+ if (n > 0) {
241
+ this.reported = sess.out.taken
242
+ this.host.netPost({ t: 'wrote', n })
243
+ }
244
+ }
245
+ pending() {
246
+ return 0
247
+ }
248
+ end() {
249
+ const net = this.host.net
250
+ if (net.session !== this) return
251
+ net.session = null
252
+ // what was queued for this guest will not be written: the Worker must not wait for it
253
+ if (this.sess.out.bytes) this.host.netPost({ t: 'wrote', n: this.sess.out.bytes })
254
+ this.sess.out = new OutQueue()
255
+ }
256
+ }
257
+
258
+ class SinkSession {
259
+ constructor(host, kind) {
260
+ this.host = host
261
+ this.kind = kind
262
+ this.hdr = 0
263
+ this.left = 0
264
+ this.lenBuf = []
265
+ }
266
+ room() {
267
+ return Infinity
268
+ }
269
+ process() {}
270
+ data(_sess, bytes) {
271
+ const st = this.host.stats.kinds[KIND_NAMES[this.kind]]
272
+ st.crc32 = crc32(bytes, st.crc32) // every byte the stubs took, in order
273
+ if (this.kind !== CHAN_NET) return
274
+ // count the frames: 4-byte big-endian length, then the frame
275
+ let i = 0
276
+ while (i < bytes.length) {
277
+ if (this.left === 0) {
278
+ this.lenBuf.push(bytes[i++])
279
+ if (this.lenBuf.length === 4) {
280
+ const [a, b, c, d] = this.lenBuf
281
+ this.left = ((a << 24) | (b << 16) | (c << 8) | d) >>> 0
282
+ this.lenBuf = []
283
+ if (this.left === 0) continue // keepalive
284
+ st.frames++
285
+ }
286
+ } else {
287
+ const k = Math.min(this.left, bytes.length - i)
288
+ i += k
289
+ this.left -= k
290
+ }
291
+ }
292
+ }
293
+ pending() {
294
+ return 0
295
+ }
296
+ }
297
+
298
+ export class ChanHost {
299
+ /**
300
+ * @param {SharedArrayBuffer} sab the chan block
301
+ * @param {object} o
302
+ * @param {Record<number, import('../fs/overlay.js').Overlay>} [o.fs] 9P trees by port
303
+ * @param {(n:number)=>void} [o.onConsole] console bytes went into the pipe
304
+ * @param {number} [o.consoleInputBytes] console input (page -> guest) held at most (CONSOLE_IN_LIMIT)
305
+ */
306
+ constructor(sab, { fs = {}, onConsole, consoleInputBytes = CONSOLE_IN_LIMIT, yamuxPorts = [], ctlKeepAliveMs = 5000, netPost = null, restore = {} } = {}) {
307
+ this.v = chanView(sab)
308
+ // P-B10: the network Worker (postMessage to it), and the net channel's session
309
+ this.netPost = netPost
310
+ this.net = { session: null, inflight: 0, opens: 0, framesDropped: 0, bytesDropped: 0 }
311
+ this.fs = fs
312
+ // P-B9: the 9P state of a checkpoint (port -> bytes): the tree is replaced now, the fid table waits for the connection
313
+ this.restoredServers = new Map()
314
+ for (const [port, bytes] of Object.entries(restore)) {
315
+ if (!fs[port]) throw new Error(`checkpoint has 9P state for port ${port} and this boot has no 9P service there`)
316
+ this.restoredServers.set(Number(port), loadService(bytes, fs[port]))
317
+ }
318
+ this.yamuxPorts = new Set(yamuxPorts)
319
+ this.ctlKeepAliveMs = ctlKeepAliveMs
320
+ // P-B11: the control session and the page's streams on it (k -> YamuxStream)
321
+ this.ctl = { session: null, epoch: 0, streams: new Map(), events: [], opened: 0, refusedOpens: 0 }
322
+ this.onConsole = onConsole
323
+ this.kindOfPort = new Map(chanPorts(sab).map(p => [p.port, p.kind]))
324
+ this.sessions = new Array(this.v.nslots).fill(null)
325
+ this.inbuf = new Uint8Array(READ_CHUNK)
326
+ this.consoleIn = [] // bytes from the page waiting for the console channel
327
+ this.consoleInQueued = 0 // bytes in consoleIn
328
+ this.consoleInLimit = consoleInputBytes
329
+ this.stats = {
330
+ // per kind; firstInAt/lastInAt: absolute ms of the first and the latest guest bytes read
331
+ kinds: Object.fromEntries(Object.values(KIND_NAMES).map(k => [k, { opens: 0, bytesIn: 0, bytesOut: 0, frames: 0, eofs: 0, protocolErrors: 0, firstInAt: 0, lastInAt: 0, crc32: 0 }])),
332
+ p9: { requests: 0, byType: {}, hist: new Array(RTT_BUCKETS).fill(0), sumUs: 0, maxUs: 0 },
333
+ console: { bytes: 0, firstAt: 0, lastAt: 0, inputBytes: 0, inputDropped: 0 },
334
+ sessionsDropped: 0,
335
+ errors: [],
336
+ }
337
+ this.stopped = false
338
+ }
339
+
340
+ /** Queue an event for the page (chan-service.js posts ctlEvents after each step). */
341
+ ctlEvent(e) {
342
+ this.ctl.events.push(e)
343
+ }
344
+
345
+ /** Takes the queued control events. */
346
+ takeCtlEvents() {
347
+ const e = this.ctl.events
348
+ if (!e.length) return null
349
+ this.ctl.events = []
350
+ return e
351
+ }
352
+
353
+ ctlSessionDown(cs, why) {
354
+ const ctl = this.ctl
355
+ if (ctl.session !== cs) return
356
+ ctl.session = null
357
+ ctl.lastWhy = why
358
+ for (const [k, st] of ctl.streams) {
359
+ ctl.streams.delete(k)
360
+ st.onClose = null
361
+ this.ctlEvent({ ev: 'closed', k, why: `control session ended: ${why}` })
362
+ }
363
+ this.ctlEvent({ ev: 'session', up: false, epoch: cs.epoch, why })
364
+ }
365
+
366
+ /**
367
+ * A message from the page for the control session: {op:'open'|'write'|'close'|'reset'|'ack', k, bytes?, n?}.
368
+ * Returns true if the session took it.
369
+ */
370
+ controlInput(m) {
371
+ const ctl = this.ctl
372
+ const cs = ctl.session
373
+ if (m.op === 'open') {
374
+ if (!cs || cs.yamux.closed) {
375
+ ctl.refusedOpens++
376
+ this.ctlEvent({
377
+ ev: 'closed',
378
+ k: m.k,
379
+ why: ctl.epoch
380
+ ? `no control session: the guest's control session ${ctl.epoch} has ended (${ctl.lastWhy || 'closed'}) and none is open`
381
+ : 'no control session (the guest has not opened the control port)',
382
+ })
383
+ return false
384
+ }
385
+ let st
386
+ try {
387
+ st = cs.yamux.open()
388
+ } catch (e) {
389
+ this.ctlEvent({ ev: 'closed', k: m.k, why: e.message })
390
+ return false
391
+ }
392
+ ctl.opened++
393
+ ctl.streams.set(m.k, st)
394
+ st.onData = u8 => this.ctlEvent({ ev: 'data', k: m.k, bytes: u8 })
395
+ st.onFin = () => this.ctlEvent({ ev: 'fin', k: m.k })
396
+ // stdin backpressure: the page counts what it posted until it hears it went out
397
+ st.onSent = n => {
398
+ const last = this.ctl.events.at(-1)
399
+ if (last?.ev === 'sent' && last.k === m.k) last.n += n
400
+ else this.ctlEvent({ ev: 'sent', k: m.k, n })
401
+ }
402
+ st.onClose = why => {
403
+ if (ctl.streams.get(m.k) === st) ctl.streams.delete(m.k)
404
+ this.ctlEvent({ ev: 'closed', k: m.k, why })
405
+ }
406
+ if (m.bytes?.length) st.write(new Uint8Array(m.bytes))
407
+ return true
408
+ }
409
+ const st = ctl.streams.get(m.k)
410
+ if (!st) return false
411
+ if (m.op === 'write') {
412
+ if (st.localFin) return false
413
+ st.write(new Uint8Array(m.bytes))
414
+ } else if (m.op === 'close') st.closeWrite()
415
+ else if (m.op === 'reset') st.abort()
416
+ else if (m.op === 'ack') st.consume(m.n)
417
+ return true
418
+ }
419
+
420
+ /** P-B10: a message from the network Worker: {t:'out', buf} frames for the guest, {t:'took', n}. */
421
+ netMessage(m) {
422
+ const net = this.net
423
+ if (m.t === 'took') net.inflight = Math.max(0, net.inflight - m.n)
424
+ else if (m.t === 'out') {
425
+ const u8 = new Uint8Array(m.buf)
426
+ const sess = net.session?.sess
427
+ if (!sess || sess.broken || sess.eof) {
428
+ // no guest to take it (not open yet, or gone): it is dropped, and the Worker is told
429
+ net.framesDropped++
430
+ net.bytesDropped += u8.length
431
+ this.netPost?.({ t: 'wrote', n: u8.length })
432
+ return
433
+ }
434
+ sess.out.push(u8)
435
+ }
436
+ }
437
+
438
+ /**
439
+ * P-B9: the state of every 9P service for a checkpoint: {port: {segments: Uint8Array[], bytes, fids}}. The
440
+ * live connection's fids go with the tree; the guest is stopped at its checkpoint point, so nothing is in flight.
441
+ */
442
+ snapshotState() {
443
+ const out = {}
444
+ for (const [port, fs] of Object.entries(this.fs)) {
445
+ let server = null
446
+ for (const sess of this.sessions) {
447
+ if (sess && sess.port === Number(port) && sess.impl instanceof P9Session) {
448
+ // The guest is stopped with its share drained: every request it made is answered, so no frame is half received. A
449
+ // half frame here would be lost by a restore (the guest would wait for a reply to a request the new server never saw).
450
+ if (sess.impl.pending()) throw new Error(`the 9P connection on port ${port} holds ${sess.impl.pending()} bytes of a half-received request at the checkpoint point; the checkpoint is refused`)
451
+ server = sess.impl.server
452
+ }
453
+ }
454
+ // no live connection: the fids a restore has not yet handed to one are still the state
455
+ const d = dumpService(fs, server || this.restoredServers.get(Number(port)) || null)
456
+ out[port] = { segments: d.segments, bytes: d.bytes, fids: d.fids }
457
+ }
458
+ return out
459
+ }
460
+
461
+ // What a checkpoint does not carry (docs): the channel rings and every session of the other services are rebuilt by the
462
+ // restoring machine: the guest's 9P channel is opened again (virtio_p9_reconnect), the console and control ports are reset
463
+ // and the control yamux session is a new one (the caller sends resync {now_ns, seed}, then attach). The net service is a
464
+ // new stack: a TCP connection the guest believed open hangs in the guest until its own timeout.
465
+
466
+ fsFor(port) {
467
+ const f = this.fs[port]
468
+ if (!f) throw new Error(`no 9P tree for port ${port}`)
469
+ return f
470
+ }
471
+
472
+ p9Requests() {
473
+ return this.stats.p9.requests
474
+ }
475
+
476
+ noteP9(type, us) {
477
+ const p = this.stats.p9
478
+ p.requests++
479
+ p.byType[type] = (p.byType[type] || 0) + 1
480
+ p.hist[rttBucket(us)]++
481
+ p.sumUs += us
482
+ if (us > p.maxUs) p.maxUs = us
483
+ }
484
+
485
+ consoleBytes(n) {
486
+ const c = this.stats.console
487
+ const t = performance.timeOrigin + performance.now()
488
+ if (!c.bytes) c.firstAt = t
489
+ c.lastAt = t
490
+ c.bytes += n
491
+ this.onConsole?.(n)
492
+ }
493
+
494
+ /** Console input held for the guest: queued, plus what a console session has not written into its ring yet. */
495
+ consoleInputHeld() {
496
+ let n = this.consoleInQueued
497
+ for (const sess of this.sessions) if (sess && sess.kind === CHAN_CONSOLE) n += sess.out.bytes
498
+ return n
499
+ }
500
+
501
+ /**
502
+ * Bytes for the guest's console (page -> guest), queued until the channel is open.
503
+ * Holds at most consoleInputBytes: the rest is dropped and counted (the page's
504
+ * consoleInputGate never sends more than that). Returns the bytes kept.
505
+ */
506
+ consoleInput(u8) {
507
+ const c = this.stats.console
508
+ const keep = Math.max(0, Math.min(u8.length, this.consoleInLimit - this.consoleInputHeld()))
509
+ if (keep < u8.length) {
510
+ const drop = u8.length - keep
511
+ c.inputDropped += drop
512
+ Atomics.add(this.v.i32, H_CIN_TAKEN, drop) // never coming: the page's gate must not wait for it
513
+ if (c.inputDropped === drop) this.#error(`console input over ${this.consoleInLimit} bytes not read by the guest: ${drop} bytes dropped`)
514
+ }
515
+ if (keep) {
516
+ this.consoleIn.push(keep < u8.length ? u8.subarray(0, keep) : u8)
517
+ this.consoleInQueued += keep
518
+ c.inputBytes += keep
519
+ }
520
+ return keep
521
+ }
522
+
523
+ #error(msg) {
524
+ this.stats.errors.push(msg)
525
+ if (this.stats.errors.length > 32) this.stats.errors.shift()
526
+ }
527
+
528
+ protocolError(sess, why) {
529
+ this.stats.kinds[KIND_NAMES[sess.kind]].protocolErrors++
530
+ this.#error(`handle ${sess.slot}: ${why}; closing the channel`)
531
+ const v = this.v
532
+ // as a server dropping the connection: the guest's sends fail, its receives see EOF
533
+ const g = v.ring(sess.slot, G2H)
534
+ const h = v.ring(sess.slot, H2G)
535
+ Atomics.store(v.i32, g.c + R_GONE, 1)
536
+ ringKick(v, g)
537
+ sess.broken = true
538
+ sess.out = new OutQueue()
539
+ Atomics.store(v.i32, h.c + R_SHUT, 1)
540
+ ringKick(v, h)
541
+ }
542
+
543
+ #open(slot, gen) {
544
+ const v = this.v
545
+ const port = Atomics.load(v.i32, v.slotBase(slot) + S_PORT)
546
+ const kind = this.kindOfPort.get(port)
547
+ const sess = { slot, gen, port, kind, out: new OutQueue(), eof: false, closedOut: false, broken: false, impl: null }
548
+ this.stats.kinds[KIND_NAMES[kind]].opens++
549
+ if (kind === CHAN_P9) sess.impl = new P9Session(this, port)
550
+ else if (kind === CHAN_CONSOLE) sess.impl = new ConsoleSession(this)
551
+ else if (kind === CHAN_CONTROL && this.yamuxPorts.has(port)) sess.impl = new ControlSession(this, sess)
552
+ else if (kind === CHAN_NET && this.netPost) sess.impl = new NetSession(this, sess)
553
+ else sess.impl = new SinkSession(this, kind)
554
+ return sess
555
+ }
556
+
557
+ /** One pass over every channel. Returns true if anything moved. */
558
+ step() {
559
+ const v = this.v
560
+ const i32 = v.i32
561
+ if (Atomics.load(i32, H_GONE)) {
562
+ if (!this.stopped) {
563
+ this.stopped = true
564
+ for (let s = 0; s < v.nslots; s++) if (this.sessions[s]) this.#drop(s)
565
+ }
566
+ return false
567
+ }
568
+ let work = false
569
+ for (let s = 0; s < v.nslots; s++) {
570
+ const b = v.slotBase(s)
571
+ const st = Atomics.load(i32, b + S_STATE)
572
+ let sess = this.sessions[s]
573
+ if (st === ST_CLOSING) {
574
+ if (sess) this.#drop(s)
575
+ Atomics.store(i32, b + S_STATE, ST_FREE)
576
+ work = true
577
+ continue
578
+ }
579
+ if (st !== ST_OPEN) {
580
+ if (sess) this.#drop(s)
581
+ continue
582
+ }
583
+ const gen = Atomics.load(i32, b + S_GEN)
584
+ if (!sess || sess.gen !== gen) {
585
+ if (sess) this.#drop(s)
586
+ sess = this.sessions[s] = this.#open(s, gen)
587
+ work = true
588
+ }
589
+ if (this.#pump(sess)) work = true
590
+ }
591
+ return work
592
+ }
593
+
594
+ #drop(s) {
595
+ const sess = this.sessions[s]
596
+ this.sessions[s] = null
597
+ if (!sess) return
598
+ if (sess.impl?.pending()) this.stats.sessionsDropped++ // a message was cut off
599
+ sess.impl?.end?.('the guest closed the control channel')
600
+ // console input the session still held will never reach a guest ring
601
+ if (sess.kind === CHAN_CONSOLE && sess.out.bytes) Atomics.add(this.v.i32, H_CIN_TAKEN, sess.out.bytes)
602
+ }
603
+
604
+ #pump(sess) {
605
+ const v = this.v
606
+ const g = v.ring(sess.slot, G2H)
607
+ const h = v.ring(sess.slot, H2G)
608
+ const ks = this.stats.kinds[KIND_NAMES[sess.kind]]
609
+ let work = false
610
+ if (sess.kind === CHAN_CONSOLE && this.consoleIn.length && !sess.broken) {
611
+ for (const c of this.consoleIn) sess.out.push(c)
612
+ this.consoleIn = []
613
+ this.consoleInQueued = 0
614
+ }
615
+ sess.impl.tick?.()
616
+ const before = sess.out.bytes
617
+ let wrote = sess.out.flush(v, h)
618
+ if (sess.kind === CHAN_CONSOLE && wrote) Atomics.add(v.i32, H_CIN_TAKEN, wrote) // console input taken by the ring
619
+ if (!sess.broken && sess.impl.pending() && sess.out.bytes < OUT_LIMIT) {
620
+ // requests held back while the replies were not read
621
+ const n = sess.impl.pending()
622
+ sess.impl.process(sess)
623
+ if (sess.impl.pending() !== n) work = true
624
+ wrote += sess.out.flush(v, h)
625
+ }
626
+ if (wrote) {
627
+ ks.bytesOut += wrote
628
+ work = true
629
+ }
630
+ if (before && !sess.out.bytes) work = true
631
+ // read, as much as the service can take, up to STEP_READ_BUDGET this step
632
+ let budget = STEP_READ_BUDGET
633
+ while (!sess.broken && sess.out.bytes < OUT_LIMIT && budget > 0) {
634
+ const room = Math.min(sess.impl.room(sess), this.inbuf.length)
635
+ if (room <= 0) break
636
+ const n = ringRead(v, g, this.inbuf, 0, Math.min(room, budget))
637
+ if (!n) break
638
+ budget -= n
639
+ const t = performance.timeOrigin + performance.now()
640
+ if (!ks.bytesIn) ks.firstInAt = t
641
+ ks.lastInAt = t
642
+ ks.bytesIn += n
643
+ work = true
644
+ sess.impl.data(sess, this.inbuf.subarray(0, n))
645
+ const w2 = sess.out.flush(v, h)
646
+ if (sess.kind === CHAN_CONSOLE && w2) Atomics.add(v.i32, H_CIN_TAKEN, w2)
647
+ ks.bytesOut += w2
648
+ }
649
+ sess.impl.afterPump?.(sess)
650
+ // EOF from the guest: close our side once the output is out
651
+ if (!sess.eof && Atomics.load(v.i32, g.c + R_SHUT) && ringAvail(v, g) === 0) {
652
+ sess.eof = true
653
+ ks.eofs++
654
+ sess.impl.end?.('the guest closed the control channel')
655
+ if (sess.impl.pending()) this.stats.sessionsDropped++
656
+ work = true
657
+ }
658
+ if (sess.eof && !sess.closedOut && !sess.out.bytes) {
659
+ sess.closedOut = true
660
+ Atomics.store(v.i32, h.c + R_SHUT, 1)
661
+ ringKick(v, h)
662
+ }
663
+ return work
664
+ }
665
+
666
+ /** Stats for the page (JSON-safe). */
667
+ snapshot() {
668
+ const p = this.stats.p9
669
+ return {
670
+ kinds: this.stats.kinds,
671
+ p9: {
672
+ requests: p.requests,
673
+ byType: p.byType,
674
+ service: histSummary(p.hist, { meanUs: p.requests ? p.sumUs / p.requests : 0, maxUs: p.maxUs }),
675
+ },
676
+ console: { ...this.stats.console, inputHeld: this.consoleInputHeld() },
677
+ uppers: Object.fromEntries(
678
+ Object.entries(this.fs).map(([port, f]) => [port, { used: f.used, max: Number.isFinite(f.maxUpperBytes) ? f.maxUpperBytes : null, fullHits: f.fullHits || 0 }]),
679
+ ),
680
+ sessionsDropped: this.stats.sessionsDropped,
681
+ net: this.netPost ? { open: !!this.net.session, inflight: this.net.inflight, opens: this.net.opens, framesDropped: this.net.framesDropped, bytesDropped: this.net.bytesDropped, queued: this.net.session?.sess.out.bytes ?? 0 } : undefined,
682
+ control: this.yamuxPorts.size
683
+ ? { epoch: this.ctl.epoch, up: !!this.ctl.session, streams: this.ctl.streams.size, opened: this.ctl.opened, refusedOpens: this.ctl.refusedOpens, yamux: this.ctl.session ? { ...this.ctl.session.yamux.stats, pingRttMs: this.ctl.session.yamux.stats.pingRttMs.slice(-8) } : null }
684
+ : undefined,
685
+ errors: [...this.stats.errors],
686
+ closes: Atomics.load(this.v.i32, H_CLOSES),
687
+ }
688
+ }
689
+ }