@threadedstack/wmd-web 0.0.0-stage → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,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
|
+
}
|