@naswerks/periscope 1.0.0

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 (230) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +146 -0
  4. package/SECURITY.md +315 -0
  5. package/dist/bin/command.d.ts +97 -0
  6. package/dist/bin/command.js +141 -0
  7. package/dist/bin/config.d.ts +7 -0
  8. package/dist/bin/config.js +90 -0
  9. package/dist/bin/login.d.ts +48 -0
  10. package/dist/bin/login.js +82 -0
  11. package/dist/bin/main.d.ts +33 -0
  12. package/dist/bin/main.js +173 -0
  13. package/dist/bin/pair.d.ts +28 -0
  14. package/dist/bin/pair.js +198 -0
  15. package/dist/bin/periscope.d.ts +3 -0
  16. package/dist/bin/periscope.js +4 -0
  17. package/dist/bin/posture.d.ts +64 -0
  18. package/dist/bin/posture.js +157 -0
  19. package/dist/bin/reconfigure.d.ts +64 -0
  20. package/dist/bin/reconfigure.js +195 -0
  21. package/dist/bin/serve.d.ts +103 -0
  22. package/dist/bin/serve.js +441 -0
  23. package/dist/bin/status.d.ts +11 -0
  24. package/dist/bin/status.js +49 -0
  25. package/dist/bin/workspaces.d.ts +109 -0
  26. package/dist/bin/workspaces.js +215 -0
  27. package/dist/control/backoff.d.ts +25 -0
  28. package/dist/control/backoff.js +20 -0
  29. package/dist/control/codec.d.ts +15 -0
  30. package/dist/control/codec.js +501 -0
  31. package/dist/control/credential.d.ts +23 -0
  32. package/dist/control/credential.js +8 -0
  33. package/dist/control/frames.d.ts +1180 -0
  34. package/dist/control/frames.js +441 -0
  35. package/dist/control/link-state.d.ts +32 -0
  36. package/dist/control/link-state.js +39 -0
  37. package/dist/control/link.d.ts +109 -0
  38. package/dist/control/link.js +736 -0
  39. package/dist/control/queue.d.ts +110 -0
  40. package/dist/control/queue.js +174 -0
  41. package/dist/control/seq.d.ts +49 -0
  42. package/dist/control/seq.js +52 -0
  43. package/dist/control/stream-routing.d.ts +200 -0
  44. package/dist/control/stream-routing.js +167 -0
  45. package/dist/control/stream.d.ts +43 -0
  46. package/dist/control/stream.js +64 -0
  47. package/dist/core/async-queue.d.ts +26 -0
  48. package/dist/core/async-queue.js +63 -0
  49. package/dist/core/failure.d.ts +22 -0
  50. package/dist/core/failure.js +53 -0
  51. package/dist/core/index.d.ts +18 -0
  52. package/dist/core/index.js +7 -0
  53. package/dist/core/keyed-turns.d.ts +28 -0
  54. package/dist/core/keyed-turns.js +44 -0
  55. package/dist/core/paths.d.ts +25 -0
  56. package/dist/core/paths.js +77 -0
  57. package/dist/core/refusal.d.ts +43 -0
  58. package/dist/core/refusal.js +399 -0
  59. package/dist/core/result.d.ts +22 -0
  60. package/dist/core/result.js +16 -0
  61. package/dist/core/time.d.ts +19 -0
  62. package/dist/core/time.js +25 -0
  63. package/dist/core/vocab.d.ts +23 -0
  64. package/dist/core/vocab.js +37 -0
  65. package/dist/core/workspace-id.d.ts +12 -0
  66. package/dist/core/workspace-id.js +12 -0
  67. package/dist/gate/command.d.ts +136 -0
  68. package/dist/gate/command.js +551 -0
  69. package/dist/gate/decision.d.ts +111 -0
  70. package/dist/gate/decision.js +97 -0
  71. package/dist/gate/escalate.d.ts +82 -0
  72. package/dist/gate/escalate.js +92 -0
  73. package/dist/gate/gate.d.ts +173 -0
  74. package/dist/gate/gate.js +417 -0
  75. package/dist/gate/index.d.ts +39 -0
  76. package/dist/gate/index.js +31 -0
  77. package/dist/gate/jail.d.ts +92 -0
  78. package/dist/gate/jail.js +131 -0
  79. package/dist/gate/local.d.ts +69 -0
  80. package/dist/gate/local.js +51 -0
  81. package/dist/gate/outcome.d.ts +72 -0
  82. package/dist/gate/outcome.js +91 -0
  83. package/dist/gate/shell.d.ts +36 -0
  84. package/dist/gate/shell.js +226 -0
  85. package/dist/host/agent-process.d.ts +452 -0
  86. package/dist/host/agent-process.js +382 -0
  87. package/dist/host/bulk-post.d.ts +44 -0
  88. package/dist/host/bulk-post.js +114 -0
  89. package/dist/host/claude-transcripts.d.ts +84 -0
  90. package/dist/host/claude-transcripts.js +339 -0
  91. package/dist/host/config-file.d.ts +60 -0
  92. package/dist/host/config-file.js +216 -0
  93. package/dist/host/git-facts.d.ts +7 -0
  94. package/dist/host/git-facts.js +106 -0
  95. package/dist/host/hooks.d.ts +56 -0
  96. package/dist/host/hooks.js +75 -0
  97. package/dist/host/host.d.ts +356 -0
  98. package/dist/host/host.js +1294 -0
  99. package/dist/host/index.d.ts +35 -0
  100. package/dist/host/index.js +22 -0
  101. package/dist/host/link-state-file.d.ts +18 -0
  102. package/dist/host/link-state-file.js +66 -0
  103. package/dist/host/loopback.d.ts +38 -0
  104. package/dist/host/loopback.js +122 -0
  105. package/dist/host/machine.d.ts +9 -0
  106. package/dist/host/machine.js +19 -0
  107. package/dist/host/mcp-server.d.ts +11 -0
  108. package/dist/host/mcp-server.js +48 -0
  109. package/dist/host/package-facts.d.ts +3 -0
  110. package/dist/host/package-facts.js +26 -0
  111. package/dist/host/paired-credential-store.d.ts +32 -0
  112. package/dist/host/paired-credential-store.js +112 -0
  113. package/dist/host/paths.d.ts +80 -0
  114. package/dist/host/paths.js +165 -0
  115. package/dist/host/repository-read.d.ts +33 -0
  116. package/dist/host/repository-read.js +201 -0
  117. package/dist/host/session-store.d.ts +53 -0
  118. package/dist/host/session-store.js +100 -0
  119. package/dist/host/sign-in.d.ts +90 -0
  120. package/dist/host/sign-in.js +239 -0
  121. package/dist/host/telemetry.d.ts +42 -0
  122. package/dist/host/telemetry.js +74 -0
  123. package/dist/host/token-cache.d.ts +62 -0
  124. package/dist/host/token-cache.js +185 -0
  125. package/dist/host/transcript-fs.d.ts +4 -0
  126. package/dist/host/transcript-fs.js +104 -0
  127. package/dist/host/wire-request.d.ts +80 -0
  128. package/dist/host/wire-request.js +196 -0
  129. package/dist/host/workspace-fs.d.ts +14 -0
  130. package/dist/host/workspace-fs.js +84 -0
  131. package/dist/host/workspace-trust.d.ts +23 -0
  132. package/dist/host/workspace-trust.js +80 -0
  133. package/dist/identity/authorize.d.ts +59 -0
  134. package/dist/identity/authorize.js +72 -0
  135. package/dist/identity/config.d.ts +98 -0
  136. package/dist/identity/config.js +130 -0
  137. package/dist/identity/credential.d.ts +91 -0
  138. package/dist/identity/credential.js +76 -0
  139. package/dist/identity/device-code.d.ts +115 -0
  140. package/dist/identity/device-code.js +134 -0
  141. package/dist/identity/index.d.ts +33 -0
  142. package/dist/identity/index.js +10 -0
  143. package/dist/identity/mode.d.ts +106 -0
  144. package/dist/identity/mode.js +69 -0
  145. package/dist/identity/paired-credential.d.ts +52 -0
  146. package/dist/identity/paired-credential.js +43 -0
  147. package/dist/identity/pkce.d.ts +50 -0
  148. package/dist/identity/pkce.js +94 -0
  149. package/dist/identity/store.d.ts +43 -0
  150. package/dist/identity/store.js +43 -0
  151. package/dist/identity/token.d.ts +58 -0
  152. package/dist/identity/token.js +149 -0
  153. package/dist/index.d.ts +45 -0
  154. package/dist/index.js +53 -0
  155. package/dist/mcp/descriptor.d.ts +59 -0
  156. package/dist/mcp/descriptor.js +14 -0
  157. package/dist/mcp/index.d.ts +14 -0
  158. package/dist/mcp/index.js +4 -0
  159. package/dist/mcp/schema.d.ts +37 -0
  160. package/dist/mcp/schema.js +175 -0
  161. package/dist/mcp/server.d.ts +107 -0
  162. package/dist/mcp/server.js +66 -0
  163. package/dist/persistence/entry.d.ts +74 -0
  164. package/dist/persistence/entry.js +105 -0
  165. package/dist/persistence/index.d.ts +22 -0
  166. package/dist/persistence/index.js +8 -0
  167. package/dist/persistence/key.d.ts +46 -0
  168. package/dist/persistence/key.js +33 -0
  169. package/dist/persistence/mirror.d.ts +71 -0
  170. package/dist/persistence/mirror.js +57 -0
  171. package/dist/persistence/receipt.d.ts +111 -0
  172. package/dist/persistence/receipt.js +85 -0
  173. package/dist/persistence/retention.d.ts +68 -0
  174. package/dist/persistence/retention.js +68 -0
  175. package/dist/persistence/store.d.ts +106 -0
  176. package/dist/persistence/store.js +86 -0
  177. package/dist/persistence/transition-log.d.ts +73 -0
  178. package/dist/persistence/transition-log.js +133 -0
  179. package/dist/protocol.d.ts +27 -0
  180. package/dist/protocol.js +12 -0
  181. package/dist/sessions/index.d.ts +8 -0
  182. package/dist/sessions/index.js +4 -0
  183. package/dist/sessions/registry.d.ts +186 -0
  184. package/dist/sessions/registry.js +190 -0
  185. package/dist/sessions/session.d.ts +178 -0
  186. package/dist/sessions/session.js +288 -0
  187. package/dist/sessions/spawn-env.d.ts +88 -0
  188. package/dist/sessions/spawn-env.js +277 -0
  189. package/dist/state/coverage.d.ts +308 -0
  190. package/dist/state/coverage.js +315 -0
  191. package/dist/state/index.d.ts +12 -0
  192. package/dist/state/index.js +7 -0
  193. package/dist/state/machine.d.ts +112 -0
  194. package/dist/state/machine.js +237 -0
  195. package/dist/state/model.d.ts +233 -0
  196. package/dist/state/model.js +214 -0
  197. package/dist/state/observer.d.ts +55 -0
  198. package/dist/state/observer.js +413 -0
  199. package/dist/state/reporter.d.ts +45 -0
  200. package/dist/state/reporter.js +35 -0
  201. package/dist/state/store.d.ts +56 -0
  202. package/dist/state/store.js +120 -0
  203. package/dist/telemetry/index.d.ts +11 -0
  204. package/dist/telemetry/index.js +2 -0
  205. package/dist/telemetry/usage.d.ts +78 -0
  206. package/dist/telemetry/usage.js +69 -0
  207. package/dist/workspace/git-worktree.d.ts +150 -0
  208. package/dist/workspace/git-worktree.js +417 -0
  209. package/dist/workspace/index.d.ts +9 -0
  210. package/dist/workspace/index.js +4 -0
  211. package/dist/workspace/plain-dir.d.ts +34 -0
  212. package/dist/workspace/plain-dir.js +90 -0
  213. package/dist/workspace/provider.d.ts +152 -0
  214. package/dist/workspace/provider.js +2 -0
  215. package/dist/workspace/worktree-porcelain.d.ts +29 -0
  216. package/dist/workspace/worktree-porcelain.js +100 -0
  217. package/docs/architecture.md +277 -0
  218. package/docs/configuration.md +187 -0
  219. package/docs/gate.md +219 -0
  220. package/docs/identity.md +107 -0
  221. package/docs/protocol.md +501 -0
  222. package/docs/state-machine.md +160 -0
  223. package/examples/README.md +141 -0
  224. package/examples/minimal-controller/controller.ts +38 -0
  225. package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
  226. package/examples/parallel-run-proof/run.ts +850 -0
  227. package/examples/test-controller/controller.ts +655 -0
  228. package/examples/test-controller/serve.ts +29 -0
  229. package/examples/tsconfig.json +30 -0
  230. package/package.json +98 -0
@@ -0,0 +1,736 @@
1
+ /**
2
+ * The outbound link: one WebSocket the host dials OUT to the controller.
3
+ *
4
+ * Outbound is the defining constraint — there is no listening port anywhere in this package, which
5
+ * is what lets a host run behind a firewall that would never allow an inbound one. It also means
6
+ * this file has no routing surface and therefore no route that could be added later without an
7
+ * auth check in front of it.
8
+ *
9
+ * A raw WebSocket pins the host to one controller instance. Accepted for this version, stated here
10
+ * rather than discovered in production.
11
+ */
12
+ import { WebSocket } from 'ws';
13
+ import { systemClock, systemTicker } from '../core/time.js';
14
+ import { certificateRemedy, describeFailure, isCertificateRefusal } from '../core/failure.js';
15
+ import { needsHumanReauthentication, refusal } from '../core/refusal.js';
16
+ import { ok } from '../core/result.js';
17
+ import { UnconfiguredCredential } from './credential.js';
18
+ import { PROTOCOL_VERSION, PROTOCOL_VERSION_MIN, unsetHostConfiguration, wireRefusalUpdate, } from './frames.js';
19
+ import { decode, encode } from './codec.js';
20
+ import { DEFAULT_BACKOFF, nextDelayMs } from './backoff.js';
21
+ import { BoundedFrameQueue } from './queue.js';
22
+ import { SeqTracker } from './seq.js';
23
+ import { LinkStateMachine } from './link-state.js';
24
+ const DEFAULTS = {
25
+ queueCapacity: 1000,
26
+ heartbeatIntervalMs: 15_000,
27
+ heartbeatTimeoutMs: 45_000,
28
+ highWaterMarkBytes: 1024 * 1024,
29
+ connectTimeoutMs: 15_000,
30
+ };
31
+ /**
32
+ * How soon a stalled drain retries while frames wait on a backed-up socket. Short, because a delta
33
+ * held past the buffer's recovery is latency for nothing; unref'd and armed only while something
34
+ * is actually pending, so an idle link holds no handle and spins on nothing.
35
+ */
36
+ const DRAIN_RETRY_MS = 25;
37
+ /**
38
+ * How long an ended session's written-but-unacked frames are held before they are released.
39
+ *
40
+ * This bounds a hold, and the hold itself is right. A session's last frames are its most important
41
+ * and are exactly the ones in flight if the link is down when it ends, so `forgetSession`
42
+ * deliberately does not discard them; see its own note. What needs a bound is the other side of
43
+ * that decision: nothing in the protocol obliges a controller to ack, and one that never does would
44
+ * leave every ended session's frames retained forever. Written frames are never eviction
45
+ * candidates, so the queue would fill with the dead and eventually refuse live traffic
46
+ * (`queue-overflow-undroppable`), while the outbound counter and the ended-session set grow without
47
+ * limit. A host built to run many sessions would die of the ones that finished.
48
+ *
49
+ * The trade, stated so the next reader can see it was chosen. A link down longer than this loses
50
+ * that session's final frames: they are released and replay can no longer produce them. That is a
51
+ * real cost and it is accepted deliberately: an unbounded hold trades a certainty (this host dies)
52
+ * for a possibility (a controller offline past the bound misses a few closing transitions), and the
53
+ * bound is the cheaper side. Sixty seconds sits past the 45 s heartbeat timeout on purpose, so a
54
+ * link that is merely slow is never punished; by the time this fires, the link has already been
55
+ * declared dead once.
56
+ *
57
+ * Swept on the existing heartbeat tick, never on a timer of its own. Guarantees armed on
58
+ * `unref()`d timers can be dropped when the event loop drains, and the cheapest way to avoid that
59
+ * is to add no timer at all: the heartbeat is already running whenever a socket is open, and a link
60
+ * with no socket cannot be acked anyway.
61
+ */
62
+ const ENDED_RETENTION_MS = 60_000;
63
+ export class ControllerLink {
64
+ #options;
65
+ #handlers;
66
+ #credential;
67
+ #clock;
68
+ #ticker;
69
+ #random;
70
+ #backoff;
71
+ #machine = new LinkStateMachine();
72
+ #queue;
73
+ /** Numbers this side hands out. */
74
+ #outbound = new SeqTracker();
75
+ /** Numbers this side judges. */
76
+ #inbound = new SeqTracker();
77
+ /** Ended sessions whose counter is held only until their last retained frame is acked. */
78
+ #forgotten = new Set();
79
+ /** When each of those ended, so the hold can be bounded. See `ENDED_RETENTION_MS`. */
80
+ #forgottenAtMs = new Map();
81
+ /**
82
+ * Sessions whose inbound lane is wedged and has already been reported.
83
+ *
84
+ * One emission per wedge, not per gapped frame: once `last` stops advancing every later frame on
85
+ * that session is also a gap, so reporting each one would turn a single lost frame into a frame
86
+ * storm aimed at the peer that is already in trouble. Cleared the moment a frame is accepted,
87
+ * which is what makes a healed lane reportable again.
88
+ */
89
+ #wedged = new Set();
90
+ /** What the next hello declares. Replaced by `announce` when the host reconfigures itself. */
91
+ #capabilities;
92
+ #configuration;
93
+ #pendingRestart;
94
+ #socket = null;
95
+ #attempt = 0;
96
+ /** The version the controller chose at the last accepted handshake; null before one. */
97
+ #negotiatedVersion = null;
98
+ #stopping = false;
99
+ #retryTimer = null;
100
+ #connectTimer = null;
101
+ #heartbeatTimer = null;
102
+ #drainTimer = null;
103
+ #lastPongAtMs = 0;
104
+ constructor(options) {
105
+ this.#options = options;
106
+ this.#handlers = options.handlers;
107
+ this.#credential = options.credential ?? new UnconfiguredCredential();
108
+ this.#clock = options.clock ?? systemClock;
109
+ this.#ticker = options.ticker ?? systemTicker;
110
+ this.#random = options.random ?? Math.random;
111
+ this.#backoff = options.backoff ?? DEFAULT_BACKOFF;
112
+ this.#queue = new BoundedFrameQueue(options.queueCapacity ?? DEFAULTS.queueCapacity);
113
+ this.#capabilities = options.capabilities ?? [];
114
+ this.#configuration = options.configuration ?? unsetHostConfiguration();
115
+ this.#pendingRestart = options.pendingRestart ?? [];
116
+ }
117
+ /**
118
+ * Replace what the next `link_hello` declares. A host that reconfigured itself dials the same
119
+ * controller with the same identity; only the description moves, and it moves at the next
120
+ * hello rather than on a frame of its own, because the hello is where a controller reads it.
121
+ */
122
+ announce(capabilities, configuration, pendingRestart = []) {
123
+ this.#capabilities = capabilities;
124
+ this.#configuration = configuration;
125
+ this.#pendingRestart = pendingRestart;
126
+ }
127
+ get state() {
128
+ return this.#machine.state;
129
+ }
130
+ /** The version the controller chose at the last accepted handshake; null before one. */
131
+ get negotiatedVersion() {
132
+ return this.#negotiatedVersion;
133
+ }
134
+ get queueStats() {
135
+ return this.#queue.stats;
136
+ }
137
+ /** Cursors of what this side has received: sent to the controller so it replays the right frames. */
138
+ cursors() {
139
+ return this.#inbound.cursors();
140
+ }
141
+ start() {
142
+ this.#stopping = false;
143
+ this.#transition('connecting', 'start_requested');
144
+ this.#openSocket();
145
+ }
146
+ /** Graceful close. Idempotent, and clears every timer: a stopped link holds no handles. */
147
+ stop(detail = 'stop requested') {
148
+ this.#stopping = true;
149
+ this.#clearTimers();
150
+ const socket = this.#socket;
151
+ this.#socket = null;
152
+ if (socket !== null && socket.readyState === WebSocket.OPEN) {
153
+ // Best effort; the socket is going away either way.
154
+ const encoded = encode(this.#controlFrame({ kind: 'link_bye', cause: detail }));
155
+ if (encoded.ok) {
156
+ try {
157
+ socket.send(encoded.value);
158
+ }
159
+ catch {
160
+ // A send failure on the way out changes nothing.
161
+ }
162
+ }
163
+ }
164
+ socket?.close();
165
+ this.#transition('closed', 'shutdown_requested', detail);
166
+ }
167
+ /**
168
+ * Accept a payload for delivery: retained until acked, written as soon as the link allows.
169
+ *
170
+ * A `seq` is minted only when the frame is first written to the wire, so nothing refused or
171
+ * discarded before that point ever had one — the wire's numbering stays dense whatever happens
172
+ * here. `ok` means accepted and retained; a refusal names why nothing was accepted, and it is
173
+ * the caller's pressure signal — a dropped delta and a refused transition are different events
174
+ * and only the caller knows which one it can live with.
175
+ */
176
+ send(sessionId, payload) {
177
+ const at = this.#clock();
178
+ // Admission gate: if this payload cannot encode inside the frame limit with the widest seq a
179
+ // frame can ever carry, no real seq can save it later — refuse now, while the caller is still
180
+ // here to be told. Admission therefore guarantees the eventual write cannot fail to encode.
181
+ //
182
+ // The probe is conservative by at most 15 bytes of 65,536, and that is the honest direction.
183
+ // `Number.MAX_SAFE_INTEGER` serializes to 16 digits while a real seq is 1 to 16, so a payload
184
+ // sitting within 15 bytes of the limit is refused although its actual frame would have fitted.
185
+ // The refusal is truthful (it names the size and the bulk lane) and the alternative is not:
186
+ // admitting on the real seq would let a frame pass admission and then fail to encode at write
187
+ // time, with the caller long gone and a retained entry that can never be written. A rejection
188
+ // someone can act on beats a hole nobody can see.
189
+ const probe = encode({ frame: 'session', sessionId, seq: Number.MAX_SAFE_INTEGER, at, payload });
190
+ if (!probe.ok) {
191
+ this.#handlers.onRefusal(probe.refusal);
192
+ return { ok: false, refusal: probe.refusal };
193
+ }
194
+ // Retained before any write attempt, and kept after a successful one. A frame that has been
195
+ // written is not yet safe to forget: if the socket dies before the controller processes it,
196
+ // that frame is precisely the one replay has to produce. Only an ack releases it.
197
+ const held = this.#queue.push(sessionId, at, payload);
198
+ if (!held.ok) {
199
+ this.#handlers.onRefusal(held.refusal);
200
+ return { ok: false, refusal: held.refusal };
201
+ }
202
+ if (held.value.evicted !== null) {
203
+ // The discarded entry's producer was told `ok` when it was accepted, so this is the only
204
+ // place its loss can be named. It had no seq, so the wire never misses it.
205
+ this.#handlers.onRefusal(refusal('queue-dropped-droppable', `discarded a pending ${held.value.evicted.kind} for session ` +
206
+ `${held.value.evicted.sessionId} to hold a ${payload.kind}`));
207
+ }
208
+ this.#drain();
209
+ return ok(undefined);
210
+ }
211
+ /**
212
+ * Release what this session holds. Called at session end, always.
213
+ *
214
+ * It does not discard frames the controller has not acked; see `BoundedFrameQueue.forget`.
215
+ * A session's last frames are its most important ones and are exactly the ones still unacked if
216
+ * the link is down when it ends. What is released here is everything that cannot still be owed:
217
+ * pending entries, which never had a seq, and the inbound cursor, which is about what the
218
+ * controller sent this side rather than what this side owes it.
219
+ *
220
+ * The outbound counter outlives the call while anything is still retained, and that is not an
221
+ * oversight. Resetting it while stamped frames for the same key are awaiting replay would let a
222
+ * later frame on that key be numbered below one already on the wire: a seq regression, which the
223
+ * receiver reads as a hole it can never fill. The counter is released by the ack that empties the
224
+ * retention instead, so the bound this method exists to keep is kept a moment later rather than
225
+ * abandoned.
226
+ */
227
+ forgetSession(sessionId) {
228
+ this.#inbound.forget(sessionId);
229
+ this.#queue.forget(sessionId);
230
+ this.#wedged.delete(sessionId);
231
+ this.#forgotten.add(sessionId);
232
+ // Stamped so the hold can be bounded. See `ENDED_RETENTION_MS` for what is being traded.
233
+ this.#forgottenAtMs.set(sessionId, this.#ticker());
234
+ this.#releaseIfDrained(sessionId);
235
+ }
236
+ // -------------------------------------------------------------------------
237
+ #writeFrame(socket, frame) {
238
+ const encoded = encode(frame);
239
+ if (!encoded.ok) {
240
+ this.#handlers.onRefusal(encoded.refusal);
241
+ return { ok: false, refusal: encoded.refusal };
242
+ }
243
+ try {
244
+ socket.send(encoded.value);
245
+ return ok(frame);
246
+ }
247
+ catch (error) {
248
+ const refused = refusal('link-send-failed', describe(error));
249
+ this.#handlers.onRefusal(refused);
250
+ return { ok: false, refusal: refused };
251
+ }
252
+ }
253
+ /**
254
+ * Write pending frames in arrival order while the socket is open and under the high-water mark.
255
+ *
256
+ * Every frame passes through the queue, so a new send can never overtake a waiting one — delivery
257
+ * order is arrival order, which is what keeps the receiver's arithmetic a straight `last + 1`.
258
+ * Above the mark the link stops writing and queues; the bounded queue and its refusals are what
259
+ * slow a producer down, and the retry timer resumes the flow the moment the buffer empties —
260
+ * a reconnect is not part of this path. A frame is stamped as it is written and never before,
261
+ * and a stamped frame is retained until acked, so a write that fails mid-drain is replay's
262
+ * problem, not a hole.
263
+ */
264
+ #drain() {
265
+ const socket = this.#socket;
266
+ if (socket !== null && socket.readyState === WebSocket.OPEN) {
267
+ const highWater = this.#options.highWaterMarkBytes ?? DEFAULTS.highWaterMarkBytes;
268
+ while (socket.bufferedAmount < highWater) {
269
+ const frame = this.#queue.stampNext((sessionId, at, payload) => ({
270
+ frame: 'session',
271
+ sessionId,
272
+ seq: this.#outbound.next(sessionId),
273
+ at,
274
+ payload,
275
+ }));
276
+ if (frame === null)
277
+ break;
278
+ const written = this.#writeFrame(socket, frame);
279
+ if (!written.ok)
280
+ break; // the socket is dying; the frame is retained and replay covers it
281
+ }
282
+ }
283
+ this.#armDrainTimer();
284
+ }
285
+ #armDrainTimer() {
286
+ const socket = this.#socket;
287
+ const useful = !this.#stopping && this.#queue.hasPending && socket !== null && socket.readyState === WebSocket.OPEN;
288
+ if (!useful) {
289
+ // Nothing to poll for: an empty queue drains on the next send, a dead socket drains on the
290
+ // reconnect's replay. Holding a timer here would be a handle that can only spin.
291
+ this.#clearDrainTimer();
292
+ return;
293
+ }
294
+ if (this.#drainTimer !== null)
295
+ return; // one at a time
296
+ this.#drainTimer = setTimeout(() => {
297
+ this.#drainTimer = null;
298
+ this.#drain();
299
+ }, DRAIN_RETRY_MS);
300
+ this.#drainTimer.unref?.();
301
+ }
302
+ #openSocket() {
303
+ // The credential is resolved before the socket is opened, so an unauthorized link never
304
+ // reaches a state where it could carry a frame. It is the only async step in the connect path
305
+ // and its rejection cannot escape: every branch below ends in a transition.
306
+ //
307
+ // The precondition, the same one `EscalationOptions.credential` states, and it is easier to
308
+ // misread here because this side is fail-open. Connecting with no headers is not the same as
309
+ // connecting successfully: what this package can present is a delegated user token, a paired
310
+ // credential, or nothing (there is no client-credentials grant in `identity/`), so a controller
311
+ // that admits only a machine app role refuses the upgrade either way, and the retry loop below
312
+ // will keep dialling a door that cannot open for it. That reads in the transitions as an
313
+ // unreachable controller rather than as an identity this host was never able to hold.
314
+ void this.#credential
315
+ .authorize()
316
+ .then((authorized) => {
317
+ if (this.#stopping)
318
+ return;
319
+ const headers = {};
320
+ if (authorized.ok) {
321
+ headers[authorized.value.header] = authorized.value.value;
322
+ }
323
+ else {
324
+ // The refusal is surfaced before anything is decided about it. Dropping it here (no
325
+ // handler, no transition, no counter) would leave the one fact an operator could act on
326
+ // as the one thing that never left this function.
327
+ this.#handlers.onRefusal(authorized.refusal);
328
+ if (needsHumanReauthentication(authorized.refusal.reason)) {
329
+ // And this one does not dial. Connecting headerless against a controller that has
330
+ // already refused the identity is not a degraded connection, it is a retry loop against
331
+ // a door that structurally cannot open, which reads in the transitions as an
332
+ // unreachable controller rather than as an identity this host is no longer able to hold.
333
+ this.#failCredential(authorized.refusal);
334
+ return;
335
+ }
336
+ // Every other refusal still dials, deliberately: a host with no identity configured is
337
+ // supposed to connect without a header rather than pretend to have a scheme.
338
+ }
339
+ this.#attachSocket(new WebSocket(this.#options.url, { headers }));
340
+ })
341
+ .catch((error) => {
342
+ this.#handlers.onRefusal(refusal('credential-unavailable', describe(error)));
343
+ this.#scheduleRetry('credential_unavailable', describe(error));
344
+ });
345
+ }
346
+ #attachSocket(socket) {
347
+ this.#socket = socket;
348
+ // The dial's own clock: a socket that has not opened by the deadline is abandoned and the retry
349
+ // ladder takes over, never a `connecting` that outlives the controller's whole restart.
350
+ const connectTimeoutMs = this.#options.connectTimeoutMs ?? DEFAULTS.connectTimeoutMs;
351
+ this.#clearConnectTimer();
352
+ this.#connectTimer = setTimeout(() => {
353
+ this.#connectTimer = null;
354
+ if (this.#socket !== socket || socket.readyState === WebSocket.OPEN)
355
+ return;
356
+ socket.once('error', () => { });
357
+ socket.terminate();
358
+ this.#scheduleRetry('connect_timeout', `no open within ${connectTimeoutMs}ms`);
359
+ }, connectTimeoutMs);
360
+ this.#connectTimer.unref?.();
361
+ socket.on('open', () => {
362
+ this.#clearConnectTimer();
363
+ // `#attempt` is not reset here. A socket that opens has proven TCP and TLS work; it has
364
+ // proven nothing about whether the peer will accept this host at all. The one failure that
365
+ // survives a successful open is a protocol-version mismatch: the peer answers `link_welcome`
366
+ // with a version this host does not speak, the link retries, the socket opens again, and
367
+ // resetting the counter here would put every retry back at attempt 0. Attempt 0 has zero
368
+ // jitter span, so that would be a hard 500 ms reconnect loop, forever, against a controller
369
+ // that will never accept this version: a self-inflicted denial of service, and the single
370
+ // most likely first-contact failure the moment PROTOCOL_VERSION moves. The reset lives on the
371
+ // handshake's accepted branch, where acceptance is actually known.
372
+ this.#lastPongAtMs = this.#ticker();
373
+ this.#transition('open', 'socket_connected');
374
+ this.#sendControl({
375
+ kind: 'link_hello',
376
+ protocolVersion: PROTOCOL_VERSION,
377
+ protocolRange: { min: PROTOCOL_VERSION_MIN, max: PROTOCOL_VERSION },
378
+ hostId: this.#options.hostId,
379
+ // `bulk-post` is structural (this link can take a bulk_request) and never displaced by
380
+ // configuration; the configured markers (the workspace mode) ride beside it.
381
+ capabilities: ['bulk-post', ...this.#capabilities],
382
+ cursors: this.#inbound.cursors(),
383
+ configuration: this.#configuration,
384
+ pendingRestart: this.#pendingRestart,
385
+ });
386
+ this.#startHeartbeat();
387
+ });
388
+ socket.on('message', (data) => {
389
+ // Handler is sync throughout: there is no promise here to leave unhandled.
390
+ this.#onMessage(String(data));
391
+ });
392
+ // A refused upgrade is not a broken transport, and only this event can tell them apart.
393
+ // Without a listener here, `ws` folds every non-101 handshake into the generic error event as
394
+ // "Unexpected server response: NNN", so a controller refusing this host's identity would read
395
+ // as a socket fault and go round the backoff loop forever, dialling a door that refuses the
396
+ // same way every time. The status code is the discriminator: 401/403 is the peer judging who
397
+ // is asking (terminal, the same predicate the token layer's refusals use, one home), while any
398
+ // other status is the peer misbehaving or mid-deploy, which stays a retry.
399
+ socket.on('unexpected-response', (request, response) => {
400
+ const status = response.statusCode ?? 0;
401
+ request.destroy();
402
+ if (status === 401 || status === 403) {
403
+ const refused = refusal('link-unauthorized', `the controller refused this host's identity at the WebSocket upgrade (HTTP ${status}) — ` +
404
+ `the same identity will be refused on every redial, so sign in again`);
405
+ this.#handlers.onRefusal(refused);
406
+ if (needsHumanReauthentication(refused.reason)) {
407
+ this.#failCredential(refused);
408
+ return;
409
+ }
410
+ }
411
+ const detail = `unexpected server response: ${status}`;
412
+ this.#handlers.onRefusal(refusal('link-send-failed', detail));
413
+ this.#scheduleRetry('socket_error', detail);
414
+ });
415
+ socket.on('error', (error) => {
416
+ // A certificate the runtime refuses is named as such, with the remedy: retried like any other
417
+ // socket fault (a certificate can be rotated), but never reported as the controller being away.
418
+ const detail = isCertificateRefusal(error)
419
+ ? `${describeFailure(error)}. ${certificateRemedy('the controller')}`
420
+ : describe(error);
421
+ this.#handlers.onRefusal(refusal('link-send-failed', detail));
422
+ this.#scheduleRetry('socket_error', detail);
423
+ });
424
+ socket.on('close', (code, reason) => {
425
+ // A controller refuses a version window it cannot overlap by closing with 1002 (protocol
426
+ // error) and naming both windows in the reason; read it as the refusal it is, not as a drop.
427
+ if (code === 1002) {
428
+ const named = reason.length > 0
429
+ ? reason.toString('utf8')
430
+ : 'the controller closed the socket as a protocol error';
431
+ // A seq-gap close is a replay request: the controller names the position it holds and the
432
+ // next dial replays from it. Naming it as a version rejection sent six of these to the log
433
+ // in nine seconds while a session's turn-end frames were lost behind the backoff.
434
+ this.#scheduleRetry(named.startsWith('seq gap') ? 'replay_requested' : 'protocol_version_rejected', named);
435
+ return;
436
+ }
437
+ this.#scheduleRetry('socket_closed', 'socket closed');
438
+ });
439
+ }
440
+ #onMessage(raw) {
441
+ const decoded = decode(raw);
442
+ if (!decoded.ok) {
443
+ // A stranger's malformed or unknown frame does not take the link down.
444
+ this.#handlers.onRefusal(decoded.refusal);
445
+ return;
446
+ }
447
+ const frame = decoded.value;
448
+ if (frame.frame === 'control') {
449
+ this.#onControl(frame.payload);
450
+ return;
451
+ }
452
+ const check = this.#inbound.accept(frame.sessionId, frame.seq);
453
+ if (check.disposition === 'duplicate')
454
+ return; // replay after reconnect; expected
455
+ if (check.disposition === 'gap') {
456
+ this.#handlers.onGap(frame.sessionId, check.expected, check.received);
457
+ this.#reportWedge(frame.sessionId, check.expected, check.received);
458
+ return;
459
+ }
460
+ // The lane moved, so a wedge reported earlier is over and a later one is worth reporting again.
461
+ this.#wedged.delete(frame.sessionId);
462
+ this.#handlers.onSessionFrame(frame);
463
+ }
464
+ #onControl(payload) {
465
+ switch (payload.kind) {
466
+ case 'link_welcome': {
467
+ if (payload.protocolVersion < PROTOCOL_VERSION_MIN || payload.protocolVersion > PROTOCOL_VERSION) {
468
+ this.#scheduleRetry('protocol_version_rejected', `controller chose v${payload.protocolVersion}, this host speaks v${PROTOCOL_VERSION_MIN} to v${PROTOCOL_VERSION}`);
469
+ return;
470
+ }
471
+ this.#negotiatedVersion = payload.protocolVersion;
472
+ // Here, and only here. The peer answered with a version this host speaks, so the
473
+ // connection is established in the sense that matters; see the note on the `open` handler
474
+ // for the loop this placement prevents. Backoff grows across a rejected handshake and
475
+ // resets only when one succeeds.
476
+ this.#attempt = 0;
477
+ // The controller said what it already has; replay from there.
478
+ this.#replay(payload.cursors);
479
+ this.#transition('accepted', 'hello_completed', `protocol v${payload.protocolVersion}`);
480
+ return;
481
+ }
482
+ case 'link_ack':
483
+ this.#prune(payload.cursors);
484
+ return;
485
+ case 'link_ping':
486
+ this.#sendControl({ kind: 'link_pong', nonce: payload.nonce });
487
+ return;
488
+ case 'link_pong':
489
+ this.#lastPongAtMs = this.#ticker();
490
+ return;
491
+ case 'link_bye':
492
+ this.#scheduleRetry('socket_closed', `controller said bye: ${payload.cause}`);
493
+ return;
494
+ case 'link_hello':
495
+ // A controller dialling this host would be an inbound link; this package has none.
496
+ return;
497
+ default:
498
+ return;
499
+ }
500
+ }
501
+ /**
502
+ * Re-send what the controller is missing, then resume the pending flow.
503
+ *
504
+ * Every written frame still retained is by definition unconfirmed, so it all goes back out in
505
+ * order. Re-sending something the controller already had is harmless — its own seq filter drops
506
+ * it, while failing to re-send is a permanent hole. That asymmetry is what dense `seq` is for.
507
+ * Pending entries drain after, so their stamps land above every replayed number.
508
+ */
509
+ #replay(cursors) {
510
+ this.#prune(cursors);
511
+ const socket = this.#socket;
512
+ if (socket === null || socket.readyState !== WebSocket.OPEN)
513
+ return;
514
+ for (const frame of this.#queue.writtenFrames()) {
515
+ this.#writeFrame(socket, frame);
516
+ }
517
+ this.#drain();
518
+ }
519
+ #prune(cursors) {
520
+ for (const cursor of cursors) {
521
+ this.#queue.pruneUpTo(cursor.sessionId, cursor.seq);
522
+ this.#releaseIfDrained(cursor.sessionId);
523
+ }
524
+ }
525
+ /** An ended session's counter goes when its last retained frame does, and not before. */
526
+ #releaseIfDrained(sessionId) {
527
+ if (!this.#forgotten.has(sessionId))
528
+ return;
529
+ if (this.#queue.retainedFor(sessionId) > 0)
530
+ return;
531
+ this.#outbound.forget(sessionId);
532
+ this.#forgotten.delete(sessionId);
533
+ this.#forgottenAtMs.delete(sessionId);
534
+ }
535
+ /**
536
+ * Release ended sessions whose retention has outlived the bound. Never silent.
537
+ *
538
+ * The emission matters as much as the release. Dropping frames quietly is the shape this package
539
+ * refuses everywhere else: a controller that later notices a session's closing transitions never
540
+ * arrived would have nothing to read, and "the host discarded them after a minute" and "they were
541
+ * never sent" are indistinguishable from the outside. So the release is a named refusal carrying
542
+ * the session and the count, on the same lane every other refusal uses.
543
+ */
544
+ #sweepEndedRetention() {
545
+ const bound = this.#options.endedRetentionMs ?? ENDED_RETENTION_MS;
546
+ const now = this.#ticker();
547
+ for (const sessionId of [...this.#forgotten]) {
548
+ const endedAtMs = this.#forgottenAtMs.get(sessionId);
549
+ if (endedAtMs === undefined || now - endedAtMs < bound)
550
+ continue;
551
+ const dropped = this.#queue.releaseSession(sessionId);
552
+ this.#outbound.forget(sessionId);
553
+ this.#forgotten.delete(sessionId);
554
+ this.#forgottenAtMs.delete(sessionId);
555
+ if (dropped > 0) {
556
+ this.#handlers.onRefusal(refusal('retention-released-unacked', `released ${dropped} written frame(s) for ended session ${sessionId}: they went unacked ` +
557
+ `for ${bound}ms, and holding an ended session's frames forever fills this ` +
558
+ `host's queue with the dead. Those frames can no longer be replayed`));
559
+ }
560
+ }
561
+ }
562
+ /**
563
+ * Tell the peer its lane is wedged, once, with the number that heals it.
564
+ *
565
+ * This is the only signal that reaches the party who can fix it. `onGap` is host-local: it tells
566
+ * the embedder, and the embedder is not the one holding the frame that was lost. The sender is,
567
+ * and without this it is told nothing at all; the lane simply stops, forever, in silence.
568
+ *
569
+ * `expected` makes this a resync request rather than a complaint. It is the exact seq this side
570
+ * will accept next, so a peer that re-sends from there heals the lane with no new protocol, no
571
+ * NACK vocabulary and no negotiation. The wire already guarantees dense numbering; this hands back
572
+ * the one number that lets a sender restore it.
573
+ */
574
+ #reportWedge(sessionId, expected, received) {
575
+ if (this.#wedged.has(sessionId))
576
+ return;
577
+ this.#wedged.add(sessionId);
578
+ this.send(sessionId, wireRefusalUpdate({
579
+ reason: 'seq-gap',
580
+ detail: `this side expected seq ${expected} and received ${received}, so the frame was dropped ` +
581
+ `and this lane will refuse every later frame until it is re-sent from ${expected}. There ` +
582
+ `is no retransmit request in this protocol: re-send from ${expected} to heal it`,
583
+ }, expected, received));
584
+ }
585
+ /**
586
+ * Write a control frame immediately, without consulting the high-water mark. Deliberate.
587
+ *
588
+ * Control frames bypass backpressure by construction, and the reason is that they are how the
589
+ * link's own health is judged. A heartbeat queued behind a backed-up session lane does not arrive
590
+ * late — it arrives after the peer has already concluded the socket is dead, so a controller
591
+ * under load would declare a perfectly healthy host gone precisely when it is busiest. The same
592
+ * holds for `link_pong` (an unanswered ping IS the death signal) and `link_bye` (a deliberate
593
+ * close that queues is a silent one).
594
+ *
595
+ * What makes it safe rather than a hole: these frames are small, bounded in number, and not
596
+ * sequenced or replayed, so they cannot displace a session frame or leave a gap. Never route a
597
+ * session payload through here to skip the queue; that would break both the ordering and the
598
+ * dense-seq guarantee in one move.
599
+ */
600
+ #sendControl(payload) {
601
+ const socket = this.#socket;
602
+ if (socket === null || socket.readyState !== WebSocket.OPEN)
603
+ return;
604
+ const encoded = encode(this.#controlFrame(payload));
605
+ if (!encoded.ok) {
606
+ this.#handlers.onRefusal(encoded.refusal);
607
+ return;
608
+ }
609
+ try {
610
+ socket.send(encoded.value);
611
+ }
612
+ catch (error) {
613
+ this.#handlers.onRefusal(refusal('link-send-failed', describe(error)));
614
+ }
615
+ }
616
+ #controlFrame(payload) {
617
+ return { frame: 'control', at: this.#clock(), payload };
618
+ }
619
+ #startHeartbeat() {
620
+ this.#clearHeartbeat();
621
+ const interval = this.#options.heartbeatIntervalMs ?? DEFAULTS.heartbeatIntervalMs;
622
+ const timeout = this.#options.heartbeatTimeoutMs ?? DEFAULTS.heartbeatTimeoutMs;
623
+ this.#heartbeatTimer = setInterval(() => {
624
+ // Rides this tick rather than a timer of its own; see `ENDED_RETENTION_MS`. It runs before
625
+ // the liveness check so a link about to be declared dead still sweeps once on the way out.
626
+ this.#sweepEndedRetention();
627
+ // A half-open socket looks alive to TCP and reads as a hung session to a human. The only way
628
+ // to tell is to ask and require an answer.
629
+ if (this.#ticker() - this.#lastPongAtMs > timeout) {
630
+ this.#scheduleRetry('heartbeat_timeout', `no pong within ${timeout}ms`);
631
+ return;
632
+ }
633
+ this.#sendControl({ kind: 'link_ping', nonce: String(this.#ticker()) });
634
+ }, interval);
635
+ this.#heartbeatTimer.unref?.();
636
+ }
637
+ /**
638
+ * The one path out of this link that is not a retry and not a shutdown.
639
+ *
640
+ * It is separate from `#scheduleRetry` because every other failure this link meets is worth
641
+ * another attempt, so the retry is the default and that is right. This one is not: the identity
642
+ * provider has refused the material outright, and the next dial refuses identically. Sending it
643
+ * round the backoff loop produces a host that looks busy reconnecting, is accomplishing nothing,
644
+ * and whose process eventually drains and exits zero, so a supervisor and a dashboard both read a
645
+ * clean run.
646
+ *
647
+ * It sets `#stopping`, which is what makes the state honest rather than merely reported. Without
648
+ * it an in-flight socket callback could schedule a retry after this transition and the link would
649
+ * quietly resume dialling behind a `closed` it had already announced.
650
+ */
651
+ #failCredential(refused) {
652
+ this.#stopping = true;
653
+ this.#clearTimers();
654
+ const socket = this.#socket;
655
+ this.#socket = null;
656
+ socket?.removeAllListeners();
657
+ // A socket still mid-handshake aborts by emitting an error ("closed before the connection was
658
+ // established") — and the line above just removed every listener, so without this mute the
659
+ // abort becomes an unhandled throw inside the teardown. The error carries no information this
660
+ // method does not already have: it is the close it itself asked for.
661
+ socket?.once('error', () => { });
662
+ socket?.close();
663
+ this.#transition('closed', 'credential_rejected', refused.detail);
664
+ }
665
+ #scheduleRetry(cause, detail) {
666
+ if (this.#stopping)
667
+ return;
668
+ this.#clearHeartbeat();
669
+ this.#clearDrainTimer(); // nothing to poll without a socket; the reconnect's replay resumes it
670
+ const socket = this.#socket;
671
+ this.#socket = null;
672
+ socket?.removeAllListeners();
673
+ // Same mute as `#failCredential`, for the same reason: a refused upgrade arrives here with the
674
+ // handshake still open, and closing it emits an error at a socket that no longer has listeners.
675
+ socket?.once('error', () => { });
676
+ socket?.close();
677
+ this.#clearConnectTimer();
678
+ this.#transition('backoff', cause, detail);
679
+ if (this.#retryTimer !== null)
680
+ return; // one retry in flight at a time
681
+ const delay = nextDelayMs(this.#attempt, this.#random, this.#backoff);
682
+ this.#attempt += 1;
683
+ this.#transition('backoff', 'retry_scheduled', `retry in ${delay}ms`);
684
+ // This timer holds the process open, deliberately. With no sessions running it is often the
685
+ // only handle left; unref'd, a host in the middle of an ordinary outage would drain and exit
686
+ // 0, and a supervisor would read a clean finish out of a network blip. A retry that is
687
+ // scheduled is work this process has promised to do; the handle stays ref'd so the promise
688
+ // outlives an empty event loop. Only the retryable class ever gets here (a terminal refusal
689
+ // takes `#failCredential`, which clears this timer and never schedules another), and the
690
+ // heartbeat and drain timers stay unref'd deliberately: they exist to serve an open socket,
691
+ // not to keep a dead one's process alive.
692
+ this.#retryTimer = setTimeout(() => {
693
+ this.#retryTimer = null;
694
+ if (this.#stopping)
695
+ return;
696
+ this.#transition('connecting', 'start_requested');
697
+ this.#openSocket();
698
+ }, delay);
699
+ }
700
+ #clearConnectTimer() {
701
+ if (this.#connectTimer !== null) {
702
+ clearTimeout(this.#connectTimer);
703
+ this.#connectTimer = null;
704
+ }
705
+ }
706
+ #transition(to, cause, detail = null) {
707
+ const transition = this.#machine.to(to, cause, this.#clock(), detail);
708
+ if (transition !== null)
709
+ this.#handlers.onTransition(transition);
710
+ }
711
+ #clearHeartbeat() {
712
+ if (this.#heartbeatTimer !== null) {
713
+ clearInterval(this.#heartbeatTimer);
714
+ this.#heartbeatTimer = null;
715
+ }
716
+ }
717
+ #clearDrainTimer() {
718
+ if (this.#drainTimer !== null) {
719
+ clearTimeout(this.#drainTimer);
720
+ this.#drainTimer = null;
721
+ }
722
+ }
723
+ #clearTimers() {
724
+ this.#clearHeartbeat();
725
+ this.#clearDrainTimer();
726
+ if (this.#retryTimer !== null) {
727
+ clearTimeout(this.#retryTimer);
728
+ this.#clearConnectTimer();
729
+ this.#retryTimer = null;
730
+ }
731
+ }
732
+ }
733
+ function describe(error) {
734
+ return error instanceof Error ? error.message : String(error);
735
+ }
736
+ //# sourceMappingURL=link.js.map