@vzn/vx-dte 0.0.0 → 0.0.649

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/src/wire.ts ADDED
@@ -0,0 +1,1060 @@
1
+ // A participant of the relay protocol (@vzn/vx-dte-server's PROTOCOL.md).
2
+
3
+ import { ui } from '@vzn/vx'
4
+ import { printable } from './printable.js'
5
+
6
+ export interface RelayMessage {
7
+ readonly from: string
8
+ readonly body: unknown
9
+ }
10
+
11
+ export class RelayError extends Error {
12
+ constructor(
13
+ readonly status: number,
14
+ message: string,
15
+ ) {
16
+ super(message)
17
+ }
18
+ }
19
+
20
+ /**
21
+ * A send the relay would not take, said by `warn` (2026-10-23: no fault is
22
+ * silent). One to a participant the relay no longer knows (404) is not:
23
+ * its loss reaches the other side as a notice of its own.
24
+ */
25
+ export function unsent(
26
+ warn: (message: string) => void,
27
+ what: string,
28
+ to: string,
29
+ ): (err: unknown) => void {
30
+ return (err) => {
31
+ if (err instanceof RelayError && err.status === 404) return
32
+ warn(
33
+ `dte: sending ${what} to ${to} failed: ${err instanceof Error ? err.message : String(err)}`,
34
+ )
35
+ }
36
+ }
37
+
38
+ /**
39
+ * A dropped socket's first redial waits up to this, doubling per failure
40
+ * (full jitter) to half a heartbeat: the relay loses a participant three
41
+ * heartbeats without one.
42
+ */
43
+ const FIRST_REDIAL_MS = 250
44
+ /**
45
+ * The orchestrator probes a worker quiet this many heartbeats: each probe
46
+ * and its ack are two frames, so a peer heard from is never asked.
47
+ */
48
+ const PROBE_BEATS = 1.5
49
+ /** A worker probes its orchestrator quiet this long: after the orchestrator would have. */
50
+ const WORKER_PROBE_BEATS = 1.6
51
+ /**
52
+ * A peer quiet this many is lost: a live one, acking a probe at once, is
53
+ * heard within a round trip of it. Checked each tenth, loss lands by two
54
+ * (4 s on the default).
55
+ */
56
+ const LOST_BEATS = 1.9
57
+ /**
58
+ * A relay that forgot this participant (a restart: it stores nothing) is
59
+ * joined again for this many heartbeats; the orchestrator joins as the
60
+ * run's host again, a worker as itself (2026-10-25: "If they can't
61
+ * rejoin for 2 heartbeats, it's over").
62
+ */
63
+ const REJOIN_BEATS = 2
64
+ /** What a probe delivers: nothing. */
65
+ const PROBED = Symbol('probe')
66
+ /** A frame of several sends stays under this, half the relay's 1 MiB body limit. */
67
+ const FRAME_BYTES = 512 * 1024
68
+ /** How long `leave` waits on its socket's close handshake. */
69
+ const CLOSE_WAIT_MS = 2_000
70
+ const UNANSWERED = 'the relay answered no frame'
71
+ /** The close code of a socket whose participant the relay lost or let go (PROTOCOL.md § Sockets). */
72
+ const GONE = 4404
73
+ /** This side's close of a socket that carried a frame no relay sends (RFC 6455 protocol error). */
74
+ const NOT_PROTOCOL = 1002
75
+ /**
76
+ * No answer from the relay this long, every try counted, and it is gone
77
+ * (2026-10-23: "no reply for 10s no matter of attempts its over").
78
+ */
79
+ const REPLY_MS = 10_000
80
+ /**
81
+ * How many times a request or a socket is tried again, after a fault (a
82
+ * 5xx, a dropped connection), before the participant gives
83
+ * up: the run is live, or it isn't (2026-10-23: "3 retries nothing
84
+ * more").
85
+ */
86
+ const RETRIES = 3
87
+ /** The least either wait is: a loaded machine's loopback, under a test's short heartbeat. */
88
+ const MIN_WAIT_MS = 1_000
89
+ /**
90
+ * How long a request or a dial waits on its answer: two heartbeats, a
91
+ * relay's longest poll being one, at most `REPLY_MS`.
92
+ */
93
+ const replyMs = (heartbeatMs: number): number =>
94
+ Math.min(Math.max(2 * heartbeatMs, MIN_WAIT_MS), REPLY_MS)
95
+ /**
96
+ * How long a frame waits on its answer: a heartbeat, so a quiet relay asked
97
+ * after one is dead after two, 4 s on the default, and this side
98
+ * dials again before the relay, losing it after two silent, does.
99
+ */
100
+ const answerMs = (heartbeatMs: number): number =>
101
+ Math.min(Math.max(heartbeatMs, MIN_WAIT_MS), REPLY_MS)
102
+
103
+ /**
104
+ * A send as it travels: numbered per receiver, so a resend is dropped as
105
+ * seen, and carrying the sender's ack of what it took from that receiver,
106
+ * so most acks cost no message of their own.
107
+ */
108
+ interface Numbered {
109
+ readonly seq: number
110
+ readonly body: unknown
111
+ readonly acked?: number
112
+ }
113
+ /** The receiver took every send to it numbered up to `acked`. */
114
+ interface Acked {
115
+ readonly acked: number
116
+ }
117
+ const isNumbered = (b: unknown): b is Numbered =>
118
+ typeof b === 'object' &&
119
+ b !== null &&
120
+ typeof (b as Numbered).seq === 'number' &&
121
+ 'body' in b &&
122
+ Object.keys(b).length === ('acked' in b ? 3 : 2)
123
+ /**
124
+ * A peer quiet a heartbeat is asked: a numbered send with no body, which
125
+ * its receiver acknowledges at once and delivers to no one.
126
+ */
127
+ interface Probe {
128
+ readonly seq: number
129
+ readonly acked?: number
130
+ }
131
+ const isProbe = (b: unknown): b is Probe =>
132
+ typeof b === 'object' &&
133
+ b !== null &&
134
+ typeof (b as Probe).seq === 'number' &&
135
+ !('body' in b) &&
136
+ Object.keys(b).length === ('acked' in b ? 2 : 1)
137
+ const isAcked = (b: unknown): b is Acked =>
138
+ typeof b === 'object' &&
139
+ b !== null &&
140
+ typeof (b as Acked).acked === 'number' &&
141
+ Object.keys(b).length === 1
142
+
143
+ /** Runs `f` again after a fault, at most `RETRIES` times and within `REPLY_MS`. */
144
+ async function paced<T>(f: () => Promise<T>): Promise<T> {
145
+ const by = performance.now() + REPLY_MS
146
+ for (let tries = 0; ; tries++) {
147
+ try {
148
+ return await f()
149
+ } catch (err) {
150
+ // A request the relay held past its deadline already took the whole wait.
151
+ const fault =
152
+ err instanceof RelayError ? err.status >= 500 : (err as Error).name !== 'TimeoutError'
153
+ const wait = Math.random() * FIRST_REDIAL_MS * 2 ** tries
154
+ if (!fault || tries >= RETRIES) throw err
155
+ if (performance.now() + wait >= by) throw err
156
+ await Bun.sleep(wait)
157
+ }
158
+ }
159
+ }
160
+
161
+ export interface Participant {
162
+ readonly id: string
163
+ readonly heartbeatMs: number
164
+ /** Whether it has a socket: with none, nobody can be heard. */
165
+ readonly online: boolean
166
+ /**
167
+ * `f` is told when this side is back on a socket after none (no peer
168
+ * named), or a worker joined again (it named): what each holds was not
169
+ * said meanwhile, and is not lost for that.
170
+ */
171
+ resumed(f: (peer?: string) => void): void
172
+ send(to: string, body: unknown): Promise<void>
173
+ /** Held up to one heartbeat, which also keeps this participant alive. */
174
+ poll(signal?: AbortSignal): Promise<RelayMessage[]>
175
+ /** The orchestrator takes a worker it did not admit off the run. */
176
+ remove(worker: string): Promise<void>
177
+ leave(): Promise<void>
178
+ }
179
+
180
+ export async function join(
181
+ relay: string,
182
+ run: string,
183
+ role: 'orchestrator' | 'worker',
184
+ info: unknown,
185
+ /** A function is asked again for a token about to expire: GitHub's last 5 minutes. */
186
+ token: string | undefined | (() => Promise<string | undefined>),
187
+ /** `dte({ headers })`: on every dial, the redials included. */
188
+ extra: Readonly<Record<string, string>> = {},
189
+ ): Promise<Participant> {
190
+ const base = `${relay.replace(/\/+$/, '')}/v1/sessions/${encodeURIComponent(run)}/`
191
+ const auth = bearer(token, extra)
192
+ // The orchestrator signs each request by a key the relay keeps from its join.
193
+ const prover =
194
+ role === 'orchestrator' ? await (await import('@vzn/vx-dte-server')).createProver() : undefined
195
+ const headers = async (method: string, url: string, body: string) => {
196
+ const h = await auth()
197
+ if (prover !== undefined) {
198
+ const { pathname, search } = new URL(url)
199
+ h[PROOF_HEADER] = await prover.proof(method, pathname + search, body)
200
+ }
201
+ return h
202
+ }
203
+ const asked = {
204
+ role: role === 'orchestrator' ? 'host' : 'peer',
205
+ info,
206
+ ...(prover && { key: prover.key }),
207
+ }
208
+ let id = ''
209
+ let heartbeatMs = 0
210
+ /** The relay's id for this participant now: a rejoin gets a new one, `id` stays the first. */
211
+ let current = ''
212
+ /** Joining again until then: the relay forgot this participant. */
213
+ let rejoinBy: number | undefined
214
+ const dial = async () => {
215
+ if (rejoinBy === undefined)
216
+ return connect(
217
+ base,
218
+ `id=${current}`,
219
+ await headers('GET', `${base}socket?id=${current}`, ''),
220
+ heartbeatMs,
221
+ )
222
+ // A worker names the id it first had: the orchestrator still holds it
223
+ // by that one, and nobody else knows it (PROTOCOL.md: a secret).
224
+ const again = role === 'orchestrator' ? asked : { ...asked, info: { rejoin: id } }
225
+ const q = `join=${encodeURIComponent(JSON.stringify(again))}`
226
+ const s = await connect(base, q, await headers('GET', `${base}socket?${q}`, ''))
227
+ if (s?.joined !== undefined) {
228
+ current = s.joined.id
229
+ joins.add(s)
230
+ }
231
+ return s
232
+ }
233
+ /**
234
+ * Sockets that joined: one the relay closes 4404 let this participant go
235
+ * (removed, refused), where one dialed by its id was forgotten, by a
236
+ * restart, and joins again.
237
+ */
238
+ const joins = new WeakSet<Socket>()
239
+ let onResumed: ((peer?: string) => void) | undefined
240
+ /** The orchestrator's workers that joined again: their relay id now, by the id it holds them by. */
241
+ const relayIds = new Map<string, string>()
242
+ /** And back: the id it holds a worker by, by its relay id now. */
243
+ const heldIds = new Map<string, string>()
244
+ /** What the sockets brought and nobody took yet, kept across redials. */
245
+ const inbox: RelayMessage[] = []
246
+ /**
247
+ * Each receiver's sends it has not acknowledged yet, and when each last
248
+ * went: sent again after a heartbeat, or at once on a new socket, so a
249
+ * send survives a socket or a relay that dropped it (NATS-style: the
250
+ * relay holds nothing a participant cannot send again).
251
+ */
252
+ const outbox = new Map<
253
+ string,
254
+ { next: number; unacked: Map<number, { body: unknown; at: number }> }
255
+ >()
256
+ /** Each sender's sends taken in order, and those that came ahead of a gap. */
257
+ const taken = new Map<string, { upTo: number; ahead: Map<number, unknown> }>()
258
+ /**
259
+ * Acks owed, by peer: each rides the next send to its peer; those with
260
+ * none in half a heartbeat go together in one frame, before any peer
261
+ * sends again, in place of a ping.
262
+ */
263
+ const owing = new Set<string>()
264
+ let acking: ReturnType<typeof setTimeout> | undefined
265
+ const flushAcks = (): void => {
266
+ clearTimeout(acking)
267
+ acking = undefined
268
+ if (left || ended !== undefined) return
269
+ for (const peer of owing) {
270
+ const upTo = taken.get(peer)?.upTo
271
+ if (upTo === undefined) continue
272
+ // quiet: a lost ack only costs a resend, which is dropped as seen
273
+ put(peer, { acked: upTo } satisfies Acked).catch(() => undefined)
274
+ }
275
+ owing.clear()
276
+ }
277
+ /** `now`: a probe's ack goes at once, so its asker hears it within the loss window. */
278
+ const ack = (to: string, now = false): void => {
279
+ owing.add(to)
280
+ if (now) return flushAcks()
281
+ if (acking !== undefined) return
282
+ acking = setTimeout(flushAcks, heartbeatMs / 2)
283
+ acking.unref()
284
+ }
285
+ /** The ack owed `to`, if any, for a send to it to carry. */
286
+ const owed = (to: string): { acked?: number } =>
287
+ owing.delete(to) ? { acked: taken.get(to)!.upTo } : {}
288
+ /**
289
+ * When each peer was last heard from: the orchestrator's workers from
290
+ * their `joined`, a worker's orchestrator from its join. One quiet
291
+ * `PROBE_BEATS` is probed; one quiet `LOST_BEATS` is lost
292
+ * (2026-10-23: "if it does not reply for 10s its dead"), on a socket that
293
+ * never closed: the relay loses one whose socket closed and says so.
294
+ */
295
+ const heard = new Map<string, number>()
296
+ /** `quiet`: an ack, a probe or a resend, which waits on no answer: the relay says only a failure. */
297
+ const put = (to: string, frame: object, quiet = true): Promise<void> =>
298
+ over((s) => s.send(relayIds.get(to) ?? to, frame, quiet))
299
+ const settle = (from: string, acked: number): void => {
300
+ const o = outbox.get(from)
301
+ if (o !== undefined) for (const seq of o.unacked.keys()) if (seq <= acked) o.unacked.delete(seq)
302
+ }
303
+ /** What a socket brought, in each sender's order, each send once; acks and losses settle the outbox. */
304
+ const take = (messages: readonly RelayMessage[]): RelayMessage[] => {
305
+ const out: RelayMessage[] = []
306
+ for (const m of messages) {
307
+ const body = m.body
308
+ if (m.from === 'relay') {
309
+ const notice = body as { type?: unknown; id?: unknown; info?: unknown }
310
+ if (notice?.type === 'joined' && typeof notice.id === 'string') {
311
+ const was = (notice.info as { rejoin?: unknown } | null | undefined)?.rejoin
312
+ if (typeof was === 'string') {
313
+ rejoined(notice.id, was)
314
+ continue
315
+ }
316
+ heard.set(notice.id, performance.now())
317
+ }
318
+ if (notice?.type === 'lost' && typeof notice.id === 'string') {
319
+ const held = heldIds.get(notice.id) ?? notice.id
320
+ forget(held)
321
+ out.push({ from: m.from, body: { ...notice, id: held } })
322
+ continue
323
+ }
324
+ out.push(m)
325
+ continue
326
+ }
327
+ const from = heldIds.get(m.from) ?? m.from
328
+ if (heard.has(from)) heard.set(from, performance.now())
329
+ if (isAcked(body)) settle(from, body.acked)
330
+ else if (isProbe(body)) {
331
+ if (body.acked !== undefined) settle(from, body.acked)
332
+ const t = taken.get(from) ?? { upTo: 0, ahead: new Map() }
333
+ taken.set(from, t)
334
+ if (body.seq > t.upTo) t.ahead.set(body.seq, PROBED)
335
+ while (t.ahead.has(t.upTo + 1)) {
336
+ const next = t.ahead.get(++t.upTo)
337
+ t.ahead.delete(t.upTo)
338
+ if (next !== PROBED) out.push({ from, body: next })
339
+ }
340
+ ack(from, true)
341
+ } else if (isNumbered(body)) {
342
+ if (body.acked !== undefined) settle(from, body.acked)
343
+ let t = taken.get(from)
344
+ if (t === undefined) taken.set(from, (t = { upTo: 0, ahead: new Map() }))
345
+ if (body.seq > t.upTo) t.ahead.set(body.seq, body.body)
346
+ while (t.ahead.has(t.upTo + 1)) {
347
+ const next = t.ahead.get(++t.upTo)
348
+ t.ahead.delete(t.upTo)
349
+ if (next !== PROBED) out.push({ from, body: next })
350
+ }
351
+ ack(from)
352
+ } else out.push({ from, body })
353
+ }
354
+ return out
355
+ }
356
+ /**
357
+ * A worker joined again, as `relayId`, naming `was`, the id this side
358
+ * holds it by: the run goes on with it, and what it missed goes again.
359
+ * One this side does not hold is let go.
360
+ */
361
+ const rejoined = (relayId: string, was: string): void => {
362
+ if (role !== 'orchestrator' || !heard.has(was)) {
363
+ // quiet: let go, its socket's 4404 ends it
364
+ over((s) => s.request({ remove: relayId })).catch(() => undefined)
365
+ return
366
+ }
367
+ const old = relayIds.get(was)
368
+ if (old !== undefined) heldIds.delete(old)
369
+ relayIds.set(was, relayId)
370
+ heldIds.set(relayId, was)
371
+ heard.set(was, performance.now())
372
+ resend(0, was)
373
+ onResumed?.(was)
374
+ }
375
+ const forget = (peer: string): void => {
376
+ heard.delete(peer)
377
+ outbox.delete(peer)
378
+ taken.delete(peer)
379
+ owing.delete(peer)
380
+ const now = relayIds.get(peer)
381
+ relayIds.delete(peer)
382
+ if (now !== undefined) heldIds.delete(now)
383
+ }
384
+ /**
385
+ * Only a quiet peer is probed: the orchestrator a worker quiet
386
+ * `PROBE_BEATS`, a worker its orchestrator quiet a tenth longer, so in a
387
+ * quiet pair the orchestrator's probe comes first and the pair trades
388
+ * one probe and its ack. Either loses a peer quiet `LOST_BEATS`.
389
+ */
390
+ const listen = (): void => {
391
+ // With no socket nobody is heard: the redial or rejoin decides.
392
+ if (socket?.open !== true) return
393
+ resend(heartbeatMs)
394
+ const now = performance.now()
395
+ // With no peer, the relay is asked instead: a frame it answers.
396
+ if (heard.size === 0 && socket?.open === true && now - socket.heardAt >= heartbeatMs)
397
+ socket.ask()
398
+ for (const [peer, at] of heard) {
399
+ const quiet = now - at
400
+ if (quiet >= LOST_BEATS * heartbeatMs) {
401
+ forget(peer)
402
+ if (peer === 'orchestrator') {
403
+ end(new RelayError(410, 'relay: the orchestrator went silent'))
404
+ return
405
+ }
406
+ inbox.push({ from: 'relay', body: { type: 'lost', id: peer } })
407
+ nudge()
408
+ } else if (
409
+ quiet >= (role === 'orchestrator' ? PROBE_BEATS : WORKER_PROBE_BEATS) * heartbeatMs &&
410
+ // a numbered send's ack may be deferred past the loss line; a probe's is not
411
+ ![...(outbox.get(peer)?.unacked.values() ?? [])].some((u) => u.body === PROBED)
412
+ ) {
413
+ let o = outbox.get(peer)
414
+ if (o === undefined) outbox.set(peer, (o = { next: 0, unacked: new Map() }))
415
+ const seq = ++o.next
416
+ o.unacked.set(seq, { body: PROBED, at: now })
417
+ // quiet: unacknowledged, it goes again
418
+ put(peer, { seq, ...owed(peer) } satisfies Probe).catch(() => undefined)
419
+ }
420
+ }
421
+ }
422
+ /** Sends again what waited `ms` unacknowledged, to `only` or to all; all of it, at 0. */
423
+ const resend = (ms: number, only?: string): void => {
424
+ const now = performance.now()
425
+ for (const [to, o] of outbox) {
426
+ if (only !== undefined && to !== only) continue
427
+ for (const [seq, u] of o.unacked) {
428
+ if (now - u.at < ms) continue
429
+ u.at = now
430
+ // quiet: unacknowledged, it goes again next heartbeat
431
+ const again = u.body === PROBED ? { seq } : { seq, body: u.body }
432
+ put(to, { ...again, ...owed(to) }).catch(() => undefined)
433
+ }
434
+ }
435
+ }
436
+ // The socket joins as it opens: one request a participant, which a host
437
+ // may bill. A refusal closes it saying why (PROTOCOL.md § Sockets).
438
+ const query = `join=${encodeURIComponent(JSON.stringify(asked))}`
439
+ let socket: (Socket & { joined?: Joined }) | undefined
440
+ let dialed: { s: Socket & { joined?: Joined } } | { tokenless: unknown }
441
+ try {
442
+ dialed = await paced(async () => {
443
+ let h: Record<string, string>
444
+ try {
445
+ h = await headers('GET', `${base}socket?${query}`, '')
446
+ } catch (tokenless) {
447
+ // Not the relay's fault, and its source already tried again.
448
+ return { tokenless }
449
+ }
450
+ const s = await connect(base, query, h)
451
+ // A fault: dialed again within `REPLY_MS`.
452
+ if (s === undefined) throw new Error('no socket')
453
+ return { s }
454
+ })
455
+ } catch (err) {
456
+ if (err instanceof RelayError) throw err
457
+ throw new RelayError(
458
+ 504,
459
+ `cannot connect to the relay ${new URL(base).origin}: no answer in ${REPLY_MS / 1000} s`,
460
+ )
461
+ }
462
+ // A token it cannot get is said as itself, not as a relay that never
463
+ // answered: the relay was not asked.
464
+ if ('tokenless' in dialed) throw dialed.tokenless
465
+ socket = dialed.s
466
+ ;({ id, heartbeatMs } = socket.joined!)
467
+ current = id
468
+ joins.add(socket)
469
+ let failures = 0
470
+ /** When the socket was last open: dials past `REPLY_MS` from it give up. */
471
+ let lostAt: number | undefined
472
+ let redial: ReturnType<typeof setTimeout> | undefined
473
+ /** A redial in flight, which `leave` waits out: its socket closes before the process exits. */
474
+ let dialing: Promise<void> | undefined
475
+ let left = false
476
+ /** Its `leave` said: the relay's 4404 is that answer, not a restart. */
477
+ let leaving = false
478
+ /** Why no socket is dialed again: the relay closed the run or forgot this participant. */
479
+ let ended: RelayError | undefined
480
+ /** Woken when a socket opens, brings messages or ends. */
481
+ const waiters = new Set<() => void>()
482
+ const nudge = (): void => {
483
+ for (const w of [...waiters]) w()
484
+ }
485
+ const end = (err: RelayError): void => {
486
+ ended ??= err
487
+ clearInterval(listening)
488
+ clearTimeout(redial)
489
+ redial = undefined
490
+ nudge()
491
+ }
492
+ /** The last socket that closed: its close handshake may not have ended. */
493
+ let dropped: Socket | undefined
494
+ let adopted = false
495
+ const adopt = (s: Socket): void => {
496
+ socket = s
497
+ s.onMessages = (messages) => {
498
+ inbox.push(...take(messages))
499
+ if (
500
+ messages.some(
501
+ (m) => m.from === 'relay' && (m.body as { type?: unknown })?.type === 'closed',
502
+ )
503
+ )
504
+ end(new RelayError(410, 'relay: the run is closed'))
505
+ nudge()
506
+ }
507
+ s.onClose = (code, why) => {
508
+ // The relay closes the socket of a participant it lost or let go, or
509
+ // refused (4000 plus the status).
510
+ if (code === GONE && (joins.has(s) || leaving)) end(new RelayError(404, 'relay: unknown id'))
511
+ else if (code === GONE) rejoinBy ??= performance.now() + REJOIN_BEATS * heartbeatMs
512
+ else if ((refusal(code) ?? 500) < 500) end(new RelayError(code - 4000, `relay: ${why}`))
513
+ if (code === NOT_PROTOCOL) end(new RelayError(502, `relay: ${why}`))
514
+ if (socket === s) socket = undefined
515
+ dropped = s
516
+ again()
517
+ nudge()
518
+ }
519
+ inbox.push(...take(s.inbox.splice(0)))
520
+ // A new socket: what the old one may have dropped goes again, and
521
+ // nobody could be heard while there was none.
522
+ if (adopted) {
523
+ const now = performance.now()
524
+ for (const peer of heard.keys()) heard.set(peer, now)
525
+ resend(0)
526
+ onResumed?.()
527
+ }
528
+ adopted = true
529
+ nudge()
530
+ }
531
+ /** A socket that failed or dropped is dialed again after a jittered backoff. */
532
+ const again = (): void => {
533
+ if (left || ended !== undefined || redial !== undefined || socket?.open === true) return
534
+ lostAt ??= performance.now()
535
+ const ms = Math.random() * Math.min(heartbeatMs / 2, FIRST_REDIAL_MS * 2 ** failures)
536
+ redial = setTimeout(() => {
537
+ dialing = dial()
538
+ .catch((err: unknown) => {
539
+ // A join refused for good ends it; one before the orchestrator
540
+ // is back (410), or a fault, is tried again.
541
+ if (
542
+ rejoinBy !== undefined &&
543
+ err instanceof RelayError &&
544
+ err.status !== 409 &&
545
+ err.status !== 410 &&
546
+ err.status < 500
547
+ )
548
+ end(err)
549
+ // quiet: a failed redial dials again; past REPLY_MS the wait throws
550
+ return undefined
551
+ })
552
+ .then(async (s) => {
553
+ redial = undefined
554
+ dialing = undefined
555
+ if (left || ended !== undefined) await s?.close()
556
+ else took(s)
557
+ })
558
+ }, ms)
559
+ redial.unref()
560
+ }
561
+ /** A dial's socket: one the relay closed at once is a failed dial, or the end. */
562
+ const took = (s: Socket | undefined): void => {
563
+ if (s?.open === true) {
564
+ failures = 0
565
+ lostAt = undefined
566
+ rejoinBy = undefined
567
+ adopt(s)
568
+ } else if (rejoinBy !== undefined) {
569
+ if (performance.now() < rejoinBy) again()
570
+ else
571
+ end(
572
+ new RelayError(404, `relay: unknown id, not joined again in ${REJOIN_BEATS} heartbeats`),
573
+ )
574
+ } else if (s?.code === GONE) {
575
+ // The relay forgot it: a restart, as it stores nothing.
576
+ rejoinBy = performance.now() + REJOIN_BEATS * heartbeatMs
577
+ again()
578
+ } else if (++failures > RETRIES)
579
+ end(new RelayError(404, `relay: no socket in ${RETRIES} dials`))
580
+ else if (performance.now() - lostAt! >= REPLY_MS)
581
+ end(new RelayError(404, `relay: no socket in ${REPLY_MS / 1000} s`))
582
+ else again()
583
+ }
584
+ // Every tenth of a heartbeat, so a peer quiet `LOST_BEATS` is lost within
585
+ // two heartbeats (4 s on the default) of its last word, and what went
586
+ // unacknowledged a heartbeat goes again before its receiver loses this.
587
+ const listening = setInterval(listen, heartbeatMs / 10)
588
+ listening.unref()
589
+ if (role === 'worker') heard.set('orchestrator', performance.now())
590
+ const first = socket
591
+ socket = undefined
592
+ took(first)
593
+ /** The open socket, waiting out a redial: `RETRIES` failed dials and the participant is gone. */
594
+ const live = async (signal?: AbortSignal): Promise<Socket> => {
595
+ for (;;) {
596
+ if (ended !== undefined) throw ended
597
+ const s = socket
598
+ if (s?.open === true) return s
599
+ again()
600
+ if (signal?.aborted === true) throw signal.reason as Error
601
+ await idle(heartbeatMs, signal)
602
+ }
603
+ }
604
+ /** Until `nudge`, `ms` or `signal`, whichever comes first. */
605
+ const idle = (ms: number, signal?: AbortSignal): Promise<void> =>
606
+ new Promise<void>((resolve) => {
607
+ const done = (): void => {
608
+ clearTimeout(timer)
609
+ signal?.removeEventListener('abort', done)
610
+ waiters.delete(done)
611
+ resolve()
612
+ }
613
+ const timer = setTimeout(done, ms)
614
+ signal?.addEventListener('abort', done)
615
+ waiters.add(done)
616
+ })
617
+ /** Down the open socket; one that closes under it is redialed and asked again. */
618
+ const over = async (down: (s: Socket) => Promise<void>, signal?: AbortSignal): Promise<void> => {
619
+ for (;;) {
620
+ const s = await live(signal)
621
+ try {
622
+ await down(s)
623
+ return
624
+ } catch (err) {
625
+ // Closed under it: sent again on the next (a resent seal is a
626
+ // replay its receiver drops).
627
+ if (err instanceof RelayError) throw err
628
+ }
629
+ }
630
+ }
631
+ return {
632
+ id,
633
+ heartbeatMs,
634
+ get online() {
635
+ return socket?.open === true
636
+ },
637
+ resumed(f) {
638
+ onResumed = f
639
+ },
640
+ async send(to, body) {
641
+ let o = outbox.get(to)
642
+ if (o === undefined) outbox.set(to, (o = { next: 0, unacked: new Map() }))
643
+ const seq = ++o.next
644
+ o.unacked.set(seq, { body, at: performance.now() })
645
+ // With no socket, as while the relay restarts, it waits in the outbox
646
+ // for the next: the caller goes on (2026-10-25: "they just
647
+ // continue uninterrupted").
648
+ const s = socket
649
+ if (s?.open !== true && ended === undefined) {
650
+ again()
651
+ return
652
+ }
653
+ try {
654
+ if (s?.open !== true) throw ended!
655
+ await s.send(relayIds.get(to) ?? to, { seq, body, ...owed(to) } satisfies Numbered)
656
+ } catch (err) {
657
+ // Closed under it: it goes again on the next socket.
658
+ if (!(err instanceof RelayError)) return
659
+ // To a peer this side holds that the relay does not know now, as
660
+ // while it joins again: it goes again when that one is back, and
661
+ // its loss is said apart.
662
+ if (err instanceof RelayError && err.status === 404 && ended === undefined && heard.has(to))
663
+ return
664
+ // Refused: the caller hears it. Its number still goes, delivering
665
+ // nothing: dropped, its receiver waited on it and on every send after.
666
+ // One neither the relay nor this side knows waits on nothing, and
667
+ // would be asked every heartbeat for the rest of the run.
668
+ if (err.status === 404 && !heard.has(to)) outbox.delete(to)
669
+ else o.unacked.set(seq, { body: PROBED, at: 0 })
670
+ throw err
671
+ }
672
+ },
673
+ async poll(signal) {
674
+ if (inbox.length === 0 && signal?.aborted !== true) {
675
+ const s = await live(signal)
676
+ // It may have closed, or brought something, since `live` returned it.
677
+ if (s.open && inbox.length === 0) await idle(heartbeatMs, signal)
678
+ }
679
+ if (inbox.length > 0) return inbox.splice(0)
680
+ if (ended !== undefined) throw ended
681
+ return []
682
+ },
683
+ async remove(worker) {
684
+ const now = relayIds.get(worker) ?? worker
685
+ forget(worker)
686
+ await over((s) => s.request({ remove: now }))
687
+ },
688
+ async leave() {
689
+ leaving = true
690
+ clearInterval(listening)
691
+ clearTimeout(acking)
692
+ // The final call (2026-10-25): the relay keeps one whose socket
693
+ // dropped for its redial, so only `leave` ends it, and a host's
694
+ // session, at once. A socket that drops under it is dialed again
695
+ // for it. The relay drops it and closes the socket: that is its answer.
696
+ if (ended === undefined) {
697
+ const said = over(
698
+ (s) => s.request({ leave: true }),
699
+ AbortSignal.timeout(replyMs(heartbeatMs)),
700
+ )
701
+ // quiet: leaving: the socket's close is the answer either way
702
+ await said.catch(() => undefined)
703
+ }
704
+ left = true
705
+ // Ends what still waits on a socket (an ack, a probe): no redial
706
+ // would ever bring one, and each waits a heartbeat at a time, for good.
707
+ end(new RelayError(410, 'relay: this participant left'))
708
+ const s = socket
709
+ // One the relay's close frame dropped may still be in its handshake:
710
+ // Bun 1.4.4 fires the close only after it.
711
+ await Promise.all([(s ?? dropped)?.close(), dialing])
712
+ },
713
+ }
714
+ }
715
+
716
+ /** A token more than a minute from its `exp` is used again. */
717
+ function bearer(
718
+ token: string | undefined | (() => Promise<string | undefined>),
719
+ extra: Readonly<Record<string, string>>,
720
+ ): () => Promise<Record<string, string>> {
721
+ let held: { value: string | undefined; until: number } | undefined
722
+ return async () => {
723
+ if (typeof token !== 'function') held ??= { value: token, until: Infinity }
724
+ else if (held === undefined || Date.now() >= held.until) {
725
+ const value = await token()
726
+ held = { value, until: expiry(value) - 60_000 }
727
+ }
728
+ const headers: Record<string, string> = { ...extra, 'content-type': 'application/json' }
729
+ if (held.value !== undefined) headers['authorization'] = `Bearer ${held.value}`
730
+ return headers
731
+ }
732
+ }
733
+
734
+ /** A JWT's `exp` in ms; a token that is not one never expires here. */
735
+ function expiry(token: string | undefined): number {
736
+ try {
737
+ const exp = (
738
+ JSON.parse(atob(token!.split('.')[1]!.replace(/-/g, '+').replace(/_/g, '/'))) as {
739
+ exp?: unknown
740
+ }
741
+ ).exp
742
+ return typeof exp === 'number' ? exp * 1000 : Infinity
743
+ // quiet: a token with no readable expiry is asked for again only when refused
744
+ } catch {
745
+ return Infinity
746
+ }
747
+ }
748
+
749
+ interface Socket {
750
+ readonly open: boolean
751
+ /** What it brought before `onMessages` was set. */
752
+ readonly inbox: RelayMessage[]
753
+ /** The close code it closed with; 0 while open or with none. */
754
+ readonly code: number
755
+ onMessages?: (messages: RelayMessage[]) => void
756
+ /** Its close, by the close code the relay gave (`GONE`), `NOT_PROTOCOL`, or none; `why` says it. */
757
+ onClose?: (code: number, why: string) => void
758
+ /** One frame, `{ leave: true }` or `{ remove }`; resolves on the relay's answer. */
759
+ request(frame: Record<string, unknown>): Promise<void>
760
+ /**
761
+ * A send; those asked for in one turn of the event loop go as one frame.
762
+ * A `quiet` one resolves once written: its frame, if all quiet, goes
763
+ * unnumbered, which the relay answers only when a send in it fails.
764
+ */
765
+ send(to: string, body: unknown, quiet?: boolean): Promise<void>
766
+ /** When the relay last said anything down it. */
767
+ readonly heardAt: number
768
+ /** An empty numbered frame, which the relay itself answers. */
769
+ ask(): void
770
+ close(): Promise<void>
771
+ }
772
+
773
+ /** The relay protocol this client speaks (PROTOCOL.md § Versions). */
774
+ const PROTOCOL = 'switchboard.v1'
775
+ /** The proof header (PROTOCOL.md § The host's key). */
776
+ const PROOF_HEADER = 'relay-proof'
777
+
778
+ /**
779
+ * A relay message in vx-dte's words: the run's orchestrator is the
780
+ * session's host, and a worker the relay lost is `lost`.
781
+ */
782
+ function inward(m: RelayMessage): RelayMessage {
783
+ if (m.from === 'host') return { from: 'orchestrator', body: m.body }
784
+ const body = m.body as { type?: unknown } | null
785
+ return m.from === 'relay' && body?.type === 'left'
786
+ ? { from: m.from, body: { ...body, type: 'lost' } }
787
+ : m
788
+ }
789
+
790
+ /** What a socket that joined says first (PROTOCOL.md `socket?join`). */
791
+ interface Joined {
792
+ id: string
793
+ heartbeatMs: number
794
+ }
795
+
796
+ /**
797
+ * The participant's websocket (PROTOCOL.md `socket`): messages arrive the
798
+ * moment they are sent, with no poll between. Undefined when it fails to
799
+ * open; `join` dials again.
800
+ */
801
+ async function connect(
802
+ base: string,
803
+ /** `id=<id>`, or `join=<body>` for one that joins as it opens. */
804
+ query: string,
805
+ headers: Record<string, string>,
806
+ /** Unknown to one that joins, until its socket says. */
807
+ heartbeatMs?: number,
808
+ ): Promise<(Socket & { joined?: Joined }) | undefined> {
809
+ const ws = new WebSocket(`${base.replace(/^http/, 'ws')}socket?${query}`, {
810
+ headers,
811
+ protocols: [PROTOCOL],
812
+ // Each body is zstd-compressed before it is sealed, and sealed bytes do
813
+ // not compress: deflate would cost both ends CPU for base64's overhead.
814
+ perMessageDeflate: false,
815
+ } as unknown as string[])
816
+ // An upgrade the relay holds is a dial that failed, as a request past two heartbeats.
817
+ const wait = heartbeatMs === undefined ? REPLY_MS : replyMs(heartbeatMs)
818
+ // Its handlers are set before it opens: a relay that refuses closes at
819
+ // once, and Bun may report that close, or its frame, with the open.
820
+ let opening: (ok: boolean) => void = () => {}
821
+ const opened = new Promise<boolean>((resolve) => {
822
+ const timer = setTimeout(() => resolve(false), wait)
823
+ opening = (ok) => {
824
+ clearTimeout(timer)
825
+ resolve(ok)
826
+ }
827
+ })
828
+ ws.onopen = () => opening(true)
829
+ let open = true
830
+ const inbox: RelayMessage[] = []
831
+ let code = 0
832
+ let reason = ''
833
+ let n = 0
834
+ let hb = heartbeatMs ?? REPLY_MS
835
+ type Answer = {
836
+ status?: number
837
+ error?: string
838
+ results?: (Answer | null)[]
839
+ }
840
+ const pending = new Map<number, { settle(a: Answer): void; reject(err: Error): void }>()
841
+ // A host can answer pings without the object behind it (Cloudflare's
842
+ // auto-response), so a socket whose relay fails every frame still looks
843
+ // alive: one that leaves a frame unanswered for two heartbeats is dead.
844
+ const answer = (k: number, p: { settle(a: Answer): void; reject(err: Error): void }): void => {
845
+ pending.set(k, p)
846
+ setTimeout(() => {
847
+ if (!pending.has(k)) return
848
+ closed(UNANSWERED)
849
+ ws.close()
850
+ }, answerMs(hb)).unref()
851
+ }
852
+ const refused = (a: Answer): RelayError =>
853
+ new RelayError(a.status!, `relay send: ${a.status} ${printable(a.error ?? '')}`)
854
+ /** Sends asked for in this turn of the event loop, which go as one frame. */
855
+ let sends: {
856
+ to: string
857
+ body: unknown
858
+ quiet: boolean
859
+ resolve(): void
860
+ reject(err: Error): void
861
+ }[] = []
862
+ const flush = (): void => {
863
+ const all = sends
864
+ sends = []
865
+ // Frames under the relay's body limit, which it applies to a frame whole.
866
+ let batch: typeof all = []
867
+ let bytes = 0
868
+ for (const one of all) {
869
+ const size = JSON.stringify(one.body)?.length ?? 0
870
+ if (batch.length > 0 && bytes + size > FRAME_BYTES) {
871
+ frame(batch)
872
+ batch = []
873
+ bytes = 0
874
+ }
875
+ batch.push(one)
876
+ bytes += size
877
+ }
878
+ frame(batch)
879
+ }
880
+ const frame = (batch: typeof sends, asked = false): void => {
881
+ const out = batch.map(({ to, body }) => ({ to: to === 'orchestrator' ? 'host' : to, body }))
882
+ if (!asked && batch.every((one) => one.quiet)) {
883
+ ws.send(JSON.stringify({ sends: out }))
884
+ for (const one of batch) one.resolve()
885
+ return
886
+ }
887
+ const k = ++n
888
+ answer(k, {
889
+ settle(a) {
890
+ batch.forEach((one, i) => {
891
+ const r = a.status !== undefined ? a : (a.results?.[i] ?? null)
892
+ if (r === null || r.status === undefined) one.resolve()
893
+ else one.reject(refused(r))
894
+ })
895
+ },
896
+ reject: (err) => {
897
+ for (const one of batch) one.reject(err)
898
+ },
899
+ })
900
+ ws.send(JSON.stringify({ n: k, sends: out }))
901
+ for (const one of batch) if (one.quiet) one.resolve()
902
+ }
903
+ const request = (frame: Record<string, unknown>): Promise<void> =>
904
+ new Promise((resolve, reject) => {
905
+ const k = ++n
906
+ answer(k, {
907
+ settle: (a) => (a.status === undefined ? resolve() : reject(refused(a))),
908
+ reject,
909
+ })
910
+ ws.send(JSON.stringify({ n: k, ...frame }))
911
+ })
912
+ // A frame unanswered past `answerMs` closes the socket (`answer`); `join`
913
+ // decides when to ask.
914
+ let spoke = performance.now()
915
+ let said: (j: Joined | undefined) => void = () => {}
916
+ const joined = new Promise<Joined | undefined>((resolve) => {
917
+ said = resolve
918
+ })
919
+ const closed = (words = 'socket closed', by = 0): void => {
920
+ if (!open) return
921
+ const why = printable(words)
922
+ open = false
923
+ code = by
924
+ reason = why
925
+ for (const p of pending.values()) p.reject(new Error(why))
926
+ pending.clear()
927
+ said(undefined)
928
+ me.onClose?.(by, why)
929
+ }
930
+ let shut: () => void = () => {}
931
+ const done = new Promise<void>((resolve) => {
932
+ shut = resolve
933
+ })
934
+ ws.onclose = (e) => {
935
+ // An error before it closes the socket with no code: the close's code
936
+ // and reason still say why it was refused.
937
+ if (!open && code === 0) {
938
+ code = e.code
939
+ if (e.reason !== '') reason = e.reason
940
+ }
941
+ closed(e.reason || 'socket closed', e.code)
942
+ shut()
943
+ opening(false)
944
+ }
945
+ ws.onerror = () => {
946
+ closed()
947
+ opening(false)
948
+ }
949
+ ws.onmessage = (e) => {
950
+ opening(true)
951
+ spoke = performance.now()
952
+ let m: Answer & {
953
+ messages?: RelayMessage[]
954
+ n?: number | null
955
+ joined?: Joined
956
+ close?: number
957
+ }
958
+ try {
959
+ m = JSON.parse(String(e.data)) as typeof m
960
+ if (typeof m !== 'object' || m === null) throw new Error()
961
+ } catch {
962
+ // Thrown here it left the process on a bare SyntaxError: a relay is anyone's to host.
963
+ closed(`a frame that is not its protocol: ${String(e.data).slice(0, 200)}`, NOT_PROTOCOL)
964
+ ws.close(NOT_PROTOCOL)
965
+ return
966
+ }
967
+ // The relay says its close first: Bun 1.4.4 reports the close itself
968
+ // only once the host ends TCP, which workerd never did (10 s late).
969
+ if (m.close !== undefined) {
970
+ closed(m.error ?? 'socket closed', typeof m.close === 'number' ? m.close : 0)
971
+ ws.close()
972
+ return
973
+ }
974
+ if (m.joined !== undefined) {
975
+ hb = m.joined.heartbeatMs
976
+ me.joined = m.joined
977
+ said(m.joined)
978
+ return
979
+ }
980
+ if (m.messages !== undefined) {
981
+ m.messages = m.messages.map(inward)
982
+ if (me.onMessages === undefined) inbox.push(...m.messages)
983
+ else me.onMessages(m.messages)
984
+ return
985
+ }
986
+ if (m.n === undefined || m.n === null) {
987
+ // An unnumbered frame's failure: an ack or probe to one the relay no
988
+ // longer knows (404) is not one, its loss reaching this side apart.
989
+ const failed = [m, ...(m.results ?? [])].filter(
990
+ (a): a is Answer => a?.status !== undefined && a.status !== 404,
991
+ )
992
+ for (const a of failed) ui.say('warn', `[vx] dte: ${refused(a).message}`)
993
+ return
994
+ }
995
+ const p = pending.get(m.n)
996
+ pending.delete(m.n)
997
+ p?.settle(m)
998
+ }
999
+ const me: Socket & { joined?: Joined } = {
1000
+ get open() {
1001
+ return open
1002
+ },
1003
+ get code() {
1004
+ return code
1005
+ },
1006
+ inbox,
1007
+ request,
1008
+ get heardAt() {
1009
+ return spoke
1010
+ },
1011
+ ask: () => frame([], true),
1012
+ send(to, body, quiet = false) {
1013
+ if (!open) return Promise.reject(new Error('socket closed'))
1014
+ return new Promise((resolve, reject) => {
1015
+ if (sends.length === 0) queueMicrotask(flush)
1016
+ sends.push({ to, body, quiet, resolve, reject })
1017
+ })
1018
+ },
1019
+ async close() {
1020
+ closed()
1021
+ ws.close(1000)
1022
+ // Its close handshake, so a process exiting next does not cut the
1023
+ // socket (the host logs that as a client that disconnected).
1024
+ await Promise.race([done, Bun.sleep(CLOSE_WAIT_MS)])
1025
+ },
1026
+ }
1027
+ if (!(await opened)) {
1028
+ closed()
1029
+ ws.close()
1030
+ if (heartbeatMs !== undefined) return undefined
1031
+ }
1032
+ if (heartbeatMs === undefined) {
1033
+ const timer = setTimeout(() => said(undefined), wait)
1034
+ const j = await joined
1035
+ clearTimeout(timer)
1036
+ if (j === undefined) {
1037
+ await me.close()
1038
+ const status = refusal(code)
1039
+ if (status === undefined) return undefined
1040
+ // A GitLab job with no `id_tokens` entry was told only `401 no bearer
1041
+ // token`, not what gives it one (2026-10-26).
1042
+ const hint =
1043
+ status === 401 && headers['authorization'] === undefined
1044
+ ? `: this job has no token for the relay (on GitLab, an id_tokens entry VX_DTE_TOKEN with aud ${base.replace(/\/v1\/sessions\/.*$/, '')}; on GitHub Actions, permissions: id-token: write)`
1045
+ : ''
1046
+ const why = `${status} ${reason}${hint}`
1047
+ throw new RelayError(
1048
+ status,
1049
+ status === 401 || status === 403
1050
+ ? `cannot connect to the relay ${new URL(base).origin}: ${why}`
1051
+ : `relay join: ${why}`,
1052
+ )
1053
+ }
1054
+ }
1055
+ return me
1056
+ }
1057
+
1058
+ /** The HTTP status a close code refuses with (4000 plus it), if it is one. */
1059
+ const refusal = (code: number): number | undefined =>
1060
+ code >= 4400 && code < 4600 ? code - 4000 : undefined