@vzn/vx-dte 0.0.650 → 0.0.651

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.
@@ -0,0 +1,98 @@
1
+ // The thread `joinOffLoop` (wire-thread.ts) starts: the orchestrator's
2
+ // participant, its socket, acks, beats and redials, on a loop the run's
3
+ // work never holds.
4
+
5
+ import { join, type Participant } from './wire.js'
6
+
7
+ declare const self: Worker
8
+
9
+ /**
10
+ * The run's loop held this many heartbeats (10 s on the default) is taken
11
+ * as hung: the thread leaves, so the relay closes the run and its workers
12
+ * end, as they would have for an orchestrator quiet that long (owner,
13
+ * 2026-10-23: "if it does not reply for 10s its dead").
14
+ */
15
+ const HELD_BEATS = 5
16
+
17
+ let me: Participant | undefined
18
+ let n = 0
19
+ const tokens = new Map<number, { resolve(v: string | undefined): void; reject(e: Error): void }>()
20
+ const post = (m: unknown): void => self.postMessage(m)
21
+ const failure = (err: unknown): { status?: number; message: string } => ({
22
+ ...(typeof (err as { status?: unknown }).status === 'number'
23
+ ? { status: (err as { status: number }).status }
24
+ : {}),
25
+ message: err instanceof Error ? err.message : String(err),
26
+ })
27
+
28
+ self.onmessage = async (e: MessageEvent<Record<string, unknown>>) => {
29
+ const m = e.data
30
+ if (m['type'] === 'token') {
31
+ const t = tokens.get(m['n'] as number)
32
+ tokens.delete(m['n'] as number)
33
+ if (typeof m['error'] === 'string') t?.reject(new Error(m['error']))
34
+ else t?.resolve(m['value'] as string | undefined)
35
+ return
36
+ }
37
+ const reply = (value?: unknown): void => post({ type: 'done', n: m['n'], value })
38
+ const fail = (err: unknown): void => post({ type: 'done', n: m['n'], error: failure(err) })
39
+ try {
40
+ if (m['type'] === 'join') {
41
+ const shared = new Float64Array(m['shared'] as SharedArrayBuffer)
42
+ const token = m['asks']
43
+ ? () =>
44
+ new Promise<string | undefined>((resolve, reject) => {
45
+ const k = ++n
46
+ tokens.set(k, { resolve, reject })
47
+ post({ type: 'token', n: k })
48
+ })
49
+ : (m['token'] as string | undefined)
50
+ const p = await join(
51
+ m['relay'] as string,
52
+ m['run'] as string,
53
+ 'orchestrator',
54
+ m['info'],
55
+ token,
56
+ m['extra'] as Record<string, string>,
57
+ )
58
+ me = p
59
+ p.resumed((peer) => post({ type: 'resumed', ...(peer === undefined ? {} : { peer }) }))
60
+ reply({ id: p.id, heartbeatMs: p.heartbeatMs })
61
+ const watch = setInterval(
62
+ () => {
63
+ shared[1] = p.online ? 1 : 0
64
+ const held = Date.now() - shared[0]!
65
+ if (held < HELD_BEATS * p.heartbeatMs) return
66
+ clearInterval(watch)
67
+ post({
68
+ type: 'ended',
69
+ status: 410,
70
+ message: `dte: this machine's run was held ${Math.round(held / 1000)} s, past ${HELD_BEATS} heartbeats: the run is over`,
71
+ })
72
+ // quiet: `ended` above already said the run is over; a failed
73
+ // leave changes nothing the relay does with it.
74
+ void p.leave().catch(() => undefined)
75
+ },
76
+ Math.max(10, p.heartbeatMs / 10),
77
+ )
78
+ for (;;) {
79
+ let got
80
+ try {
81
+ got = await p.poll()
82
+ } catch (err) {
83
+ clearInterval(watch)
84
+ const f = failure(err)
85
+ post({ type: 'ended', status: f.status ?? 500, message: f.message })
86
+ return
87
+ }
88
+ if (got.length > 0) post({ type: 'messages', messages: got })
89
+ }
90
+ }
91
+ if (me === undefined) throw new Error('dte: not joined')
92
+ if (m['type'] === 'send') reply(await me.send(m['to'] as string, m['body']))
93
+ else if (m['type'] === 'remove') reply(await me.remove(m['worker'] as string))
94
+ else if (m['type'] === 'leave') reply(await me.leave())
95
+ } catch (err) {
96
+ fail(err)
97
+ }
98
+ }
@@ -0,0 +1,168 @@
1
+ // The orchestrator's relay participant on a thread of its own.
2
+ //
3
+ // Liveness is in the protocol: a quiet worker reports every 1.5
4
+ // heartbeats, its orchestrator acks, and a peer quiet 3 heartbeats is lost. On the orchestrator's loop, its own
5
+ // sync work held its beats: in CI run 40886829588 (2026-10-29) it said
6
+ // nothing for 12 s and every worker then running ended, stopping 13
7
+ // tasks. Here the socket, its acks, beats and redials run on a thread the
8
+ // run's work never holds; the run talks to it by messages.
9
+
10
+ import { ui } from '@vzn/vx'
11
+ import { RelayError, type Participant, type RelayMessage } from './wire.js'
12
+
13
+ /** From the thread: what `wire-host.ts` posts. */
14
+ type FromHost =
15
+ | { type: 'token'; n: number }
16
+ | { type: 'messages'; messages: RelayMessage[] }
17
+ | { type: 'resumed'; peer?: string }
18
+ | { type: 'ended'; status: number; message: string }
19
+ | { type: 'done'; n: number; value?: unknown; error?: { status?: number; message: string } }
20
+
21
+ /** How often this loop says it is alive to the thread (`HELD_BEATS` in wire-host.ts reads it). */
22
+ const TICK_MS = 100
23
+ /**
24
+ * A tick this late says so: the thread kept the run alive, but what held the
25
+ * loop is not known (run 40886829588's profile had no slice in its 12 s gap),
26
+ * and the line's time in the log is where to look. Half the 2 s heartbeat.
27
+ */
28
+ const HELD_SAY_MS = 1000
29
+
30
+ export async function joinOffLoop(
31
+ relay: string,
32
+ run: string,
33
+ info: unknown,
34
+ token: string | undefined | (() => Promise<string | undefined>),
35
+ extra: Readonly<Record<string, string>> = {},
36
+ ): Promise<Participant> {
37
+ // [0]: when this loop last ran (Date.now, the clock both threads share); [1]: online.
38
+ const shared = new Float64Array(new SharedArrayBuffer(16))
39
+ const tick = (): void => {
40
+ const now = Date.now()
41
+ const held = now - shared[0]! - TICK_MS
42
+ if (shared[0] && held >= HELD_SAY_MS)
43
+ ui.say('warn', `[vx] dte: this machine's loop was held ${held} ms`)
44
+ shared[0] = now
45
+ }
46
+ tick()
47
+ const ticking = setInterval(tick, TICK_MS)
48
+ ticking.unref()
49
+ const thread = new Worker(new URL('./wire-host.ts', import.meta.url).href)
50
+ let n = 0
51
+ const calls = new Map<number, { resolve(v: unknown): void; reject(e: unknown): void }>()
52
+ const inbox: RelayMessage[] = []
53
+ let ended: RelayError | undefined
54
+ let onResumed: ((peer?: string) => void) | undefined
55
+ /** The token function's own error, said as itself rather than as the thread's copy. */
56
+ let tokenError: unknown
57
+ const waiters = new Set<() => void>()
58
+ const wake = (): void => {
59
+ for (const w of [...waiters]) w()
60
+ }
61
+ const call = (msg: Record<string, unknown>): Promise<unknown> =>
62
+ new Promise((resolve, reject) => {
63
+ const k = ++n
64
+ calls.set(k, { resolve, reject })
65
+ thread.postMessage({ ...msg, n: k })
66
+ })
67
+ thread.onmessage = (e: MessageEvent<FromHost>) => {
68
+ const m = e.data
69
+ if (m.type === 'token') {
70
+ void (async () => {
71
+ try {
72
+ const value = typeof token === 'function' ? await token() : token
73
+ thread.postMessage({ type: 'token', n: m.n, value })
74
+ } catch (err) {
75
+ tokenError = err
76
+ thread.postMessage({
77
+ type: 'token',
78
+ n: m.n,
79
+ error: err instanceof Error ? err.message : String(err),
80
+ })
81
+ }
82
+ })()
83
+ } else if (m.type === 'messages') {
84
+ inbox.push(...m.messages)
85
+ wake()
86
+ } else if (m.type === 'resumed') onResumed?.(m.peer)
87
+ else if (m.type === 'ended') {
88
+ ended ??= new RelayError(m.status, m.message)
89
+ wake()
90
+ } else {
91
+ const c = calls.get(m.n)
92
+ calls.delete(m.n)
93
+ if (m.error === undefined) c?.resolve(m.value)
94
+ else if (tokenError instanceof Error && tokenError.message === m.error.message)
95
+ c?.reject(tokenError)
96
+ else
97
+ c?.reject(
98
+ m.error.status === undefined
99
+ ? new Error(m.error.message)
100
+ : new RelayError(m.error.status, m.error.message),
101
+ )
102
+ }
103
+ }
104
+ const stop = (): void => {
105
+ clearInterval(ticking)
106
+ thread.terminate()
107
+ }
108
+ let joined: { id: string; heartbeatMs: number }
109
+ try {
110
+ joined = (await call({
111
+ type: 'join',
112
+ relay,
113
+ run,
114
+ info,
115
+ extra,
116
+ shared: shared.buffer,
117
+ token: typeof token === 'function' ? undefined : token,
118
+ asks: typeof token === 'function',
119
+ })) as { id: string; heartbeatMs: number }
120
+ } catch (err) {
121
+ stop()
122
+ throw err
123
+ }
124
+ const idle = (ms: number, signal?: AbortSignal): Promise<void> =>
125
+ new Promise((resolve) => {
126
+ const done = (): void => {
127
+ clearTimeout(timer)
128
+ signal?.removeEventListener('abort', done)
129
+ waiters.delete(done)
130
+ resolve()
131
+ }
132
+ const timer = setTimeout(done, ms)
133
+ signal?.addEventListener('abort', done)
134
+ waiters.add(done)
135
+ })
136
+ return {
137
+ id: joined.id,
138
+ heartbeatMs: joined.heartbeatMs,
139
+ get online() {
140
+ return shared[1] === 1
141
+ },
142
+ resumed(f) {
143
+ onResumed = f
144
+ },
145
+ // The thread's own wire beats for the run: its loop may be held.
146
+ quiet() {},
147
+ async send(to, body) {
148
+ await call({ type: 'send', to, body })
149
+ },
150
+ async poll(signal) {
151
+ if (inbox.length === 0 && ended === undefined && signal?.aborted !== true)
152
+ await idle(joined.heartbeatMs, signal)
153
+ if (inbox.length > 0) return inbox.splice(0)
154
+ if (ended !== undefined) throw ended
155
+ return []
156
+ },
157
+ async remove(worker) {
158
+ await call({ type: 'remove', worker })
159
+ },
160
+ async leave() {
161
+ try {
162
+ await call({ type: 'leave' })
163
+ } finally {
164
+ stop()
165
+ }
166
+ },
167
+ }
168
+ }
package/src/wire.ts CHANGED
@@ -42,18 +42,19 @@ export function unsent(
42
42
  */
43
43
  const FIRST_REDIAL_MS = 250
44
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.
45
+ * A peer quiet this many heartbeats is lost (6 s on the default): the one
46
+ * number the others derive from.
47
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
48
+ const LOST_BEATS = 3
51
49
  /**
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).
50
+ * A worker quiet half of `LOST_BEATS` sends its report (3 s), and its
51
+ * orchestrator's ack answers it: one late frame is not a loss.
55
52
  */
56
- const LOST_BEATS = 1.9
53
+ const QUIET_BEATS = LOST_BEATS / 2
54
+ /** A send unacknowledged as long goes again: its ack comes within it. */
55
+ const RESEND_BEATS = LOST_BEATS / 2
56
+ /** Why a worker ends on its orchestrator's silence. */
57
+ export const ORCHESTRATOR_SILENT = 'relay: the orchestrator went silent'
57
58
  /**
58
59
  * A relay that forgot this participant (a restart: it stores nothing) is
59
60
  * joined again for this many heartbeats; the orchestrator joins as the
@@ -61,8 +62,8 @@ const LOST_BEATS = 1.9
61
62
  * rejoin for 2 heartbeats, it's over").
62
63
  */
63
64
  const REJOIN_BEATS = 2
64
- /** What a probe delivers: nothing. */
65
- const PROBED = Symbol('probe')
65
+ /** What a beat delivers: nothing. */
66
+ const BEAT = Symbol('beat')
66
67
  /** A frame of several sends stays under this, half the relay's 1 MiB body limit. */
67
68
  const FRAME_BYTES = 512 * 1024
68
69
  /** How long `leave` waits on its socket's close handshake. */
@@ -121,17 +122,17 @@ const isNumbered = (b: unknown): b is Numbered =>
121
122
  'body' in b &&
122
123
  Object.keys(b).length === ('acked' in b ? 3 : 2)
123
124
  /**
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.
125
+ * What a side with nothing else to say a heartbeat sends: a numbered send
126
+ * with no body, carrying its acks, delivered to no one.
126
127
  */
127
- interface Probe {
128
+ interface Beat {
128
129
  readonly seq: number
129
130
  readonly acked?: number
130
131
  }
131
- const isProbe = (b: unknown): b is Probe =>
132
+ const isBeat = (b: unknown): b is Beat =>
132
133
  typeof b === 'object' &&
133
134
  b !== null &&
134
- typeof (b as Probe).seq === 'number' &&
135
+ typeof (b as Beat).seq === 'number' &&
135
136
  !('body' in b) &&
136
137
  Object.keys(b).length === ('acked' in b ? 2 : 1)
137
138
  const isAcked = (b: unknown): b is Acked =>
@@ -169,6 +170,12 @@ export interface Participant {
169
170
  * said meanwhile, and is not lost for that.
170
171
  */
171
172
  resumed(f: (peer?: string) => void): void
173
+ /**
174
+ * A worker's `f` is told when it said nothing to its orchestrator a
175
+ * heartbeat: it says something now, its live report. Untold, the wire
176
+ * sends a beat. The orchestrator is never told: it speaks on no timer.
177
+ */
178
+ quiet(f: (peer: string) => void): void
172
179
  send(to: string, body: unknown): Promise<void>
173
180
  /** Held up to one heartbeat, which also keeps this participant alive. */
174
181
  poll(signal?: AbortSignal): Promise<RelayMessage[]>
@@ -255,45 +262,47 @@ export async function join(
255
262
  >()
256
263
  /** Each sender's sends taken in order, and those that came ahead of a gap. */
257
264
  const taken = new Map<string, { upTo: number; ahead: Map<number, unknown> }>()
265
+ /** Acks owed, by peer: each rides the next send to its peer. */
266
+ const owing = new Set<string>()
258
267
  /**
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.
268
+ * The orchestrator sends only orders and acks: an ack no order carried
269
+ * 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.
262
271
  */
263
- const owing = new Set<string>()
264
272
  let acking: ReturnType<typeof setTimeout> | undefined
265
273
  const flushAcks = (): void => {
266
- clearTimeout(acking)
267
274
  acking = undefined
268
275
  if (left || ended !== undefined) return
269
- for (const peer of owing) {
276
+ for (const peer of [...owing]) {
270
277
  const upTo = taken.get(peer)?.upTo
271
- if (upTo === undefined) continue
278
+ owing.delete(peer)
272
279
  // quiet: a lost ack only costs a resend, which is dropped as seen
273
- put(peer, { acked: upTo } satisfies Acked).catch(() => undefined)
280
+ if (upTo !== undefined) put(peer, { acked: upTo } satisfies Acked).catch(() => undefined)
274
281
  }
275
- owing.clear()
276
282
  }
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
283
+ const owe = (peer: string): void => {
284
+ owing.add(peer)
285
+ if (role !== 'orchestrator' || acking !== undefined) return
282
286
  acking = setTimeout(flushAcks, heartbeatMs / 2)
283
287
  acking.unref()
284
288
  }
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 } : {}
289
+ /** When this side last sent each peer anything: a worker quiet a heartbeat says something. */
290
+ const said = new Map<string, number>()
291
+ let onQuiet: ((peer: string) => void) | undefined
292
+ /** 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 } => {
294
+ said.set(to, performance.now())
295
+ return owing.delete(to) ? { acked: taken.get(to)!.upTo } : {}
296
+ }
288
297
  /**
289
298
  * When each peer was last heard from: the orchestrator's workers from
290
299
  * their `joined`, a worker's orchestrator from its join. One quiet
291
- * `PROBE_BEATS` is probed; one quiet `LOST_BEATS` is lost
300
+ * `LOST_BEATS` is lost
292
301
  * (2026-10-23: "if it does not reply for 10s its dead"), on a socket that
293
302
  * never closed: the relay loses one whose socket closed and says so.
294
303
  */
295
304
  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. */
305
+ /** `quiet`: a beat or a resend, which waits on no answer: the relay says only a failure. */
297
306
  const put = (to: string, frame: object, quiet = true): Promise<void> =>
298
307
  over((s) => s.send(relayIds.get(to) ?? to, frame, quiet))
299
308
  const settle = (from: string, acked: number): void => {
@@ -327,17 +336,23 @@ export async function join(
327
336
  const from = heldIds.get(m.from) ?? m.from
328
337
  if (heard.has(from)) heard.set(from, performance.now())
329
338
  if (isAcked(body)) settle(from, body.acked)
330
- else if (isProbe(body)) {
339
+ else if (isBeat(body)) {
331
340
  if (body.acked !== undefined) settle(from, body.acked)
332
341
  const t = taken.get(from) ?? { upTo: 0, ahead: new Map() }
333
342
  taken.set(from, t)
334
- if (body.seq > t.upTo) t.ahead.set(body.seq, PROBED)
343
+ if (body.seq > t.upTo) t.ahead.set(body.seq, BEAT)
335
344
  while (t.ahead.has(t.upTo + 1)) {
336
345
  const next = t.ahead.get(++t.upTo)
337
346
  t.ahead.delete(t.upTo)
338
- if (next !== PROBED) out.push({ from, body: next })
347
+ if (next !== BEAT) out.push({ from, body: next })
339
348
  }
340
- ack(from, true)
349
+ // A beat comes only from a worker quiet `QUIET_BEATS`: half a
350
+ // heartbeat more would leave it 2 s of `LOST_BEATS` for a stall.
351
+ if (role === 'orchestrator') {
352
+ owing.delete(from)
353
+ // quiet: a lost ack only costs a resend, which is dropped as seen
354
+ put(from, { acked: t.upTo } satisfies Acked).catch(() => undefined)
355
+ } else owe(from)
341
356
  } else if (isNumbered(body)) {
342
357
  if (body.acked !== undefined) settle(from, body.acked)
343
358
  let t = taken.get(from)
@@ -346,9 +361,9 @@ export async function join(
346
361
  while (t.ahead.has(t.upTo + 1)) {
347
362
  const next = t.ahead.get(++t.upTo)
348
363
  t.ahead.delete(t.upTo)
349
- if (next !== PROBED) out.push({ from, body: next })
364
+ if (next !== BEAT) out.push({ from, body: next })
350
365
  }
351
- ack(from)
366
+ owe(from)
352
367
  } else out.push({ from, body })
353
368
  }
354
369
  return out
@@ -377,46 +392,49 @@ export async function join(
377
392
  outbox.delete(peer)
378
393
  taken.delete(peer)
379
394
  owing.delete(peer)
395
+ said.delete(peer)
380
396
  const now = relayIds.get(peer)
381
397
  relayIds.delete(peer)
382
398
  if (now !== undefined) heldIds.delete(now)
383
399
  }
384
400
  /**
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`.
401
+ * A worker says something to its orchestrator whenever it said nothing
402
+ * `QUIET_BEATS`: its live report (`quiet`), or a beat. The orchestrator
403
+ * answers with orders and acks only. Either loses a peer quiet `LOST_BEATS`.
389
404
  */
390
405
  const listen = (): void => {
391
406
  // With no socket nobody is heard: the redial or rejoin decides.
392
407
  if (socket?.open !== true) return
393
- resend(heartbeatMs)
408
+ resend(RESEND_BEATS * heartbeatMs)
394
409
  const now = performance.now()
395
410
  // With no peer, the relay is asked instead: a frame it answers.
396
411
  if (heard.size === 0 && socket?.open === true && now - socket.heardAt >= heartbeatMs)
397
412
  socket.ask()
398
413
  for (const [peer, at] of heard) {
399
- const quiet = now - at
400
- if (quiet >= LOST_BEATS * heartbeatMs) {
414
+ if (now - at >= LOST_BEATS * heartbeatMs) {
401
415
  forget(peer)
402
416
  if (peer === 'orchestrator') {
403
- end(new RelayError(410, 'relay: the orchestrator went silent'))
417
+ end(new RelayError(410, ORCHESTRATOR_SILENT))
404
418
  return
405
419
  }
406
420
  inbox.push({ from: 'relay', body: { type: 'lost', id: peer } })
407
421
  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)
422
+ continue
423
+ }
424
+ // The orchestrator never speaks on a timer: its workers do.
425
+ if (role === 'orchestrator') continue
426
+ if (now - (said.get(peer) ?? 0) < QUIET_BEATS * heartbeatMs) continue
427
+ said.set(peer, now)
428
+ if (onQuiet !== undefined) {
429
+ onQuiet(peer)
430
+ continue
419
431
  }
432
+ let o = outbox.get(peer)
433
+ if (o === undefined) outbox.set(peer, (o = { next: 0, unacked: new Map() }))
434
+ const seq = ++o.next
435
+ o.unacked.set(seq, { body: BEAT, at: now })
436
+ // quiet: unacknowledged, it goes again
437
+ put(peer, { seq, ...owed(peer) } satisfies Beat).catch(() => undefined)
420
438
  }
421
439
  }
422
440
  /** Sends again what waited `ms` unacknowledged, to `only` or to all; all of it, at 0. */
@@ -428,7 +446,7 @@ export async function join(
428
446
  if (now - u.at < ms) continue
429
447
  u.at = now
430
448
  // quiet: unacknowledged, it goes again next heartbeat
431
- const again = u.body === PROBED ? { seq } : { seq, body: u.body }
449
+ const again = u.body === BEAT ? { seq } : { seq, body: u.body }
432
450
  put(to, { ...again, ...owed(to) }).catch(() => undefined)
433
451
  }
434
452
  }
@@ -582,8 +600,8 @@ export async function join(
582
600
  else again()
583
601
  }
584
602
  // 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.
603
+ // two heartbeats (4 s on the default) of its last word, and a quiet
604
+ // worker speaks within a tenth of its heartbeat.
587
605
  const listening = setInterval(listen, heartbeatMs / 10)
588
606
  listening.unref()
589
607
  if (role === 'worker') heard.set('orchestrator', performance.now())
@@ -637,6 +655,9 @@ export async function join(
637
655
  resumed(f) {
638
656
  onResumed = f
639
657
  },
658
+ quiet(f) {
659
+ onQuiet = f
660
+ },
640
661
  async send(to, body) {
641
662
  let o = outbox.get(to)
642
663
  if (o === undefined) outbox.set(to, (o = { next: 0, unacked: new Map() }))
@@ -666,7 +687,7 @@ export async function join(
666
687
  // One neither the relay nor this side knows waits on nothing, and
667
688
  // would be asked every heartbeat for the rest of the run.
668
689
  if (err.status === 404 && !heard.has(to)) outbox.delete(to)
669
- else o.unacked.set(seq, { body: PROBED, at: 0 })
690
+ else o.unacked.set(seq, { body: BEAT, at: 0 })
670
691
  throw err
671
692
  }
672
693
  },
@@ -702,7 +723,7 @@ export async function join(
702
723
  await said.catch(() => undefined)
703
724
  }
704
725
  left = true
705
- // Ends what still waits on a socket (an ack, a probe): no redial
726
+ // Ends what still waits on a socket (a send, a beat): no redial
706
727
  // would ever bring one, and each waits a heartbeat at a time, for good.
707
728
  end(new RelayError(410, 'relay: this participant left'))
708
729
  const s = socket
@@ -984,7 +1005,7 @@ async function connect(
984
1005
  return
985
1006
  }
986
1007
  if (m.n === undefined || m.n === null) {
987
- // An unnumbered frame's failure: an ack or probe to one the relay no
1008
+ // An unnumbered frame's failure: a beat or resend to one the relay no
988
1009
  // longer knows (404) is not one, its loss reaching this side apart.
989
1010
  const failed = [m, ...(m.results ?? [])].filter(
990
1011
  (a): a is Answer => a?.status !== undefined && a.status !== 404,