@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.
- package/README.md +42 -29
- package/package.json +3 -3
- package/src/github.ts +16 -5
- package/src/messages.ts +38 -27
- package/src/orchestrator.ts +138 -152
- package/src/plugin.ts +15 -5
- package/src/seal.ts +11 -4
- package/src/wire-host.ts +98 -0
- package/src/wire-thread.ts +168 -0
- package/src/wire.ts +87 -66
- package/src/worker.ts +86 -58
package/src/wire-host.ts
ADDED
|
@@ -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
|
-
*
|
|
46
|
-
*
|
|
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
|
|
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
|
|
53
|
-
*
|
|
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
|
|
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
|
|
65
|
-
const
|
|
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
|
-
*
|
|
125
|
-
*
|
|
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
|
|
128
|
+
interface Beat {
|
|
128
129
|
readonly seq: number
|
|
129
130
|
readonly acked?: number
|
|
130
131
|
}
|
|
131
|
-
const
|
|
132
|
+
const isBeat = (b: unknown): b is Beat =>
|
|
132
133
|
typeof b === 'object' &&
|
|
133
134
|
b !== null &&
|
|
134
|
-
typeof (b as
|
|
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
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
/**
|
|
286
|
-
const
|
|
287
|
-
|
|
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
|
-
* `
|
|
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`:
|
|
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 (
|
|
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,
|
|
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 !==
|
|
347
|
+
if (next !== BEAT) out.push({ from, body: next })
|
|
339
348
|
}
|
|
340
|
-
|
|
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 !==
|
|
364
|
+
if (next !== BEAT) out.push({ from, body: next })
|
|
350
365
|
}
|
|
351
|
-
|
|
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
|
-
*
|
|
386
|
-
* `
|
|
387
|
-
*
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
)
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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 ===
|
|
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
|
|
586
|
-
//
|
|
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:
|
|
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 (
|
|
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:
|
|
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,
|