@vzn/vx-dte 0.0.652 → 0.0.653
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +135 -62
- package/package.json +3 -3
- package/src/dispatch.ts +39 -4
- package/src/github.ts +0 -3
- package/src/index.ts +1 -0
- package/src/init.ts +9 -10
- package/src/local.ts +112 -28
- package/src/messages.ts +14 -3
- package/src/orchestrator.ts +265 -33
- package/src/planner.ts +183 -55
- package/src/plugin.ts +209 -30
- package/src/seal.ts +159 -43
- package/src/server-fit.ts +80 -0
- package/src/tree.ts +215 -7
- package/src/usage.ts +65 -3
- package/src/warm-ups.ts +72 -6
- package/src/wire.ts +84 -42
- package/src/worker.ts +148 -15
package/src/usage.ts
CHANGED
|
@@ -17,9 +17,19 @@ export interface UsageStats {
|
|
|
17
17
|
readonly cpuAvg: number
|
|
18
18
|
readonly memoryPeak: number
|
|
19
19
|
readonly memoryAvg: number
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Its machine's busy core-time over the core-time it had, from its first
|
|
22
|
+
* task's send to its last one's end: whether the run left cores idle. Its
|
|
23
|
+
* tasks' own CPU when its machine said none.
|
|
24
|
+
*/
|
|
21
25
|
readonly utilization: number
|
|
26
|
+
/** Its tasks' core-time over the same, and its worker process's (undefined when it said none). */
|
|
27
|
+
readonly tasksUtilization: number
|
|
28
|
+
readonly workerUtilization?: number | undefined
|
|
22
29
|
readonly cpuMs: number
|
|
30
|
+
/** From its first task's send to its last one's end, and its busy core-time then. */
|
|
31
|
+
readonly workMs: number
|
|
32
|
+
readonly workCoreMs: number
|
|
23
33
|
/** Cores the run held for its tasks, on average over its time online: what packing gave it. */
|
|
24
34
|
readonly bookedAvg: number
|
|
25
35
|
/**
|
|
@@ -30,6 +40,12 @@ export interface UsageStats {
|
|
|
30
40
|
readonly machineAvg?: number | undefined
|
|
31
41
|
}
|
|
32
42
|
|
|
43
|
+
interface Point {
|
|
44
|
+
readonly at: number
|
|
45
|
+
readonly busy: number
|
|
46
|
+
readonly self?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
33
49
|
export class Usage {
|
|
34
50
|
done = 0
|
|
35
51
|
failed = 0
|
|
@@ -48,8 +64,23 @@ export class Usage {
|
|
|
48
64
|
private bookedAt = 0
|
|
49
65
|
/** Its machine's busy cores on average, as its last frame said. */
|
|
50
66
|
machineAvg: number | undefined
|
|
67
|
+
/** Its machine's busy core-time since it joined, as its last frame said, and when. */
|
|
68
|
+
private heard: Point
|
|
69
|
+
private first: Point | undefined
|
|
70
|
+
private last: Point | undefined
|
|
71
|
+
private firstAt: number | undefined
|
|
72
|
+
private lastAt: number | undefined
|
|
51
73
|
|
|
52
|
-
constructor(readonly from: number = performance.now()) {
|
|
74
|
+
constructor(readonly from: number = performance.now()) {
|
|
75
|
+
this.heard = { at: from, busy: 0, self: 0 }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Its machine said `avg` cores busy on average since it started, its worker process `self`. */
|
|
79
|
+
machine(avg: number, self: number | undefined, at: number = performance.now()): void {
|
|
80
|
+
this.machineAvg = avg
|
|
81
|
+
const ms = Math.max(0, at - this.from)
|
|
82
|
+
this.heard = { at, busy: avg * ms, ...(self === undefined ? {} : { self: self * ms }) }
|
|
83
|
+
}
|
|
53
84
|
|
|
54
85
|
/**
|
|
55
86
|
* A task it finished, as it ends: on it `from` its send (else its
|
|
@@ -73,6 +104,8 @@ export class Usage {
|
|
|
73
104
|
this.aborted++
|
|
74
105
|
else this.failed++
|
|
75
106
|
const end = performance.now()
|
|
107
|
+
this.lastAt = end
|
|
108
|
+
this.last = this.heard
|
|
76
109
|
const ms = Math.max(1, from === undefined ? r.durationMs : end - from)
|
|
77
110
|
this.spans.push({
|
|
78
111
|
start: end - ms,
|
|
@@ -82,6 +115,13 @@ export class Usage {
|
|
|
82
115
|
})
|
|
83
116
|
}
|
|
84
117
|
|
|
118
|
+
/** The run sent it a task at `at`: its first send opens the window utilization reads. */
|
|
119
|
+
sent(at: number = performance.now()): void {
|
|
120
|
+
if (this.firstAt !== undefined) return
|
|
121
|
+
this.firstAt = at
|
|
122
|
+
this.first = this.heard
|
|
123
|
+
}
|
|
124
|
+
|
|
85
125
|
/** The run now holds `cores` more of it for its tasks (fewer when negative). */
|
|
86
126
|
book(cores: number, at: number = performance.now()): void {
|
|
87
127
|
this.bookedMs += this.booked * (at - this.bookedAt)
|
|
@@ -119,6 +159,24 @@ export class Usage {
|
|
|
119
159
|
cpuMs += s.cores * (s.end - s.start)
|
|
120
160
|
memoryMs += s.memory * (s.end - s.start)
|
|
121
161
|
}
|
|
162
|
+
const workMs =
|
|
163
|
+
this.firstAt !== undefined && this.lastAt !== undefined && this.lastAt > this.firstAt
|
|
164
|
+
? this.lastAt - this.firstAt
|
|
165
|
+
: onlineMs
|
|
166
|
+
const f = this.first
|
|
167
|
+
const l = this.last
|
|
168
|
+
// Between the frames nearest its first send and its last end: an avg
|
|
169
|
+
// since it started says nothing of a window inside that.
|
|
170
|
+
const framed = f !== undefined && l !== undefined && l.at > f.at
|
|
171
|
+
const tasksUtilization = Math.min(1, cpuMs / (cores * workMs))
|
|
172
|
+
const utilization =
|
|
173
|
+
framed && this.machineAvg !== undefined
|
|
174
|
+
? Math.min(1, (l.busy - f.busy) / (cores * (l.at - f.at)))
|
|
175
|
+
: tasksUtilization
|
|
176
|
+
const workerUtilization =
|
|
177
|
+
framed && l.self !== undefined && f.self !== undefined
|
|
178
|
+
? Math.min(1, (l.self - f.self) / (cores * (l.at - f.at)))
|
|
179
|
+
: undefined
|
|
122
180
|
return {
|
|
123
181
|
onlineMs,
|
|
124
182
|
busyMs,
|
|
@@ -126,8 +184,12 @@ export class Usage {
|
|
|
126
184
|
cpuAvg: cpuMs / onlineMs,
|
|
127
185
|
memoryPeak,
|
|
128
186
|
memoryAvg: memoryMs / onlineMs,
|
|
129
|
-
utilization
|
|
187
|
+
utilization,
|
|
188
|
+
tasksUtilization,
|
|
189
|
+
...(workerUtilization === undefined ? {} : { workerUtilization }),
|
|
130
190
|
cpuMs,
|
|
191
|
+
workMs,
|
|
192
|
+
workCoreMs: utilization * cores * workMs,
|
|
131
193
|
bookedAvg: (this.bookedMs + this.booked * Math.max(0, end - this.bookedAt)) / onlineMs,
|
|
132
194
|
machineAvg: this.machineAvg,
|
|
133
195
|
}
|
package/src/warm-ups.ts
CHANGED
|
@@ -56,14 +56,24 @@ async function commits(root: string): Promise<string[]> {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/** The newest warm-ups an ancestor's run wrote, the base branch's on a scoped run. */
|
|
59
|
-
export
|
|
60
|
-
|
|
59
|
+
export function readWarmUps(cache: PluginCache, root: string): Promise<Map<string, number>> {
|
|
60
|
+
return newest(cache, root, keyOf, parseWarmUps)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The newest blob under an ancestor's `key` that `parse` reads; an empty map for none. */
|
|
64
|
+
async function newest(
|
|
65
|
+
cache: PluginCache,
|
|
66
|
+
root: string,
|
|
67
|
+
key: (sha: string) => string,
|
|
68
|
+
parse: (bytes: Uint8Array) => Map<string, number> | null,
|
|
69
|
+
): Promise<Map<string, number>> {
|
|
70
|
+
const keys = (await commits(root)).map(key)
|
|
61
71
|
if (keys.length === 0) return new Map()
|
|
62
72
|
const held = await cache.hasMany(keys, false)
|
|
63
|
-
for (const
|
|
64
|
-
if (held !== null && !held.has(
|
|
65
|
-
const bytes = await cache.get(
|
|
66
|
-
const got = bytes === null ? null :
|
|
73
|
+
for (const k of keys) {
|
|
74
|
+
if (held !== null && !held.has(k)) continue
|
|
75
|
+
const bytes = await cache.get(k, false)
|
|
76
|
+
const got = bytes === null ? null : parse(bytes)
|
|
67
77
|
if (got !== null) return got
|
|
68
78
|
}
|
|
69
79
|
return new Map()
|
|
@@ -83,3 +93,59 @@ export async function writeWarmUps(
|
|
|
83
93
|
for (const [name, ms] of [...learned, ...run]) sizes[name] = Math.round(ms)
|
|
84
94
|
await cache.put(keyOf(head), new TextEncoder().encode(JSON.stringify({ version: 1, sizes })))
|
|
85
95
|
}
|
|
96
|
+
|
|
97
|
+
// Each server's base, the cores it uses with no dependant's work
|
|
98
|
+
// (`server-fit.ts`): booked once on each worker that starts it. Kept as
|
|
99
|
+
// the warm-ups are, under each CI run's commit; untrusted, it only books.
|
|
100
|
+
|
|
101
|
+
const MAX_SERVERS = 1024
|
|
102
|
+
/** More cores than any machine a run books. */
|
|
103
|
+
const MAX_CORES = 4096
|
|
104
|
+
|
|
105
|
+
const basesKeyOf = (sha: string): string => `server-bases\0${sha}`
|
|
106
|
+
|
|
107
|
+
/** `{ version: 1, servers: { id: cores } }`'s bases, each finite in [0, MAX_CORES]; null for anything else. */
|
|
108
|
+
export function parseServerBases(bytes: Uint8Array): Map<string, number> | null {
|
|
109
|
+
if (bytes.byteLength > MAX_BYTES) return null
|
|
110
|
+
let parsed: unknown
|
|
111
|
+
try {
|
|
112
|
+
parsed = JSON.parse(new TextDecoder().decode(bytes))
|
|
113
|
+
// quiet: not JSON reads as no blob
|
|
114
|
+
} catch {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
if (typeof parsed !== 'object' || parsed === null) return null
|
|
118
|
+
const { version, servers } = parsed as { version?: unknown; servers?: unknown }
|
|
119
|
+
if (version !== 1 || typeof servers !== 'object' || servers === null || Array.isArray(servers))
|
|
120
|
+
return null
|
|
121
|
+
const entries = Object.entries(servers)
|
|
122
|
+
if (entries.length > MAX_SERVERS) return null
|
|
123
|
+
const out = new Map<string, number>()
|
|
124
|
+
for (const [id, cores] of entries)
|
|
125
|
+
if (typeof cores === 'number' && Number.isFinite(cores) && cores >= 0 && cores <= MAX_CORES)
|
|
126
|
+
out.set(id, cores)
|
|
127
|
+
return out
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The newest server bases an ancestor's run wrote. */
|
|
131
|
+
export function readServerBases(cache: PluginCache, root: string): Promise<Map<string, number>> {
|
|
132
|
+
return newest(cache, root, basesKeyOf, parseServerBases)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `learned` with this run's own over it, under HEAD. */
|
|
136
|
+
export async function writeServerBases(
|
|
137
|
+
cache: PluginCache,
|
|
138
|
+
root: string,
|
|
139
|
+
learned: ReadonlyMap<string, number>,
|
|
140
|
+
run: ReadonlyMap<string, number>,
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
if (run.size === 0) return
|
|
143
|
+
const [head] = await commits(root)
|
|
144
|
+
if (head === undefined) return
|
|
145
|
+
const servers: Record<string, number> = {}
|
|
146
|
+
for (const [id, cores] of [...learned, ...run]) servers[id] = Math.round(cores * 100) / 100
|
|
147
|
+
await cache.put(
|
|
148
|
+
basesKeyOf(head),
|
|
149
|
+
new TextEncoder().encode(JSON.stringify({ version: 1, servers })),
|
|
150
|
+
)
|
|
151
|
+
}
|
package/src/wire.ts
CHANGED
|
@@ -107,48 +107,70 @@ const answerMs = (heartbeatMs: number): number =>
|
|
|
107
107
|
Math.min(Math.max(heartbeatMs, MIN_WAIT_MS), REPLY_MS)
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
|
-
* A send as it travels: numbered per receiver, so a resend is
|
|
111
|
-
* seen, and carrying the sender's ack of
|
|
112
|
-
* so most acks cost no message of their
|
|
110
|
+
* A send as it travels: numbered (`n`) per receiver, so a resend is
|
|
111
|
+
* dropped as seen, its body `b`, and carrying the sender's ack (`a`) of
|
|
112
|
+
* what it took from that receiver, so most acks cost no message of their
|
|
113
|
+
* own. One letter each: they travel in every frame, twice.
|
|
113
114
|
*/
|
|
114
115
|
interface Numbered {
|
|
115
|
-
readonly
|
|
116
|
-
readonly
|
|
117
|
-
readonly
|
|
116
|
+
readonly n: number
|
|
117
|
+
readonly b: unknown
|
|
118
|
+
readonly a?: number
|
|
118
119
|
}
|
|
119
|
-
/** The receiver took every send to it numbered up to `
|
|
120
|
+
/** The receiver took every send to it numbered up to `a`. */
|
|
120
121
|
interface Acked {
|
|
121
|
-
readonly
|
|
122
|
+
readonly a: number
|
|
122
123
|
}
|
|
123
124
|
const isNumbered = (b: unknown): b is Numbered =>
|
|
124
125
|
typeof b === 'object' &&
|
|
125
126
|
b !== null &&
|
|
126
|
-
typeof (b as Numbered).
|
|
127
|
-
'
|
|
128
|
-
Object.keys(b).length === ('
|
|
127
|
+
typeof (b as Numbered).n === 'number' &&
|
|
128
|
+
'b' in b &&
|
|
129
|
+
Object.keys(b).length === ('a' in b ? 3 : 2)
|
|
129
130
|
/**
|
|
130
131
|
* What a side with nothing else to say a heartbeat sends: a numbered send
|
|
131
132
|
* with no body, carrying its acks, delivered to no one.
|
|
132
133
|
*/
|
|
133
134
|
interface Beat {
|
|
134
|
-
readonly
|
|
135
|
-
readonly
|
|
135
|
+
readonly n: number
|
|
136
|
+
readonly a?: number
|
|
136
137
|
}
|
|
137
138
|
const isBeat = (b: unknown): b is Beat =>
|
|
138
139
|
typeof b === 'object' &&
|
|
139
140
|
b !== null &&
|
|
140
|
-
typeof (b as Beat).
|
|
141
|
-
!('
|
|
142
|
-
Object.keys(b).length === ('
|
|
141
|
+
typeof (b as Beat).n === 'number' &&
|
|
142
|
+
!('b' in b) &&
|
|
143
|
+
Object.keys(b).length === ('a' in b ? 2 : 1)
|
|
143
144
|
const isAcked = (b: unknown): b is Acked =>
|
|
144
145
|
typeof b === 'object' &&
|
|
145
146
|
b !== null &&
|
|
146
|
-
typeof (b as Acked).
|
|
147
|
+
typeof (b as Acked).a === 'number' &&
|
|
147
148
|
Object.keys(b).length === 1
|
|
148
149
|
|
|
150
|
+
/** The last of `paced`'s tries failed: `cause` is that try's error. */
|
|
151
|
+
class Exhausted extends Error {
|
|
152
|
+
constructor(
|
|
153
|
+
override readonly cause: unknown,
|
|
154
|
+
readonly tries: number,
|
|
155
|
+
readonly ms: number,
|
|
156
|
+
) {
|
|
157
|
+
super(cause instanceof Error ? cause.message : String(cause))
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The wait before try `tries + 2`: doubling from an eighth of `REPLY_MS`,
|
|
163
|
+
* jittered down to half, so `RETRIES` waits span 4.4 to 8.75 s of it. A
|
|
164
|
+
* fault that fails at once (a refused or unresolved host) spent four tries
|
|
165
|
+
* in about 2 s under a 250 ms start (PR #5082's CI, 2026-11-07).
|
|
166
|
+
*/
|
|
167
|
+
const pause = (tries: number): number =>
|
|
168
|
+
((REPLY_MS / 2 ** RETRIES) * 2 ** tries * (1 + Math.random())) / 2
|
|
169
|
+
|
|
149
170
|
/** Runs `f` again after a fault, at most `RETRIES` times and within `REPLY_MS`. */
|
|
150
171
|
async function paced<T>(f: () => Promise<T>): Promise<T> {
|
|
151
|
-
const
|
|
172
|
+
const at = performance.now()
|
|
173
|
+
const by = at + REPLY_MS
|
|
152
174
|
for (let tries = 0; ; tries++) {
|
|
153
175
|
try {
|
|
154
176
|
return await f()
|
|
@@ -156,9 +178,10 @@ async function paced<T>(f: () => Promise<T>): Promise<T> {
|
|
|
156
178
|
// A request the relay held past its deadline already took the whole wait.
|
|
157
179
|
const fault =
|
|
158
180
|
err instanceof RelayError ? err.status >= 500 : (err as Error).name !== 'TimeoutError'
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (performance.now() + wait >= by)
|
|
181
|
+
if (!fault) throw err
|
|
182
|
+
const wait = pause(tries)
|
|
183
|
+
if (tries >= RETRIES || performance.now() + wait >= by)
|
|
184
|
+
throw new Exhausted(err, tries + 1, performance.now() - at)
|
|
162
185
|
await Bun.sleep(wait)
|
|
163
186
|
}
|
|
164
187
|
}
|
|
@@ -285,7 +308,7 @@ export async function join(
|
|
|
285
308
|
owing.delete(peer)
|
|
286
309
|
said.set(peer, performance.now())
|
|
287
310
|
// quiet: a lost ack only costs a resend, which is dropped as seen
|
|
288
|
-
if (upTo !== undefined) put(peer, {
|
|
311
|
+
if (upTo !== undefined) put(peer, { a: upTo } satisfies Acked).catch(() => undefined)
|
|
289
312
|
}
|
|
290
313
|
const flushAcks = (): void => {
|
|
291
314
|
acking = undefined
|
|
@@ -318,9 +341,9 @@ export async function join(
|
|
|
318
341
|
const said = new Map<string, number>()
|
|
319
342
|
let onQuiet: ((peer: string) => void) | undefined
|
|
320
343
|
/** The ack owed `to`, if any, for a send to it to carry; and `to` was just said to. */
|
|
321
|
-
const owed = (to: string): {
|
|
344
|
+
const owed = (to: string): { a?: number } => {
|
|
322
345
|
said.set(to, performance.now())
|
|
323
|
-
return owing.delete(to) ? {
|
|
346
|
+
return owing.delete(to) ? { a: taken.get(to)!.upTo } : {}
|
|
324
347
|
}
|
|
325
348
|
/**
|
|
326
349
|
* When each peer was last heard from: the orchestrator's workers from
|
|
@@ -363,12 +386,12 @@ export async function join(
|
|
|
363
386
|
}
|
|
364
387
|
const from = heldIds.get(m.from) ?? m.from
|
|
365
388
|
if (heard.has(from)) heard.set(from, performance.now())
|
|
366
|
-
if (isAcked(body)) settle(from, body.
|
|
389
|
+
if (isAcked(body)) settle(from, body.a)
|
|
367
390
|
else if (isBeat(body)) {
|
|
368
|
-
if (body.
|
|
391
|
+
if (body.a !== undefined) settle(from, body.a)
|
|
369
392
|
const t = taken.get(from) ?? { upTo: 0, ahead: new Map() }
|
|
370
393
|
taken.set(from, t)
|
|
371
|
-
if (body.
|
|
394
|
+
if (body.n > t.upTo) t.ahead.set(body.n, BEAT)
|
|
372
395
|
while (t.ahead.has(t.upTo + 1)) {
|
|
373
396
|
const next = t.ahead.get(++t.upTo)
|
|
374
397
|
t.ahead.delete(t.upTo)
|
|
@@ -379,13 +402,13 @@ export async function join(
|
|
|
379
402
|
if (role === 'orchestrator') {
|
|
380
403
|
owing.delete(from)
|
|
381
404
|
// quiet: a lost ack only costs a resend, which is dropped as seen
|
|
382
|
-
put(from, {
|
|
405
|
+
put(from, { a: t.upTo } satisfies Acked).catch(() => undefined)
|
|
383
406
|
} else owe(from)
|
|
384
407
|
} else if (isNumbered(body)) {
|
|
385
|
-
if (body.
|
|
408
|
+
if (body.a !== undefined) settle(from, body.a)
|
|
386
409
|
let t = taken.get(from)
|
|
387
410
|
if (t === undefined) taken.set(from, (t = { upTo: 0, ahead: new Map() }))
|
|
388
|
-
if (body.
|
|
411
|
+
if (body.n > t.upTo) t.ahead.set(body.n, body.b)
|
|
389
412
|
while (t.ahead.has(t.upTo + 1)) {
|
|
390
413
|
const next = t.ahead.get(++t.upTo)
|
|
391
414
|
t.ahead.delete(t.upTo)
|
|
@@ -462,7 +485,7 @@ export async function join(
|
|
|
462
485
|
const seq = ++o.next
|
|
463
486
|
o.unacked.set(seq, { body: BEAT, at: now })
|
|
464
487
|
// quiet: unacknowledged, it goes again
|
|
465
|
-
put(peer, { seq, ...owed(peer) } satisfies Beat).catch(() => undefined)
|
|
488
|
+
put(peer, { n: seq, ...owed(peer) } satisfies Beat).catch(() => undefined)
|
|
466
489
|
}
|
|
467
490
|
}
|
|
468
491
|
/** Sends again what waited `ms` unacknowledged, to `only` or to all; all of it, at 0. */
|
|
@@ -474,7 +497,7 @@ export async function join(
|
|
|
474
497
|
if (now - u.at < ms) continue
|
|
475
498
|
u.at = now
|
|
476
499
|
// quiet: unacknowledged, it goes again next heartbeat
|
|
477
|
-
const again = u.body === BEAT ? { seq } : { seq,
|
|
500
|
+
const again = u.body === BEAT ? { n: seq } : { n: seq, b: u.body }
|
|
478
501
|
put(to, { ...again, ...owed(to) }).catch(() => undefined)
|
|
479
502
|
}
|
|
480
503
|
}
|
|
@@ -494,16 +517,19 @@ export async function join(
|
|
|
494
517
|
return { tokenless }
|
|
495
518
|
}
|
|
496
519
|
const s = await connect(base, query, h)
|
|
497
|
-
// A fault: dialed again within `REPLY_MS`.
|
|
498
520
|
if (s === undefined) throw new Error('no socket')
|
|
499
521
|
return { s }
|
|
500
522
|
})
|
|
501
523
|
} catch (err) {
|
|
502
524
|
if (err instanceof RelayError) throw err
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
525
|
+
const cause = err instanceof Exhausted ? err.cause : err
|
|
526
|
+
if (cause instanceof RelayError) throw cause
|
|
527
|
+
// Said as it was: a wait only when one was waited out.
|
|
528
|
+
const why =
|
|
529
|
+
err instanceof Exhausted && (cause as Error).name !== 'TimeoutError'
|
|
530
|
+
? `${err.message}, ${err.tries} dials in ${(err.ms / 1000).toFixed(1)} s`
|
|
531
|
+
: `no answer in ${REPLY_MS / 1000} s`
|
|
532
|
+
throw new RelayError(504, `cannot connect to the relay ${new URL(base).origin}: ${why}`)
|
|
507
533
|
}
|
|
508
534
|
// A token it cannot get is said as itself, not as a relay that never
|
|
509
535
|
// answered: the relay was not asked.
|
|
@@ -701,7 +727,7 @@ export async function join(
|
|
|
701
727
|
}
|
|
702
728
|
try {
|
|
703
729
|
if (s?.open !== true) throw ended!
|
|
704
|
-
await s.send(relayIds.get(to) ?? to, { seq, body, ...owed(to) } satisfies Numbered)
|
|
730
|
+
await s.send(relayIds.get(to) ?? to, { n: seq, b: body, ...owed(to) } satisfies Numbered)
|
|
705
731
|
} catch (err) {
|
|
706
732
|
// Closed under it: it goes again on the next socket.
|
|
707
733
|
if (!(err instanceof RelayError)) return
|
|
@@ -867,8 +893,14 @@ async function connect(
|
|
|
867
893
|
// Its handlers are set before it opens: a relay that refuses closes at
|
|
868
894
|
// once, and Bun may report that close, or its frame, with the open.
|
|
869
895
|
let opening: (ok: boolean) => void = () => {}
|
|
896
|
+
/** Why the dial failed, as the platform said it (`ECONNREFUSED`, `ENOTFOUND`). */
|
|
897
|
+
let failure = ''
|
|
898
|
+
let waited = false
|
|
870
899
|
const opened = new Promise<boolean>((resolve) => {
|
|
871
|
-
const timer = setTimeout(() =>
|
|
900
|
+
const timer = setTimeout(() => {
|
|
901
|
+
waited = true
|
|
902
|
+
resolve(false)
|
|
903
|
+
}, wait)
|
|
872
904
|
opening = (ok) => {
|
|
873
905
|
clearTimeout(timer)
|
|
874
906
|
resolve(ok)
|
|
@@ -991,7 +1023,9 @@ async function connect(
|
|
|
991
1023
|
shut()
|
|
992
1024
|
opening(false)
|
|
993
1025
|
}
|
|
994
|
-
ws.onerror = () => {
|
|
1026
|
+
ws.onerror = (e) => {
|
|
1027
|
+
const err = (e as ErrorEvent).error as { code?: unknown } | undefined
|
|
1028
|
+
failure ||= typeof err?.code === 'string' ? err.code : ((e as ErrorEvent).message ?? '')
|
|
995
1029
|
closed()
|
|
996
1030
|
opening(false)
|
|
997
1031
|
}
|
|
@@ -1079,13 +1113,21 @@ async function connect(
|
|
|
1079
1113
|
if (heartbeatMs !== undefined) return undefined
|
|
1080
1114
|
}
|
|
1081
1115
|
if (heartbeatMs === undefined) {
|
|
1082
|
-
const timer = setTimeout(() =>
|
|
1116
|
+
const timer = setTimeout(() => {
|
|
1117
|
+
waited = true
|
|
1118
|
+
said(undefined)
|
|
1119
|
+
}, wait)
|
|
1083
1120
|
const j = await joined
|
|
1084
1121
|
clearTimeout(timer)
|
|
1085
1122
|
if (j === undefined) {
|
|
1086
1123
|
await me.close()
|
|
1087
1124
|
const status = refusal(code)
|
|
1088
|
-
if (status === undefined)
|
|
1125
|
+
if (status === undefined) {
|
|
1126
|
+
// A fault, dialed again: its words say why, or that it was waited out.
|
|
1127
|
+
const err = new Error(printable(failure || reason || 'socket closed'))
|
|
1128
|
+
if (waited) err.name = 'TimeoutError'
|
|
1129
|
+
throw err
|
|
1130
|
+
}
|
|
1089
1131
|
// A GitLab job with no `id_tokens` entry was told only `401 no bearer
|
|
1090
1132
|
// token`, not what gives it one (2026-10-26).
|
|
1091
1133
|
const hint =
|