@oar-sh/oar 0.9.11 → 0.9.13
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/CHANGELOG.md +332 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +201 -12
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +179 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +152 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/acp-host-services.mjs +48 -6
- package/server/grok-worker/grok-background-tasks.mjs +248 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +70 -3
- package/server/public/app/background-tasks-view.mjs +334 -16
- package/server/public/app/bootstrap.js +102 -5
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +462 -8
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +259 -67
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +162 -2
- package/server/public/app/socket-handlers.js +59 -7
- package/server/public/app/store.js +25 -0
- package/server/public/app/subagent-viewer.mjs +7 -1
- package/server/public/app/task-console-render.mjs +401 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +730 -0
- package/server/public/app/task-output-text.mjs +243 -0
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
- package/server/public/app/transcript-jump-bottom.mjs +68 -11
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +20 -1
- package/server/public/index.html +306 -6
- package/server/relay-tools.md +6 -0
- package/server/repositories/message-repository.mjs +11 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/routes/messages-routes.mjs +397 -23
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +208 -66
- package/server/server-runtime.mjs +269 -137
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +470 -6
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +9 -2
- package/server/services/relay-event-service.mjs +27 -9
- package/server/services/relay-prompt-sanitizer.mjs +18 -4
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +380 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +316 -20
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +761 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/relay-events.mjs +55 -2
- package/shared/remote-relay-contract.mjs +47 -10
- package/shared/task-output-text.mjs +850 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// The spool a session worker writes a background task's output into when the
|
|
2
|
+
// provider keeps none in a file of its own (Copilot's attached shells, Grok's
|
|
3
|
+
// terminals): `<os.tmpdir()>/oar-task-output/<sessionId>/<taskId>.log`,
|
|
4
|
+
// append-only. The worker names the file as the task row's `outputPath`
|
|
5
|
+
// (`outputKind: 'spool'`) and the relay reads it like any other output file,
|
|
6
|
+
// so the console works the same for every provider and outlives a worker
|
|
7
|
+
// restart.
|
|
8
|
+
//
|
|
9
|
+
// Bounded: a file stops growing at 50 MB with one "output capped" line, and
|
|
10
|
+
// `sweep()` (at worker start and hourly) deletes files that ended more than an
|
|
11
|
+
// hour ago. Nothing is deleted at exit: the relay keeps showing an ended
|
|
12
|
+
// task's output for about an hour.
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
export const TASK_OUTPUT_SPOOL_DIR = 'oar-task-output';
|
|
19
|
+
export const TASK_OUTPUT_SPOOL_MAX_BYTES = 50 * 1024 * 1024;
|
|
20
|
+
export const TASK_OUTPUT_SPOOL_MAX_AGE_MS = 60 * 60 * 1000;
|
|
21
|
+
// A dead worker's session folder is left for its own next start to sweep;
|
|
22
|
+
// another session's worker removes it only once it is a day old.
|
|
23
|
+
export const TASK_OUTPUT_SPOOL_FOREIGN_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
const CAPPED_LINE = '\n[output capped at 50 MB: later output is not kept]\n';
|
|
26
|
+
|
|
27
|
+
/** A file-name-safe form of an id: [A-Za-z0-9._-], at most 128 characters, never `.` or `..`. */
|
|
28
|
+
export function spoolSafeName(value) {
|
|
29
|
+
const text = String(value ?? '').trim().replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 128);
|
|
30
|
+
return /^\.+$/.test(text) ? text.replace(/\./g, '_') : text;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `createTaskOutputSpool({ sessionId })` →
|
|
35
|
+
* `{ root, pathFor(taskId), append(taskId, text), end(taskId), sweep(maxAgeMs) }`.
|
|
36
|
+
*
|
|
37
|
+
* - `pathFor(taskId)`: the absolute file path for the task's row.
|
|
38
|
+
* - `append(taskId, text)`: `{ ok, bytes, capped }`; never throws. The cap
|
|
39
|
+
* writes one marker line and drops what comes after it.
|
|
40
|
+
* - `end(taskId)`: the task finished; its file stays for the relay until a
|
|
41
|
+
* sweep finds it older than the age limit.
|
|
42
|
+
* - `sweep(maxAgeMs = 1 h)`: deletes this session's files not written to for
|
|
43
|
+
* that long (a task still appending is never swept), and other sessions'
|
|
44
|
+
* folders a day old. Returns the number of files removed.
|
|
45
|
+
*
|
|
46
|
+
* `baseDir` (default the OS temp dir) and `now` are for tests.
|
|
47
|
+
*/
|
|
48
|
+
export function createTaskOutputSpool({
|
|
49
|
+
sessionId,
|
|
50
|
+
now = () => Date.now(),
|
|
51
|
+
baseDir = os.tmpdir(),
|
|
52
|
+
maxBytes = TASK_OUTPUT_SPOOL_MAX_BYTES,
|
|
53
|
+
fsImpl = fs,
|
|
54
|
+
} = {}) {
|
|
55
|
+
const sessionName = spoolSafeName(sessionId);
|
|
56
|
+
if (!sessionName) throw new Error('createTaskOutputSpool requires sessionId');
|
|
57
|
+
const top = path.join(path.resolve(baseDir), TASK_OUTPUT_SPOOL_DIR);
|
|
58
|
+
const root = path.join(top, sessionName);
|
|
59
|
+
// taskId → { bytes, capped }; a task listed here is live (still appending).
|
|
60
|
+
const live = new Map();
|
|
61
|
+
let rootReady = false;
|
|
62
|
+
|
|
63
|
+
// The shared folder is ours to write and sweep only when it is a real
|
|
64
|
+
// folder (not a symlink someone else put into a shared temp dir) owned by
|
|
65
|
+
// this user where the platform says who owns it.
|
|
66
|
+
function topIsOurs() {
|
|
67
|
+
let stats;
|
|
68
|
+
try { stats = fsImpl.lstatSync(top); } catch { return false; }
|
|
69
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) return false;
|
|
70
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
71
|
+
return uid === null || typeof stats.uid !== 'number' || stats.uid === uid;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ensureRoot() {
|
|
75
|
+
if (rootReady) return;
|
|
76
|
+
// Owner-only: the temp dir is shared on a multi-user host.
|
|
77
|
+
try { fsImpl.mkdirSync(top, { mode: 0o700 }); } catch (error) { if (error?.code !== 'EEXIST') throw error; }
|
|
78
|
+
if (!topIsOurs()) throw new Error(`${top} is not a folder of this user`);
|
|
79
|
+
fsImpl.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
80
|
+
rootReady = true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pathFor(taskId) {
|
|
84
|
+
const name = spoolSafeName(taskId);
|
|
85
|
+
if (!name) throw new Error('task id is empty');
|
|
86
|
+
return path.join(root, `${name}.log`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function stateOf(taskId, file) {
|
|
90
|
+
let state = live.get(taskId);
|
|
91
|
+
if (!state) {
|
|
92
|
+
let bytes = 0;
|
|
93
|
+
try { bytes = fsImpl.statSync(file).size; } catch {}
|
|
94
|
+
state = { bytes, capped: bytes >= maxBytes };
|
|
95
|
+
live.set(taskId, state);
|
|
96
|
+
}
|
|
97
|
+
return state;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function append(taskId, text) {
|
|
101
|
+
const id = String(taskId ?? '').trim();
|
|
102
|
+
const value = typeof text === 'string' ? text : (Buffer.isBuffer(text) ? text.toString('utf8') : String(text ?? ''));
|
|
103
|
+
if (!id) return { ok: false, bytes: 0, capped: false };
|
|
104
|
+
try {
|
|
105
|
+
ensureRoot();
|
|
106
|
+
const file = pathFor(id);
|
|
107
|
+
const state = stateOf(id, file);
|
|
108
|
+
if (state.capped || !value) return { ok: true, bytes: state.bytes, capped: state.capped };
|
|
109
|
+
let data = Buffer.from(value, 'utf8');
|
|
110
|
+
const room = maxBytes - state.bytes;
|
|
111
|
+
if (data.length > room) {
|
|
112
|
+
// Cut on a character boundary, then say so once.
|
|
113
|
+
let end = Math.max(0, room);
|
|
114
|
+
while (end > 0 && (data[end] & 0xc0) === 0x80) end -= 1;
|
|
115
|
+
data = Buffer.concat([data.subarray(0, end), Buffer.from(CAPPED_LINE, 'utf8')]);
|
|
116
|
+
state.capped = true;
|
|
117
|
+
}
|
|
118
|
+
fsImpl.appendFileSync(file, data, { mode: 0o600 });
|
|
119
|
+
state.bytes += data.length;
|
|
120
|
+
return { ok: true, bytes: state.bytes, capped: state.capped };
|
|
121
|
+
} catch {
|
|
122
|
+
return { ok: false, bytes: live.get(id)?.bytes || 0, capped: !!live.get(id)?.capped };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function end(taskId) {
|
|
127
|
+
live.delete(String(taskId ?? '').trim());
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function liveNames() {
|
|
131
|
+
return new Set([...live.keys()].map((id) => `${spoolSafeName(id)}.log`));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sweepDir(dir, maxAge, skip = new Set()) {
|
|
135
|
+
let removed = 0;
|
|
136
|
+
let left = 0;
|
|
137
|
+
let entries = [];
|
|
138
|
+
try { entries = fsImpl.readdirSync(dir, { withFileTypes: true }); } catch { return { removed, left: 0, missing: true }; }
|
|
139
|
+
const cutoff = now() - maxAge;
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
if (!entry.isFile() || skip.has(entry.name)) {
|
|
142
|
+
left += 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const file = path.join(dir, entry.name);
|
|
146
|
+
try {
|
|
147
|
+
if (fsImpl.statSync(file).mtimeMs < cutoff) {
|
|
148
|
+
fsImpl.unlinkSync(file);
|
|
149
|
+
removed += 1;
|
|
150
|
+
} else {
|
|
151
|
+
left += 1;
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
left += 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { removed, left, missing: false };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function sweep(maxAgeMs = TASK_OUTPUT_SPOOL_MAX_AGE_MS) {
|
|
161
|
+
const maxAge = Number.isFinite(Number(maxAgeMs)) && Number(maxAgeMs) >= 0 ? Number(maxAgeMs) : TASK_OUTPUT_SPOOL_MAX_AGE_MS;
|
|
162
|
+
// Never through a symlinked or foreign shared folder: its "session
|
|
163
|
+
// folders" could be anything.
|
|
164
|
+
if (!topIsOurs()) return 0;
|
|
165
|
+
let removed = sweepDir(root, maxAge, liveNames()).removed;
|
|
166
|
+
let folders = [];
|
|
167
|
+
try { folders = fsImpl.readdirSync(top, { withFileTypes: true }); } catch {}
|
|
168
|
+
for (const folder of folders) {
|
|
169
|
+
if (!folder.isDirectory() || folder.name === sessionName) continue;
|
|
170
|
+
const dir = path.join(top, folder.name);
|
|
171
|
+
const result = sweepDir(dir, Math.max(maxAge, TASK_OUTPUT_SPOOL_FOREIGN_MAX_AGE_MS));
|
|
172
|
+
removed += result.removed;
|
|
173
|
+
if (!result.missing && result.left === 0) {
|
|
174
|
+
try { fsImpl.rmdirSync(dir); } catch {}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return removed;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { root, pathFor, append, end, sweep };
|
|
181
|
+
}
|
|
@@ -69,6 +69,15 @@ export function createWorkerWebSocketLink({
|
|
|
69
69
|
minBackoffMs = 1000,
|
|
70
70
|
maxBackoffMs = 8_000,
|
|
71
71
|
jitterMs = 250,
|
|
72
|
+
// A connection counts as made only at the relay's first server.hello (the
|
|
73
|
+
// relay sends it as soon as it accepts the socket). One still connecting,
|
|
74
|
+
// or open but silent, when this runs out is given up and retried: a relay
|
|
75
|
+
// that hangs in its shutdown can hold a socket in either state without
|
|
76
|
+
// ever ending it.
|
|
77
|
+
connectTimeoutMs = 15_000,
|
|
78
|
+
// Failed attempts after the first few are logged at most once per this
|
|
79
|
+
// interval, with a count of the ones in between.
|
|
80
|
+
failureLogIntervalMs = 60_000,
|
|
72
81
|
readyRefreshMs = 10_000,
|
|
73
82
|
staleConnectionMs = 30_000,
|
|
74
83
|
WebSocketImpl = globalThis.WebSocket,
|
|
@@ -84,6 +93,10 @@ export function createWorkerWebSocketLink({
|
|
|
84
93
|
let readyRefreshTimer = null;
|
|
85
94
|
let reconnectAttempt = 0;
|
|
86
95
|
let reconnectDelayMs = 0;
|
|
96
|
+
// The current socket's connect deadline (see connectTimeoutMs).
|
|
97
|
+
let connectDeadlineTimer = null;
|
|
98
|
+
let lastFailureLoggedAt = null;
|
|
99
|
+
let failuresNotLogged = 0;
|
|
87
100
|
// Ends the current socket's connection (see connect).
|
|
88
101
|
let endCurrentSocket = null;
|
|
89
102
|
// Insertion-ordered: the first entry is the oldest in-flight delivery, which
|
|
@@ -239,6 +252,12 @@ export function createWorkerWebSocketLink({
|
|
|
239
252
|
reconnectTimer = null;
|
|
240
253
|
}
|
|
241
254
|
|
|
255
|
+
function clearConnectDeadline() {
|
|
256
|
+
if (!connectDeadlineTimer) return;
|
|
257
|
+
clearTimeoutImpl(connectDeadlineTimer);
|
|
258
|
+
connectDeadlineTimer = null;
|
|
259
|
+
}
|
|
260
|
+
|
|
242
261
|
function clearReadyRefreshTimer() {
|
|
243
262
|
if (!readyRefreshTimer) return;
|
|
244
263
|
clearIntervalImpl(readyRefreshTimer);
|
|
@@ -290,7 +309,23 @@ export function createWorkerWebSocketLink({
|
|
|
290
309
|
if (typeof readyRefreshTimer?.unref === "function") readyRefreshTimer.unref();
|
|
291
310
|
}
|
|
292
311
|
|
|
293
|
-
|
|
312
|
+
// One line per failed attempt for the first few, then at most one per
|
|
313
|
+
// failureLogIntervalMs: a relay that stays away for an hour must not fill
|
|
314
|
+
// the worker log with a line every 8 seconds.
|
|
315
|
+
function logFailedAttempt(reason) {
|
|
316
|
+
const nowMs = getNowMs();
|
|
317
|
+
const intervalMs = Math.max(0, Number(failureLogIntervalMs) || 0);
|
|
318
|
+
if (reconnectAttempt > 3 && lastFailureLoggedAt !== null && (nowMs - lastFailureLoggedAt) < intervalMs) {
|
|
319
|
+
failuresNotLogged += 1;
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const notLogged = failuresNotLogged ? [`notLogged=${failuresNotLogged}`] : [];
|
|
323
|
+
dbg("worker ws disconnected", reason, `attempt=${reconnectAttempt}`, `retryInMs=${reconnectDelayMs}`, ...notLogged);
|
|
324
|
+
lastFailureLoggedAt = nowMs;
|
|
325
|
+
failuresNotLogged = 0;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function scheduleReconnect(reason) {
|
|
294
329
|
if (stopped) return;
|
|
295
330
|
// One timer at a time: a second end reported for the same outage must not
|
|
296
331
|
// stack another attempt or push the backoff up.
|
|
@@ -299,7 +334,7 @@ export function createWorkerWebSocketLink({
|
|
|
299
334
|
const backoff = Math.min(maxBackoffMs, minBackoffMs * (2 ** Math.max(0, reconnectAttempt - 1)));
|
|
300
335
|
const jitter = Math.floor(Math.random() * Math.max(0, jitterMs));
|
|
301
336
|
reconnectDelayMs = Math.min(maxBackoffMs, backoff + jitter);
|
|
302
|
-
|
|
337
|
+
logFailedAttempt(reason);
|
|
303
338
|
reconnectTimer = setTimeoutImpl(() => {
|
|
304
339
|
reconnectTimer = null;
|
|
305
340
|
connect();
|
|
@@ -312,42 +347,70 @@ export function createWorkerWebSocketLink({
|
|
|
312
347
|
const openState = resolveSocketStateValue(ws, "OPEN", 1);
|
|
313
348
|
const connectingState = resolveSocketStateValue(ws, "CONNECTING", 0);
|
|
314
349
|
if (ws.readyState === openState || ws.readyState === connectingState) return;
|
|
350
|
+
// A socket left closing whose end was never reported: give it up
|
|
351
|
+
// quietly, this connect is its reconnect.
|
|
352
|
+
endCurrentSocket?.("replaced", { reconnect: false });
|
|
315
353
|
}
|
|
316
354
|
if (typeof WebSocketImpl !== "function") {
|
|
317
355
|
dbg("worker ws unavailable: global WebSocket is missing");
|
|
318
356
|
return;
|
|
319
357
|
}
|
|
320
358
|
clearReconnectTimer();
|
|
321
|
-
|
|
322
|
-
|
|
359
|
+
let socket = null;
|
|
360
|
+
try {
|
|
361
|
+
const url = toWebSocketUrl(serverUrl, token, getSessionId, getPid);
|
|
362
|
+
socket = new WebSocketImpl(url);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
// A socket that fails in its constructor raises no events: nothing but
|
|
365
|
+
// this would ever try again.
|
|
366
|
+
scheduleReconnect(`connect-failed: ${error?.message || String(error)}`);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
323
369
|
ws = socket;
|
|
324
370
|
let serverHelloSeen = false;
|
|
325
|
-
// Set once this socket's end has been handled, by whichever of error
|
|
326
|
-
// close
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
371
|
+
// Set once this socket's end has been handled, by whichever of error,
|
|
372
|
+
// close, the connect deadline or a stale/draining close comes first;
|
|
373
|
+
// every later event from it is ignored. Node 22 fires no close for a
|
|
374
|
+
// socket closed while still connecting, and closing it from its own
|
|
375
|
+
// error handler raised the error again, so the reconnect has to be
|
|
376
|
+
// scheduled from the error as well, and only once.
|
|
330
377
|
let ended = false;
|
|
331
|
-
function endSocket(reason) {
|
|
378
|
+
function endSocket(reason, { reconnect = true } = {}) {
|
|
332
379
|
if (ended) return false;
|
|
333
380
|
ended = true;
|
|
381
|
+
if (endCurrentSocket === endSocket) {
|
|
382
|
+
endCurrentSocket = null;
|
|
383
|
+
clearConnectDeadline();
|
|
384
|
+
}
|
|
334
385
|
if (ws === socket) {
|
|
335
386
|
ws = null;
|
|
336
387
|
clearReadyRefreshTimer();
|
|
337
388
|
// The next connection may reach a different (restarted) relay.
|
|
338
389
|
serverCapabilities = new Set();
|
|
339
390
|
}
|
|
340
|
-
if (stopped) return true;
|
|
341
|
-
|
|
342
|
-
|
|
391
|
+
if (stopped || !reconnect) return true;
|
|
392
|
+
// A newer socket is already under way and owns the reconnect.
|
|
393
|
+
if (ws) return true;
|
|
394
|
+
scheduleReconnect(reason);
|
|
343
395
|
return true;
|
|
344
396
|
}
|
|
345
397
|
endCurrentSocket = endSocket;
|
|
398
|
+
clearConnectDeadline();
|
|
399
|
+
const deadlineMs = Math.max(0, Number(connectTimeoutMs) || 0);
|
|
400
|
+
if (deadlineMs > 0) {
|
|
401
|
+
connectDeadlineTimer = setTimeoutImpl(() => {
|
|
402
|
+
connectDeadlineTimer = null;
|
|
403
|
+
if (ended) return;
|
|
404
|
+
const opened = socket.readyState === resolveSocketStateValue(socket, "OPEN", 1);
|
|
405
|
+
// Ended before the close call, as in the error handler below.
|
|
406
|
+
endSocket(opened ? "no-server-hello" : "connect-timeout");
|
|
407
|
+
try { socket.close?.(); } catch {}
|
|
408
|
+
}, deadlineMs);
|
|
409
|
+
if (typeof connectDeadlineTimer?.unref === "function") connectDeadlineTimer.unref();
|
|
410
|
+
}
|
|
346
411
|
socket.addEventListener("open", () => {
|
|
347
412
|
if (ended) return;
|
|
348
413
|
const openedAt = getNowMs();
|
|
349
|
-
reconnectAttempt = 0;
|
|
350
|
-
reconnectDelayMs = 0;
|
|
351
414
|
lastOpenAt = openedAt;
|
|
352
415
|
lastMessageAt = openedAt;
|
|
353
416
|
dbg("worker ws connected");
|
|
@@ -373,6 +436,14 @@ export function createWorkerWebSocketLink({
|
|
|
373
436
|
serverCapabilities = new Set(Array.isArray(payload.capabilities) ? payload.capabilities.map(String) : []);
|
|
374
437
|
if (!serverHelloSeen) {
|
|
375
438
|
serverHelloSeen = true;
|
|
439
|
+
// The handshake is complete: only now does the backoff start over.
|
|
440
|
+
// A relay that accepts the socket and drops it before its hello
|
|
441
|
+
// keeps backing off instead of being retried every second.
|
|
442
|
+
clearConnectDeadline();
|
|
443
|
+
reconnectAttempt = 0;
|
|
444
|
+
reconnectDelayMs = 0;
|
|
445
|
+
lastFailureLoggedAt = null;
|
|
446
|
+
failuresNotLogged = 0;
|
|
376
447
|
try {
|
|
377
448
|
onServerHello(String(payload.reason || "server-hello"));
|
|
378
449
|
} catch (error) {
|
|
@@ -388,6 +459,9 @@ export function createWorkerWebSocketLink({
|
|
|
388
459
|
}
|
|
389
460
|
if (payload?.type === "server.draining") {
|
|
390
461
|
dbg("worker ws draining", payload.reason || "relay-shutdown");
|
|
462
|
+
// A relay stuck in its shutdown may never complete the close
|
|
463
|
+
// handshake: the reconnect does not wait for the close event.
|
|
464
|
+
endSocket("draining");
|
|
391
465
|
try { socket.close?.(); } catch {}
|
|
392
466
|
return;
|
|
393
467
|
}
|
|
@@ -408,15 +482,16 @@ export function createWorkerWebSocketLink({
|
|
|
408
482
|
dbg("worker ws blocked", payload.reason || "blocked");
|
|
409
483
|
}
|
|
410
484
|
});
|
|
411
|
-
socket.addEventListener("close", () => {
|
|
412
|
-
|
|
485
|
+
socket.addEventListener("close", (event) => {
|
|
486
|
+
const code = Number(event?.code);
|
|
487
|
+
endSocket(Number.isFinite(code) && code > 0 ? `close code=${code}` : "close");
|
|
413
488
|
});
|
|
414
|
-
socket.addEventListener("error", (
|
|
489
|
+
socket.addEventListener("error", (event) => {
|
|
415
490
|
if (ended) return;
|
|
416
|
-
|
|
491
|
+
const detail = event?.message || event?.error?.message || "unknown";
|
|
417
492
|
// Ended before the close call, so an error or close it raises
|
|
418
493
|
// (synchronously on some runtimes) finds the socket already handled.
|
|
419
|
-
endSocket(
|
|
494
|
+
endSocket(`error: ${detail}`);
|
|
420
495
|
try { socket.close?.(); } catch {}
|
|
421
496
|
});
|
|
422
497
|
}
|
|
@@ -429,6 +504,7 @@ export function createWorkerWebSocketLink({
|
|
|
429
504
|
function stop() {
|
|
430
505
|
stopped = true;
|
|
431
506
|
clearReconnectTimer();
|
|
507
|
+
clearConnectDeadline();
|
|
432
508
|
clearReadyRefreshTimer();
|
|
433
509
|
if (ws) {
|
|
434
510
|
try { ws.close(); } catch {}
|