@naswerks/periscope 1.0.0 → 1.0.1

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