@oar-sh/oar 0.9.10 → 0.9.11

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.
Files changed (104) hide show
  1. package/CHANGELOG.md +314 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +95 -22
  5. package/server/claude-worker/claude-session-process.mjs +89 -11
  6. package/server/claude-worker/claude-session-worker.mjs +9 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
  8. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  9. package/server/db-schema.mjs +59 -2
  10. package/server/grok-worker/acp-client.mjs +2 -1
  11. package/server/grok-worker/acp-host-services.mjs +4 -3
  12. package/server/migrations/0013-previews.mjs +130 -0
  13. package/server/migrations/0014-conversation-groups.mjs +89 -0
  14. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  15. package/server/migrations/0016-share-access-log.mjs +110 -0
  16. package/server/public/app/about-ui.js +73 -0
  17. package/server/public/app/about-view.mjs +234 -0
  18. package/server/public/app/action-confirmations.js +31 -14
  19. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  20. package/server/public/app/api-client.js +251 -16
  21. package/server/public/app/ask-user-view.js +17 -15
  22. package/server/public/app/bootstrap.js +158 -32
  23. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  24. package/server/public/app/context-menu.mjs +90 -6
  25. package/server/public/app/conversation-group-drag.mjs +191 -0
  26. package/server/public/app/conversation-group-order.mjs +265 -0
  27. package/server/public/app/conversation-group-state.mjs +100 -0
  28. package/server/public/app/conversation-list-drag.mjs +449 -0
  29. package/server/public/app/conversation-turn-state.mjs +81 -0
  30. package/server/public/app/conversation-view.js +136 -93
  31. package/server/public/app/cwd-picker.js +1 -1
  32. package/server/public/app/hidden-messages-view.mjs +312 -0
  33. package/server/public/app/host-suspend-ui.js +7 -1
  34. package/server/public/app/journal-view.js +787 -22
  35. package/server/public/app/preview-cards.mjs +117 -31
  36. package/server/public/app/preview-command.mjs +10 -1
  37. package/server/public/app/relay-board-view.js +19 -6
  38. package/server/public/app/relay-restart-error.mjs +16 -0
  39. package/server/public/app/settings-modal.js +40 -8
  40. package/server/public/app/settings-tabs.js +1 -1
  41. package/server/public/app/share-format.mjs +49 -0
  42. package/server/public/app/share-preview-marks.mjs +167 -0
  43. package/server/public/app/share-state.js +109 -0
  44. package/server/public/app/shared-conversations-settings.js +480 -0
  45. package/server/public/app/socket-handlers.js +80 -14
  46. package/server/public/app/store.js +96 -30
  47. package/server/public/app/subagent-row.mjs +3 -3
  48. package/server/public/app/subagent-viewer.mjs +30 -6
  49. package/server/public/app/thinking-follow.js +0 -4
  50. package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
  51. package/server/public/app/transcript-jump-bottom.mjs +132 -21
  52. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  53. package/server/public/index.html +386 -15
  54. package/server/relay-tools.md +2 -1
  55. package/server/relay.mjs +2 -1
  56. package/server/repositories/conversation-group-repository.mjs +444 -0
  57. package/server/repositories/message-repository.mjs +1 -0
  58. package/server/repositories/session-repository.mjs +12 -37
  59. package/server/repositories/share-repository.mjs +322 -0
  60. package/server/repositories/share-visibility-statements.mjs +10 -0
  61. package/server/routes/about-routes.mjs +15 -0
  62. package/server/routes/cache-routes.mjs +4 -0
  63. package/server/routes/conversation-group-routes.mjs +171 -0
  64. package/server/routes/messages-routes.mjs +184 -65
  65. package/server/routes/preview-routes.mjs +9 -1
  66. package/server/routes/sessions-routes.mjs +771 -193
  67. package/server/server-runtime.mjs +316 -100
  68. package/server/services/about-service.mjs +461 -0
  69. package/server/services/background-task-store.mjs +128 -0
  70. package/server/services/background-work-view.mjs +228 -0
  71. package/server/services/cache-rebuild-service.mjs +30 -0
  72. package/server/services/delete-archive-service.mjs +4 -1
  73. package/server/services/detached-worker-watch-service.mjs +150 -0
  74. package/server/services/hidden-message-list.mjs +21 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/message-pin-list.mjs +1 -1
  77. package/server/services/preview-health-probe.mjs +8 -1
  78. package/server/services/preview-proxy-server.mjs +8 -4
  79. package/server/services/preview-registry-service.mjs +676 -26
  80. package/server/services/preview-static-handler.mjs +40 -7
  81. package/server/services/preview-store.mjs +274 -0
  82. package/server/services/rate-limit-service.mjs +12 -1
  83. package/server/services/relay-auth.mjs +183 -0
  84. package/server/services/relay-cli-launcher-service.mjs +3 -1
  85. package/server/services/relay-event-service.mjs +17 -0
  86. package/server/services/remote-relay-dispatcher.mjs +57 -5
  87. package/server/services/request-boundary.mjs +183 -0
  88. package/server/services/session-activity-tracker.mjs +227 -19
  89. package/server/services/session-worker-launch-service.mjs +190 -6
  90. package/server/services/session-worker-process-service.mjs +10 -6
  91. package/server/services/share-access-log-service.mjs +123 -0
  92. package/server/services/share-path-redaction.mjs +424 -0
  93. package/server/services/status-event-service.mjs +7 -3
  94. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  95. package/server/services/windows-autostart-service.mjs +6 -1
  96. package/server/services/windows-boot-autostart-service.mjs +207 -23
  97. package/shared/preview-tool-core.mjs +15 -5
  98. package/shared/remote-relay-contract.mjs +3 -1
  99. package/shared/share-path-privacy.mjs +30 -0
  100. package/shared/windows-system-tools.mjs +106 -0
  101. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  102. package/shared/worker-runtime/heartbeat.mjs +24 -0
  103. package/shared/worker-runtime/kept-posts.mjs +83 -0
  104. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -0,0 +1,106 @@
1
+ // Windows system programs by absolute path, and the bits of a process
2
+ // environment that decide whether a child can start at all.
3
+ //
4
+ // A relay started by the boot-time Scheduled Task (S4U, before anyone signs
5
+ // in) gets an environment built without a full profile: user PATH entries
6
+ // that refer to other variables can arrive unexpanded, and a bare `node`
7
+ // that a signed-in shell finds was not found there. Node looks a bare name
8
+ // up on PATH only, so the programs Windows ships are called by their full
9
+ // path under %SystemRoot%; one that is not there falls back to the bare
10
+ // name, which is what every call site did before.
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ const WINDOWS_DEFAULT_ROOT = 'C:\\Windows';
16
+
17
+ function text(value) {
18
+ return String(value == null ? '' : value).trim();
19
+ }
20
+
21
+ /**
22
+ * The value of an environment variable, matched without regard to case.
23
+ * Windows names are case-insensitive, and a copied environment (`{ ...env }`)
24
+ * keeps whatever case it came with: `Path`, not `PATH`, on most machines.
25
+ */
26
+ export function readEnvValue(env, name) {
27
+ if (!env || typeof env !== 'object') return '';
28
+ if (Object.prototype.hasOwnProperty.call(env, name)) return text(env[name]);
29
+ const wanted = String(name).toLowerCase();
30
+ for (const key of Object.keys(env)) {
31
+ if (key.toLowerCase() === wanted) return text(env[key]);
32
+ }
33
+ return '';
34
+ }
35
+
36
+ /** The key PATH is stored under in `env` (`Path` on most Windows machines). */
37
+ export function envPathKey(env, platform = process.platform) {
38
+ if (env && typeof env === 'object') {
39
+ if (Object.prototype.hasOwnProperty.call(env, 'PATH')) return 'PATH';
40
+ if (platform === 'win32') {
41
+ const found = Object.keys(env).find((key) => key.toLowerCase() === 'path');
42
+ if (found) return found;
43
+ }
44
+ }
45
+ return platform === 'win32' ? 'Path' : 'PATH';
46
+ }
47
+
48
+ export function windowsSystemRoot(env = process.env) {
49
+ return readEnvValue(env, 'SystemRoot') || readEnvValue(env, 'windir') || WINDOWS_DEFAULT_ROOT;
50
+ }
51
+
52
+ const TOOL_SUBPATHS = Object.freeze({
53
+ 'powershell.exe': ['System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'],
54
+ });
55
+
56
+ /**
57
+ * Full path of a program that ships with Windows (`schtasks.exe`,
58
+ * `powershell.exe`, `taskkill.exe`, ...), or the bare name when the file is
59
+ * not where Windows keeps it.
60
+ */
61
+ export function windowsSystemToolPath(name, {
62
+ env = process.env,
63
+ existsImpl = fs.existsSync,
64
+ } = {}) {
65
+ const bare = text(name);
66
+ const fileName = /\.exe$/i.test(bare) ? bare : `${bare}.exe`;
67
+ const parts = TOOL_SUBPATHS[fileName.toLowerCase()] || ['System32', fileName];
68
+ const full = path.win32.join(windowsSystemRoot(env), ...parts);
69
+ try {
70
+ if (existsImpl(full)) return full;
71
+ } catch {}
72
+ return bare;
73
+ }
74
+
75
+ /** The command interpreter: %ComSpec%, else cmd.exe under %SystemRoot%. */
76
+ export function windowsCmdPath({ env = process.env, existsImpl = fs.existsSync } = {}) {
77
+ return readEnvValue(env, 'ComSpec') || windowsSystemToolPath('cmd.exe', { env, existsImpl });
78
+ }
79
+
80
+ /**
81
+ * One log line's worth of the environment a child was started with: PATH in
82
+ * full (the entries are what a failed lookup needs), SystemRoot and ComSpec.
83
+ */
84
+ export function describeSpawnEnvironment(env = process.env, platform = process.platform) {
85
+ const pathValue = readEnvValue(env, 'PATH');
86
+ const parts = [`PATH=${pathValue || '(not set)'}`];
87
+ if (platform === 'win32') {
88
+ parts.push(`SystemRoot=${readEnvValue(env, 'SystemRoot') || '(not set)'}`);
89
+ parts.push(`ComSpec=${readEnvValue(env, 'ComSpec') || '(not set)'}`);
90
+ }
91
+ return parts.join(' ');
92
+ }
93
+
94
+ /** What a failed execFile/execFileSync reports, on one line. */
95
+ export function describeExecError(error) {
96
+ if (!error) return 'unknown error';
97
+ const parts = [];
98
+ const code = error.code ?? error.status;
99
+ if (code !== undefined && code !== null) parts.push(`code=${code}`);
100
+ if (error.signal) parts.push(`signal=${error.signal}`);
101
+ const stderr = text(Buffer.isBuffer(error.stderr) ? error.stderr.toString('utf8') : error.stderr);
102
+ const message = text(error.message).split(/\r?\n/)[0];
103
+ if (message) parts.push(message);
104
+ if (stderr) parts.push(`stderr=${stderr.replace(/\s+/g, ' ').slice(0, 500)}`);
105
+ return parts.join(' ') || 'unknown error';
106
+ }
@@ -0,0 +1,30 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /**
4
+ * The digest of a background-task set, as a worker's heartbeat carries it and
5
+ * the relay computes it for the set it holds: `{ count, digest }` over the
6
+ * sorted task ids only. Descriptions, summaries and token counts change every
7
+ * few seconds and are no reason to send the set again; membership is.
8
+ */
9
+ export function backgroundTaskDigest(taskIds) {
10
+ const ids = [...new Set((Array.isArray(taskIds) ? taskIds : [])
11
+ .map((id) => String(id ?? "").trim())
12
+ .filter(Boolean))].sort();
13
+ return {
14
+ count: ids.length,
15
+ digest: createHash("sha256").update(ids.join("\n")).digest("hex").slice(0, 16),
16
+ };
17
+ }
18
+
19
+ /** A digest off the wire, or null when the sender gave none (it has no opinion). */
20
+ export function normalizeBackgroundTaskDigest(raw) {
21
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
22
+ const digest = String(raw.digest ?? "").trim();
23
+ const count = Number(raw.count);
24
+ if (!/^[0-9a-f]{16}$/.test(digest) || !Number.isInteger(count) || count < 0) return null;
25
+ return { count, digest };
26
+ }
27
+
28
+ export function sameBackgroundTaskDigest(a, b) {
29
+ return !!a && !!b && a.count === b.count && a.digest === b.digest;
30
+ }
@@ -1,3 +1,5 @@
1
+ import { backgroundTaskDigest } from "./background-task-digest.mjs";
2
+
1
3
  export function createHeartbeatController({
2
4
  api,
3
5
  pollMs,
@@ -19,6 +21,18 @@ export function createHeartbeatController({
19
21
  // `settleFailedHandled`, which `onSettleFailedHandled` receives.
20
22
  getSettleFailed,
21
23
  onSettleFailedHandled = () => {},
24
+ // Optional: the ids of the worker's live background tasks (an array, empty
25
+ // for none). The body then carries their digest, and a relay whose own set
26
+ // for this worker differs (it restarted, or a post was lost) answers
27
+ // `resyncBackgroundTasks`: `onResyncBackgroundTasks` publishes the full
28
+ // set again. It is asked again by every beat until the sets agree, so a
29
+ // failed publish needs no retry of its own. Workers that publish no task
30
+ // sets leave both unset; the relay reads a missing digest as no opinion.
31
+ getBackgroundTaskIds,
32
+ onResyncBackgroundTasks = () => {},
33
+ // Optional: called after every beat the relay answered, for work that
34
+ // waits for a reachable relay (posts kept after a failure).
35
+ onAnswered = () => {},
22
36
  // requestPulse's window: one turn transition can flip the steering
23
37
  // snapshot several times in quick succession, and each flip only needs to
24
38
  // reach the relay once.
@@ -43,11 +57,15 @@ export function createHeartbeatController({
43
57
  const settleFailed = typeof getSettleFailed === "function"
44
58
  ? (getSettleFailed() || []).filter((entry) => entry && String(entry.id || "").trim())
45
59
  : [];
60
+ const backgroundTaskIds = typeof getBackgroundTaskIds === "function"
61
+ ? getBackgroundTaskIds()
62
+ : null;
46
63
  const body = {
47
64
  ...(activeQueueMessageId ? { activeQueueMessageId } : {}),
48
65
  ...(activeQueueMessageIds.length ? { activeQueueMessageIds } : {}),
49
66
  ...(steering && typeof steering === "object" ? { steering } : {}),
50
67
  ...(settleFailed.length ? { settleFailed } : {}),
68
+ ...(Array.isArray(backgroundTaskIds) ? { backgroundTasks: backgroundTaskDigest(backgroundTaskIds) } : {}),
51
69
  };
52
70
  const response = await api("POST", "/api/heartbeat", body);
53
71
  // Failed by the relay, or skipped because this worker has no claim on
@@ -59,6 +77,12 @@ export function createHeartbeatController({
59
77
  if (handled.length) {
60
78
  try { onSettleFailedHandled(handled); } catch {}
61
79
  }
80
+ // Kept posts first: an end the relay has not heard of yet goes before
81
+ // the set that no longer lists the task.
82
+ try { await onAnswered(); } catch {}
83
+ if (response?.resyncBackgroundTasks === true) {
84
+ try { await onResyncBackgroundTasks(); } catch {}
85
+ }
62
86
  return true;
63
87
  } catch {
64
88
  return false;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Posts that must not be lost to a relay that is away for a moment: a post
3
+ * that fails is kept and sent again once the relay answers (the worker's
4
+ * heartbeat calls `flush`). Bounded both ways, the newest `max` posts for
5
+ * `ttlMs`, because what they report is only worth telling for a while and a
6
+ * relay that stays away must not grow the worker.
7
+ *
8
+ * A post the relay refused (a 4xx answer) is not kept: sending it again
9
+ * would be refused again.
10
+ */
11
+ export const KEPT_POSTS_MAX = 20;
12
+ export const KEPT_POSTS_TTL_MS = 10 * 60_000;
13
+ // A post that gets no answer is given up after this long and kept: fetch
14
+ // alone waits five minutes, and the heartbeat's resync waits for the flush.
15
+ export const KEPT_POSTS_TIMEOUT_MS = 10_000;
16
+
17
+ export function createKeptPosts({
18
+ api,
19
+ max = KEPT_POSTS_MAX,
20
+ ttlMs = KEPT_POSTS_TTL_MS,
21
+ timeoutMs = KEPT_POSTS_TIMEOUT_MS,
22
+ now = () => Date.now(),
23
+ dbg = () => {},
24
+ } = {}) {
25
+ let kept = [];
26
+ let flushing = null;
27
+
28
+ function worthKeeping(error) {
29
+ const status = Number(error?.status);
30
+ return !(status >= 400 && status < 500);
31
+ }
32
+
33
+ function send(routePath, body) {
34
+ return api("POST", routePath, body, timeoutMs > 0 ? { signal: AbortSignal.timeout(timeoutMs) } : {});
35
+ }
36
+
37
+ function keep(entry) {
38
+ kept.push(entry);
39
+ if (kept.length > max) kept = kept.slice(-max);
40
+ }
41
+
42
+ /** Post now; on a failure keep it for `flush`. Never rejects. */
43
+ async function post(routePath, body) {
44
+ try {
45
+ await send(routePath, body);
46
+ return true;
47
+ } catch (error) {
48
+ dbg("post failed", routePath, error?.message || String(error));
49
+ if (worthKeeping(error)) keep({ routePath, body, at: now() });
50
+ return false;
51
+ }
52
+ }
53
+
54
+ /** Send the kept posts again, oldest first; what fails again stays. */
55
+ function flush() {
56
+ if (flushing) return flushing;
57
+ if (!kept.length) return Promise.resolve(0);
58
+ flushing = (async () => {
59
+ const pending = kept;
60
+ kept = [];
61
+ let sent = 0;
62
+ for (let index = 0; index < pending.length; index += 1) {
63
+ const entry = pending[index];
64
+ if (now() - entry.at > ttlMs) continue;
65
+ try {
66
+ await send(entry.routePath, entry.body);
67
+ sent += 1;
68
+ } catch (error) {
69
+ dbg("kept post failed again", entry.routePath, error?.message || String(error));
70
+ if (!worthKeeping(error)) continue;
71
+ // The relay is still away: the rest waits too, in order, ahead of
72
+ // whatever was kept meanwhile.
73
+ kept = [...pending.slice(index).filter((later) => now() - later.at <= ttlMs), ...kept].slice(-max);
74
+ break;
75
+ }
76
+ }
77
+ return sent;
78
+ })().finally(() => { flushing = null; });
79
+ return flushing;
80
+ }
81
+
82
+ return { post, flush, size: () => kept.length };
83
+ }
@@ -84,6 +84,8 @@ export function createWorkerWebSocketLink({
84
84
  let readyRefreshTimer = null;
85
85
  let reconnectAttempt = 0;
86
86
  let reconnectDelayMs = 0;
87
+ // Ends the current socket's connection (see connect).
88
+ let endCurrentSocket = null;
87
89
  // Insertion-ordered: the first entry is the oldest in-flight delivery, which
88
90
  // is what a legacy worker's coalesced delivery waits on.
89
91
  const deliveriesInFlight = new Set();
@@ -246,7 +248,11 @@ export function createWorkerWebSocketLink({
246
248
  function closeStaleSocket(reason) {
247
249
  if (!isSocketOpen()) return false;
248
250
  dbg("worker ws stale connection closing", reason);
249
- try { ws?.close?.(); } catch {}
251
+ const socket = ws;
252
+ // A dead peer may never complete the close handshake: the reconnect does
253
+ // not wait for the close event.
254
+ endCurrentSocket?.(`stale:${reason}`);
255
+ try { socket?.close?.(); } catch {}
250
256
  return true;
251
257
  }
252
258
 
@@ -286,7 +292,9 @@ export function createWorkerWebSocketLink({
286
292
 
287
293
  function scheduleReconnect() {
288
294
  if (stopped) return;
289
- clearReconnectTimer();
295
+ // One timer at a time: a second end reported for the same outage must not
296
+ // stack another attempt or push the backoff up.
297
+ if (reconnectTimer) return;
290
298
  reconnectAttempt += 1;
291
299
  const backoff = Math.min(maxBackoffMs, minBackoffMs * (2 ** Math.max(0, reconnectAttempt - 1)));
292
300
  const jitter = Math.floor(Math.random() * Math.max(0, jitterMs));
@@ -309,10 +317,34 @@ export function createWorkerWebSocketLink({
309
317
  dbg("worker ws unavailable: global WebSocket is missing");
310
318
  return;
311
319
  }
320
+ clearReconnectTimer();
312
321
  const url = toWebSocketUrl(serverUrl, token, getSessionId, getPid);
313
- ws = new WebSocketImpl(url);
322
+ const socket = new WebSocketImpl(url);
323
+ ws = socket;
314
324
  let serverHelloSeen = false;
315
- ws.addEventListener("open", () => {
325
+ // Set once this socket's end has been handled, by whichever of error and
326
+ // close comes first; every later event from it is ignored. Node 22 fires
327
+ // no close for a socket closed while still connecting, and closing it
328
+ // from its own error handler raised the error again, so the reconnect
329
+ // has to be scheduled from the error as well, and only once.
330
+ let ended = false;
331
+ function endSocket(reason) {
332
+ if (ended) return false;
333
+ ended = true;
334
+ if (ws === socket) {
335
+ ws = null;
336
+ clearReadyRefreshTimer();
337
+ // The next connection may reach a different (restarted) relay.
338
+ serverCapabilities = new Set();
339
+ }
340
+ if (stopped) return true;
341
+ dbg("worker ws disconnected", reason);
342
+ scheduleReconnect();
343
+ return true;
344
+ }
345
+ endCurrentSocket = endSocket;
346
+ socket.addEventListener("open", () => {
347
+ if (ended) return;
316
348
  const openedAt = getNowMs();
317
349
  reconnectAttempt = 0;
318
350
  reconnectDelayMs = 0;
@@ -323,7 +355,8 @@ export function createWorkerWebSocketLink({
323
355
  void notifyReady("ws-open");
324
356
  startReadyRefreshTimer();
325
357
  });
326
- ws.addEventListener("message", (event) => {
358
+ socket.addEventListener("message", (event) => {
359
+ if (ended) return;
327
360
  lastMessageAt = getNowMs();
328
361
  let payload = null;
329
362
  try {
@@ -355,7 +388,7 @@ export function createWorkerWebSocketLink({
355
388
  }
356
389
  if (payload?.type === "server.draining") {
357
390
  dbg("worker ws draining", payload.reason || "relay-shutdown");
358
- try { ws?.close?.(); } catch {}
391
+ try { socket.close?.(); } catch {}
359
392
  return;
360
393
  }
361
394
  if (payload?.type === "queue.changed") {
@@ -375,17 +408,16 @@ export function createWorkerWebSocketLink({
375
408
  dbg("worker ws blocked", payload.reason || "blocked");
376
409
  }
377
410
  });
378
- ws.addEventListener("close", () => {
379
- clearReadyRefreshTimer();
380
- // The next connection may reach a different (restarted) relay.
381
- serverCapabilities = new Set();
382
- if (stopped) return;
383
- dbg("worker ws disconnected");
384
- scheduleReconnect();
411
+ socket.addEventListener("close", () => {
412
+ endSocket("close");
385
413
  });
386
- ws.addEventListener("error", (error) => {
414
+ socket.addEventListener("error", (error) => {
415
+ if (ended) return;
387
416
  dbg("worker ws error", error?.message || String(error));
388
- try { ws?.close?.(); } catch {}
417
+ // Ended before the close call, so an error or close it raises
418
+ // (synchronously on some runtimes) finds the socket already handled.
419
+ endSocket("error");
420
+ try { socket.close?.(); } catch {}
389
421
  });
390
422
  }
391
423