@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,778 @@
1
+ // P-B11 (ROADMAP 2.2b): wmd's control protocol, the page's half
2
+ // (internal/engine/ctlproto.go, control.go; the guest's guest/container2wasm/cmd/init
3
+ // control.go, procs.go).
4
+ //
5
+ // One yamux stream per operation (the yamux client runs in the channel service Worker,
6
+ // yamux.js; this module sees each stream as bytes in and out):
7
+ // 1. The host writes one JSON request line: {"op":"run"|"exec"|"kill"|"attach"|"procs"|
8
+ // "pause"|"resume"|"ps"|"stats"|"step"|"resync", "id", "args", "env", "cwd", "user",
9
+ // "tty", "sig", "all", "ps_args", "now", "seed" (base64 of 32 bytes, Go's []byte),
10
+ // "p9tag", "p9opts", "ckpt", ...}; empty fields are left out (Go's omitempty).
11
+ // 2. run / exec / step: frames both ways, id[1] | length[4] big-endian | payload; ids 0
12
+ // control (JSON {"resize":[rows,cols]} or {"close_stdin":true}), 1 stdin, 2 stdout,
13
+ // 3 stderr, 4 exit (4-byte BE code, optionally 4 more bytes of flags, bit 0 "synced").
14
+ // The host sends close_stdin at once when there is no stdin (control.go).
15
+ // 3. attach: one JSON line ({"ok","error","state","code","buffered","dropped"}), then the
16
+ // frames of the process (the ring it kept while detached first), as for run/exec.
17
+ // 4. Other ops: one JSON response line {"ok","error","exit_code","titles","rows","stats","ids"}.
18
+ // A stream that ends without an exit frame is exit 125 (control.go runProcessExit).
19
+
20
+ export const STREAM_CONTROL = 0
21
+ export const STREAM_STDIN = 1
22
+ export const STREAM_STDOUT = 2
23
+ export const STREAM_STDERR = 3
24
+ export const STREAM_EXIT = 4
25
+ export const EXIT_SYNCED = 1
26
+ /** readFrame's sanity limit (ctlproto.go). */
27
+ export const MAX_CTL_FRAME = 10 * 1024 * 1024
28
+ export const MAIN_PROC_ID = 'main'
29
+
30
+ const enc = new TextEncoder()
31
+
32
+ /** One frame: id | len BE | payload. */
33
+ export function encodeFrame(id, payload = new Uint8Array(0)) {
34
+ const p = typeof payload === 'string' ? enc.encode(payload) : payload
35
+ const f = new Uint8Array(5 + p.length)
36
+ f[0] = id
37
+ new DataView(f.buffer).setUint32(1, p.length)
38
+ f.set(p, 5)
39
+ return f
40
+ }
41
+
42
+ /** The exit frame's payload: {code, synced} (ctlproto.go parseExitFrame). */
43
+ export function parseExitFrame(p) {
44
+ if (p.length < 4) return { code: 125, synced: false }
45
+ const dv = new DataView(p.buffer, p.byteOffset, p.length)
46
+ return { code: dv.getInt32(0), synced: p.length >= 8 && (dv.getUint32(4) & EXIT_SYNCED) !== 0 }
47
+ }
48
+
49
+ /** base64 of bytes (Go encodes []byte in JSON as standard base64). */
50
+ export function base64(u8) {
51
+ let s = ''
52
+ for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i])
53
+ return btoa(s)
54
+ }
55
+
56
+ /** The request line, with Go's omitempty: empty strings, false, 0 and empty lists are left out. */
57
+ export function requestLine(req) {
58
+ const o = {}
59
+ for (const [k, v] of Object.entries(req)) {
60
+ if (v === undefined || v === null || v === '' || v === false || v === 0) continue
61
+ if (Array.isArray(v) && !v.length) continue
62
+ o[k] = v instanceof Uint8Array ? base64(v) : v
63
+ }
64
+ if (!o.op) throw new Error('control request without op')
65
+ return enc.encode(JSON.stringify(o) + '\n')
66
+ }
67
+
68
+ /**
69
+ * What the engine adds to a run request (run.go fillRunRequest): the host clock, 32 bytes
70
+ * of entropy (crypto.getRandomValues: the guest has no entropy device, P-B3 problem 2),
71
+ * the share's 9P tag and the guest's mount options. The browser mounts with cache=mmap
72
+ * (p9mount.js), not the native engine's cache=loose.
73
+ */
74
+ export function fillRunRequest(req, { p9opts, now = Date.now(), random = globalThis.crypto } = {}) {
75
+ if (!p9opts) throw new Error('fillRunRequest: p9opts is required (the init refuses a first run without it)')
76
+ const seed = new Uint8Array(32)
77
+ random.getRandomValues(seed)
78
+ return { ...req, now: Math.floor(now / 1000), seed, p9tag: 'wmdroot', p9opts }
79
+ }
80
+
81
+ function cat(a, b) {
82
+ const o = new Uint8Array(a.length + b.length)
83
+ o.set(a)
84
+ o.set(b, a.length)
85
+ return o
86
+ }
87
+
88
+ /** Bytes kept as chunks: appending costs no copy, only taking does (no O(n^2) on small pieces). */
89
+ class ByteQueue {
90
+ constructor() {
91
+ this.chunks = []
92
+ this.off = 0 // into chunks[0]
93
+ this.length = 0
94
+ }
95
+ push(u8) {
96
+ if (u8.length) {
97
+ this.chunks.push(u8)
98
+ this.length += u8.length
99
+ }
100
+ }
101
+ /** The first n bytes (n <= length), copied; removed when take is true. */
102
+ read(n, take) {
103
+ const out = new Uint8Array(n)
104
+ let got = 0
105
+ let i = 0
106
+ let off = this.off
107
+ while (got < n) {
108
+ const c = this.chunks[i]
109
+ const k = Math.min(n - got, c.length - off)
110
+ out.set(c.subarray(off, off + k), got)
111
+ got += k
112
+ off += k
113
+ if (off === c.length) {
114
+ i++
115
+ off = 0
116
+ }
117
+ }
118
+ if (take) {
119
+ this.chunks.splice(0, i)
120
+ this.off = off
121
+ this.length -= n
122
+ }
123
+ return out
124
+ }
125
+ }
126
+
127
+ /** Splits a byte stream into frames. Throws on a frame over MAX_CTL_FRAME. */
128
+ export class FrameReader {
129
+ constructor() {
130
+ this.q = new ByteQueue()
131
+ this.need = -1 // payload length of the frame whose header is read, or -1
132
+ this.id = 0
133
+ }
134
+ /** Returns the whole frames now available: [{id, payload}]. */
135
+ push(u8) {
136
+ this.q.push(u8)
137
+ const out = []
138
+ for (;;) {
139
+ if (this.need < 0) {
140
+ if (this.q.length < 5) break
141
+ const h = this.q.read(5, true)
142
+ const n = new DataView(h.buffer).getUint32(1)
143
+ if (n > MAX_CTL_FRAME) throw new Error(`control frame too large: ${n} bytes`)
144
+ this.id = h[0]
145
+ this.need = n
146
+ }
147
+ if (this.q.length < this.need) break
148
+ out.push({ id: this.id, payload: this.q.read(this.need, true) })
149
+ this.need = -1
150
+ }
151
+ return out
152
+ }
153
+ }
154
+
155
+ /** Splits off the first line (for response and attach lines). */
156
+ export class LineReader {
157
+ constructor(max = 1 << 20) {
158
+ this.buf = new Uint8Array(0)
159
+ this.max = max
160
+ }
161
+ /** Returns {line, rest} once a newline came, else null. */
162
+ push(u8) {
163
+ this.buf = this.buf.length ? cat(this.buf, u8) : u8
164
+ const nl = this.buf.indexOf(10)
165
+ if (nl < 0) {
166
+ if (this.buf.length > this.max) throw new Error('control: response line too long')
167
+ return null
168
+ }
169
+ const line = new TextDecoder().decode(this.buf.subarray(0, nl))
170
+ const rest = this.buf.slice(nl + 1)
171
+ this.buf = new Uint8Array(0)
172
+ return { line, rest }
173
+ }
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // The client. transport: {post(msg)}; the boot wires post() to the channel service
178
+ // Worker and calls client.onEvents(evs) with what the service sends:
179
+ // {ev:'session', up, epoch, why?} the guest opened (or lost) the control port
180
+ // {ev:'data', k, bytes} bytes on stream k (the page acks them)
181
+ // {ev:'fin', k} the guest closed its side of stream k
182
+ // {ev:'closed', k, why} stream k is gone (why null: both sides closed)
183
+ // Messages to the service ({t:'ctl', ...}):
184
+ // {op:'open', k, bytes} {op:'write', k, bytes} {op:'close', k} (FIN)
185
+ // {op:'reset', k} {op:'ack', k, n}
186
+ // ---------------------------------------------------------------------------
187
+
188
+ /** Bytes the page may have posted to a stream that the service has not sent on yet
189
+ * (stdin backpressure): past it, CtlProcess.write refuses and writeAsync waits. 4 MiB of
190
+ * data plus room for the request line and frame headers, so one write of up to 4 MiB
191
+ * goes whole to an idle stream. */
192
+ export const STREAM_CREDIT = (4 << 20) + (64 << 10)
193
+ /** Streams the page has open at once; more wait their turn. The guest's yamux server
194
+ * resets streams it cannot queue for accept (AcceptBacklog 256). */
195
+ export const MAX_OPEN_STREAMS = 128
196
+
197
+ export class CtlStream {
198
+ constructor(client, k) {
199
+ this.client = client
200
+ this.k = k
201
+ this.onBytes = null
202
+ this.onFin = null
203
+ this.ended = false // closed (both sides) or reset
204
+ this.why = null
205
+ this.opened = false // the open has been posted (MAX_OPEN_STREAMS)
206
+ this.queued = [] // messages before the open went out
207
+ this.inflight = 0 // bytes posted and not yet sent on by the service
208
+ this.gotBytes = 0
209
+ this.creditWaiters = []
210
+ let resolve
211
+ this.closed = new Promise(r => (resolve = r))
212
+ this._resolve = resolve
213
+ }
214
+ _post(m) {
215
+ if (this.opened) this.client.transport.post(m)
216
+ else this.queued.push(m)
217
+ }
218
+ /** Posts u8 (counted against STREAM_CREDIT). False once the stream has ended. */
219
+ write(u8) {
220
+ if (this.ended) return false
221
+ this.inflight += u8.length
222
+ this._post({ t: 'ctl', op: 'write', k: this.k, bytes: u8 })
223
+ return true
224
+ }
225
+ /** Bytes that may be posted now without passing STREAM_CREDIT. */
226
+ credit() {
227
+ return Math.max(0, STREAM_CREDIT - this.inflight)
228
+ }
229
+ /** Resolves when credit() >= n (or at once if the stream ended). */
230
+ waitCredit(n) {
231
+ if (this.ended || this.credit() >= Math.min(n, STREAM_CREDIT)) return Promise.resolve()
232
+ return new Promise(r => this.creditWaiters.push({ n: Math.min(n, STREAM_CREDIT), r }))
233
+ }
234
+ _sent(n) {
235
+ this.inflight = Math.max(0, this.inflight - n)
236
+ this._wake()
237
+ }
238
+ _wake() {
239
+ const w = this.creditWaiters
240
+ this.creditWaiters = []
241
+ for (const x of w) {
242
+ if (this.ended || this.credit() >= x.n) x.r()
243
+ else this.creditWaiters.push(x)
244
+ }
245
+ }
246
+ closeWrite() {
247
+ if (!this.ended) this._post({ t: 'ctl', op: 'close', k: this.k })
248
+ }
249
+ reset() {
250
+ if (!this.ended) this._post({ t: 'ctl', op: 'reset', k: this.k })
251
+ }
252
+ }
253
+
254
+ /**
255
+ * A run, exec or attach: stdout/stderr callbacks, stdin, and `exited`
256
+ * ({code, synced, error?}). As control.go runProcessExit: a stream that ends without an
257
+ * exit frame gives 125 and the error.
258
+ *
259
+ * stdin: write() takes bytes only while the stream's credit allows (STREAM_CREDIT posted
260
+ * and not yet sent on to the guest) and returns false otherwise, sending nothing;
261
+ * writeAsync() queues and resolves once its bytes are posted, waiting for credit as the
262
+ * guest's yamux window lets the service send. Nothing is queued without bound.
263
+ */
264
+ export class CtlProcess {
265
+ constructor(stream, { onStdout, onStderr, onControl, op = '', mapExec = false } = {}) {
266
+ this.stream = stream
267
+ this.onStdout = onStdout
268
+ this.onStderr = onStderr
269
+ this.onControl = onControl
270
+ this.op = op
271
+ this.mapExec = mapExec
272
+ this.frames = new FrameReader()
273
+ this.exit = null
274
+ this.stdinClosed = false
275
+ this.closeAfterQueue = false
276
+ this.queue = [] // writeAsync data waiting for credit, in order
277
+ this.pumping = false
278
+ this.bytes = { stdout: 0, stderr: 0, stdin: 0 }
279
+ this.errTail = '' // the last stderr text, for exec failures crun reports (mapExec)
280
+ this.sawStdout = false
281
+ let resolve
282
+ this.exited = new Promise(r => (resolve = r))
283
+ this._resolve = resolve
284
+ }
285
+ _frames(u8) {
286
+ let frames
287
+ try {
288
+ frames = this.frames.push(u8)
289
+ } catch (e) {
290
+ this.stream.reset()
291
+ this._end(e.message)
292
+ return
293
+ }
294
+ for (const f of frames) {
295
+ if (f.id === STREAM_STDOUT) {
296
+ this.bytes.stdout += f.payload.length
297
+ this.sawStdout = true
298
+ this.onStdout?.(f.payload)
299
+ } else if (f.id === STREAM_STDERR) {
300
+ this.bytes.stderr += f.payload.length
301
+ let p = f.payload
302
+ if (this.mapExec && !this.sawStdout) {
303
+ const text = new TextDecoder().decode(p)
304
+ this.errTail = (this.errTail + text).slice(-8192)
305
+ const clean = cleanRuntimeText(text)
306
+ if (clean !== text) p = enc.encode(clean)
307
+ }
308
+ this.onStderr?.(p)
309
+ } else if (f.id === STREAM_EXIT) {
310
+ if (!this.exit) {
311
+ this.exit = parseExitFrame(f.payload)
312
+ if (this.mapExec && this.exit.code === 1 && !this.sawStdout) {
313
+ // as the init does for run (control.go crunExecStatus), which it does not for exec
314
+ const code = crunExecStatus(this.errTail)
315
+ if (code) {
316
+ this.exit.code = code
317
+ this.exit.error = cleanRuntimeText(this.errTail.trim().split('\n').at(-1) || '')
318
+ }
319
+ }
320
+ this.stream.closeWrite() // nothing more to say; the guest closes its side
321
+ this._resolve({ ...this.exit })
322
+ }
323
+ } else if (f.id === STREAM_CONTROL) this.onControl?.(f.payload)
324
+ }
325
+ }
326
+ _end(why) {
327
+ if (this.exit) return
328
+ this.exit = { code: 125, synced: false, error: why || 'process stream closed' }
329
+ this._resolve({ ...this.exit })
330
+ this.stream._wake()
331
+ }
332
+ #frame(p) {
333
+ return encodeFrame(STREAM_STDIN, p)
334
+ }
335
+ /**
336
+ * Bytes (or text) for stdin, now: true when all of it was posted (in frames of at most
337
+ * 1 MiB), false (nothing sent) when the credit does not allow it, writeAsync data is
338
+ * still waiting, stdin is closed or the process has ended.
339
+ */
340
+ write(data) {
341
+ if (this.stdinClosed || this.closeAfterQueue || this.exit || this.queue.length) return false
342
+ const p = typeof data === 'string' ? enc.encode(data) : data
343
+ const need = p.length + 5 * Math.ceil(p.length / (1 << 20))
344
+ if (need > this.stream.credit()) return false
345
+ this.bytes.stdin += p.length
346
+ for (let o = 0; o < p.length; o += 1 << 20) this.stream.write(this.#frame(p.subarray(o, Math.min(p.length, o + (1 << 20)))))
347
+ return true
348
+ }
349
+ /** Queues bytes (or text) for stdin; resolves true once all are posted, false if the process ended first. */
350
+ writeAsync(data) {
351
+ const p = typeof data === 'string' ? enc.encode(data) : data
352
+ if (this.stdinClosed || this.closeAfterQueue || this.exit) return Promise.resolve(false)
353
+ return new Promise(resolve => {
354
+ this.queue.push({ p, off: 0, resolve })
355
+ this.#pump()
356
+ })
357
+ }
358
+ async #pump() {
359
+ if (this.pumping) return
360
+ this.pumping = true
361
+ while (this.queue.length) {
362
+ const item = this.queue[0]
363
+ if (this.exit || this.stream.ended) {
364
+ for (const q of this.queue.splice(0)) q.resolve(false)
365
+ break
366
+ }
367
+ const chunk = Math.min(item.p.length - item.off, 1 << 20)
368
+ await this.stream.waitCredit(chunk + 5)
369
+ if (this.exit || this.stream.ended) continue
370
+ const n = Math.min(chunk, Math.max(1, this.stream.credit() - 5))
371
+ this.stream.write(this.#frame(item.p.subarray(item.off, item.off + n)))
372
+ this.bytes.stdin += n
373
+ item.off += n
374
+ if (item.off >= item.p.length) {
375
+ this.queue.shift()
376
+ item.resolve(true)
377
+ }
378
+ }
379
+ this.pumping = false
380
+ if (this.closeAfterQueue && !this.queue.length) {
381
+ this.closeAfterQueue = false
382
+ this.#closeNow()
383
+ }
384
+ }
385
+ /** Resolves when every writeAsync byte has been posted. */
386
+ drained() {
387
+ if (!this.queue.length) return Promise.resolve()
388
+ return new Promise(r => this.queue[this.queue.length - 1].resolve = (orig => v => (orig(v), r()))(this.queue[this.queue.length - 1].resolve))
389
+ }
390
+ /** close_stdin, after any writeAsync data still queued. */
391
+ closeStdin() {
392
+ if (this.stdinClosed || this.exit) return
393
+ if (this.queue.length) {
394
+ this.closeAfterQueue = true
395
+ return
396
+ }
397
+ this.#closeNow()
398
+ }
399
+ #closeNow() {
400
+ if (this.stdinClosed || this.exit) return
401
+ this.stdinClosed = true
402
+ this.stream.write(encodeFrame(STREAM_CONTROL, JSON.stringify({ close_stdin: true })))
403
+ }
404
+ resize(rows, cols) {
405
+ if (!this.exit) this.stream.write(encodeFrame(STREAM_CONTROL, JSON.stringify({ resize: [rows, cols] })))
406
+ }
407
+ }
408
+
409
+ /** The init's name for the container (crun's id); never shown to the user. */
410
+ const CRUN_ID = 'foo'
411
+
412
+ /** crun's messages with the internal container id taken out. */
413
+ export function cleanRuntimeText(t) {
414
+ return t
415
+ .replaceAll(`the container \`${CRUN_ID}\``, 'the container')
416
+ .replaceAll(`container \`${CRUN_ID}\``, 'the container')
417
+ .replaceAll(`\`/run/crun/${CRUN_ID}/config.json\``, "the container's state")
418
+ .replaceAll(`/run/crun/${CRUN_ID}/`, '')
419
+ }
420
+
421
+ /** The init's crunExecStatus (control.go): 127 not found, 126 not executable, else 0. */
422
+ export function crunExecStatus(log) {
423
+ for (const line of log.split('\n')) {
424
+ const l = line.replace(/^[^:]*crun: /, '').replace(/^Error: /, '')
425
+ if (/executable file `.*` not found/.test(l)) return 127
426
+ if (/the path `.*` exists but it is not executable/.test(l)) return 126
427
+ if (/exec container process `.*`: Permission denied$/.test(l)) return 126
428
+ if (/exec container process `.*`: No such file or directory$/.test(l)) return 127
429
+ }
430
+ return 0
431
+ }
432
+
433
+ export class ControlClient {
434
+ constructor(transport) {
435
+ this.transport = transport
436
+ this.streams = new Map()
437
+ this.nextK = 1
438
+ this.session = { up: false, epoch: 0, why: null }
439
+ this.waiters = []
440
+ this.acks = new Map()
441
+ this.waitingOpens = [] // streams past MAX_OPEN_STREAMS, in order
442
+ this.openNow = 0
443
+ this.ids = new Set() // run/exec ids of this page's processes that have not exited
444
+ this.stats = { opened: 0, bytesIn: 0, sessionUpAt: 0, queuedOpens: 0 }
445
+ }
446
+
447
+ onEvents(evs) {
448
+ for (const e of evs) this.#event(e)
449
+ this.#flushAcks()
450
+ }
451
+
452
+ #event(e) {
453
+ if (e.ev === 'session') {
454
+ this.session = { up: e.up, epoch: e.epoch, why: e.why || null }
455
+ if (e.up) {
456
+ if (!this.stats.sessionUpAt) this.stats.sessionUpAt = performance.now()
457
+ const w = this.waiters
458
+ this.waiters = []
459
+ for (const x of w) {
460
+ clearTimeout(x.timer)
461
+ x.resolve()
462
+ }
463
+ }
464
+ return
465
+ }
466
+ const s = this.streams.get(e.k)
467
+ if (!s) return
468
+ if (e.ev === 'data') {
469
+ const u8 = new Uint8Array(e.bytes)
470
+ this.stats.bytesIn += u8.length
471
+ s.gotBytes += u8.length
472
+ s.onBytes?.(u8)
473
+ // acked once delivered: a page that stops serving events stops the guest (backpressure)
474
+ this.acks.set(e.k, (this.acks.get(e.k) || 0) + u8.length)
475
+ } else if (e.ev === 'sent') s._sent(e.n)
476
+ else if (e.ev === 'fin') s.onFin?.()
477
+ else if (e.ev === 'closed') {
478
+ s.ended = true
479
+ s.why = e.why === 'reset by the guest' && !s.gotBytes
480
+ ? 'the guest refused the stream before any reply (reset: its yamux server queues at most 256 streams waiting to be accepted)'
481
+ : e.why
482
+ this.streams.delete(e.k)
483
+ this.acks.delete(e.k)
484
+ if (s.opened) {
485
+ this.openNow--
486
+ this.#openNext()
487
+ }
488
+ s._wake()
489
+ s._resolve(s.why)
490
+ }
491
+ }
492
+
493
+ #openNext() {
494
+ while (this.waitingOpens.length && this.openNow < MAX_OPEN_STREAMS) this.#postOpen(this.waitingOpens.shift())
495
+ }
496
+
497
+ #postOpen({ s, line }) {
498
+ this.openNow++
499
+ s.opened = true
500
+ s.inflight += line.length
501
+ this.transport.post({ t: 'ctl', op: 'open', k: s.k, bytes: line })
502
+ for (const m of s.queued.splice(0)) this.transport.post(m)
503
+ }
504
+
505
+ #flushAcks() {
506
+ for (const [k, n] of this.acks) this.transport.post({ t: 'ctl', op: 'ack', k, n })
507
+ this.acks.clear()
508
+ }
509
+
510
+ /**
511
+ * Resolves once the guest has opened the control port (the yamux session is up). Rejects at once, with the
512
+ * reason, when the machine has ended or its channel service has died (fail()): a wait on a machine that is
513
+ * gone must not run out its timeout.
514
+ */
515
+ waitSession(timeoutMs = 600_000) {
516
+ if (this.session.up) return Promise.resolve()
517
+ if (this.failure) return Promise.reject(new Error(this.failure))
518
+ return new Promise((resolve, reject) => {
519
+ const t = setTimeout(() => {
520
+ this.waiters = this.waiters.filter(w => w.timer !== t)
521
+ reject(new Error(`the guest did not open the control channel within ${timeoutMs} ms`))
522
+ }, timeoutMs)
523
+ this.waiters.push({ timer: t, resolve, reject })
524
+ })
525
+ }
526
+
527
+ /** The machine is over (it exited, trapped, or the channel service died): every wait rejects with `message` now. */
528
+ fail(message) {
529
+ if (this.failure) return
530
+ this.failure = message
531
+ const w = this.waiters
532
+ this.waiters = []
533
+ for (const x of w) {
534
+ clearTimeout(x.timer)
535
+ x.reject(new Error(message))
536
+ }
537
+ }
538
+
539
+ /** A stream with the request line sent. */
540
+ openStream(req) {
541
+ const line = requestLine(req)
542
+ const k = this.nextK++
543
+ const s = new CtlStream(this, k)
544
+ this.streams.set(k, s)
545
+ this.stats.opened++
546
+ if (this.openNow < MAX_OPEN_STREAMS) this.#postOpen({ s, line })
547
+ else {
548
+ this.stats.queuedOpens++
549
+ this.waitingOpens.push({ s, line })
550
+ }
551
+ return s
552
+ }
553
+
554
+ /** A stream that ends at once with `why` (a request refused on the page). */
555
+ #refused(why) {
556
+ const s = new CtlStream(this, this.nextK++)
557
+ s.ended = true
558
+ s.why = why
559
+ queueMicrotask(() => s._resolve(why))
560
+ return s
561
+ }
562
+
563
+ /** run / exec / step: a CtlProcess. stdin: false sends close_stdin at once (control.go). */
564
+ process(req, { stdin = false, ...handlers } = {}) {
565
+ const bad = checkProcessRequest(req, this.ids)
566
+ const s = bad ? this.#refused(bad) : this.openStream(req)
567
+ const p = new CtlProcess(s, { ...handlers, op: req.op, mapExec: req.op === 'exec' })
568
+ if (!bad && req.id && (req.op === 'exec' || req.op === 'run')) {
569
+ this.ids.add(req.id)
570
+ p.exited.then(() => this.ids.delete(req.id))
571
+ }
572
+ s.onBytes = u8 => p._frames(u8)
573
+ s.closed.then(why => p._end(why || 'process stream closed'))
574
+ if (!stdin && !bad) p.closeStdin()
575
+ return p
576
+ }
577
+
578
+ run(req, o) {
579
+ return this.process({ ...req, op: 'run' }, o)
580
+ }
581
+
582
+ /** op "exec" (runc exec in the container). */
583
+ startExec(req, o) {
584
+ return this.process({ ...req, op: 'exec' }, o)
585
+ }
586
+
587
+ /** attach (W3.4): {reply, proc}; proc is null when the reply is an error. */
588
+ attach(id, handlers = {}) {
589
+ const s = this.openStream({ op: 'attach', id })
590
+ const lines = new LineReader()
591
+ return new Promise((resolve, reject) => {
592
+ let proc = null
593
+ s.onBytes = u8 => {
594
+ if (proc) return proc._frames(u8)
595
+ let got
596
+ try {
597
+ got = lines.push(u8)
598
+ } catch (e) {
599
+ s.reset()
600
+ return reject(e)
601
+ }
602
+ if (!got) return
603
+ let reply
604
+ try {
605
+ reply = JSON.parse(got.line)
606
+ } catch {
607
+ s.reset()
608
+ return reject(new Error(`attach ${id}: bad reply ${JSON.stringify(got.line.slice(0, 200))}`))
609
+ }
610
+ if (!reply.ok) {
611
+ s.closeWrite()
612
+ return resolve({ reply, proc: null })
613
+ }
614
+ proc = new CtlProcess(s, handlers)
615
+ s.closed.then(why => proc._end(why || 'process stream closed'))
616
+ resolve({ reply, proc })
617
+ if (got.rest.length) proc._frames(got.rest)
618
+ }
619
+ s.closed.then(why => {
620
+ if (!proc) reject(new Error(`attach ${id}: the stream closed before the reply${why ? `: ${why}` : ''}`))
621
+ })
622
+ })
623
+ }
624
+
625
+ /** kill / procs / pause / resume / ps / stats: the JSON response. */
626
+ simple(req) {
627
+ const s = this.openStream(req)
628
+ const lines = new LineReader()
629
+ return new Promise((resolve, reject) => {
630
+ let done = false
631
+ s.onBytes = u8 => {
632
+ if (done) return
633
+ let got
634
+ try {
635
+ got = lines.push(u8)
636
+ } catch (e) {
637
+ done = true
638
+ s.reset()
639
+ return reject(e)
640
+ }
641
+ if (!got) return
642
+ done = true
643
+ s.closeWrite()
644
+ try {
645
+ resolve(JSON.parse(got.line))
646
+ } catch {
647
+ reject(new Error(`control ${req.op}: bad response ${JSON.stringify(got.line.slice(0, 200))}`))
648
+ }
649
+ }
650
+ s.closed.then(why => {
651
+ if (!done) reject(new Error(`control ${req.op}: the stream closed before a response${why ? `: ${why}` : ''}`))
652
+ })
653
+ })
654
+ }
655
+
656
+ kill({ id, sig = '', all = false } = {}) {
657
+ return this.simple({ op: 'kill', id, sig, all })
658
+ }
659
+
660
+ procs() {
661
+ return this.simple({ op: 'procs' })
662
+ }
663
+ }
664
+
665
+ /**
666
+ * Why a run/exec request cannot go to the guest, or null. The init would answer some of
667
+ * these with a bare 125 (a NUL in an argument or env entry: Go's exec refuses it) or run
668
+ * them anyway (an id already in use by a process of this page).
669
+ */
670
+ export function checkProcessRequest(req, liveIds = new Set()) {
671
+ if ((req.op === 'exec' || req.op === 'run') && (!Array.isArray(req.args) || !req.args.length) && req.op === 'exec') {
672
+ return 'exec: no command (args is empty)'
673
+ }
674
+ for (const [what, list] of [['argument', req.args || []], ['environment entry', req.env || []]]) {
675
+ for (let i = 0; i < list.length; i++) {
676
+ if (typeof list[i] !== 'string') return `${req.op}: ${what} ${i} is not a string`
677
+ if (list[i].includes('\0')) return `${req.op}: ${what} ${i} contains a NUL byte, which a process cannot be given (${JSON.stringify(list[i].slice(0, 40))})`
678
+ }
679
+ }
680
+ if (req.cwd && req.cwd.includes('\0')) return `${req.op}: the working directory contains a NUL byte`
681
+ if (req.id && liveIds.has(req.id)) return `${req.op}: the process id ${JSON.stringify(req.id)} is already in use by a running process`
682
+ return null
683
+ }
684
+
685
+ // ---------------------------------------------------------------------------
686
+ // The bundle: the OCI runtime spec as the engine writes it (internal/engine/ocispec.go
687
+ // buildSpec, bundle.go writeBundle), for the guest init's config/config.json.
688
+ // ---------------------------------------------------------------------------
689
+
690
+ export const GUEST_9P_MOUNT = '/run/wmd9p'
691
+ export const DEFAULT_CAPS = [
692
+ 'CAP_CHOWN', 'CAP_DAC_OVERRIDE', 'CAP_FSETID', 'CAP_FOWNER', 'CAP_MKNOD', 'CAP_NET_RAW', 'CAP_SETGID',
693
+ 'CAP_SETUID', 'CAP_SETFCAP', 'CAP_SETPCAP', 'CAP_NET_BIND_SERVICE', 'CAP_SYS_CHROOT', 'CAP_KILL', 'CAP_AUDIT_WRITE',
694
+ ]
695
+ const MASKED = ['/proc/acpi', '/proc/asound', '/proc/kcore', '/proc/keys', '/proc/latency_stats', '/proc/timer_list', '/proc/timer_stats', '/proc/sched_debug', '/proc/scsi', '/sys/firmware']
696
+ const READONLY = ['/proc/bus', '/proc/fs', '/proc/irq', '/proc/sys', '/proc/sysrq-trigger']
697
+ const ETC_FILES = ['hosts', 'resolv.conf', 'hostname']
698
+
699
+ function defaultMounts() {
700
+ return [
701
+ { destination: '/proc', type: 'proc', source: 'proc', options: ['nosuid', 'noexec', 'nodev'] },
702
+ { destination: '/dev', type: 'tmpfs', source: 'tmpfs', options: ['nosuid', 'strictatime', 'mode=755', 'size=65536k'] },
703
+ { destination: '/dev/pts', type: 'devpts', source: 'devpts', options: ['nosuid', 'noexec', 'newinstance', 'ptmxmode=0666', 'mode=0620', 'gid=5'] },
704
+ { destination: '/dev/shm', type: 'tmpfs', source: 'shm', options: ['nosuid', 'noexec', 'nodev', 'mode=1777', 'size=65536k'] },
705
+ { destination: '/dev/mqueue', type: 'mqueue', source: 'mqueue', options: ['nosuid', 'noexec', 'nodev'] },
706
+ { destination: '/sys', type: 'sysfs', source: 'sysfs', options: ['nosuid', 'noexec', 'nodev', 'ro'] },
707
+ ]
708
+ }
709
+
710
+ /**
711
+ * The 9P tree's files for a container (paths relative to the share root, beside the
712
+ * rootfs/ the packs give): bundle/config/config.json, bundle/config/imageconfig.json,
713
+ * bundle/etc/{hosts,resolv.conf,hostname}, and one share per mount (vol0, vol1, ...)
714
+ * holding the mount's files.
715
+ *
716
+ * @param {object} o
717
+ * @param {string[]} o.args process args
718
+ * @param {string[]} [o.env]
719
+ * @param {string} [o.cwd] default "/"
720
+ * @param {{uid:number, gid:number, additionalGids?:number[]}} [o.user] numeric (the engine resolves names on the host)
721
+ * @param {boolean} [o.terminal]
722
+ * @param {string} [o.hostname] default "localhost"
723
+ * @param {boolean} [o.networkDisabled] no address line in /etc/hosts, empty resolv.conf (default true: no network yet)
724
+ * @param {{destination:string, files:Record<string,{data:Uint8Array|string, mode?:number}|string>, file?:string, readOnly?:boolean}[]} [o.mounts]
725
+ * each mount is a share vol<i> with these files; `file` binds only that file of the share
726
+ * @param {string} [o.arch] for imageconfig.json (default arm64)
727
+ * @returns {{files: Record<string, string|Uint8Array|{data, mode}>, spec: object}}
728
+ */
729
+ export function containerBundle(o) {
730
+ const cwd = o.cwd || '/'
731
+ const user = o.user || { uid: 0, gid: 0 }
732
+ const hostname = o.hostname || 'localhost'
733
+ const spec = {
734
+ ociVersion: '1.0.2',
735
+ process: {
736
+ terminal: !!o.terminal,
737
+ user: { uid: user.uid, gid: user.gid, ...(user.additionalGids?.length ? { additionalGids: user.additionalGids } : {}) },
738
+ args: [...o.args],
739
+ env: [...(o.env || [])],
740
+ cwd,
741
+ capabilities: { bounding: DEFAULT_CAPS, effective: DEFAULT_CAPS, permitted: DEFAULT_CAPS },
742
+ rlimits: [{ type: 'RLIMIT_NOFILE', hard: 1 << 20, soft: 1 << 20 }],
743
+ oomScoreAdj: 0,
744
+ },
745
+ root: { path: `${GUEST_9P_MOUNT}/rootfs`, readonly: false },
746
+ hostname,
747
+ mounts: defaultMounts(),
748
+ linux: {
749
+ namespaces: [{ type: 'pid' }, { type: 'ipc' }, { type: 'uts' }, { type: 'mount' }],
750
+ maskedPaths: MASKED,
751
+ readonlyPaths: READONLY,
752
+ resources: { devices: [{ allow: false, access: 'rwm' }] },
753
+ },
754
+ }
755
+ for (const name of ETC_FILES) {
756
+ spec.mounts.push({ destination: `/etc/${name}`, type: 'bind', source: `${GUEST_9P_MOUNT}/bundle/etc/${name}`, options: ['rbind', 'rprivate', 'rw'] })
757
+ }
758
+ const files = {}
759
+ ;(o.mounts || []).forEach((m, i) => {
760
+ const share = `vol${i}`
761
+ const entries = Object.entries(m.files || {})
762
+ for (const [rel, f] of entries) files[`${share}/${rel}`] = f
763
+ if (!entries.length) files[`${share}/.keep`] = '' // the share's directory
764
+ let src = `${GUEST_9P_MOUNT}/${share}`
765
+ if (m.file) src += `/${m.file}`
766
+ spec.mounts.push({ destination: m.destination, type: 'bind', source: src, options: ['rbind', 'rprivate', m.readOnly ? 'ro' : 'rw'] })
767
+ })
768
+ const networkDisabled = o.networkDisabled ?? true
769
+ let hosts = '127.0.0.1\tlocalhost\n::1\tlocalhost ip6-localhost ip6-loopback\nfe00::\tip6-localnet\nff00::\tip6-mcastprefix\nff02::1\tip6-allnodes\nff02::2\tip6-allrouters\n'
770
+ if (!networkDisabled) hosts += `${o.ip || '10.88.0.2'}\t${hostname}\n`
771
+ files['bundle/etc/hosts'] = hosts
772
+ files['bundle/etc/resolv.conf'] = networkDisabled ? '' : `nameserver ${o.gateway || '10.88.0.1'}\n`
773
+ files['bundle/etc/hostname'] = `${hostname}\n`
774
+ files['bundle/config/config.json'] = JSON.stringify(spec, null, 2) + '\n'
775
+ files['bundle/config/imageconfig.json'] =
776
+ JSON.stringify({ architecture: o.arch || 'arm64', os: 'linux', config: { Env: [...(o.env || [])], Cmd: [...o.args], WorkingDir: cwd }, rootfs: { type: 'layers', diff_ids: null } }, null, 2) + '\n'
777
+ return { files, spec }
778
+ }