@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/LICENSE +21 -0
- package/README.md +923 -1
- package/index.ts +4 -0
- package/package.json +40 -4
- package/src/cpu-account.ts +134 -0
- package/src/dispatch.ts +268 -0
- package/src/docker.ts +156 -0
- package/src/forecast.ts +68 -0
- package/src/github.ts +523 -0
- package/src/gitlab.ts +130 -0
- package/src/index.ts +12 -0
- package/src/init.ts +522 -0
- package/src/local.ts +256 -0
- package/src/messages.ts +219 -0
- package/src/noise.ts +248 -0
- package/src/orchestrator.ts +2359 -0
- package/src/outputs.ts +117 -0
- package/src/planner.ts +988 -0
- package/src/plugin.ts +1020 -0
- package/src/printable.ts +11 -0
- package/src/seal.ts +509 -0
- package/src/strategy.ts +50 -0
- package/src/trace.ts +288 -0
- package/src/tree.ts +72 -0
- package/src/usage.ts +135 -0
- package/src/warm-ups.ts +85 -0
- package/src/wire.ts +1060 -0
- package/src/worker.ts +1038 -0
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
|