@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/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
- /** Core-time its tasks used over the core-time it had. */
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: cpuMs / (cores * onlineMs),
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 async function readWarmUps(cache: PluginCache, root: string): Promise<Map<string, number>> {
60
- const keys = (await commits(root)).map(keyOf)
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 key of keys) {
64
- if (held !== null && !held.has(key)) continue
65
- const bytes = await cache.get(key, false)
66
- const got = bytes === null ? null : parseWarmUps(bytes)
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 dropped as
106
- * seen, and carrying the sender's ack of what it took from that receiver,
107
- * so most acks cost no message of their own.
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 seq: number
111
- readonly body: unknown
112
- readonly acked?: number
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 `acked`. */
120
+ /** The receiver took every send to it numbered up to `a`. */
115
121
  interface Acked {
116
- readonly acked: number
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).seq === 'number' &&
122
- 'body' in b &&
123
- Object.keys(b).length === ('acked' in b ? 3 : 2)
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 seq: number
130
- readonly acked?: number
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).seq === 'number' &&
136
- !('body' in b) &&
137
- Object.keys(b).length === ('acked' in b ? 2 : 1)
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).acked === 'number' &&
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 by = performance.now() + REPLY_MS
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
- const wait = Math.random() * FIRST_REDIAL_MS * 2 ** tries
155
- if (!fault || tries >= RETRIES) throw err
156
- if (performance.now() + wait >= by) throw err
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
- const upTo = taken.get(peer)?.upTo
278
- owing.delete(peer)
279
- // quiet: a lost ack only costs a resend, which is dropped as seen
280
- if (upTo !== undefined) put(peer, { acked: upTo } satisfies Acked).catch(() => undefined)
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' || acking !== undefined) return
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): { acked?: number } => {
344
+ const owed = (to: string): { a?: number } => {
294
345
  said.set(to, performance.now())
295
- return owing.delete(to) ? { acked: taken.get(to)!.upTo } : {}
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.acked)
389
+ if (isAcked(body)) settle(from, body.a)
339
390
  else if (isBeat(body)) {
340
- if (body.acked !== undefined) settle(from, body.acked)
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.seq > t.upTo) t.ahead.set(body.seq, BEAT)
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, { acked: t.upTo } satisfies Acked).catch(() => undefined)
405
+ put(from, { a: t.upTo } satisfies Acked).catch(() => undefined)
355
406
  } else owe(from)
356
407
  } else if (isNumbered(body)) {
357
- if (body.acked !== undefined) settle(from, body.acked)
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.seq > t.upTo) t.ahead.set(body.seq, body.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, body: u.body }
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
- throw new RelayError(
476
- 504,
477
- `cannot connect to the relay ${new URL(base).origin}: no answer in ${REPLY_MS / 1000} s`,
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(() => resolve(false), wait)
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(() => said(undefined), wait)
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) return 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 =