@vzn/vx-dte 0.0.651 → 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 +164 -60
- package/package.json +3 -3
- package/src/dispatch.ts +540 -30
- package/src/forecast.ts +1 -2
- 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 +26 -5
- package/src/orchestrator.ts +678 -233
- package/src/planner.ts +1470 -192
- package/src/plugin.ts +242 -33
- 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 +119 -49
- package/src/worker.ts +174 -19
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
|
@@ -51,6 +51,11 @@ const LOST_BEATS = 3
|
|
|
51
51
|
* orchestrator's ack answers it: one late frame is not a loss.
|
|
52
52
|
*/
|
|
53
53
|
const QUIET_BEATS = LOST_BEATS / 2
|
|
54
|
+
/**
|
|
55
|
+
* How long the orchestrator gives its own reaction to a frame (a pump, a
|
|
56
|
+
* batch) to carry the frame's ack, before the ack goes alone.
|
|
57
|
+
*/
|
|
58
|
+
const REACT_MS = 20
|
|
54
59
|
/** A send unacknowledged as long goes again: its ack comes within it. */
|
|
55
60
|
const RESEND_BEATS = LOST_BEATS / 2
|
|
56
61
|
/** Why a worker ends on its orchestrator's silence. */
|
|
@@ -102,48 +107,70 @@ const answerMs = (heartbeatMs: number): number =>
|
|
|
102
107
|
Math.min(Math.max(heartbeatMs, MIN_WAIT_MS), REPLY_MS)
|
|
103
108
|
|
|
104
109
|
/**
|
|
105
|
-
* A send as it travels: numbered per receiver, so a resend is
|
|
106
|
-
* seen, and carrying the sender's ack of
|
|
107
|
-
* 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.
|
|
108
114
|
*/
|
|
109
115
|
interface Numbered {
|
|
110
|
-
readonly
|
|
111
|
-
readonly
|
|
112
|
-
readonly
|
|
116
|
+
readonly n: number
|
|
117
|
+
readonly b: unknown
|
|
118
|
+
readonly a?: number
|
|
113
119
|
}
|
|
114
|
-
/** The receiver took every send to it numbered up to `
|
|
120
|
+
/** The receiver took every send to it numbered up to `a`. */
|
|
115
121
|
interface Acked {
|
|
116
|
-
readonly
|
|
122
|
+
readonly a: number
|
|
117
123
|
}
|
|
118
124
|
const isNumbered = (b: unknown): b is Numbered =>
|
|
119
125
|
typeof b === 'object' &&
|
|
120
126
|
b !== null &&
|
|
121
|
-
typeof (b as Numbered).
|
|
122
|
-
'
|
|
123
|
-
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)
|
|
124
130
|
/**
|
|
125
131
|
* What a side with nothing else to say a heartbeat sends: a numbered send
|
|
126
132
|
* with no body, carrying its acks, delivered to no one.
|
|
127
133
|
*/
|
|
128
134
|
interface Beat {
|
|
129
|
-
readonly
|
|
130
|
-
readonly
|
|
135
|
+
readonly n: number
|
|
136
|
+
readonly a?: number
|
|
131
137
|
}
|
|
132
138
|
const isBeat = (b: unknown): b is Beat =>
|
|
133
139
|
typeof b === 'object' &&
|
|
134
140
|
b !== null &&
|
|
135
|
-
typeof (b as Beat).
|
|
136
|
-
!('
|
|
137
|
-
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)
|
|
138
144
|
const isAcked = (b: unknown): b is Acked =>
|
|
139
145
|
typeof b === 'object' &&
|
|
140
146
|
b !== null &&
|
|
141
|
-
typeof (b as Acked).
|
|
147
|
+
typeof (b as Acked).a === 'number' &&
|
|
142
148
|
Object.keys(b).length === 1
|
|
143
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
|
+
|
|
144
170
|
/** Runs `f` again after a fault, at most `RETRIES` times and within `REPLY_MS`. */
|
|
145
171
|
async function paced<T>(f: () => Promise<T>): Promise<T> {
|
|
146
|
-
const
|
|
172
|
+
const at = performance.now()
|
|
173
|
+
const by = at + REPLY_MS
|
|
147
174
|
for (let tries = 0; ; tries++) {
|
|
148
175
|
try {
|
|
149
176
|
return await f()
|
|
@@ -151,9 +178,10 @@ async function paced<T>(f: () => Promise<T>): Promise<T> {
|
|
|
151
178
|
// A request the relay held past its deadline already took the whole wait.
|
|
152
179
|
const fault =
|
|
153
180
|
err instanceof RelayError ? err.status >= 500 : (err as Error).name !== 'TimeoutError'
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
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)
|
|
157
185
|
await Bun.sleep(wait)
|
|
158
186
|
}
|
|
159
187
|
}
|
|
@@ -267,22 +295,45 @@ export async function join(
|
|
|
267
295
|
/**
|
|
268
296
|
* The orchestrator sends only orders and acks: an ack no order carried
|
|
269
297
|
* goes on its own after half a heartbeat, every peer's in one frame. A
|
|
270
|
-
* worker's ride its next send, which it makes within a heartbeat.
|
|
298
|
+
* worker's ride its next send, which it makes within a heartbeat. A peer
|
|
299
|
+
* the orchestrator said nothing to in a heartbeat (a quiet worker's
|
|
300
|
+
* report) is answered once the orchestrator reacted: an order sent in
|
|
301
|
+
* reply carries the ack, and with none it goes alone, `REACT_MS` on.
|
|
271
302
|
*/
|
|
272
303
|
let acking: ReturnType<typeof setTimeout> | undefined
|
|
304
|
+
let answering: ReturnType<typeof setTimeout> | undefined
|
|
305
|
+
const soon = new Set<string>()
|
|
306
|
+
const ack = (peer: string): void => {
|
|
307
|
+
const upTo = taken.get(peer)?.upTo
|
|
308
|
+
owing.delete(peer)
|
|
309
|
+
said.set(peer, performance.now())
|
|
310
|
+
// quiet: a lost ack only costs a resend, which is dropped as seen
|
|
311
|
+
if (upTo !== undefined) put(peer, { a: upTo } satisfies Acked).catch(() => undefined)
|
|
312
|
+
}
|
|
273
313
|
const flushAcks = (): void => {
|
|
274
314
|
acking = undefined
|
|
275
315
|
if (left || ended !== undefined) return
|
|
276
|
-
for (const peer of [...owing])
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
316
|
+
for (const peer of [...owing]) ack(peer)
|
|
317
|
+
}
|
|
318
|
+
const answer = (): void => {
|
|
319
|
+
answering = undefined
|
|
320
|
+
const peers = [...soon]
|
|
321
|
+
soon.clear()
|
|
322
|
+
if (left || ended !== undefined) return
|
|
323
|
+
for (const peer of peers) if (owing.has(peer)) ack(peer)
|
|
282
324
|
}
|
|
283
325
|
const owe = (peer: string): void => {
|
|
284
326
|
owing.add(peer)
|
|
285
|
-
if (role !== 'orchestrator'
|
|
327
|
+
if (role !== 'orchestrator') return
|
|
328
|
+
if (performance.now() - (said.get(peer) ?? 0) >= heartbeatMs) {
|
|
329
|
+
soon.add(peer)
|
|
330
|
+
if (answering === undefined) {
|
|
331
|
+
answering = setTimeout(answer, REACT_MS)
|
|
332
|
+
answering.unref()
|
|
333
|
+
}
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
if (acking !== undefined) return
|
|
286
337
|
acking = setTimeout(flushAcks, heartbeatMs / 2)
|
|
287
338
|
acking.unref()
|
|
288
339
|
}
|
|
@@ -290,9 +341,9 @@ export async function join(
|
|
|
290
341
|
const said = new Map<string, number>()
|
|
291
342
|
let onQuiet: ((peer: string) => void) | undefined
|
|
292
343
|
/** The ack owed `to`, if any, for a send to it to carry; and `to` was just said to. */
|
|
293
|
-
const owed = (to: string): {
|
|
344
|
+
const owed = (to: string): { a?: number } => {
|
|
294
345
|
said.set(to, performance.now())
|
|
295
|
-
return owing.delete(to) ? {
|
|
346
|
+
return owing.delete(to) ? { a: taken.get(to)!.upTo } : {}
|
|
296
347
|
}
|
|
297
348
|
/**
|
|
298
349
|
* When each peer was last heard from: the orchestrator's workers from
|
|
@@ -335,12 +386,12 @@ export async function join(
|
|
|
335
386
|
}
|
|
336
387
|
const from = heldIds.get(m.from) ?? m.from
|
|
337
388
|
if (heard.has(from)) heard.set(from, performance.now())
|
|
338
|
-
if (isAcked(body)) settle(from, body.
|
|
389
|
+
if (isAcked(body)) settle(from, body.a)
|
|
339
390
|
else if (isBeat(body)) {
|
|
340
|
-
if (body.
|
|
391
|
+
if (body.a !== undefined) settle(from, body.a)
|
|
341
392
|
const t = taken.get(from) ?? { upTo: 0, ahead: new Map() }
|
|
342
393
|
taken.set(from, t)
|
|
343
|
-
if (body.
|
|
394
|
+
if (body.n > t.upTo) t.ahead.set(body.n, BEAT)
|
|
344
395
|
while (t.ahead.has(t.upTo + 1)) {
|
|
345
396
|
const next = t.ahead.get(++t.upTo)
|
|
346
397
|
t.ahead.delete(t.upTo)
|
|
@@ -351,13 +402,13 @@ export async function join(
|
|
|
351
402
|
if (role === 'orchestrator') {
|
|
352
403
|
owing.delete(from)
|
|
353
404
|
// quiet: a lost ack only costs a resend, which is dropped as seen
|
|
354
|
-
put(from, {
|
|
405
|
+
put(from, { a: t.upTo } satisfies Acked).catch(() => undefined)
|
|
355
406
|
} else owe(from)
|
|
356
407
|
} else if (isNumbered(body)) {
|
|
357
|
-
if (body.
|
|
408
|
+
if (body.a !== undefined) settle(from, body.a)
|
|
358
409
|
let t = taken.get(from)
|
|
359
410
|
if (t === undefined) taken.set(from, (t = { upTo: 0, ahead: new Map() }))
|
|
360
|
-
if (body.
|
|
411
|
+
if (body.n > t.upTo) t.ahead.set(body.n, body.b)
|
|
361
412
|
while (t.ahead.has(t.upTo + 1)) {
|
|
362
413
|
const next = t.ahead.get(++t.upTo)
|
|
363
414
|
t.ahead.delete(t.upTo)
|
|
@@ -434,7 +485,7 @@ export async function join(
|
|
|
434
485
|
const seq = ++o.next
|
|
435
486
|
o.unacked.set(seq, { body: BEAT, at: now })
|
|
436
487
|
// quiet: unacknowledged, it goes again
|
|
437
|
-
put(peer, { seq, ...owed(peer) } satisfies Beat).catch(() => undefined)
|
|
488
|
+
put(peer, { n: seq, ...owed(peer) } satisfies Beat).catch(() => undefined)
|
|
438
489
|
}
|
|
439
490
|
}
|
|
440
491
|
/** Sends again what waited `ms` unacknowledged, to `only` or to all; all of it, at 0. */
|
|
@@ -446,7 +497,7 @@ export async function join(
|
|
|
446
497
|
if (now - u.at < ms) continue
|
|
447
498
|
u.at = now
|
|
448
499
|
// quiet: unacknowledged, it goes again next heartbeat
|
|
449
|
-
const again = u.body === BEAT ? { seq } : { seq,
|
|
500
|
+
const again = u.body === BEAT ? { n: seq } : { n: seq, b: u.body }
|
|
450
501
|
put(to, { ...again, ...owed(to) }).catch(() => undefined)
|
|
451
502
|
}
|
|
452
503
|
}
|
|
@@ -466,16 +517,19 @@ export async function join(
|
|
|
466
517
|
return { tokenless }
|
|
467
518
|
}
|
|
468
519
|
const s = await connect(base, query, h)
|
|
469
|
-
// A fault: dialed again within `REPLY_MS`.
|
|
470
520
|
if (s === undefined) throw new Error('no socket')
|
|
471
521
|
return { s }
|
|
472
522
|
})
|
|
473
523
|
} catch (err) {
|
|
474
524
|
if (err instanceof RelayError) throw err
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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}`)
|
|
479
533
|
}
|
|
480
534
|
// A token it cannot get is said as itself, not as a relay that never
|
|
481
535
|
// answered: the relay was not asked.
|
|
@@ -673,7 +727,7 @@ export async function join(
|
|
|
673
727
|
}
|
|
674
728
|
try {
|
|
675
729
|
if (s?.open !== true) throw ended!
|
|
676
|
-
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)
|
|
677
731
|
} catch (err) {
|
|
678
732
|
// Closed under it: it goes again on the next socket.
|
|
679
733
|
if (!(err instanceof RelayError)) return
|
|
@@ -839,8 +893,14 @@ async function connect(
|
|
|
839
893
|
// Its handlers are set before it opens: a relay that refuses closes at
|
|
840
894
|
// once, and Bun may report that close, or its frame, with the open.
|
|
841
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
|
|
842
899
|
const opened = new Promise<boolean>((resolve) => {
|
|
843
|
-
const timer = setTimeout(() =>
|
|
900
|
+
const timer = setTimeout(() => {
|
|
901
|
+
waited = true
|
|
902
|
+
resolve(false)
|
|
903
|
+
}, wait)
|
|
844
904
|
opening = (ok) => {
|
|
845
905
|
clearTimeout(timer)
|
|
846
906
|
resolve(ok)
|
|
@@ -963,7 +1023,9 @@ async function connect(
|
|
|
963
1023
|
shut()
|
|
964
1024
|
opening(false)
|
|
965
1025
|
}
|
|
966
|
-
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 ?? '')
|
|
967
1029
|
closed()
|
|
968
1030
|
opening(false)
|
|
969
1031
|
}
|
|
@@ -1051,13 +1113,21 @@ async function connect(
|
|
|
1051
1113
|
if (heartbeatMs !== undefined) return undefined
|
|
1052
1114
|
}
|
|
1053
1115
|
if (heartbeatMs === undefined) {
|
|
1054
|
-
const timer = setTimeout(() =>
|
|
1116
|
+
const timer = setTimeout(() => {
|
|
1117
|
+
waited = true
|
|
1118
|
+
said(undefined)
|
|
1119
|
+
}, wait)
|
|
1055
1120
|
const j = await joined
|
|
1056
1121
|
clearTimeout(timer)
|
|
1057
1122
|
if (j === undefined) {
|
|
1058
1123
|
await me.close()
|
|
1059
1124
|
const status = refusal(code)
|
|
1060
|
-
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
|
+
}
|
|
1061
1131
|
// A GitLab job with no `id_tokens` entry was told only `401 no bearer
|
|
1062
1132
|
// token`, not what gives it one (2026-10-26).
|
|
1063
1133
|
const hint =
|