@cplieger/web-terminal-engine 5.3.0 → 6.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.
package/src/connection.ts CHANGED
@@ -1,28 +1,11 @@
1
- // WebSocket lifecycle with reliable input delivery across reconnects.
2
- //
3
- // This is the client → server half of the terminal protocol (the
4
- // server → client half is the binary screen/scroll/modes wire format
5
- // decoded by wire-binary.ts). It owns the socket, the reconnect
6
- // backoff, and the resume/inputAck reliability layer.
7
- //
8
- // Protocol (resume / inputAck):
9
- // - Client maintains a resume key for the page lifetime, an `outbox` of
10
- // input bytes sent but not yet acknowledged, and a `bytesSent` counter.
11
- // Unmanaged mode: a per-tab UUID. Managed mode: `<serverSessionId>#<per-
12
- // client instance id>`, so every device/tab attached to one session owns
13
- // its OWN server-side input ledger (see resumeKey — shared ledgers acked
14
- // other devices' bytes to this one and corrupted its outbox).
15
- // - On WS open, client sends control: {type:"resume", sessionId, sentBytes}.
16
- // - Server replies with {type:"resumeAck", received:M}; subsequent
17
- // screen/scroll messages also carry inputAck = bytesReceived. Client
18
- // trims the outbox by acked count and retransmits the remainder.
19
- // - This handles the network-blip failure mode where ws.send() reports
20
- // success but TCP couldn't deliver before the connection broke.
21
- //
22
- // Outbox is bounded at MAX_OUTBOX_BYTES; once full, sendBinary refuses
23
- // new input and surfaces the failure via onOutboxFull. This prevents
24
- // holding-down a key during a long disconnect from growing the outbox
25
- // without bound.
1
+ // WebSocket lifecycle with reliable input delivery across reconnects: the
2
+ // client → server half of the protocol (wire-binary.ts decodes the other). The
3
+ // client keeps an `outbox` of input bytes sent but not yet acknowledged and a
4
+ // `bytesSent` counter; on open it sends {type:"resume", sessionId, sentBytes},
5
+ // the server answers {type:"resumeAck", received} and every later frame carries
6
+ // inputAck, and the client trims the outbox by the acked count and retransmits
7
+ // the rest, which covers a ws.send() that reported success before TCP failed.
8
+ // The outbox is bounded at MAX_OUTBOX_BYTES; sendBinary refuses beyond it.
26
9
 
27
10
  import { wsURL } from "./wsurl.js";
28
11
  import { WS_PATH } from "./routes.js";
@@ -34,72 +17,45 @@ import {
34
17
  WIRE_PROTOCOL_VERSION,
35
18
  type WireIncompatibility,
36
19
  } from "./wire-compatibility.js";
37
- import * as modes from "./modes.js";
38
- // ONE page size for the whole feature. The transport used to declare its own
39
- // identical literal, and nothing held the two equal: the fetch trigger anchors a
40
- // request from the STORE's value while the transport clamps the length to its
41
- // own, so tuning either one alone produces a shrunken page with a full-size
42
- // anchor — the shape that leaves the rows under the reader blank while the far
43
- // end of the gap fills.
20
+ import { type ModeSnapshot, type ModeState, POWER_ON_MODES } from "./modes.js";
21
+ // ONE page size for the whole feature: the fetch trigger anchors a request from
22
+ // the STORE's value while the transport clamps the length, so two literals would
23
+ // let a shrunken page carry a full-size anchor.
44
24
  import { PAGE_SIZE } from "./store.js";
45
- import * as render from "./render.js";
46
- import * as mouse from "./mouse.js";
25
+ import type { Renderer } from "./render.js";
26
+ import type { MouseController } from "./mouse.js";
47
27
  import type { ControlMessage, ScrollMessage, ServerMessage } from "./types.js";
48
28
  import { INITIAL_DELAY_MS, nextBackoffDelay } from "./reconnect.js";
49
29
 
50
- /**
51
- * First wire revision with typed client→server framing (text = control,
52
- * binary = full-alphabet input). A resumeAck whose serverWireVersion is at
53
- * least this triggers the per-socket upgrade (design §4 phase 3). Mirrors
54
- * typedFramingMinVersion in the Go terminal package.
55
- */
30
+ // First wire revision with typed client→server framing (text = control, binary
31
+ // = full-alphabet input); a resumeAck at or above it triggers the per-socket
32
+ // upgrade. Mirrors typedFramingMinVersion in the Go terminal package.
56
33
  const TYPED_FRAMING_MIN_VERSION = 4;
57
34
 
58
35
  type ConnState =
59
36
  | { status: "disconnected" }
60
37
  | { status: "connecting"; sock: WebSocket; abort: AbortController }
61
- /**
62
- * `upgraded` is the v4 typed-framing latch for THIS socket (per-socket by
63
- * construction: the state object is replaced on every transition, so it can
64
- * never leak across a reconnect). false = v3 mode: controls go as
65
- * 0x00-sentinel binary frames and input is leading-NUL-split. true = the
66
- * resumeAck proved a v4 server and the text `upgrade` transition was sent:
67
- * controls go as text frames and input is raw full-alphabet binary.
68
- */
38
+ // `upgraded` is the v4 typed-framing latch for THIS socket (the state object
39
+ // is replaced on every transition, so it cannot leak across a reconnect).
40
+ // false: controls are 0x00-sentinel binary frames and input is leading-NUL-
41
+ // split. true: controls are text frames and input is raw binary.
69
42
  | { status: "connected"; sock: WebSocket; abort: AbortController; upgraded: boolean }
70
43
  | { status: "reconnecting"; timer: ReturnType<typeof setTimeout>; delayMs: number }
71
44
  | { status: "incompatible" };
72
45
 
73
- // The server's application close code for "the session's child process has
74
- // exited" (terminal/terminal.go statusProcessExited). It marks a close as
75
- // definitive — the session cannot produce output again — as opposed to a
76
- // transient network drop that the backoff reconnect should heal. Private
77
- // application range (4000-4999) per RFC 6455.
46
+ // The server's close code for "the child process has exited"
47
+ // (terminal/terminal.go statusProcessExited): definitive, so no reconnect.
78
48
  const PROCESS_EXITED_CLOSE_CODE = 4001;
79
49
 
80
- // The server's application close code for "the manager does not know this
81
- // session id" (terminal/terminal.go statusUnknownSession): reaped, closed
82
- // elsewhere, or a restarted server. The server ACCEPTS the upgrade and closes
83
- // with this code precisely so the client can read it (a pre-upgrade 404 is an
84
- // opaque 1006 in browser JS). Like 4001 it is definitive — the session will
85
- // never produce output — so it routes to the same no-reconnect ended path
86
- // instead of an endless "Reconnecting…" flap.
50
+ // The server's close code for "the manager does not know this session id"
51
+ // (statusUnknownSession). The server ACCEPTS the upgrade and closes with it so
52
+ // the client can read it (a pre-upgrade 404 is an opaque 1006 in browser JS);
53
+ // like 4001 it is definitive and takes the no-reconnect path.
87
54
  const SESSION_UNKNOWN_CLOSE_CODE = 4004;
88
55
 
89
- let connState: ConnState = { status: "disconnected" };
90
- let reconnectDelay = INITIAL_DELAY_MS;
91
- let lastSentCols = 0;
92
- let lastSentRows = 0;
93
- let wsPath: string = WS_PATH;
94
-
95
- // --- Per-session resume state (the switching cache's connection half) ---
96
- //
97
- // Each server session carries its own reliable-input accounting: an outbox of
98
- // unacked bytes, byte counters, and the last server boot-epoch it saw. Scoping
99
- // this per session (rather than one module-global set) is what lets a tab switch
100
- // reconnect to a different session without replaying the previous tab's unacked
101
- // bytes onto it and without firing a false server-restart reset, because each
102
- // session's epoch is compared only against its own bootEpoch (design section 8).
56
+ // Per-session reliable-input accounting, so a tab switch reconnects to another
57
+ // session without replaying the previous tab's unacked bytes onto it and
58
+ // without a false restart reset (each epoch is compared only against its own).
103
59
  interface ResumeState {
104
60
  id: string; // server session id: the routing id (?session=); resumeKey derives the ledger key
105
61
  bytesSent: number; // total bytes ever passed to sendBinary for this session
@@ -107,63 +63,15 @@ interface ResumeState {
107
63
  outbox: Uint8Array[]; // unacked chunks (sum of lengths = bytesSent - bytesAcked)
108
64
  outboxBytes: number; // running sum of outbox chunk lengths; keeps applyAck O(n) not O(n²)
109
65
  lastServerEpoch: number | null; // process-start nanos last seen for this session
110
- // Whether lastServerEpoch was SEEDED from persisted content rather than learned
111
- // from a resumeAck. A seeded epoch is a claim about content restored from
112
- // storage, and it is only worth anything if a server confirms or contradicts it —
113
- // so a resume that reports no epoch at all leaves it unverifiable, which is
114
- // handled as a restart. Learned epochs must not get that treatment: an ack
115
- // without an epoch from a server that never reports one is ordinary operation.
66
+ // A SEEDED epoch is a claim about content restored from storage, worth
67
+ // something only once a server confirms or contradicts it, so a resume that
68
+ // reports no epoch leaves it unverifiable and is handled as a restart. A
69
+ // learned epoch must not get that treatment: an ack without an epoch from a
70
+ // server that never reports one is ordinary operation.
116
71
  epochSeeded: boolean;
117
- /** The session's last-announced DEC-mode state (P3: per-session mode
118
- * mirror). Written on every inbound modes frame; restored synchronously
119
- * into the modes singleton by setSession, so a keystroke in the switch
120
- * window encodes under THIS session's modes, never the previous tab's.
121
- * Power-on defaults until the session announces modes. */
122
- modes: modes.ModeSnapshot;
123
- }
124
-
125
- const sessions = new Map<string, ResumeState>();
126
- // The session the live socket currently serves. null until the first connect or
127
- // setSession; the unmanaged single-terminal path lazily creates a default
128
- // session with a sessionStorage-backed id.
129
- let activeId: string | null = null;
130
- // managed = a consumer selected sessions explicitly via setSession, so the WS URL
131
- // carries ?session=<id>. Unmanaged keeps the bare wsPath and a sessionStorage id,
132
- // preserving the original single-terminal behavior and its iOS-resume semantics
133
- // (sessionStorage survives iOS tab-suspend/BFCache, so an unmanaged reload
134
- // resumes rather than orphaning its outbox). A tabbed shell is managed; it
135
- // rebuilds tabs from GET /api/sessions on reload (section 17), so it needs no
136
- // client-side id persistence.
137
- let managed = false;
138
-
139
- const SESSION_ID_KEY = "vterm-session-id";
140
-
141
- // --- Per-client-instance resume key (P1: per-sender input-ack scoping) ---
142
- //
143
- // In managed mode the routing id (?session=) is SHARED by every device/tab
144
- // attached to that session, but the server-side input ledger (bytesReceived,
145
- // keyed by the resume frame's sessionId) must be PER SENDER: with a shared
146
- // key, device B's input advances the one ledger and the server acks that
147
- // total to device A, whose applyAck then trims bytes the server never
148
- // received from A — silent input loss on A's next resume (the cross-device
149
- // outbox corruption class). So the resume frame carries
150
- // `<serverSessionId>#<clientInstanceId>` — the URL keeps the bare routing id
151
- // (the server routes on it), while the registry keys A's and B's ledgers
152
- // separately. The unmanaged path already has per-sender semantics (its
153
- // sessionStorage id is per tab) and is unchanged.
154
- //
155
- // The instance id is crypto-random and page-lifetime (NOT persisted): a
156
- // reload is a fresh sender whose ledger starts at zero, which matches its
157
- // empty outbox. Lazy so a cryptoless environment throws on first CONNECT
158
- // (as before), not at module import.
159
- let clientInstanceId: string | null = null;
160
-
161
- function resumeKey(st: ResumeState): string {
162
- if (!managed) {
163
- return st.id;
164
- }
165
- clientInstanceId ??= generateSessionId();
166
- return `${st.id}#${clientInstanceId}`;
72
+ // Restored synchronously into the ModeState by setSession, so a keystroke in
73
+ // the switch window encodes under THIS session's modes, never the previous tab's.
74
+ modes: ModeSnapshot;
167
75
  }
168
76
 
169
77
  function newResumeState(id: string): ResumeState {
@@ -175,46 +83,10 @@ function newResumeState(id: string): ResumeState {
175
83
  outboxBytes: 0,
176
84
  lastServerEpoch: null,
177
85
  epochSeeded: false,
178
- modes: { ...modes.POWER_ON_MODES },
86
+ modes: { ...POWER_ON_MODES },
179
87
  };
180
88
  }
181
89
 
182
- function ensureState(id: string): ResumeState {
183
- let s = sessions.get(id);
184
- if (s === undefined) {
185
- s = newResumeState(id);
186
- sessions.set(id, s);
187
- }
188
- return s;
189
- }
190
-
191
- // activeState returns the ResumeState the live socket serves, lazily creating
192
- // the default (unmanaged) session from a sessionStorage-backed id on first use.
193
- function activeState(): ResumeState {
194
- activeId ??= loadOrCreateSessionId();
195
- return ensureState(activeId);
196
- }
197
-
198
- // --- Client-side liveness (bug 2 defense-in-depth) ---
199
- //
200
- // On iOS wake, visibilitychange + pageshow fire and call reconnectNow(),
201
- // which is the primary fix. But a socket can also go silently half-open
202
- // without any wake event (a NAT/idle timeout on a backgrounded-then-
203
- // foregrounded tab, a flaky network that drops the path without a close
204
- // frame). The socket then reads OPEN forever and delivers nothing. The
205
- // server's ping loop notices the dead client, but those are WS-protocol
206
- // pings the browser answers without surfacing to JS, so the client can't
207
- // see them. So the client runs its own probe: after a stretch of silence
208
- // it sends an app-level ping; the server echoes a pong. Any inbound frame
209
- // (the pong, or normal output) proves the socket is alive and clears the
210
- // probe. If the probe goes unanswered, the socket is stale and we
211
- // reconnectNow() — which resumes by absolute index, so nothing is lost or
212
- // duplicated. The probe is what distinguishes "idle but alive" from
213
- // "dead": without it, a quiet-but-healthy terminal would reconnect-flap.
214
- let lastActivityAt = 0; // Date.now() of the last inbound frame (any kind)
215
- let probeSentAt = 0; // Date.now() the outstanding probe ping was sent; 0 = none
216
- let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
217
-
218
90
  /** How often liveness is evaluated. */
219
91
  const HEARTBEAT_INTERVAL_MS = 5_000;
220
92
  /** Inbound silence that must elapse before we actively probe with a ping. */
@@ -222,8 +94,6 @@ const IDLE_BEFORE_PROBE_MS = 10_000;
222
94
  /** How long an unanswered probe is tolerated before declaring the socket stale. */
223
95
  const PONG_TIMEOUT_MS = 7_000;
224
96
 
225
- // --- demand-paged scrollback: per-socket fetch state (docs/paged-scrollback.md) ---
226
-
227
97
  /** Requests served back-to-back before the client's own bucket throttles. */
228
98
  const HISTORY_BURST = 4;
229
99
 
@@ -252,18 +122,14 @@ const HISTORY_MIN_PAGE = 125;
252
122
  * value the server will actually honor. The equality is load-bearing: the
253
123
  * replay-jump prediction computes `committed - sentReplayMax`, so a server
254
124
  * honoring something smaller would place the real replay start above the
255
- * prediction and leave a genuine jump undetected (§4.5).
125
+ * prediction and leave a genuine jump undetected (docs/paged-scrollback.md §4.5).
256
126
  */
257
127
  export const MAX_REPLAY_LINES = 2000;
258
128
 
259
- /**
260
- * Everything the fetch controller owns for ONE socket. Held in the socket's
261
- * closure lifetime — replaced wholesale on reconnect — because every field is
262
- * a statement about that socket: a surviving empty bucket would stall against a
263
- * fresh server bucket, a carried-over capability would page against a server
264
- * that never declared it, and a carried-over 125-line budget would punish a new
265
- * link for the old one's congestion.
266
- */
129
+ // Everything the fetch controller owns for ONE socket, replaced wholesale on
130
+ // reconnect: a surviving empty bucket would stall against a fresh server
131
+ // bucket, a carried-over capability would page against a server that never
132
+ // declared it, and a carried-over 125-line budget would punish a new link.
267
133
  interface HistoryState {
268
134
  /** The server declared paging on this socket's resume ack. */
269
135
  paging: boolean;
@@ -303,14 +169,8 @@ function newHistoryState(): HistoryState {
303
169
  };
304
170
  }
305
171
 
306
- let history: HistoryState = newHistoryState();
307
-
308
- /**
309
- * The capabilities the server DECLARED on this socket's resume ack. Per-socket
310
- * for the same reason as HistoryState: a carried-over capability would send a
311
- * control to a server that never declared it, and post-latch an unrecognized
312
- * text control is tolerated and DROPPED — so the message would vanish silently.
313
- */
172
+ // The capabilities the server DECLARED on this socket's resume ack, per socket
173
+ // because post-latch an unrecognized text control is DROPPED silently.
314
174
  interface ServerCaps {
315
175
  /** ackFlags bit3: the server serves the `ephemeralInput` control. */
316
176
  ephemeralInput: boolean;
@@ -318,73 +178,11 @@ interface ServerCaps {
318
178
  serverFocus: boolean;
319
179
  }
320
180
 
321
- let caps: ServerCaps = { ephemeralInput: false, serverFocus: false };
322
-
323
- /**
324
- * Whether the consumer's terminal widget is focused. Per PAGE, not per socket:
325
- * it is the widget's own state and must survive a reconnect, because the new
326
- * socket has to be told the CURRENT value.
327
- */
328
- let clientFocused = false;
329
-
330
- /**
331
- * The focus value THIS socket has been told, or null when it has been told
332
- * nothing. Per socket, so a reconnect re-asserts focus instead of suppressing it
333
- * as a repeat.
334
- */
335
- let focusReported: boolean | null = null;
336
-
337
181
  /** WebSocket.OPEN. Spelled out because a test's fake constructor has no statics. */
338
182
  const WS_OPEN = 1;
339
183
 
340
184
  const ephemeralEncoder = new TextEncoder();
341
185
 
342
- /** Cancel both timers and release single-flight. */
343
- function clearHistoryTimers(): void {
344
- if (history.dataTimer !== null) {
345
- clearTimeout(history.dataTimer);
346
- history.dataTimer = null;
347
- }
348
- if (history.demandTimer !== null) {
349
- clearTimeout(history.demandTimer);
350
- history.demandTimer = null;
351
- }
352
- }
353
-
354
- /**
355
- * Reset everything a new socket must not inherit: the fetch state (capability,
356
- * single-flight, pacing and the adaptive budget), the declared server
357
- * capabilities, and the focus latch. All of it goes together, atomically.
358
- * Splitting them is how a client ends up paging against a server that never
359
- * declared it, bursting into a depleted server bucket, or holding a focus report
360
- * back from the new socket as a repeat of what the old one was told.
361
- */
362
- function resetForNewSocket(): void {
363
- clearHistoryTimers();
364
- history = newHistoryState();
365
- caps = { ephemeralInput: false, serverFocus: false };
366
- focusReported = null;
367
- }
368
-
369
- /**
370
- * Spend a pacing token, returning the wait in ms when the bucket is empty (so
371
- * the caller can arm the coalesced pending demand for exactly that instant)
372
- * or 0 when a token was granted.
373
- */
374
- function takeHistoryToken(): number {
375
- const now = Date.now();
376
- history.tokens = Math.min(
377
- HISTORY_BURST,
378
- history.tokens + (now - history.lastRefill) / HISTORY_REFILL_MS,
379
- );
380
- history.lastRefill = now;
381
- if (history.tokens < 1) {
382
- return Math.ceil((1 - history.tokens) * HISTORY_REFILL_MS);
383
- }
384
- history.tokens -= 1;
385
- return 0;
386
- }
387
-
388
186
  /**
389
187
  * Maximum bytes we keep in the outbox before refusing new input. 1
390
188
  * MiB at typical typing rates is hours of held keys; fast enough to
@@ -393,316 +191,27 @@ function takeHistoryToken(): number {
393
191
  */
394
192
  export const MAX_OUTBOX_BYTES = 1 << 20;
395
193
 
396
- function loadOrCreateSessionId(): string {
397
- // sessionStorage is per-tab and survives most iOS lifecycle events
398
- // (suspend/resume, BFCache restore, page reload). It does NOT survive
399
- // a true tab close + reopen, which is the desired semantic: a fresh
400
- // tab should be a fresh terminal session, not a resume of an older one.
401
- try {
402
- const existing = sessionStorage.getItem(SESSION_ID_KEY);
403
- if (existing) {
404
- return existing;
405
- }
406
- const fresh = generateSessionId();
407
- sessionStorage.setItem(SESSION_ID_KEY, fresh);
408
- return fresh;
409
- } catch {
410
- // Private mode or storage disabled — fall back to in-memory only.
411
- // Reload-as-new-session semantics in this fallback path are
412
- // unavoidable; the outbox-clear safeguard in handleResumeAck below
413
- // protects against duplicate retransmission when the server returns
414
- // bytesReceived=0 for a session it doesn't recognize.
415
- return generateSessionId();
416
- }
417
- }
418
-
419
- /**
420
- * generateSessionId mints a fresh session id: a `crypto.randomUUID` where that
421
- * exists, otherwise 16 CSPRNG bytes as hex (128 bits, the fallback for a
422
- * non-secure-context origin). Throws when neither is available rather than
423
- * falling back to a weaker source — the caller gets no id, not a guessable one.
424
- *
425
- * Never returns an id already in use: nothing here reads storage, so a caller
426
- * that wants the tab's existing session must go through loadOrCreateSessionId.
427
- *
428
- * Exported for unit testing of the RNG fallback. Not part of the
429
- * stable client API surface; callers use loadOrCreateSessionId.
430
- */
431
- export function generateSessionId(): string {
194
+ // The session id is a resume token the server trusts to re-attach a client, so
195
+ // it must not be predictable: `crypto.randomUUID` where it exists (it needs a
196
+ // secure context), otherwise 16 CSPRNG bytes as hex, and a throw rather than
197
+ // Math.random() when neither is available.
198
+ function generateSessionId(): string {
432
199
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
433
200
  return crypto.randomUUID();
434
201
  }
435
- // Fallback when crypto.randomUUID is unavailable. randomUUID requires a
436
- // secure context (HTTPS/localhost); getRandomValues does not, so it
437
- // covers plain-HTTP origins while still being a CSPRNG. sessionId is a
438
- // resume token the server trusts to re-attach a client to its prior
439
- // session, so it must not be predictable — Math.random() (a non-crypto
440
- // PRNG whose state is recoverable from output) would allow guessing
441
- // another client's session. Emit 16 random bytes as hex (128 bits).
442
202
  if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
443
203
  const bytes = new Uint8Array(16);
444
204
  crypto.getRandomValues(bytes);
445
205
  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
446
206
  }
447
- // No Web Crypto at all: refuse rather than mint a guessable token.
448
207
  throw new Error("vterm: no cryptographically secure RNG available for session id");
449
208
  }
450
209
 
451
- /**
452
- * Callbacks is everything this module needs from its consumer, handed to init
453
- * once. Four members are required because no other layer can answer them: where
454
- * a decoded frame goes (onMessage), the two socket-lifecycle edges the UI has to
455
- * reflect (onOpen, onClose), and the terminal's current geometry (computeSize,
456
- * which must return integer cols/rows > 0 — the server FLOORS an out-of-range
457
- * resize rather than dropping it).
458
- *
459
- * The optional members are two different kinds. The notifications
460
- * (onConnecting, onOutboxFull, onProcessExit, onServerRestart, the two wire
461
- * callbacks) each change what the module DOES when wired, and every one says how
462
- * in its own comment; absence is the documented legacy treatment, never a
463
- * failure. The resume and history members (getHaveThrough, getReplayMax,
464
- * onResumeTransition, onResumeBounds, onHistoryReply, noteSolicited,
465
- * clearSolicited, onHistoryRetry) are engine-owned — supply nothing and the
466
- * renderer answers them itself (engineDefaults below). getReplayMax is the one
467
- * override that is expected.
468
- *
469
- * Every member is called, never captured: the module reads the current one at
470
- * each event, so an implementation may depend on state that changes between
471
- * calls (a bound store, a viewport in flight). A consumer callback that throws
472
- * propagates out of the module's handler, with the single exception of
473
- * initialSize, which is isolated (readInitialSize) because it runs mid-open.
474
- */
475
- export interface Callbacks {
476
- onMessage(msg: ServerMessage): void;
477
- onOpen(): void;
478
- onClose(): void;
479
- onConnecting?(): void;
480
- onOutboxFull?(): void;
481
- /** Fired instead of onClose when the server closes with a DEFINITIVE
482
- * application close code — process-exited (4001: the session's child
483
- * process has ended) or unknown-session (4004: the server does not know
484
- * the id at all — reaped, closed elsewhere, or a restarted server). In
485
- * both cases the session will never produce output again, so a transient
486
- * treatment could only earn the same close code forever (the endless
487
- * "Reconnecting…" flash). When this callback is wired, the module does
488
- * NOT auto-reconnect that socket and does not call onClose for it. The
489
- * consumer decides what "ended" looks like (banner, tab state) and may
490
- * still reconnect explicitly (setSession / reconnectNow) to re-view a
491
- * dead session's final screen. When absent, every close keeps the legacy
492
- * transient treatment (onClose + backoff reconnect), so existing
493
- * consumers are unaffected until they opt in. */
494
- onProcessExit?(): void;
495
- /** Fired when the client's queued input can no longer be trusted, which
496
- * happens two ways: the server's boot-epoch in resumeAck differs from the
497
- * one observed on a previous connection (the server restarted), or the
498
- * server no longer holds this session's input ledger AND the outbox still
499
- * held unacked bytes. A forgotten ledger whose every byte was already acked
500
- * does NOT fire this — nothing was lost, and reporting it made a phone
501
- * waking from sleep announce a restart that never happened. By the time this
502
- * fires, the connection module has already reset bytesSent/bytesAcked/outbox
503
- * so subsequent input starts from zero. UI should surface a banner so the
504
- * user knows recent input may have been lost. */
505
- onServerRestart?(): void;
506
- /** Fired when a resumeAck carries an explicit server revision outside
507
- * [MIN_SUPPORTED_SERVER_WIRE_VERSION, WIRE_PROTOCOL_VERSION]. A newer
508
- * server warns but continues because it may retain this client's baseline.
509
- * A below-floor server also fires onWireIncompatible and stops the socket.
510
- * Version-silent servers never fire either callback. */
511
- onWireVersionMismatch?(server: number, client: number): void;
512
- /** Fired when the module definitively stops a socket for an incompatible
513
- * declared wire revision, either from resumeAck metadata or close code
514
- * 4002. Automatic and wake-triggered reconnects remain blocked until an
515
- * explicit disconnect clears the terminal state (normally a page reload).
516
- * The callback is optional; refusal is enforced even when no UI consumes
517
- * it. */
518
- onWireIncompatible?(details: WireIncompatibility): void;
519
- computeSize(): { cols: number; rows: number };
520
- /** Returns the highest absolute line index the client will not ask the server
521
- * to re-send, or -1 to request a full retained replay. Sent as `haveThrough`
522
- * on resume so the server replays only the lines missed (e.g. printed while
523
- * the device slept).
524
- *
525
- * NOT simply the highest index held. A client that holds a row PROVISIONALLY
526
- * — a screen row the application drew, which the server has not committed at
527
- * that absolute index — must not claim it, or the replay starts above it and
528
- * the provisional copy is never corrected. Wire this to
529
- * `render.getReplayBoundary`, which answers exactly that question.
530
- * When absent, the client requests a full retained replay (-1). */
531
- getHaveThrough?(): number;
532
- /** Fired on resume with the server's retained-history bounds: `committed`
533
- * is one past the newest retained line, `oldest` the oldest retained
534
- * absolute index. The consumer forwards these to the renderer/store so it
535
- * can tell a genuine history trim (the server evicted lines the client was
536
- * missing) from a still-loading state. Resync guard 8.2.2. */
537
- onResumeBounds?(committed: number, oldest: number): void;
538
- /**
539
- * How many lines of resume replay this client wants at most — a REFINEMENT of
540
- * the protocol ceiling, never an opt-out: the server bounds the replay to
541
- * MAX_REPLAY_LINES whether or not this is wired, and the client sends that
542
- * ceiling when it is absent so both sides predict the same replay start.
543
- * Return the depth the consumer intends to keep resident.
544
- *
545
- * The value is clamped to MAX_REPLAY_LINES before it is sent, because the
546
- * client's replay-jump prediction is only exact while the SENT value equals the
547
- * honored one (docs/paged-scrollback.md §4.5).
548
- */
549
- getReplayMax?(): number | null;
550
- /** Fired when a correlated history page arrives, carrying the reply and how
551
- * the client should read it. The consumer forwards `msg` to the store's
552
- * `applyHistoryScroll` (which classifies it as browse cache), and applies
553
- * `raiseFloorTo` when present — a clamped or empty reply is the server
554
- * proving nothing at or below that index survives. */
555
- onHistoryReply?(msg: ScrollMessage, raiseFloorTo: number | null): void;
556
- /** Fired once per resume ack with everything the store's single ack
557
- * transition needs, including the values this socket SENT (which the
558
- * server's reply is a function of) and the capability the server declared.
559
- * The consumer builds a closure over its store and renderer viewport and
560
- * calls `store.applyResumeAck(...)`; see docs/paged-scrollback.md §4.5 for
561
- * why the five steps must not be split across separate callbacks. */
562
- onResumeTransition?(ack: {
563
- epochChanged: boolean;
564
- committed: number | null;
565
- serverOldest: number | null;
566
- paging: boolean;
567
- sentHaveThrough: number;
568
- sentReplayMax: number | null;
569
- }): void;
570
- /** The store port's two solicited-window methods. `connection` is store-blind
571
- * by design, but the events that open and close a solicited window all
572
- * originate here (send, data timeout, socket close), so the store needs a
573
- * bridge rather than a reach-in. */
574
- noteSolicited?(fromAbs: number, end: number): void;
575
- clearSolicited?(): void;
576
- /** Re-run the fetch trigger: the coalesced pending demand fired (a paced
577
- * denial's refill instant, or a data timeout's retry). The controller
578
- * re-evaluates its full guard set rather than replaying a stale range. */
579
- onHistoryRetry?(): void;
580
- /** The size to announce to the server BEFORE asking to resume, or null when
581
- * the client cannot yet measure itself trustworthily (web fonts still
582
- * loading, a viewport transition in flight).
583
- *
584
- * Establishing the size first is what makes a resume snapshot arrive at the
585
- * client's own geometry: the server reflows, and only then computes the
586
- * window and the replay. Without it the client is answered at whatever size
587
- * the session last held, repaints, and then its resize lands afterwards —
588
- * which on a program that redraws on SIGWINCH means the redraw interleaves
589
- * with the replay it just applied.
590
- *
591
- * Returning null is the explicit "not yet" answer and is byte-identical to
592
- * omitting the callback: nothing is sent, the server keeps its current size,
593
- * and the consumer's own later sendResize() (typically once the viewport
594
- * settles) establishes it. Announcing an untrustworthy measurement is worse
595
- * than announcing none, because it costs a second resize and therefore a
596
- * second redraw. */
597
- initialSize?(): { cols: number; rows: number } | null;
598
- /** Optional WebSocket endpoint path (default "/ws"). vibekit serves
599
- * the shell at "/api/shell/ws"; web-terminal-kiro at "/ws". */
600
- wsPath?: string;
601
- }
602
-
603
- let cb: Callbacks | null = null;
604
-
605
- /**
606
- * The members this module answers for ITSELF, from the renderer.
607
- *
608
- * Every one of them is a fact about local state, not a decision: what this client
609
- * already holds, how much replay it wants, where to route a resume ack or a history
610
- * reply. The renderer is the layer that knows, because several carry a viewport
611
- * reading only it can take, and both modules belong to this library. A consumer
612
- * that supplied them was choosing between engine accessors on the engine's behalf,
613
- * which is how `getHaveThrough` came to be wired to the highest index HELD rather
614
- * than the highest the server had confirmed: the resume then claimed rows the
615
- * application had merely drawn, and a frozen copy of its input box appeared in
616
- * scrollback on almost every reattach.
617
- *
618
- * A consumer may still override any of them, and `getReplayMax` is the one where
619
- * that is expected: how much history a client wants resident is its policy, not the
620
- * engine's fact. The rest exist as overrides only because removing them from
621
- * `Callbacks` would break a published surface; treat them as deprecated and prefer
622
- * supplying nothing.
623
- *
624
- * Read lazily, per resume, rather than captured at init: the renderer's answers are
625
- * functions of whichever store is currently bound, which a tab switch changes.
626
- */
627
- function engineDefaults(): Partial<Callbacks> {
628
- return {
629
- getHaveThrough: render.getReplayBoundary,
630
- getReplayMax: render.replayMaxForResume,
631
- onResumeTransition: render.applyResumeTransition,
632
- onResumeBounds: render.noteResumeBounds,
633
- onHistoryReply: render.handleHistoryReply,
634
- noteSolicited: render.noteSolicited,
635
- clearSolicited: render.clearSolicited,
636
- onHistoryRetry: render.maybeFetchHistory,
637
- };
638
- }
639
-
640
- /**
641
- * init installs the consumer's callbacks and the WS path. It must run before
642
- * connect/setSession, and before any sendBinary the consumer expects to reach a
643
- * server — the module holds one set of callbacks for the whole page, so a second
644
- * call REPLACES them rather than adding to them.
645
- *
646
- * `wsPath` is read here only, and only when present: a later init that omits it
647
- * leaves the path an earlier one set, so an established socket's endpoint is not
648
- * silently reinterpreted. Every other member is looked up per event, so a
649
- * replacement takes effect on the next one without reconnecting.
650
- */
651
- export function init(callbacks: Callbacks): void {
652
- // Consumer last: an explicit member wins, an absent one gets the engine's own.
653
- // The alternative, leaving them absent, is not neutral — the resume send reads
654
- // `cb?.getHaveThrough?.() ?? -1`, so silence asks for a full bounded replay on
655
- // every attach, which is the worst answer available rather than the safe one.
656
- cb = { ...engineDefaults(), ...callbacks };
657
- if (callbacks.wsPath !== undefined) {
658
- wsPath = callbacks.wsPath;
659
- }
660
- }
661
-
662
- /**
663
- * sendBinary queues data for delivery. Returns true if accepted, false
664
- * if the outbox is full (caller should surface a UI signal that input
665
- * was dropped). Always copies the input to defend against caller-side
666
- * buffer reuse.
667
- *
668
- * Input alphabet: any byte sequence is deliverable, including leading NULs.
669
- * On a v4-upgraded socket the bytes go out verbatim in one binary message;
670
- * on a v3-mode socket each leading 0x00 is emitted as its own solitary
671
- * 1-byte frame (see sendInputFrames) so nothing this module sends can be
672
- * misread as a control frame — byte order and count are preserved either
673
- * way, and servers with the parse-fallback deliver every byte to the PTY.
674
- */
675
- export function sendBinary(data: Uint8Array): boolean {
676
- const st = activeState();
677
- if (st.outboxBytes + data.length > MAX_OUTBOX_BYTES) {
678
- cb?.onOutboxFull?.();
679
- return false;
680
- }
681
- // Always go through the active session's outbox. Bytes leave it only when the
682
- // server explicitly acks them — guarantees correct retransmission after a
683
- // network blip even if ws.send() reported success.
684
- const copy = new Uint8Array(data); // defensive copy (caller may reuse buffer)
685
- st.outbox.push(copy);
686
- st.outboxBytes += copy.length;
687
- st.bytesSent += copy.length;
688
- if (connState.status === "connected") {
689
- sendInputFrames(connState.sock, connState.upgraded, copy);
690
- }
691
- return true;
692
- }
693
-
694
- /**
695
- * sendInputFrames is the ONE encoder for PTY input bytes — both the live
696
- * sendBinary path and retransmitOutbox route through it so the two can never
697
- * disagree on framing (design §6). On a v4-upgraded socket the bytes go out
698
- * verbatim as a single binary message (full alphabet — the server never
699
- * inspects a sentinel after the latch). On a v3-mode socket (old server, or
700
- * the pre-upgrade window of any socket), each leading 0x00 byte is emitted as
701
- * its own solitary 1-byte message so no frame the client sends can ever be
702
- * misread as a control frame; servers with the parse-fallback deliver the
703
- * solitary NUL to the PTY, and byte accounting is unchanged either way
704
- * (splitting alters message count, never byte count or order).
705
- */
210
+ // The ONE encoder for PTY input bytes, shared by sendBinary and
211
+ // retransmitOutbox so the two cannot disagree on framing. On a v4-upgraded
212
+ // socket the bytes go out verbatim; on a v3-mode socket each leading 0x00 byte
213
+ // is its own 1-byte message so no client frame can be misread as a control
214
+ // frame. Splitting alters message count, never byte count or order.
706
215
  function sendInputFrames(sock: WebSocket, upgraded: boolean, chunk: Uint8Array): void {
707
216
  let rest = chunk;
708
217
  if (!upgraded) {
@@ -716,154 +225,15 @@ function sendInputFrames(sock: WebSocket, upgraded: boolean, chunk: Uint8Array):
716
225
  }
717
226
  }
718
227
 
719
- /**
720
- * textControl encodes a control message for a v4-upgraded socket: bare JSON in
721
- * a WebSocket TEXT frame (the transport's message type is the discriminator;
722
- * no sentinel byte). The binary 0x00-sentinel form (controlFrame) remains the
723
- * bootstrap/fallback encoding for v3-mode sockets.
724
- */
228
+ // A control for a v4-upgraded socket: bare JSON in a TEXT frame, the message
229
+ // type being the discriminator. controlFrame is the v3-mode sentinel form.
725
230
  function textControl(msg: ControlMessage): string {
726
231
  return JSON.stringify(msg);
727
232
  }
728
233
 
729
- /**
730
- * sendEphemeral delivers BEST-EFFORT input. Where the server declared
731
- * `ephemeralInput` it sends only on a live socket, never queues, never
732
- * retransmits, and never touches `outbox`, `outboxBytes`, `bytesSent`,
733
- * `bytesAcked` or `onOutboxFull`; where it did NOT, the report falls back to
734
- * reliable input, so it can be retransmitted after a resume and describe a
735
- * screen since repainted. Returns whether it was sent; on false the caller's
736
- * contract is to FORGET the event, because a mouse report carries no sequence
737
- * number. Refusing on a closing socket is what xterm.js's AttachAddon and ttyd
738
- * both do.
739
- */
740
- export function sendEphemeral(text: string): boolean {
741
- if (connState.status !== "connected" || connState.sock.readyState !== WS_OPEN) {
742
- return false;
743
- }
744
- if (!connState.upgraded || !caps.ephemeralInput) {
745
- // Fall back to RELIABLE input rather than dropping the report. An
746
- // unrecognized text control is tolerated and dropped post-latch, so the
747
- // report would vanish entirely, and total silence is a worse failure than a
748
- // stale report: a third-party or mid-upgrade server keeps a working mouse.
749
- // What is DEGRADED on this path: the report rides the reliable outbox, so a
750
- // click made before a disconnect is retransmitted after the resume has
751
- // repainted the screen.
752
- return sendBinary(ephemeralEncoder.encode(text));
753
- }
754
- connState.sock.send(textControl({ type: "ephemeralInput", data: text }));
755
- return true;
756
- }
757
-
758
- /**
759
- * setClientFocus reports whether the consumer's terminal widget is focused.
760
- *
761
- * Focus is STATE, so it is reconciled rather than replayed: the latest value
762
- * wins, a repeat of the value already reported sends nothing, a report made
763
- * while no socket is live is dropped rather than queued, and a reconnect
764
- * re-asserts the current one. Where the server declared `serverFocus` this sends
765
- * the `focus` control and the server derives the DEC 1004 answer for the whole
766
- * session; where it did not, the report is dropped.
767
- */
768
- export function setClientFocus(focused: boolean): void {
769
- clientFocused = focused;
770
- if (connState.status !== "connected") {
771
- // Focus is STATE, so a report with no socket to carry it is DROPPED rather
772
- // than held: the widget's value may change again before the next socket
773
- // exists, and a held report would assert a focus it may since have lost.
774
- // Nothing is lost by dropping it — the resumeAck re-reports the current
775
- // value on the new socket.
776
- return;
777
- }
778
- if (!caps.serverFocus || !connState.upgraded) {
779
- // A server that does not derive the answer gets NO report, because the client
780
- // never writes the DEC 1004 bytes itself. Nothing is lost by dropping it: the
781
- // resumeAck re-reports the current value once the capabilities are known.
782
- return;
783
- }
784
- if (focused === focusReported) {
785
- return;
786
- }
787
- focusReported = focused;
788
- sendControl({ type: "focus", focused });
789
- }
790
-
791
- function sendControl(msg: ControlMessage): void {
792
- if (connState.status !== "connected") {
793
- return;
794
- }
795
- if (connState.upgraded) {
796
- connState.sock.send(textControl(msg));
797
- return;
798
- }
799
- connState.sock.send(controlFrame(msg));
800
- }
801
-
802
- /**
803
- * The consumer's announced bootstrap size, validated, or null when there is
804
- * none to announce.
805
- *
806
- * Isolated for two reasons. The validation: the server FLOORS an out-of-range
807
- * resize rather than dropping it (a 0 becomes the minimum size), so a malformed
808
- * value must be rejected here rather than silently resizing the session. And the
809
- * try/catch: this runs inside the socket's open handler after the connect
810
- * timeout has been cleared and BEFORE the resume is sent, so a consumer callback
811
- * that throws would otherwise abort the handler, leave the resume unsent and the
812
- * connection state stuck at "connecting" with no timeout left to rescue it. A
813
- * broken provider degrades to "announce nothing", which is the documented
814
- * behavior of omitting it.
815
- */
816
- function readInitialSize(): { cols: number; rows: number } | null {
817
- let size: { cols: number; rows: number } | null;
818
- try {
819
- size = cb?.initialSize?.() ?? null;
820
- } catch (err) {
821
- console.warn("vterm: initialSize provider threw; announcing no size", err);
822
- return null;
823
- }
824
- if (
825
- size === null ||
826
- !Number.isInteger(size.cols) ||
827
- !Number.isInteger(size.rows) ||
828
- size.cols <= 0 ||
829
- size.rows <= 0
830
- ) {
831
- return null;
832
- }
833
- return { cols: size.cols, rows: size.rows };
834
- }
835
-
836
- /**
837
- * sendResize measures the terminal through Callbacks.computeSize and announces
838
- * it to the server. Call it whenever the geometry may have changed (a window
839
- * resize, a font-metric update, the soft keyboard opening); it is cheap to call
840
- * speculatively because a size equal to the last one SENT is dropped, and the
841
- * consumer therefore needs no dedup of its own.
842
- *
843
- * A no-op when no socket is connected, and the size is not queued for later. A
844
- * fresh socket clears the dedup baseline as it opens, so the consumer's next
845
- * call after a reconnect always goes out; a consumer that can measure itself at
846
- * that moment should wire Callbacks.initialSize instead, which is announced
847
- * before the resume and therefore brings the snapshot back at this geometry.
848
- */
849
- export function sendResize(): void {
850
- if (connState.status !== "connected" || !cb) {
851
- return;
852
- }
853
- const { cols, rows } = cb.computeSize();
854
- if (cols === lastSentCols && rows === lastSentRows) {
855
- return;
856
- }
857
- lastSentCols = cols;
858
- lastSentRows = rows;
859
- sendControl({ type: "resize", cols, rows });
860
- }
861
-
862
- // resetLedger drops a session's reliable-input accounting. Called whenever the
863
- // server no longer holds the ledger these counters were keyed to (a restart, an
864
- // idle-GC'd resume key, a session the server does not recognize): its
865
- // replacement counts from zero, so keeping non-zero counters would make every
866
- // later ack read as stale in applyAck and the outbox would never trim again.
234
+ // Called whenever the server no longer holds the ledger these counters were
235
+ // keyed to: its replacement counts from zero, so non-zero counters would make
236
+ // every later ack read as stale and the outbox would never trim again.
867
237
  function resetLedger(st: ResumeState): void {
868
238
  st.bytesSent = 0;
869
239
  st.bytesAcked = 0;
@@ -871,39 +241,8 @@ function resetLedger(st: ResumeState): void {
871
241
  st.outboxBytes = 0;
872
242
  }
873
243
 
874
- // resetSessionAfterRestart resets the ledger and tells the UI the server
875
- // restarted. Reserved for a boot-epoch change, which is the one cause where
876
- // that sentence is literally true.
877
- function resetSessionAfterRestart(st: ResumeState): void {
878
- resetLedger(st);
879
- cb?.onServerRestart?.();
880
- }
881
-
882
- // resetForgottenLedger handles a ledger the server no longer holds (the
883
- // explicit ledgerLost flag, or the old-server received=0 heuristic). The server
884
- // forgetting a ledger is not a restart and is not by itself a loss: only the
885
- // UNACKED remainder was ever at risk. So the UI is notified only when there was
886
- // one, and a session whose every sent byte was already acked resets silently —
887
- // nothing can be replayed and nothing was lost.
888
- //
889
- // The silent case is the common one, and reporting it was a real defect: an
890
- // iPad whose screen slept for half an hour woke, reconnected, and announced a
891
- // server restart that never happened. Server-side, the ledger it claimed had
892
- // received EXACTLY as many bytes as the client claimed to have sent (10531 ==
893
- // 10531 in the incident logs), i.e. an empty outbox and a fully-applied
894
- // session, and the banner still fired.
895
- function resetForgottenLedger(st: ResumeState): void {
896
- const lostUnacked = st.bytesSent > st.bytesAcked;
897
- resetLedger(st);
898
- if (lostUnacked) {
899
- cb?.onServerRestart?.();
900
- }
901
- }
902
-
903
- // applyAck drops chunks from the front of the session's outbox until the
904
- // running total of unacked bytes matches (bytesSent - newAck). Runs in
905
- // O(chunks_dropped) by tracking outboxBytes incrementally rather than
906
- // re-summing on every loop iteration.
244
+ // Drops chunks from the front of the outbox until the unacked total matches
245
+ // (bytesSent - newAck); O(chunks dropped) through the running outboxBytes.
907
246
  function applyAck(st: ResumeState, received: number): void {
908
247
  if (received <= st.bytesAcked) {
909
248
  return;
@@ -925,1047 +264,1237 @@ function applyAck(st: ResumeState, received: number): void {
925
264
  }
926
265
  }
927
266
 
928
- // On reconnect, after sending the resume control message and getting resumeAck,
929
- // replay anything still in the session's outbox over its (now open) socket. The
930
- // server has adjusted bytesAcked already — only unacked bytes remain. Routed
931
- // through sendInputFrames so replay framing always matches live-send framing
932
- // (v3-mode leading-NUL split vs v4 verbatim — design §6).
267
+ // After the resumeAck adjusted bytesAcked, replay what is still unacked.
933
268
  function retransmitOutbox(sock: WebSocket, upgraded: boolean, st: ResumeState): void {
934
269
  for (const chunk of st.outbox) {
935
270
  sendInputFrames(sock, upgraded, chunk);
936
271
  }
937
272
  }
938
273
 
939
- function scheduleReconnect(): void {
940
- // Nothing to schedule when the state already owns a path back to a socket: a
941
- // backoff timer is pending ("reconnecting"), or a socket exists that will
942
- // drive its own retry from its close handler or its connect timeout
943
- // ("connecting"/"connected"). Both callers set "disconnected" before calling
944
- // cb.onClose(), so a consumer that reconnects from its own close hook lands
945
- // here at "connecting" — and scheduling over it would OVERWRITE connState,
946
- // hiding that socket from connect()'s double-call guard, which then never
947
- // aborts it: the timer's connect() adds a second live socket with the first
948
- // one's listeners still bound, i.e. the duplicate server connection and
949
- // double delivery documented inside connect().
950
- if (
951
- connState.status === "reconnecting" ||
952
- connState.status === "connecting" ||
953
- connState.status === "connected"
954
- ) {
955
- return;
956
- }
957
- const step = nextBackoffDelay(reconnectDelay);
958
- reconnectDelay = step.nextBaseMs;
959
- const timer = setTimeout(() => {
960
- connState = { status: "disconnected" };
961
- connect();
962
- }, step.scheduledMs);
963
- connState = { status: "reconnecting", timer, delayMs: step.scheduledMs };
964
- }
274
+ /** The `sessionStorage` key of the unmanaged session id when `ConnectionOptions.sessionIdKey` is absent. */
275
+ const DEFAULT_SESSION_ID_KEY = "vterm-session-id";
965
276
 
966
- function cancelScheduledReconnect(): void {
967
- if (connState.status === "reconnecting") {
968
- clearTimeout(connState.timer);
969
- connState = { status: "disconnected" };
970
- }
277
+ /**
278
+ * What the connection needs from its consumer. Every member is called, never
279
+ * captured: the connection reads the current one at each event. A member may
280
+ * call `dispose()` on the connection that invoked it; the connection stops
281
+ * after the member returns. A member that throws propagates out of the
282
+ * connection's handler, except `initialSize`, which is isolated (readInitialSize).
283
+ */
284
+ export interface ConnectionCallbacks {
285
+ onMessage(msg: ServerMessage): void;
286
+ onOpen(): void;
287
+ onClose(): void;
288
+ onConnecting?(): void;
289
+ onOutboxFull?(): void;
290
+ /**
291
+ * Fired instead of onClose when the server closes with a DEFINITIVE code:
292
+ * process-exited (4001) or unknown-session (4004). When wired, the connection
293
+ * does NOT auto-reconnect that socket and does not call onClose for it; the
294
+ * consumer may still reconnect explicitly. When absent, every close keeps
295
+ * the transient treatment (onClose plus backoff reconnect).
296
+ */
297
+ onProcessExit?(): void;
298
+ /**
299
+ * Fired when the client's queued input can no longer be trusted: the server's
300
+ * boot-epoch changed, or the server no longer holds this session's input
301
+ * ledger AND the outbox still held unacked bytes. The ledger has been reset
302
+ * by the time it fires, so subsequent input starts from zero.
303
+ */
304
+ onServerRestart?(): void;
305
+ /**
306
+ * Fired when a resumeAck carries an explicit server revision outside
307
+ * [MIN_SUPPORTED_SERVER_WIRE_VERSION, WIRE_PROTOCOL_VERSION]. A newer server
308
+ * warns but continues; a below-floor server also fires onWireIncompatible and
309
+ * stops the socket. Version-silent servers never fire either callback.
310
+ */
311
+ onWireVersionMismatch?(server: number, client: number): void;
312
+ /**
313
+ * Fired when the connection definitively stops a socket for an incompatible
314
+ * declared wire revision (resumeAck metadata or close code 4002). Reconnects
315
+ * stay blocked until an explicit disconnect, whether or not this is wired.
316
+ */
317
+ onWireIncompatible?(details: WireIncompatibility): void;
318
+ /** The terminal's current geometry, integer cols/rows > 0: the server FLOORS an out-of-range resize. */
319
+ computeSize(): { cols: number; rows: number };
320
+ /**
321
+ * How many lines of resume replay this client wants at most, a REFINEMENT of
322
+ * the protocol ceiling: clamped to MAX_REPLAY_LINES before it is sent; absent
323
+ * or null means the renderer's own residency answers.
324
+ */
325
+ getReplayMax?(): number | null;
326
+ /**
327
+ * Fired on resume with the server's retained-history bounds (`committed` is
328
+ * one past the newest retained line, `oldest` the oldest retained index),
329
+ * AFTER the renderer has been given them.
330
+ */
331
+ onResumeBounds?(committed: number, oldest: number): void;
332
+ /**
333
+ * The size to announce BEFORE asking to resume, or null while the client
334
+ * cannot measure itself trustworthily (web fonts loading). Announcing first
335
+ * makes the resume snapshot arrive at this client's own geometry, with the
336
+ * app's SIGWINCH redraw after a coherent snapshot instead of interleaved with
337
+ * the replay. Null is identical to omitting the callback.
338
+ */
339
+ initialSize?(): { cols: number; rows: number } | null;
971
340
  }
972
341
 
973
- // markActivity records that the socket just delivered a frame. Any frame —
974
- // the pong, a screen update, anything — proves the socket is alive, so it
975
- // refreshes the liveness clock and clears any outstanding probe.
976
- function markActivity(): void {
977
- lastActivityAt = Date.now();
978
- probeSentAt = 0;
342
+ /** What `createConnection` drives and reads. */
343
+ export interface ConnectionOptions {
344
+ renderer: Pick<
345
+ Renderer,
346
+ | "getReplayBoundary"
347
+ | "replayMaxForResume"
348
+ | "applyResumeTransition"
349
+ | "noteResumeBounds"
350
+ | "handleHistoryReply"
351
+ | "noteSolicited"
352
+ | "clearSolicited"
353
+ | "maybeFetchHistory"
354
+ >;
355
+ /** Receives every inbound modes frame and the restored snapshot on setSession. */
356
+ modes: Pick<ModeState, "applySnapshot">;
357
+ mouse: Pick<MouseController, "resyncGesture">;
358
+ callbacks: ConnectionCallbacks;
359
+ /** WebSocket endpoint path (default "/ws"). */
360
+ wsPath?: string;
361
+ /**
362
+ * The `sessionStorage` key of the unmanaged session id (default
363
+ * "vterm-session-id"). Two unmanaged connections on one page need distinct
364
+ * keys or they attach to the same session.
365
+ */
366
+ sessionIdKey?: string;
979
367
  }
980
368
 
981
- function startHeartbeat(): void {
982
- stopHeartbeat();
983
- markActivity();
984
- heartbeatTimer = setInterval(heartbeatTick, HEARTBEAT_INTERVAL_MS);
369
+ /**
370
+ * The client-to-server transport: it owns the socket, the jittered
371
+ * exponential-backoff reconnect, and the resume/inputAck layer that makes input
372
+ * survive a reconnect. It decodes frames and applies mode changes itself, so a
373
+ * consumer's `onMessage` only has to route screen and scroll to the renderer.
374
+ */
375
+ export interface Connection {
376
+ /**
377
+ * Queue input for reliable delivery. Returns false when the outbox is full
378
+ * (onOutboxFull has fired) or the connection is disposed. Any byte sequence is
379
+ * deliverable, leading NULs included.
380
+ */
381
+ sendBinary(data: Uint8Array): boolean;
382
+ /**
383
+ * Deliver BEST-EFFORT input (a mouse report): sent only on a live socket, never
384
+ * queued or retransmitted. Returns whether it was sent; on false the caller
385
+ * FORGETS the event. Falls back to reliable input against a server that did
386
+ * not declare `ephemeralInput`, because total silence is a worse failure.
387
+ */
388
+ sendEphemeral(text: string): boolean;
389
+ /**
390
+ * Report whether the terminal widget is focused. Reconciled, not replayed: a
391
+ * repeat sends nothing, a report with no live socket is dropped, a reconnect
392
+ * re-asserts the current value, and a server that did not declare
393
+ * `serverFocus` gets no report.
394
+ */
395
+ setClientFocus(focused: boolean): void;
396
+ /**
397
+ * Measure through `computeSize` and announce the size; a size equal to the last
398
+ * one SENT is dropped, and nothing is queued while no socket is connected.
399
+ */
400
+ sendResize(): void;
401
+ /** Replace whatever socket exists with a fresh one immediately, skipping the backoff. */
402
+ reconnectNow(): void;
403
+ /**
404
+ * Switch the live socket to server session `id`, keeping every session's resume
405
+ * state; marks the connection managed (the URL carries `?session=`). A no-op
406
+ * when `id` is already the active, connected session.
407
+ */
408
+ setSession(id: string): void;
409
+ /** Drop a session's resume state; tears the socket down without reconnecting if it was active. */
410
+ forgetSession(id: string): void;
411
+ /**
412
+ * Seed the server boot epoch a session's PERSISTED content belongs to, before
413
+ * connecting, so the first resumeAck can detect a restart. A zero or non-finite
414
+ * epoch, or a session that already knows its epoch, is ignored.
415
+ */
416
+ adoptPersistedEpoch(sessionId: string, epoch: number): void;
417
+ /** The current adaptive page budget; read it for both a request's length and its anchor. */
418
+ historyBudget(): number;
419
+ /**
420
+ * Request at most `maxLines` lines of history from `fromAbs`. Returns whether
421
+ * it went out; it declines when paging is undeclared, unacked, in flight or
422
+ * paced, and a paced denial arms a coalesced retry.
423
+ */
424
+ requestHistory(fromAbs: number, maxLines: number): boolean;
425
+ /** The server boot epoch last observed for a session, or 0 when none is known. */
426
+ serverEpochOf(sessionId: string): number;
427
+ /** The session id the socket serves, minting the unmanaged id if none is set; "" once disposed. */
428
+ currentSessionId(): string;
429
+ /** Tear down the live socket without reconnecting; resume state is kept. */
430
+ disconnect(): void;
431
+ /**
432
+ * Open the WebSocket; readiness is reported through onOpen, and a connect that
433
+ * never opens fails after 10 s with onClose and a scheduled retry. At most one
434
+ * socket exists per connection whatever the call pattern.
435
+ */
436
+ connect(): void;
437
+ /**
438
+ * Close the socket with code 1000 (no onClose fires), stop every timer, drop
439
+ * every session's queued bytes and detach the callbacks. Idempotent; every
440
+ * method is a no-op or answers its empty value afterwards.
441
+ */
442
+ dispose(): void;
985
443
  }
986
444
 
987
- function stopHeartbeat(): void {
988
- if (heartbeatTimer !== null) {
989
- clearInterval(heartbeatTimer);
990
- heartbeatTimer = null;
445
+ const noop = (): void => undefined;
446
+ const DISPOSED_CALLBACKS: ConnectionCallbacks = {
447
+ onMessage: noop,
448
+ onOpen: noop,
449
+ onClose: noop,
450
+ computeSize: () => ({ cols: 0, rows: 0 }),
451
+ };
452
+
453
+ /** Creates a connection; it does not connect until `connect()` or `setSession()`. */
454
+ export function createConnection(opts: ConnectionOptions): Connection {
455
+ // Invariant: `renderer`, `modes` and `mouse` are read by member call at each call site, never as captured methods.
456
+ const { renderer, modes, mouse } = opts;
457
+ const wsPath = opts.wsPath ?? WS_PATH;
458
+ const sessionIdKey = opts.sessionIdKey ?? DEFAULT_SESSION_ID_KEY;
459
+ let cb: ConnectionCallbacks = opts.callbacks;
460
+ // The lifetime latch (`connState` is overwritten by the reconnect paths, so it
461
+ // cannot hold it). A callback may dispose the connection that invoked it, so every
462
+ // callback followed by work re-checks it, through a call the compiler cannot narrow.
463
+ let disposed = false;
464
+ const isDisposed = (): boolean => disposed;
465
+ let connState: ConnState = { status: "disconnected" };
466
+ let reconnectDelay = INITIAL_DELAY_MS;
467
+ let lastSentCols = 0;
468
+ let lastSentRows = 0;
469
+
470
+ const sessions = new Map<string, ResumeState>();
471
+ // The session the live socket serves; null until the first connect or
472
+ // setSession, when the unmanaged path mints a sessionStorage-backed id.
473
+ let activeId: string | null = null;
474
+ // managed = a consumer selected sessions through setSession, so the URL
475
+ // carries ?session=<id>. Unmanaged keeps the bare wsPath and a sessionStorage
476
+ // id, which survives iOS tab-suspend and BFCache so a reload resumes rather
477
+ // than orphaning its outbox.
478
+ let managed = false;
479
+
480
+ // In managed mode the routing id is SHARED by every device attached to the
481
+ // session, but the server-side input ledger must be PER SENDER: with a shared
482
+ // key, device B's input advances the ledger and the ack reaches device A,
483
+ // whose applyAck trims bytes the server never received from A. So the resume
484
+ // frame carries `<serverSessionId>#<clientInstanceId>` while the URL keeps the
485
+ // routing id. Page-lifetime and NOT persisted: a reload is a fresh sender with
486
+ // an empty outbox. Lazy so a cryptoless environment throws on first CONNECT.
487
+ let clientInstanceId: string | null = null;
488
+
489
+ function resumeKey(st: ResumeState): string {
490
+ if (!managed) {
491
+ return st.id;
492
+ }
493
+ clientInstanceId ??= generateSessionId();
494
+ return `${st.id}#${clientInstanceId}`;
991
495
  }
992
- probeSentAt = 0;
993
- }
994
496
 
995
- // heartbeatTick is the one place that decides a connected socket is stale.
996
- // It never touches scrollTop, never reconnects a healthy socket, and never
997
- // probes a backgrounded tab (timer throttling makes hidden-tab timing
998
- // meaningless, and the wake path handles foregrounding). Its only actions
999
- // are: send a probe after enough silence, or reconnect after a probe goes
1000
- // unanswered.
1001
- function heartbeatTick(): void {
1002
- if (connState.status !== "connected") {
1003
- return;
497
+ function ensureState(id: string): ResumeState {
498
+ let s = sessions.get(id);
499
+ if (s === undefined) {
500
+ s = newResumeState(id);
501
+ sessions.set(id, s);
502
+ }
503
+ return s;
1004
504
  }
1005
- // A hidden tab is handled by visibilitychange/pageshow on wake; probing it
1006
- // is pointless (its timers are throttled or frozen) and could fire stale.
1007
- if (typeof document !== "undefined" && document.visibilityState === "hidden") {
1008
- return;
505
+
506
+ function activeState(): ResumeState {
507
+ activeId ??= loadOrCreateSessionId();
508
+ return ensureState(activeId);
1009
509
  }
1010
- const now = Date.now();
1011
- if (probeSentAt > 0) {
1012
- if (now - probeSentAt >= PONG_TIMEOUT_MS) {
1013
- // The probe drew no reply (nor any other frame) in the grace window:
1014
- // the socket is stale. Tear it down and resume by absolute index.
1015
- probeSentAt = 0;
1016
- reconnectNow();
510
+
511
+ // --- Client-side liveness ---
512
+ // A socket can go silently half-open with no wake event (a NAT idle timeout
513
+ // on a backgrounded tab) and then read OPEN forever while delivering nothing;
514
+ // the server's WS-protocol pings are answered by the browser without reaching
515
+ // JS. So after a stretch of silence the client sends an app-level ping; any
516
+ // inbound frame clears the probe, and an unanswered one reconnects. The probe
517
+ // is what distinguishes "idle but alive" from "dead".
518
+ let lastActivityAt = 0; // Date.now() of the last inbound frame (any kind)
519
+ let probeSentAt = 0; // Date.now() the outstanding probe ping was sent; 0 = none
520
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
521
+
522
+ // --- demand-paged scrollback: per-socket fetch state (docs/paged-scrollback.md) ---
523
+
524
+ let history: HistoryState = newHistoryState();
525
+
526
+ let caps: ServerCaps = { ephemeralInput: false, serverFocus: false };
527
+
528
+ // Per PAGE, not per socket: the widget's own state, which a new socket must be told.
529
+ let clientFocused = false;
530
+ // The focus value THIS socket has been told, or null; per socket, so a
531
+ // reconnect re-asserts focus instead of suppressing it as a repeat.
532
+ let focusReported: boolean | null = null;
533
+
534
+ /** Cancel both timers and release single-flight. */
535
+ function clearHistoryTimers(): void {
536
+ if (history.dataTimer !== null) {
537
+ clearTimeout(history.dataTimer);
538
+ history.dataTimer = null;
539
+ }
540
+ if (history.demandTimer !== null) {
541
+ clearTimeout(history.demandTimer);
542
+ history.demandTimer = null;
1017
543
  }
1018
- return;
1019
544
  }
1020
- if (now - lastActivityAt >= IDLE_BEFORE_PROBE_MS) {
1021
- probeSentAt = now;
1022
- sendControl({ type: "ping" });
545
+
546
+ // Everything a new socket must not inherit, reset together: splitting them is
547
+ // how a client pages against a server that never declared it or holds a focus
548
+ // report back from the new socket as a repeat of what the old one was told.
549
+ function resetForNewSocket(): void {
550
+ clearHistoryTimers();
551
+ history = newHistoryState();
552
+ caps = { ephemeralInput: false, serverFocus: false };
553
+ focusReported = null;
1023
554
  }
1024
- }
1025
555
 
1026
- // teardown aborts and closes the live socket (if any) and stops the heartbeat
1027
- // and any scheduled reconnect, leaving the module disconnected. It never touches
1028
- // per-session resume state, so a later connect() resumes cleanly. Shared by
1029
- // reconnectNow (reconnects after) and disconnect/forgetSession (do not).
1030
- function teardown(): void {
1031
- if (connState.status === "connecting" || connState.status === "connected") {
1032
- // Abort BEFORE close: aborting detaches all listeners on the existing
1033
- // sock, so frames arriving between close() and the close handshake aren't
1034
- // processed twice (the iPad-wake duplicate-output race).
1035
- connState.abort.abort();
556
+ // Spend a pacing token: 0 when granted, else the wait in ms so the caller can
557
+ // arm the coalesced pending demand for exactly that instant.
558
+ function takeHistoryToken(): number {
559
+ const now = Date.now();
560
+ history.tokens = Math.min(
561
+ HISTORY_BURST,
562
+ history.tokens + (now - history.lastRefill) / HISTORY_REFILL_MS,
563
+ );
564
+ history.lastRefill = now;
565
+ if (history.tokens < 1) {
566
+ return Math.ceil((1 - history.tokens) * HISTORY_REFILL_MS);
567
+ }
568
+ history.tokens -= 1;
569
+ return 0;
570
+ }
571
+
572
+ // sessionStorage is per tab and survives iOS suspend, BFCache and reload but
573
+ // not a true tab close, which is the wanted semantic: a fresh tab is a fresh
574
+ // session. Private mode or disabled storage falls back to in-memory only.
575
+ function loadOrCreateSessionId(): string {
1036
576
  try {
1037
- connState.sock.close();
577
+ const existing = sessionStorage.getItem(sessionIdKey);
578
+ if (existing) {
579
+ return existing;
580
+ }
581
+ const fresh = generateSessionId();
582
+ sessionStorage.setItem(sessionIdKey, fresh);
583
+ return fresh;
1038
584
  } catch {
1039
- /* ignore */
585
+ return generateSessionId();
1040
586
  }
1041
587
  }
1042
- stopHeartbeat();
1043
- resetForNewSocket();
1044
- cb?.clearSolicited?.();
1045
- cancelScheduledReconnect();
1046
- connState = { status: "disconnected" };
1047
- }
1048
588
 
1049
- /**
1050
- * reconnectNow replaces whatever socket exists with a fresh one immediately,
1051
- * skipping the backoff schedule. This is the call for an event that says the
1052
- * transport may be stale even though it does not look it — a tab becoming
1053
- * visible, a pageshow, a device waking — because a socket the OS froze during
1054
- * sleep keeps reading OPEN while frames printed meanwhile never arrive.
1055
- *
1056
- * Safe to call in any state and at any rate: the resume protocol aligns by
1057
- * absolute line index, so reconnecting over a healthy socket costs one handshake
1058
- * and delivers no duplicate output. Per-session resume state (outbox, byte
1059
- * counters) survives, so unacked input is retransmitted rather than dropped.
1060
- *
1061
- * Refused, silently, once the peer has been ruled wire-incompatible: that
1062
- * verdict is terminal for the page and only an explicit disconnect clears it.
1063
- */
1064
- export function reconnectNow(): void {
1065
- if (connState.status === "incompatible") {
1066
- return;
589
+ function sendBinary(data: Uint8Array): boolean {
590
+ if (isDisposed()) {
591
+ return false;
592
+ }
593
+ const st = activeState();
594
+ if (st.outboxBytes + data.length > MAX_OUTBOX_BYTES) {
595
+ cb.onOutboxFull?.();
596
+ return false;
597
+ }
598
+ // Bytes leave the outbox only when the server acks them, so a network blip
599
+ // after a successful ws.send() still retransmits.
600
+ const copy = new Uint8Array(data); // defensive copy (caller may reuse buffer)
601
+ st.outbox.push(copy);
602
+ st.outboxBytes += copy.length;
603
+ st.bytesSent += copy.length;
604
+ if (connState.status === "connected") {
605
+ sendInputFrames(connState.sock, connState.upgraded, copy);
606
+ }
607
+ return true;
1067
608
  }
1068
- // Unconditional teardown. On iOS wake (visibilitychange + pageshow), the
1069
- // socket frequently reads OPEN/"connected" for a while but is actually a
1070
- // zombie — the OS froze it during sleep and frames printed meanwhile never
1071
- // arrive. The old early-return on "connected" trusted that stale state and
1072
- // skipped the reconnect, which is exactly why content printed during sleep
1073
- // stayed missing until a manual refresh (bug 2). So we never trust the
1074
- // current state on a wake: abort + close whatever socket exists and
1075
- // reconnect. The resume protocol (by absolute index) then backfills exactly
1076
- // the missed lines, so a reconnect over a still-healthy socket is a cheap,
1077
- // duplicate-free no-op rather than a risk.
1078
- teardown();
1079
- connect();
1080
- }
1081
609
 
1082
- /**
1083
- * setSession switches the live socket to a different server session, keeping
1084
- * every session's resume state intact (design section 5, the switch). The
1085
- * current socket is torn down (its outbox and byte counters preserved for a
1086
- * later switch back) and a fresh socket connects to `id` with its own resume
1087
- * state, sending `?session=id`. Calling this marks the module "managed": the WS
1088
- * URL then carries the session id. A no-op when `id` is already the active,
1089
- * connected session.
1090
- */
1091
- export function setSession(id: string): void {
1092
- managed = true;
1093
- const target = ensureState(id);
1094
- if (id === activeId && (connState.status === "connected" || connState.status === "connecting")) {
1095
- return; // already serving this session
610
+ // Refusing on a closing socket is what xterm.js's AttachAddon and ttyd both do.
611
+ function sendEphemeral(text: string): boolean {
612
+ if (isDisposed() || connState.status !== "connected" || connState.sock.readyState !== WS_OPEN) {
613
+ return false;
614
+ }
615
+ if (!connState.upgraded || !caps.ephemeralInput) {
616
+ // RELIABLE input rather than nothing: an unrecognized text control is
617
+ // dropped post-latch, and total silence is a worse failure than a stale
618
+ // report (a click before a disconnect is retransmitted after the resume).
619
+ return sendBinary(ephemeralEncoder.encode(text));
620
+ }
621
+ connState.sock.send(textControl({ type: "ephemeralInput", data: text }));
622
+ return true;
1096
623
  }
1097
- activeId = id;
1098
- // Restore the target session's DEC-mode mirror SYNCHRONOUSLY (P3): a
1099
- // keystroke fired in the switch window — after this call returns, before
1100
- // the new session's modes frame arrives — must encode under the target's
1101
- // last-known modes (power-on defaults for a session never seen), never
1102
- // under the previous tab's. The kernel already disarms every other latched
1103
- // input class on switch (composition, sticky-Ctrl); this was the one it
1104
- // could not reach.
1105
- modes.applySnapshot(target.modes);
1106
- reconnectNow();
1107
- }
1108
624
 
1109
- /**
1110
- * forgetSession drops a session's resume state (on tab close, design section 17).
1111
- * If it was the active session, the live socket is torn down without
1112
- * reconnecting; the shell then selects another tab via setSession.
1113
- */
1114
- export function forgetSession(id: string): void {
1115
- sessions.delete(id);
1116
- if (id === activeId) {
1117
- activeId = null;
1118
- teardown();
625
+ function setClientFocus(focused: boolean): void {
626
+ if (isDisposed()) {
627
+ return;
628
+ }
629
+ clientFocused = focused;
630
+ // Focus is STATE, so a report with no socket, or for a server that does not
631
+ // derive the DEC 1004 answer, is DROPPED rather than held: the resumeAck
632
+ // re-reports the current value on the new socket.
633
+ if (connState.status !== "connected") {
634
+ return;
635
+ }
636
+ if (!caps.serverFocus || !connState.upgraded) {
637
+ return;
638
+ }
639
+ if (focused === focusReported) {
640
+ return;
641
+ }
642
+ focusReported = focused;
643
+ sendControl({ type: "focus", focused });
1119
644
  }
1120
- }
1121
645
 
1122
- /**
1123
- * Seed the server boot epoch a session's PERSISTED content belongs to, before
1124
- * connecting.
1125
- *
1126
- * Restart detection is otherwise in-memory: the first resumeAck of a page load
1127
- * records the epoch with nothing to compare against, and only a LATER mismatch
1128
- * fires onServerRestart. That is correct for a store built during this page's
1129
- * lifetime, and actively wrong for one hydrated from disk — a restarted server
1130
- * begins its absolute indices again at 0, so a hydrated store would be presented
1131
- * as live while the new session's low-index output was silently refused by the
1132
- * store's staleness guard.
1133
- *
1134
- * Seeding gives the first resumeAck something to compare against, so the
1135
- * existing restart path fires and resets the stale content exactly as it would
1136
- * mid-session. A consumer that hydrates a store MUST call this with the
1137
- * snapshot's `serverEpoch` (see StoreSnapshot in store.ts).
1138
- *
1139
- * Ignores a zero or non-finite epoch: those mean "no epoch was ever recorded",
1140
- * which is indistinguishable from a version-silent server, and seeding a
1141
- * sentinel would make the next real epoch look like a restart.
1142
- *
1143
- * Also ignores a session that already knows its epoch. "Seed" is the whole
1144
- * contract: this exists to give the FIRST resumeAck something to compare against,
1145
- * and overwriting a value a server already reported would make a genuine restart
1146
- * look like agreement, or a live session look restarted. A caller that reaches
1147
- * here twice, or after connecting, has a bug the library can simply refuse.
1148
- */
1149
- export function adoptPersistedEpoch(sessionId: string, epoch: number): void {
1150
- if (!Number.isFinite(epoch) || epoch === 0) {
1151
- return;
646
+ function sendControl(msg: ControlMessage): void {
647
+ if (connState.status !== "connected") {
648
+ return;
649
+ }
650
+ if (connState.upgraded) {
651
+ connState.sock.send(textControl(msg));
652
+ return;
653
+ }
654
+ connState.sock.send(controlFrame(msg));
1152
655
  }
1153
- const st = ensureState(sessionId);
1154
- if (st.lastServerEpoch !== null) {
1155
- return;
656
+
657
+ // Validated because the server FLOORS an out-of-range resize rather than
658
+ // dropping it. Isolated in a try/catch because this runs inside the open
659
+ // handler after the connect timeout was cleared and BEFORE the resume is sent:
660
+ // a throwing provider would otherwise leave the resume unsent and the state
661
+ // stuck at "connecting" with no timeout left to rescue it.
662
+ function readInitialSize(): { cols: number; rows: number } | null {
663
+ let size: { cols: number; rows: number } | null;
664
+ try {
665
+ size = cb.initialSize?.() ?? null;
666
+ } catch (err) {
667
+ console.warn("vterm: initialSize provider threw; announcing no size", err);
668
+ return null;
669
+ }
670
+ if (
671
+ size === null ||
672
+ !Number.isInteger(size.cols) ||
673
+ !Number.isInteger(size.rows) ||
674
+ size.cols <= 0 ||
675
+ size.rows <= 0
676
+ ) {
677
+ return null;
678
+ }
679
+ return { cols: size.cols, rows: size.rows };
1156
680
  }
1157
- st.lastServerEpoch = epoch;
1158
- // Marked as a CLAIM about restored content, not an observation. A resume that
1159
- // reports no epoch cannot confirm it, and unverifiable restored content is
1160
- // handled as a restart — see handleResumeAck. This is the one hole the epoch
1161
- // comparison alone leaves: a server downgraded to reporting no epoch between the
1162
- // save and the load never contradicts the seeded value, so the stale store would
1163
- // otherwise be presented as live and then refuse the new session's low indices.
1164
- st.epochSeeded = true;
1165
- }
1166
681
 
1167
- /**
1168
- * The current adaptive request budget: how many lines the next page request
1169
- * should ask for. The fetch trigger lives in the renderer (the only layer that
1170
- * maps scroll position to absolute indices) while this budget is per-SOCKET
1171
- * state owned here, and this accessor is the only channel between them.
1172
- *
1173
- * The trigger must read it for BOTH the request's length and its ANCHOR. Using
1174
- * it for the length alone is a real defect, not a nicety: an anchor computed
1175
- * from the full page size while the length is shrunken serves a range that ends
1176
- * far from the reader, so a slow link heals the far end of a gap and leaves the
1177
- * rows under the viewport blank (docs/paged-scrollback.md §4.2).
1178
- */
1179
- export function historyBudget(): number {
1180
- return history.effMax;
1181
- }
682
+ // A fresh socket clears the dedup baseline as it opens, so the consumer's next
683
+ // call after a reconnect always goes out.
684
+ function sendResize(): void {
685
+ if (isDisposed() || connState.status !== "connected") {
686
+ return;
687
+ }
688
+ const { cols, rows } = cb.computeSize();
689
+ if (isDisposed() || (cols === lastSentCols && rows === lastSentRows)) {
690
+ return;
691
+ }
692
+ lastSentCols = cols;
693
+ lastSentRows = rows;
694
+ sendControl({ type: "resize", cols, rows });
695
+ }
1182
696
 
1183
- /** Whether this socket's server declared demand-paged scrollback. */
1184
- export function historyPagingAvailable(): boolean {
1185
- return history.paging && history.acked;
1186
- }
697
+ // Reserved for a boot-epoch change, the one cause where "the server
698
+ // restarted" is literally true.
699
+ function resetSessionAfterRestart(st: ResumeState): void {
700
+ resetLedger(st);
701
+ cb.onServerRestart?.();
702
+ }
1187
703
 
1188
- /** Whether a page request is currently outstanding (single-flight). */
1189
- export function historyRequestInFlight(): boolean {
1190
- return history.inFlight !== null;
1191
- }
704
+ // A forgotten ledger is not a restart and not by itself a loss: only the
705
+ // UNACKED remainder was at risk, so a session whose every byte was acked
706
+ // resets silently. Reporting that common case was a real defect: an iPad that
707
+ // slept for half an hour woke to a "server restarted" banner with an empty
708
+ // outbox and a fully applied session.
709
+ function resetForgottenLedger(st: ResumeState): void {
710
+ const lostUnacked = st.bytesSent > st.bytesAcked;
711
+ resetLedger(st);
712
+ if (lostUnacked) {
713
+ cb.onServerRestart?.();
714
+ }
715
+ }
1192
716
 
1193
- /**
1194
- * Request a page of history: at most `maxLines` lines starting at `fromAbs`.
1195
- *
1196
- * Returns true when the request went out. It can decline for four reasons, all
1197
- * of them normal: the server never declared paging, an ack has not arrived yet,
1198
- * a request is already in flight (single-flight), or the pacing bucket is empty.
1199
- * The last case ARMS the coalesced pending demand — a denied trigger is not
1200
- * merely dropped, because on an idle session no future scroll or flush event
1201
- * would ever re-fire it and a byte-short continuation would stall forever.
1202
- *
1203
- * The caller (the renderer's controller) owns the geometry; this owns the
1204
- * transport, single-flight, pacing, the timers and the adaptive budget.
1205
- */
1206
- export function requestHistory(fromAbs: number, maxLines: number): boolean {
1207
- if (!history.paging || !history.acked) {
1208
- return false;
717
+ function scheduleReconnect(): void {
718
+ // Both callers set "disconnected" before cb.onClose(), so a consumer that
719
+ // reconnects from its own close hook lands here at "connecting"; scheduling
720
+ // over it would OVERWRITE connState, hide that socket from connect()'s
721
+ // double-call guard, and let the timer's connect() add a second live socket.
722
+ if (
723
+ isDisposed() ||
724
+ connState.status === "reconnecting" ||
725
+ connState.status === "connecting" ||
726
+ connState.status === "connected"
727
+ ) {
728
+ return;
729
+ }
730
+ const step = nextBackoffDelay(reconnectDelay);
731
+ reconnectDelay = step.nextBaseMs;
732
+ const timer = setTimeout(() => {
733
+ connState = { status: "disconnected" };
734
+ connect();
735
+ }, step.scheduledMs);
736
+ connState = { status: "reconnecting", timer, delayMs: step.scheduledMs };
1209
737
  }
1210
- if (history.inFlight !== null) {
1211
- return false;
738
+
739
+ function cancelScheduledReconnect(): void {
740
+ if (connState.status === "reconnecting") {
741
+ clearTimeout(connState.timer);
742
+ connState = { status: "disconnected" };
743
+ }
1212
744
  }
1213
- if (
1214
- !Number.isSafeInteger(fromAbs) ||
1215
- !Number.isSafeInteger(maxLines) ||
1216
- fromAbs < 0 ||
1217
- maxLines < 1 ||
1218
- // The subtraction form: the addition form is itself the overflow it would
1219
- // exist to reject (§4.1).
1220
- fromAbs > Number.MAX_SAFE_INTEGER - maxLines
1221
- ) {
1222
- return false;
745
+
746
+ function markActivity(): void {
747
+ lastActivityAt = Date.now();
748
+ probeSentAt = 0;
1223
749
  }
1224
- if (connState.status !== "connected" || !connState.upgraded) {
1225
- return false;
750
+
751
+ function startHeartbeat(): void {
752
+ stopHeartbeat();
753
+ markActivity();
754
+ heartbeatTimer = setInterval(heartbeatTick, HEARTBEAT_INTERVAL_MS);
1226
755
  }
1227
- const wait = takeHistoryToken();
1228
- if (wait > 0) {
1229
- armPendingDemand(wait);
1230
- return false;
756
+
757
+ function stopHeartbeat(): void {
758
+ if (heartbeatTimer !== null) {
759
+ clearInterval(heartbeatTimer);
760
+ heartbeatTimer = null;
761
+ }
762
+ probeSentAt = 0;
1231
763
  }
1232
- const bounded = Math.min(maxLines, history.effMax);
1233
- const end = fromAbs + bounded;
1234
- history.inFlight = { fromAbs, end };
1235
- cb?.noteSolicited?.(fromAbs, end);
1236
- // Through sendControl, which owns the ENCODING decision. Sending
1237
- // `controlFrame` directly here was a defect with three simultaneous
1238
- // consequences, none of them visible in this repo's tests: this function
1239
- // refuses to run unless the socket is `upgraded`, and a post-upgrade server
1240
- // parses the v3 0x00-sentinel only while UNLATCHED — so every request was
1241
- // written straight to the PTY instead (terminal.go's handleBinaryFrame). No
1242
- // page reply ever arrived (the 8 s data timeout halved the budget and retried
1243
- // forever, so paging was inert); the control's JSON was TYPED INTO THE USER'S
1244
- // SHELL, and fed to the session-title deriver; and its bytes advanced the
1245
- // server's received-byte ledger while the client never counted them, so
1246
- // applyAck's min(received, bytesSent) clamp trimmed genuinely-unacked
1247
- // keystrokes out of the outbox.
1248
- sendControl({ type: "history", fromAbs, maxLines: bounded });
1249
- history.dataTimer = setTimeout(onHistoryDataTimeout, HISTORY_DATA_TIMEOUT_MS);
1250
- return true;
1251
- }
1252
764
 
1253
- /**
1254
- * Arm the coalesced pending demand: ONE timer that re-runs the full trigger
1255
- * when the bucket refills. Coalesced because a burst of denied triggers should
1256
- * cost one retry, not one each; and re-running the FULL guard set rather than
1257
- * replaying the denied request, because the world may have changed (the gap may
1258
- * have healed, the session may have entered alt).
1259
- */
1260
- function armPendingDemand(waitMs: number): void {
1261
- if (history.demandTimer !== null) {
1262
- return;
765
+ // The one place that decides a connected socket is stale: a probe after
766
+ // enough silence, a reconnect after a probe goes unanswered.
767
+ function heartbeatTick(): void {
768
+ if (isDisposed() || connState.status !== "connected") {
769
+ return;
770
+ }
771
+ // A hidden tab's timers are throttled or frozen, so a probe could fire
772
+ // stale; the wake path handles foregrounding.
773
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
774
+ return;
775
+ }
776
+ const now = Date.now();
777
+ if (probeSentAt > 0) {
778
+ if (now - probeSentAt >= PONG_TIMEOUT_MS) {
779
+ probeSentAt = 0;
780
+ reconnectNow();
781
+ }
782
+ return;
783
+ }
784
+ if (now - lastActivityAt >= IDLE_BEFORE_PROBE_MS) {
785
+ probeSentAt = now;
786
+ sendControl({ type: "ping" });
787
+ }
1263
788
  }
1264
- history.demandTimer = setTimeout(() => {
1265
- history.demandTimer = null;
1266
- cb?.onHistoryRetry?.();
1267
- }, waitMs);
1268
- }
1269
789
 
1270
- /**
1271
- * The in-flight request did not answer in time. Release single-flight so the
1272
- * controller can try again, HALVE the budget, and remember the size that failed
1273
- * as the recovery ceiling.
1274
- *
1275
- * The ceiling is RFC 5681's `ssthresh` role — a growth target, not a cap below
1276
- * the current value. Halving alone would oscillate (a link that carries 500 but
1277
- * not 1000 would time out on every other request forever); dropping to the
1278
- * floor and climbing back toward half the failed size converges on the largest
1279
- * size the link actually carries, approaching it from below.
1280
- */
1281
- function onHistoryDataTimeout(): void {
1282
- const failed = history.inFlight;
1283
- history.dataTimer = null;
1284
- history.inFlight = null;
1285
- cb?.clearSolicited?.();
1286
- if (failed !== null) {
1287
- const size = failed.end - failed.fromAbs;
1288
- history.budgetCeiling = Math.max(HISTORY_MIN_PAGE, Math.floor(size / 2));
1289
- history.effMax = HISTORY_MIN_PAGE;
790
+ // Leaves the connection disconnected with every session's resume state
791
+ // intact, so a later connect() resumes cleanly.
792
+ function teardown(): void {
793
+ if (connState.status === "connecting" || connState.status === "connected") {
794
+ // Abort BEFORE close: aborting detaches the listeners, so frames arriving
795
+ // between close() and the close handshake are not processed twice.
796
+ connState.abort.abort();
797
+ try {
798
+ connState.sock.close();
799
+ } catch {
800
+ /* ignore */
801
+ }
802
+ }
803
+ stopHeartbeat();
804
+ resetForNewSocket();
805
+ renderer.clearSolicited();
806
+ cancelScheduledReconnect();
807
+ connState = { status: "disconnected" };
1290
808
  }
1291
- // Retry through the same coalescing path a denied trigger uses, so the
1292
- // controller re-evaluates its guards rather than replaying a stale range.
1293
- armPendingDemand(0);
1294
- }
1295
809
 
1296
- /**
1297
- * Correlate an inbound scroll frame against the in-flight request.
1298
- *
1299
- * A frame is the reply iff its `firstIndex` lies inside the request window.
1300
- * CONTAINMENT then decides the CONTROL effects separately from the content: only
1301
- * a reply that fits entirely inside the window releases single-flight and grows
1302
- * the budget. A correlated frame extending BEYOND the window — a timed-out
1303
- * larger reply sharing a retry's `fromAbs` — has its in-window intersection
1304
- * applied by the store and changes no control state, so the attempt's own reply
1305
- * still gets to complete it (§4.3).
1306
- *
1307
- * Returns the floor to raise to when the reply proves history is gone
1308
- * (`firstIndex > fromAbs` is the CLAMP signal; an empty reply means nothing in
1309
- * the whole window survives), or null.
1310
- */
1311
- function correlateHistoryReply(msg: ScrollMessage): {
1312
- correlated: boolean;
1313
- raiseFloorTo: number | null;
1314
- contained: boolean;
1315
- } {
1316
- const req = history.inFlight;
1317
- if (req === null) {
1318
- return { correlated: false, raiseFloorTo: null, contained: false };
810
+ // Unconditional teardown, whatever connState says: on iOS wake the socket
811
+ // reads OPEN for a while but is a zombie the OS froze during sleep, and
812
+ // trusting that state left content printed during sleep missing until a
813
+ // manual refresh. The resume protocol aligns by absolute index, so a
814
+ // reconnect over a healthy socket costs one handshake and no duplicate output.
815
+ function reconnectNow(): void {
816
+ if (isDisposed() || connState.status === "incompatible") {
817
+ return;
818
+ }
819
+ teardown();
820
+ connect();
1319
821
  }
1320
- if (msg.firstIndex < req.fromAbs || msg.firstIndex >= req.end) {
1321
- return { correlated: false, raiseFloorTo: null, contained: false };
822
+
823
+ function setSession(id: string): void {
824
+ if (isDisposed()) {
825
+ return;
826
+ }
827
+ managed = true;
828
+ const target = ensureState(id);
829
+ if (
830
+ id === activeId &&
831
+ (connState.status === "connected" || connState.status === "connecting")
832
+ ) {
833
+ return; // already serving this session
834
+ }
835
+ activeId = id;
836
+ // SYNCHRONOUSLY, so a keystroke in the switch window (before the new
837
+ // session's modes frame arrives) encodes under the target's modes.
838
+ modes.applySnapshot(target.modes);
839
+ reconnectNow();
1322
840
  }
1323
- const count = msg.lines.length;
1324
- let raiseFloorTo: number | null = null;
1325
- if (count === 0) {
1326
- // Nothing in [fromAbs, end) is retained: the whole window is condemned.
1327
- raiseFloorTo = req.end;
1328
- } else if (msg.firstIndex > req.fromAbs) {
1329
- // The server served from higher up than asked: everything below the served
1330
- // start is evicted server-side, permanently.
1331
- raiseFloorTo = msg.firstIndex;
841
+
842
+ function forgetSession(id: string): void {
843
+ if (isDisposed()) {
844
+ return;
845
+ }
846
+ sessions.delete(id);
847
+ if (id === activeId) {
848
+ activeId = null;
849
+ teardown();
850
+ }
1332
851
  }
1333
- const contained = msg.firstIndex + count <= req.end;
1334
- if (contained) {
1335
- if (history.dataTimer !== null) {
1336
- clearTimeout(history.dataTimer);
1337
- history.dataTimer = null;
852
+
853
+ // Restart detection is otherwise in-memory, which is wrong for a store
854
+ // hydrated from disk: a restarted server begins its indices at 0 again and the
855
+ // hydrated store would refuse the new session's output. Zero or non-finite
856
+ // means "never recorded"; a session that already knows its epoch is left
857
+ // alone, or a genuine restart could read as agreement.
858
+ function adoptPersistedEpoch(sessionId: string, epoch: number): void {
859
+ if (isDisposed() || !Number.isFinite(epoch) || epoch === 0) {
860
+ return;
1338
861
  }
1339
- history.inFlight = null;
1340
- // Recovery: climb toward the remembered ceiling, never past it.
1341
- history.effMax = Math.min(history.effMax * 2, history.budgetCeiling);
862
+ const st = ensureState(sessionId);
863
+ if (st.lastServerEpoch !== null) {
864
+ return;
865
+ }
866
+ st.lastServerEpoch = epoch;
867
+ // A CLAIM, not an observation: a server that reports no epoch never
868
+ // contradicts it, so the resume handler treats an unconfirmed seed as a restart.
869
+ st.epochSeeded = true;
1342
870
  }
1343
- return { correlated: true, raiseFloorTo, contained };
1344
- }
1345
871
 
1346
- /**
1347
- * The server boot epoch last observed for a session, or 0 when none is known —
1348
- * the read half of adoptPersistedEpoch.
1349
- *
1350
- * A consumer that persists a session's content must record WHICH server process
1351
- * the absolute indices in it belong to, and this is the only place that fact
1352
- * exists: it arrives in a resumeAck and is otherwise kept privately for restart
1353
- * detection. Pass the result to `LineStore.snapshot(serverEpoch)`.
1354
- *
1355
- * 0 means "never learned one", which happens before the first resumeAck and
1356
- * against a version-silent server that sends no epoch at all. It is deliberately
1357
- * the same value `adoptPersistedEpoch` ignores, so a snapshot taken without an
1358
- * epoch cannot later be mistaken for one taken under a known epoch.
1359
- */
1360
- export function serverEpochOf(sessionId: string): number {
1361
- return sessions.get(sessionId)?.lastServerEpoch ?? 0;
1362
- }
872
+ // An anchor computed from the full page size while the length is shrunken
873
+ // serves a range that ends far from the reader (docs/paged-scrollback.md §4.2).
874
+ function historyBudget(): number {
875
+ return isDisposed() ? 0 : history.effMax;
876
+ }
1363
877
 
1364
- /**
1365
- * The session id this module's socket serves, resolving the UNMANAGED id if no
1366
- * session has been set.
1367
- *
1368
- * A managed consumer (one calling setSession) already holds its own ids and does
1369
- * not need this. The unmanaged single-terminal path does: its identity is a
1370
- * per-tab, sessionStorage-backed id minted inside this module, so a consumer
1371
- * that wants to key anything per-session — persisted scrollback, per-session
1372
- * scroll memory — has no id to key it by, and any id it invented would be wrong
1373
- * in one direction or the other (localStorage would make two tabs share one
1374
- * terminal's state; a fresh random one would never match across a reload).
1375
- *
1376
- * Resolving means the same lazy load-or-mint the first send/connect performs, so
1377
- * calling this before connecting simply moves that by a few milliseconds. The
1378
- * semantics that make it the right persistence key are the ones sessionStorage
1379
- * already gives: stable across a reload and an iOS tab restore, fresh in a
1380
- * genuinely new tab.
1381
- */
1382
- export function currentSessionId(): string {
1383
- activeId ??= loadOrCreateSessionId();
1384
- return activeId;
1385
- }
878
+ // A paced denial ARMS the coalesced pending demand rather than dropping: on an
879
+ // idle session no future scroll or flush event would re-fire it, and a
880
+ // byte-short continuation would stall forever.
881
+ function requestHistory(fromAbs: number, maxLines: number): boolean {
882
+ if (isDisposed() || !history.paging || !history.acked) {
883
+ return false;
884
+ }
885
+ if (history.inFlight !== null) {
886
+ return false;
887
+ }
888
+ if (
889
+ !Number.isSafeInteger(fromAbs) ||
890
+ !Number.isSafeInteger(maxLines) ||
891
+ fromAbs < 0 ||
892
+ maxLines < 1 ||
893
+ // The subtraction form: the addition form is itself the overflow it
894
+ // would exist to reject.
895
+ fromAbs > Number.MAX_SAFE_INTEGER - maxLines
896
+ ) {
897
+ return false;
898
+ }
899
+ if (connState.status !== "connected" || !connState.upgraded) {
900
+ return false;
901
+ }
902
+ const wait = takeHistoryToken();
903
+ if (wait > 0) {
904
+ armPendingDemand(wait);
905
+ return false;
906
+ }
907
+ const bounded = Math.min(maxLines, history.effMax);
908
+ const end = fromAbs + bounded;
909
+ history.inFlight = { fromAbs, end };
910
+ renderer.noteSolicited(fromAbs, end);
911
+ // Through sendControl, which owns the ENCODING: this runs only on an
912
+ // `upgraded` socket, where a v3 sentinel frame is written straight to the
913
+ // PTY, typing the control's JSON into the user's shell.
914
+ sendControl({ type: "history", fromAbs, maxLines: bounded });
915
+ history.dataTimer = setTimeout(onHistoryDataTimeout, HISTORY_DATA_TIMEOUT_MS);
916
+ return true;
917
+ }
1386
918
 
1387
- /**
1388
- * disconnect tears down the live socket without reconnecting. Per-session resume
1389
- * state is kept, so a later setSession/connect resumes cleanly. Used when the
1390
- * shell has no active tab to show (e.g. the last tab closed).
1391
- */
1392
- export function disconnect(): void {
1393
- teardown();
1394
- }
919
+ // ONE timer that re-runs the FULL trigger when the bucket refills, rather than
920
+ // replaying the denied request: the gap may have healed or the session
921
+ // entered alt in the meantime.
922
+ function armPendingDemand(waitMs: number): void {
923
+ if (history.demandTimer !== null) {
924
+ return;
925
+ }
926
+ history.demandTimer = setTimeout(() => {
927
+ history.demandTimer = null;
928
+ if (isDisposed()) {
929
+ return;
930
+ }
931
+ renderer.maybeFetchHistory();
932
+ }, waitMs);
933
+ }
1395
934
 
1396
- /**
1397
- * connect opens the WebSocket and starts the module: after init, this is the one
1398
- * call a consumer needs, and everything that follows (backoff reconnects, the
1399
- * heartbeat, resume) is driven from here. It returns as soon as the socket is
1400
- * created — readiness is reported through Callbacks.onOpen, and a connect that
1401
- * never opens is failed by a 10s timeout that reports onClose and schedules a
1402
- * retry, so a caller never has to time it out itself.
1403
- *
1404
- * At most one socket exists per module, whatever the call pattern: an existing
1405
- * connecting/connected socket is aborted and closed, and a backoff reconnect
1406
- * already scheduled is cancelled, so a stray second call cannot leave two
1407
- * sockets delivering the same session's output twice. Resume state is untouched,
1408
- * so the new socket picks the session up where the old one left it.
1409
- *
1410
- * A no-op once the peer has been ruled wire-incompatible.
1411
- */
1412
- export function connect(): void {
1413
- if (connState.status === "incompatible") {
1414
- return;
935
+ // Release single-flight, drop to the floor and remember half the failed size
936
+ // as the recovery ceiling (RFC 5681's `ssthresh` role): halving alone would
937
+ // oscillate on a link that carries 500 but not 1000, while climbing back
938
+ // toward the ceiling converges on the largest size the link carries.
939
+ function onHistoryDataTimeout(): void {
940
+ if (isDisposed()) {
941
+ return;
942
+ }
943
+ const failed = history.inFlight;
944
+ history.dataTimer = null;
945
+ history.inFlight = null;
946
+ renderer.clearSolicited();
947
+ if (failed !== null) {
948
+ const size = failed.end - failed.fromAbs;
949
+ history.budgetCeiling = Math.max(HISTORY_MIN_PAGE, Math.floor(size / 2));
950
+ history.effMax = HISTORY_MIN_PAGE;
951
+ }
952
+ armPendingDemand(0);
1415
953
  }
1416
- // Guard against double-call: a stray invocation while a previous
1417
- // socket is still CONNECTING/OPEN would orphan it (its handlers
1418
- // remain bound but the new sock assignment makes it unreachable).
1419
- // Aborting the previous controller detaches all listeners on the
1420
- // old sock so it can't deliver frames to the page after we've moved
1421
- // on (the iPad-wake duplicate-output race).
1422
- if (connState.status === "connecting" || connState.status === "connected") {
1423
- connState.abort.abort();
1424
- try {
1425
- connState.sock.close();
1426
- } catch {
1427
- /* ignore */
954
+
955
+ // A frame is the reply iff its `firstIndex` lies inside the request window.
956
+ // CONTAINMENT decides the CONTROL effects separately: only a reply that fits
957
+ // entirely inside the window releases single-flight and grows the budget, so a
958
+ // timed-out larger reply sharing a retry's `fromAbs` has its intersection
959
+ // applied and leaves the attempt's own reply to complete it. `raiseFloorTo` is
960
+ // the CLAMP signal (`firstIndex > fromAbs`, or an empty reply condemning the
961
+ // whole window).
962
+ function correlateHistoryReply(msg: ScrollMessage): {
963
+ correlated: boolean;
964
+ raiseFloorTo: number | null;
965
+ contained: boolean;
966
+ } {
967
+ const req = history.inFlight;
968
+ if (req === null) {
969
+ return { correlated: false, raiseFloorTo: null, contained: false };
970
+ }
971
+ if (msg.firstIndex < req.fromAbs || msg.firstIndex >= req.end) {
972
+ return { correlated: false, raiseFloorTo: null, contained: false };
973
+ }
974
+ const count = msg.lines.length;
975
+ let raiseFloorTo: number | null = null;
976
+ if (count === 0) {
977
+ raiseFloorTo = req.end;
978
+ } else if (msg.firstIndex > req.fromAbs) {
979
+ raiseFloorTo = msg.firstIndex;
1428
980
  }
981
+ const contained = msg.firstIndex + count <= req.end;
982
+ if (contained) {
983
+ if (history.dataTimer !== null) {
984
+ clearTimeout(history.dataTimer);
985
+ history.dataTimer = null;
986
+ }
987
+ history.inFlight = null;
988
+ // Recovery: climb toward the remembered ceiling, never past it.
989
+ history.effMax = Math.min(history.effMax * 2, history.budgetCeiling);
990
+ }
991
+ return { correlated: true, raiseFloorTo, contained };
1429
992
  }
1430
- // Re-entry while a backoff reconnect is pending (e.g. a consumer calling
1431
- // connect() to restore a panel during the 500ms-8s backoff window): clear the
1432
- // scheduled timer so it cannot fire later and spawn a SECOND socket alongside
1433
- // the one created below. The orphaned timer resets connState to disconnected
1434
- // and calls connect() again, while the existing socket's listeners stay bound
1435
- // (its abort never fired) -> a duplicate server connection + double delivery.
1436
- // cancelScheduledReconnect is a no-op in any non-reconnecting state.
1437
- cancelScheduledReconnect();
1438
-
1439
- cb?.onConnecting?.();
1440
-
1441
- // The resume state this socket serves, captured for the socket's lifetime.
1442
- // A switch aborts this socket's listeners, so even a late frame is handled
1443
- // against the session it was opened for, never whoever is active now.
1444
- const st = activeState();
1445
- let url = wsURL(location.protocol, location.host, wsPath);
1446
- if (managed) {
1447
- // Route the socket to this session (SessionManager's WebSocketHandler
1448
- // dispatches on ?session=). The unmanaged single-terminal path keeps the
1449
- // bare wsPath, matching resume purely by the resume frame's sessionId.
1450
- url += (url.includes("?") ? "&" : "?") + "session=" + encodeURIComponent(st.id);
993
+
994
+ // 0 is deliberately the value `adoptPersistedEpoch` ignores, so a snapshot taken
995
+ // without an epoch cannot later be mistaken for one taken under a known epoch.
996
+ function serverEpochOf(sessionId: string): number {
997
+ return isDisposed() ? 0 : (sessions.get(sessionId)?.lastServerEpoch ?? 0);
1451
998
  }
1452
- const sock = new WebSocket(url);
1453
- sock.binaryType = "arraybuffer";
1454
-
1455
- // One AbortController governs the lifetime of THIS sock's listeners.
1456
- // - Connect-timeout fallback: aborts after 10s if open never fires.
1457
- // - Listener auto-detach: every addEventListener below uses
1458
- // { signal: connectAbort.signal }, so when the controller is
1459
- // aborted (by reconnectNow / connect / close) the listeners are
1460
- // removed atomically and can't fire again.
1461
- const connectAbort = new AbortController();
1462
- const timeoutId = setTimeout(() => {
1463
- // Aborting detaches every listener registered with connectAbort.signal
1464
- // (abort algorithms run BEFORE the abort event fires), INCLUDING the
1465
- // "close" listener that normally schedules the reconnect. A connect that
1466
- // never opens (SYN dropped by a firewall / an overloaded server) would
1467
- // otherwise leave connState pinned at "connecting" with no auto-retry.
1468
- // Drive the reconnect explicitly, mirroring the close handler.
1469
- connectAbort.abort();
1470
- if (connState.status === "connecting" && connState.sock === sock) {
1471
- stopHeartbeat();
1472
- connState = { status: "disconnected" };
1473
- cb?.onClose();
1474
- scheduleReconnect();
999
+
1000
+ function currentSessionId(): string {
1001
+ if (isDisposed()) {
1002
+ return "";
1475
1003
  }
1476
- }, 10_000);
1477
- connectAbort.signal.addEventListener("abort", () => {
1478
- clearTimeout(timeoutId);
1479
- // Force-close on abort so the OS-level socket goes away promptly,
1480
- // not only when the browser eventually completes its close
1481
- // handshake. Belt-and-braces with the .close() in our callers.
1482
- try {
1483
- sock.close();
1484
- } catch {
1485
- /* ignore */
1004
+ activeId ??= loadOrCreateSessionId();
1005
+ return activeId;
1006
+ }
1007
+
1008
+ function disconnect(): void {
1009
+ if (isDisposed()) {
1010
+ return;
1486
1011
  }
1487
- });
1012
+ teardown();
1013
+ }
1014
+
1015
+ function connect(): void {
1016
+ if (isDisposed() || connState.status === "incompatible") {
1017
+ return;
1018
+ }
1019
+ // A previous socket still CONNECTING/OPEN would be orphaned with its
1020
+ // handlers bound; aborting its controller detaches them so it cannot
1021
+ // deliver frames after the page has moved on.
1022
+ if (connState.status === "connecting" || connState.status === "connected") {
1023
+ connState.abort.abort();
1024
+ try {
1025
+ connState.sock.close();
1026
+ } catch {
1027
+ /* ignore */
1028
+ }
1029
+ }
1030
+ // A pending backoff timer would otherwise fire later and spawn a SECOND
1031
+ // socket beside the one created below, with the first one's listeners bound.
1032
+ cancelScheduledReconnect();
1488
1033
 
1489
- connState = { status: "connecting", sock, abort: connectAbort };
1034
+ cb.onConnecting?.();
1035
+ if (isDisposed()) {
1036
+ return;
1037
+ }
1490
1038
 
1491
- sock.addEventListener(
1492
- "open",
1493
- () => {
1039
+ // Captured for the socket's lifetime: a switch aborts this socket's
1040
+ // listeners, so a late frame is handled against the session it was opened for.
1041
+ const st = activeState();
1042
+ let url = wsURL(location.protocol, location.host, wsPath);
1043
+ if (managed) {
1044
+ url += (url.includes("?") ? "&" : "?") + "session=" + encodeURIComponent(st.id);
1045
+ }
1046
+ const sock = new WebSocket(url);
1047
+ sock.binaryType = "arraybuffer";
1048
+
1049
+ // One AbortController governs THIS sock's listeners: every addEventListener
1050
+ // below passes its signal, so an abort removes them atomically.
1051
+ const connectAbort = new AbortController();
1052
+ const timeoutId = setTimeout(() => {
1053
+ // Abort algorithms run BEFORE the abort event, so aborting detaches the
1054
+ // "close" listener that would schedule the reconnect; a connect that never
1055
+ // opens would otherwise pin connState at "connecting" with no retry.
1056
+ connectAbort.abort();
1057
+ if (!isDisposed() && connState.status === "connecting" && connState.sock === sock) {
1058
+ stopHeartbeat();
1059
+ connState = { status: "disconnected" };
1060
+ cb.onClose();
1061
+ if (isDisposed()) {
1062
+ return;
1063
+ }
1064
+ scheduleReconnect();
1065
+ }
1066
+ }, 10_000);
1067
+ connectAbort.signal.addEventListener("abort", () => {
1494
1068
  clearTimeout(timeoutId);
1495
- // A new socket has no size on record with the server yet, so the
1496
- // deduplication baseline resets before anything is announced below.
1497
- lastSentCols = 0;
1498
- lastSentRows = 0;
1499
- // Announce the size BEFORE asking to resume, when the consumer can
1500
- // measure trustworthily (Callbacks.initialSize). Order matters and is the
1501
- // whole point: the server applies a resize control the moment it decodes
1502
- // it, so a resize that precedes the resume makes the resume's screen
1503
- // snapshot and history replay come back at THIS client's geometry, with
1504
- // the app's SIGWINCH redraw (if any) landing after a coherent snapshot
1505
- // instead of interleaved with the replay.
1506
- //
1507
- // This does not weaken the "resume is message one" invariant, which
1508
- // exists for two reasons, neither of which a resize touches: the server
1509
- // must see the resume's protocolVersion before any TEXT frame arrives
1510
- // (a resize does not arm typed framing), and PTY INPUT must not precede
1511
- // session resolution (input sent before the resume reaches the process
1512
- // but is skipped by the server's received-byte ledger, desyncing the
1513
- // outbox accounting). That is also why cb.onOpen() still runs after the
1514
- // resume: a consumer may send input from it.
1515
- const size = readInitialSize();
1516
- if (size !== null) {
1517
- sock.send(controlFrame({ type: "resize", cols: size.cols, rows: size.rows }));
1518
- lastSentCols = size.cols;
1519
- lastSentRows = size.rows;
1069
+ // Force-close so the OS-level socket goes away promptly, not only when
1070
+ // the browser completes its close handshake.
1071
+ try {
1072
+ sock.close();
1073
+ } catch {
1074
+ /* ignore */
1520
1075
  }
1521
- // Bootstrap resume, on the captured socket, before consumer callbacks can
1522
- // run: always binary-sentinel encoded, understood by every server
1523
- // revision (the v4 negotiation bootstrap, design §4). An onOpen callback
1524
- // that calls sendResize()/sendBinary() therefore always queues AFTER it.
1525
- // Capture the resume's two history-relevant inputs BEFORE sending, and
1526
- // remember them on the socket. The server's reply is a function of these
1527
- // exact values, so the replay-jump prediction must be computed from them
1528
- // — never from store state, which a frame arriving between send and ack
1529
- // could have moved, masking a real jump (docs/paged-scrollback.md §4.5).
1530
- resetForNewSocket();
1531
- const sentHaveThrough = cb?.getHaveThrough?.() ?? -1;
1532
- // ALWAYS a number. A consumer may ask for fewer lines than the protocol
1533
- // ceiling, but it cannot opt OUT of the bound: the server clamps to the
1534
- // same ceiling unconditionally, and a client that sent nothing while the
1535
- // server bounded anyway would predict no replay jump where one happened —
1536
- // leaving the stranded band classified as live tail for enforceCap to eat
1537
- // silently. The two sides agree by construction instead of by wiring.
1538
- const rawReplayMax = cb?.getReplayMax?.() ?? MAX_REPLAY_LINES;
1539
- const sentReplayMax =
1540
- Number.isSafeInteger(rawReplayMax) && rawReplayMax >= 1
1541
- ? Math.min(rawReplayMax, MAX_REPLAY_LINES)
1542
- : MAX_REPLAY_LINES;
1543
- history.sentHaveThrough = sentHaveThrough;
1544
- history.sentReplayMax = sentReplayMax;
1545
- sock.send(
1546
- controlFrame({
1547
- type: "resume",
1548
- // Managed mode: the per-sender resume key (routing id + "#" +
1549
- // instance id), so each device/tab owns its server-side input
1550
- // ledger. Unmanaged: the per-tab sessionStorage id as-is.
1551
- sessionId: resumeKey(st),
1552
- sentBytes: st.bytesSent,
1553
- // Highest absolute line index the client holds (-1 if none). The
1554
- // server replays everything after it, so lines printed while the
1555
- // device slept are backfilled exactly on wake (bug 2), with no
1556
- // duplication because applying a line by absolute index is
1557
- // idempotent. Falls back to -1 (full retained replay) if the
1558
- // consumer wired no getHaveThrough.
1559
- haveThrough: sentHaveThrough,
1560
- // The resume replay bound, already clamped to what the server will
1561
- // honor, and always present (see above).
1562
- replayMax: sentReplayMax,
1563
- // Lets the server detect a client built against a different wire
1564
- // revision (e.g. a stale cached bundle) and warn rather than
1565
- // silently mis-decode; >= 4 also ARMS the connection for the
1566
- // typed-framing upgrade (design §4 phase 1).
1567
- protocolVersion: WIRE_PROTOCOL_VERSION,
1568
- }),
1569
- );
1570
- // Every socket starts in v3 mode; the resumeAck's serverWireVersion
1571
- // decides whether the typed-framing upgrade happens (design §4).
1572
- connState = { status: "connected", sock, abort: connectAbort, upgraded: false };
1573
- reconnectDelay = INITIAL_DELAY_MS;
1574
- cb?.onOpen();
1575
-
1576
- // Begin client-side liveness probing for this socket. Idempotent
1577
- // (clears any prior timer) and resets the activity clock to now.
1578
- startHeartbeat();
1579
- },
1580
- { signal: connectAbort.signal },
1581
- );
1582
-
1583
- // Queue for serializing Blob→ArrayBuffer conversion. iOS Safari can
1584
- // deliver binary WS frames as Blob; the conversion is async via
1585
- // .arrayBuffer() and unordered resolution would corrupt screen state.
1586
- // We chain promises so each frame is processed in arrival order.
1587
- let blobChain: Promise<void> = Promise.resolve();
1588
-
1589
- sock.addEventListener(
1590
- "message",
1591
- (ev: MessageEvent) => {
1592
- // Any inbound frame — pong, screen update, anything — proves the
1593
- // socket is delivering, so it refreshes the liveness clock before we
1594
- // even decode it. A malformed frame that decodes to null still counts.
1595
- markActivity();
1596
- if (ev.data instanceof ArrayBuffer) {
1597
- try {
1598
- handleDecoded(decodeWireBinary(ev.data));
1599
- } catch (err) {
1600
- // Mirror the Blob branch below: a throw here (a consumer onMessage
1601
- // callback, or the documented re-throw of a non-RangeError from
1602
- // decodeWireBinary) is logged with engine context instead of
1603
- // surfacing as a bare uncaught exception, so field observability is
1604
- // the same across ArrayBuffer (non-iOS) and Blob (iOS Safari) frames.
1605
- console.error("vterm: dropped binary frame", err);
1076
+ });
1077
+
1078
+ connState = { status: "connecting", sock, abort: connectAbort };
1079
+
1080
+ sock.addEventListener(
1081
+ "open",
1082
+ () => {
1083
+ clearTimeout(timeoutId);
1084
+ // A new socket has no size on record with the server.
1085
+ lastSentCols = 0;
1086
+ lastSentRows = 0;
1087
+ // The size goes BEFORE the resume: the server applies a resize the
1088
+ // moment it decodes it, so the snapshot and replay come back at THIS
1089
+ // client's geometry. The resume still precedes every TEXT frame (the
1090
+ // server must see protocolVersion first) and every PTY input (input
1091
+ // before the resume is skipped by the received-byte ledger), which is
1092
+ // why cb.onOpen() runs after it.
1093
+ const size = readInitialSize();
1094
+ if (isDisposed()) {
1095
+ return;
1096
+ }
1097
+ if (size !== null) {
1098
+ sock.send(controlFrame({ type: "resize", cols: size.cols, rows: size.rows }));
1099
+ lastSentCols = size.cols;
1100
+ lastSentRows = size.rows;
1606
1101
  }
1102
+ // The server's reply is a function of the exact haveThrough and
1103
+ // replayMax sent, so the replay-jump prediction is computed from the
1104
+ // values remembered on the socket, never from store state a frame
1105
+ // between send and ack could have moved (docs/paged-scrollback.md §4.5).
1106
+ resetForNewSocket();
1107
+ const sentHaveThrough = renderer.getReplayBoundary();
1108
+ // ALWAYS a number: the server clamps to the same ceiling
1109
+ // unconditionally, and a client that sent nothing would predict no
1110
+ // replay jump where one happened.
1111
+ const rawReplayMax = cb.getReplayMax?.() ?? renderer.replayMaxForResume();
1112
+ if (isDisposed()) {
1113
+ return;
1114
+ }
1115
+ const sentReplayMax =
1116
+ Number.isSafeInteger(rawReplayMax) && rawReplayMax >= 1
1117
+ ? Math.min(rawReplayMax, MAX_REPLAY_LINES)
1118
+ : MAX_REPLAY_LINES;
1119
+ history.sentHaveThrough = sentHaveThrough;
1120
+ history.sentReplayMax = sentReplayMax;
1121
+ // Binary-sentinel encoded, understood by every server revision; the
1122
+ // server replays everything after haveThrough, idempotent by index.
1123
+ sock.send(
1124
+ controlFrame({
1125
+ type: "resume",
1126
+ sessionId: resumeKey(st),
1127
+ sentBytes: st.bytesSent,
1128
+ haveThrough: sentHaveThrough,
1129
+ replayMax: sentReplayMax,
1130
+ protocolVersion: WIRE_PROTOCOL_VERSION,
1131
+ }),
1132
+ );
1133
+ // Every socket starts in v3 mode; the resumeAck decides the upgrade.
1134
+ connState = { status: "connected", sock, abort: connectAbort, upgraded: false };
1135
+ reconnectDelay = INITIAL_DELAY_MS;
1136
+ cb.onOpen();
1137
+ if (isDisposed()) {
1138
+ return;
1139
+ }
1140
+ startHeartbeat();
1141
+ },
1142
+ { signal: connectAbort.signal },
1143
+ );
1144
+
1145
+ // iOS Safari can deliver binary frames as Blob, whose conversion is async;
1146
+ // chaining keeps arrival order, which unordered resolution would corrupt.
1147
+ let blobChain: Promise<void> = Promise.resolve();
1148
+
1149
+ sock.addEventListener(
1150
+ "message",
1151
+ (ev: MessageEvent) => {
1152
+ markActivity();
1153
+ if (ev.data instanceof ArrayBuffer) {
1154
+ try {
1155
+ handleDecoded(decodeWireBinary(ev.data));
1156
+ } catch (err) {
1157
+ // Logged with engine context, as the Blob branch does, so field
1158
+ // observability is the same for both frame types.
1159
+ console.error("vterm: dropped binary frame", err);
1160
+ }
1161
+ return;
1162
+ }
1163
+ if (ev.data instanceof Blob) {
1164
+ const blob = ev.data;
1165
+ blobChain = blobChain
1166
+ .then(() => blob.arrayBuffer())
1167
+ .then((ab) => {
1168
+ // A conversion already queued outlives the abort, and a frame from
1169
+ // a superseded socket must not reach handleDecoded, where its
1170
+ // resumeAck could reset or retransmit against the REPLACEMENT.
1171
+ if (
1172
+ isDisposed() ||
1173
+ connectAbort.signal.aborted ||
1174
+ connState.status !== "connected" ||
1175
+ connState.sock !== sock
1176
+ ) {
1177
+ return;
1178
+ }
1179
+ handleDecoded(decodeWireBinary(ab));
1180
+ })
1181
+ .catch((err: unknown) => {
1182
+ // A throw must NOT poison the chain: a rejected blobChain skips
1183
+ // every later frame's .then, and since markActivity() already ran
1184
+ // the liveness probe never fires, so the tab looks connected and
1185
+ // renders nothing.
1186
+ console.error("vterm: dropped binary (blob) frame", err);
1187
+ });
1188
+ return;
1189
+ }
1190
+ // A server text frame is undefined in the protocol and is ignored.
1191
+ },
1192
+ { signal: connectAbort.signal },
1193
+ );
1194
+
1195
+ function handleDecoded(msg: ServerMessage | null): void {
1196
+ if (msg === null) {
1607
1197
  return;
1608
1198
  }
1609
- if (ev.data instanceof Blob) {
1610
- const blob = ev.data;
1611
- blobChain = blobChain
1612
- .then(() => blob.arrayBuffer())
1613
- .then((ab) => {
1614
- // Stale-socket guard (design §4, review F2): the async
1615
- // blob.arrayBuffer() hop can outlive this socket — teardown
1616
- // aborts the listeners, but a conversion already queued still
1617
- // resolves. A frame from a superseded socket must not reach
1618
- // handleDecoded, where its resumeAck could upgrade/reset/
1619
- // retransmit against the REPLACEMENT socket's server.
1620
- if (
1621
- connectAbort.signal.aborted ||
1622
- connState.status !== "connected" ||
1623
- connState.sock !== sock
1624
- ) {
1199
+ if (msg.type === "resumeAck") {
1200
+ // A below-floor revision is definitive: stop the socket BEFORE the
1201
+ // consumer callbacks and latch the no-reconnect state. A missing tail
1202
+ // remains the version-silent compatibility path.
1203
+ if (
1204
+ msg.serverWireVersion !== undefined &&
1205
+ msg.serverWireVersion < MIN_SUPPORTED_SERVER_WIRE_VERSION
1206
+ ) {
1207
+ const reason = `server wire protocol ${msg.serverWireVersion} is below client minimum ${MIN_SUPPORTED_SERVER_WIRE_VERSION}; upgrade the server`;
1208
+ console.warn("vterm: refusing incompatible server wire protocol", reason);
1209
+ stopHeartbeat();
1210
+ connState = { status: "incompatible" };
1211
+ try {
1212
+ sock.close(WIRE_INCOMPATIBLE_CLOSE_CODE, reason);
1213
+ } finally {
1214
+ connectAbort.abort();
1215
+ }
1216
+ cb.onWireVersionMismatch?.(msg.serverWireVersion, WIRE_PROTOCOL_VERSION);
1217
+ cb.onWireIncompatible?.({
1218
+ source: "server-version",
1219
+ serverVersion: msg.serverWireVersion,
1220
+ clientVersion: WIRE_PROTOCOL_VERSION,
1221
+ minimumServerVersion: MIN_SUPPORTED_SERVER_WIRE_VERSION,
1222
+ reason,
1223
+ });
1224
+ return;
1225
+ }
1226
+ // A newer server may retain this client's baseline: warn and continue.
1227
+ if (msg.serverWireVersion !== undefined && msg.serverWireVersion > WIRE_PROTOCOL_VERSION) {
1228
+ console.warn(
1229
+ "vterm: server wire-protocol version is newer than client",
1230
+ "server",
1231
+ msg.serverWireVersion,
1232
+ "client",
1233
+ WIRE_PROTOCOL_VERSION,
1234
+ "- upgrade the client if terminal behavior is incorrect",
1235
+ );
1236
+ cb.onWireVersionMismatch?.(msg.serverWireVersion, WIRE_PROTOCOL_VERSION);
1237
+ if (isDisposed()) {
1238
+ return;
1239
+ }
1240
+ }
1241
+ // Typed-framing upgrade: the text transition goes FIRST, so WebSocket
1242
+ // ordering guarantees the server latches before any unsplit binary
1243
+ // input, and BEFORE the retransmit below so it already uses the
1244
+ // upgraded framing.
1245
+ if (
1246
+ msg.serverWireVersion !== undefined &&
1247
+ msg.serverWireVersion >= TYPED_FRAMING_MIN_VERSION &&
1248
+ connState.status === "connected" &&
1249
+ connState.sock === sock &&
1250
+ !connState.upgraded
1251
+ ) {
1252
+ sock.send(textControl({ type: "upgrade" }));
1253
+ connState.upgraded = true;
1254
+ }
1255
+ // Server-restart detection: an epoch that differs from the recorded one
1256
+ // means the new process has no record of the previous boot's input.
1257
+ const epoch = msg.serverEpoch;
1258
+ let epochChanged = false;
1259
+ if (epoch !== undefined && epoch !== 0) {
1260
+ if (st.lastServerEpoch !== null && st.lastServerEpoch !== epoch) {
1261
+ epochChanged = true;
1262
+ resetSessionAfterRestart(st);
1263
+ if (isDisposed()) {
1625
1264
  return;
1626
1265
  }
1627
- handleDecoded(decodeWireBinary(ab));
1628
- })
1629
- .catch((err: unknown) => {
1630
- // A throw here (typically a consumer onMessage callback) must NOT
1631
- // poison the chain: without this catch blobChain stays rejected and
1632
- // every later Blob frame's .then is skipped, silently dropping all
1633
- // binary frames until reconnect. iOS Safari delivers binary WS
1634
- // frames as Blob, and markActivity() already ran on arrival, so the
1635
- // liveness probe never fires -> the tab looks connected but renders
1636
- // nothing. Log and continue; arrival order is preserved.
1637
- console.error("vterm: dropped binary (blob) frame", err);
1638
- });
1639
- return;
1640
- }
1641
- // Text frames from the server are undefined in the protocol (the server
1642
- // sends only binary frames; wire v4 made text a CLIENT->server control
1643
- // channel). The old dormant JSON.parse branch that accepted them as
1644
- // unvalidated ServerMessages was removed 2026-07 (judgement finding).
1645
- },
1646
- { signal: connectAbort.signal },
1647
- );
1648
-
1649
- function handleDecoded(msg: ServerMessage | null): void {
1650
- if (msg === null) {
1651
- return;
1652
- }
1653
- if (msg.type === "resumeAck") {
1654
- // An explicit below-floor revision is definitive: this decoder cannot
1655
- // safely consume that server's frames. Stop the socket before invoking
1656
- // consumer callbacks, latch the no-reconnect state, and require an
1657
- // explicit disconnect/page reload before another attempt. A missing
1658
- // tail remains the version-silent compatibility path.
1659
- if (
1660
- msg.serverWireVersion !== undefined &&
1661
- msg.serverWireVersion < MIN_SUPPORTED_SERVER_WIRE_VERSION
1662
- ) {
1663
- const reason = `server wire protocol ${msg.serverWireVersion} is below client minimum ${MIN_SUPPORTED_SERVER_WIRE_VERSION}; upgrade the server`;
1664
- console.warn("vterm: refusing incompatible server wire protocol", reason);
1665
- stopHeartbeat();
1666
- connState = { status: "incompatible" };
1667
- try {
1668
- sock.close(WIRE_INCOMPATIBLE_CLOSE_CODE, reason);
1669
- } finally {
1670
- connectAbort.abort();
1266
+ }
1267
+ st.lastServerEpoch = epoch;
1268
+ st.epochSeeded = false;
1269
+ } else if (st.epochSeeded) {
1270
+ // A SEEDED epoch this server will not confirm: unverifiable restored
1271
+ // content is handled as a restart, because the alternative is
1272
+ // presenting a previous run's output as live and then refusing the new
1273
+ // session's low absolute indices.
1274
+ epochChanged = true;
1275
+ resetSessionAfterRestart(st);
1276
+ if (isDisposed()) {
1277
+ return;
1278
+ }
1279
+ st.lastServerEpoch = null;
1280
+ st.epochSeeded = false;
1671
1281
  }
1672
- cb?.onWireVersionMismatch?.(msg.serverWireVersion, WIRE_PROTOCOL_VERSION);
1673
- cb?.onWireIncompatible?.({
1674
- source: "server-version",
1675
- serverVersion: msg.serverWireVersion,
1676
- clientVersion: WIRE_PROTOCOL_VERSION,
1677
- minimumServerVersion: MIN_SUPPORTED_SERVER_WIRE_VERSION,
1678
- reason,
1282
+ // An absent flags tail (an older server) reads as unset bits.
1283
+ history.paging = msg.historyPaging === true;
1284
+ history.acked = true;
1285
+ // Capabilities, the focus re-report and the store's ONE ack transition
1286
+ // all run BEFORE the ledger-lost returns below: a ledger loss is not a
1287
+ // capability event, and the long-absence attach that loses its ledger
1288
+ // is exactly the one that needs its focus asserted and carries a replay
1289
+ // jump (docs/paged-scrollback.md §4.5).
1290
+ caps = {
1291
+ ephemeralInput: msg.ephemeralInput === true,
1292
+ serverFocus: msg.serverFocus === true,
1293
+ };
1294
+ focusReported = null;
1295
+ setClientFocus(clientFocused);
1296
+ renderer.applyResumeTransition({
1297
+ epochChanged,
1298
+ committed: typeof msg.committed === "number" ? msg.committed : null,
1299
+ serverOldest: typeof msg.oldestIndex === "number" ? msg.oldestIndex : null,
1300
+ paging: history.paging,
1301
+ sentHaveThrough: history.sentHaveThrough,
1302
+ sentReplayMax: history.sentReplayMax,
1679
1303
  });
1680
- return;
1681
- }
1682
- // Higher revisions may retain this client's compatible baseline. Keep
1683
- // the socket running and surface the skew as a warning only.
1684
- if (msg.serverWireVersion !== undefined && msg.serverWireVersion > WIRE_PROTOCOL_VERSION) {
1685
- console.warn(
1686
- "vterm: server wire-protocol version is newer than client",
1687
- "server",
1688
- msg.serverWireVersion,
1689
- "client",
1690
- WIRE_PROTOCOL_VERSION,
1691
- "- upgrade the client if terminal behavior is incorrect",
1304
+ if (typeof msg.committed === "number" && typeof msg.oldestIndex === "number") {
1305
+ renderer.noteResumeBounds(msg.committed, msg.oldestIndex);
1306
+ cb.onResumeBounds?.(msg.committed, msg.oldestIndex);
1307
+ if (isDisposed()) {
1308
+ return;
1309
+ }
1310
+ }
1311
+ // The server cannot vouch for ANY previously sent input once it lost the
1312
+ // ledger, so replaying the outbox risks duplicate execution: drop it. The
1313
+ // explicit flag also covers bytesAcked === 0 (acks that never reached
1314
+ // us); the received=0 heuristic is the older server's form of the same
1315
+ // fact, skipped on a genuine first connect where bytesAcked is 0.
1316
+ if (msg.ledgerLost) {
1317
+ resetForgottenLedger(st);
1318
+ return;
1319
+ }
1320
+ if (msg.received === 0 && st.bytesAcked > 0) {
1321
+ resetForgottenLedger(st);
1322
+ return;
1323
+ }
1324
+ applyAck(st, msg.received);
1325
+ retransmitOutbox(
1326
+ sock,
1327
+ connState.status === "connected" && connState.sock === sock && connState.upgraded,
1328
+ st,
1692
1329
  );
1693
- cb?.onWireVersionMismatch?.(msg.serverWireVersion, WIRE_PROTOCOL_VERSION);
1330
+ // The first instant a best-effort report can leave (`upgraded` and
1331
+ // `caps` are both set); a gesture recorded before the gap is cancelled
1332
+ // now or the application holds that button for the rest of the session.
1333
+ mouse.resyncGesture();
1334
+ return;
1694
1335
  }
1695
- // Typed-framing upgrade (design §4 phase 3): on proof of a v4+ server,
1696
- // send the text transition FIRST — WebSocket ordering then guarantees
1697
- // the server latches before any unsplit binary input that follows —
1698
- // and only then flip the socket's mode. This must precede the
1699
- // ledger-lost/ack/retransmit handling below so the retransmit already
1700
- // uses the upgraded framing.
1701
- if (
1702
- msg.serverWireVersion !== undefined &&
1703
- msg.serverWireVersion >= TYPED_FRAMING_MIN_VERSION &&
1704
- connState.status === "connected" &&
1705
- connState.sock === sock &&
1706
- !connState.upgraded
1707
- ) {
1708
- sock.send(textControl({ type: "upgrade" }));
1709
- connState.upgraded = true;
1336
+ if (msg.type === "ackOnly") {
1337
+ // Input was applied but no content frame carried the count (a silent
1338
+ // app, `read -s`). Transport-internal, never forwarded to onMessage.
1339
+ applyAck(st, msg.inputAck);
1340
+ return;
1710
1341
  }
1711
- // Server-restart detection. The first resumeAck we see records
1712
- // the epoch; subsequent ones compare to it. A mismatch means the
1713
- // server's process has restarted, which invalidates our local
1714
- // bytesSent/bytesAcked accounting (the new server has no record
1715
- // of the previous boot's input). Reset state and notify the UI.
1716
- const epoch = msg.serverEpoch;
1717
- let epochChanged = false;
1718
- if (epoch !== undefined && epoch !== 0) {
1719
- if (st.lastServerEpoch !== null && st.lastServerEpoch !== epoch) {
1720
- epochChanged = true;
1721
- resetSessionAfterRestart(st);
1342
+ if (msg.type === "modes") {
1343
+ // The cached session and the ModeState are the same session by
1344
+ // construction: a superseded socket's listeners are aborted.
1345
+ const snap: ModeSnapshot = {
1346
+ bracketedPaste: msg.bracketedPaste,
1347
+ applicationCursor: msg.applicationCursor,
1348
+ mouseSGR: msg.mouseSGR,
1349
+ focusReporting: msg.focusReporting,
1350
+ mouseMode: msg.mouseMode,
1351
+ applicationKeypad: msg.applicationKeypad,
1352
+ reverseVideo: msg.reverseVideo,
1353
+ mousePixels: msg.mousePixels,
1354
+ keyboardFlags: msg.keyboardFlags,
1355
+ };
1356
+ st.modes = snap;
1357
+ modes.applySnapshot(snap);
1358
+ if (typeof msg.inputAck === "number") {
1359
+ applyAck(st, msg.inputAck);
1722
1360
  }
1723
- st.lastServerEpoch = epoch;
1724
- // Confirmed by a server: no longer a claim about restored content.
1725
- st.epochSeeded = false;
1726
- } else if (st.epochSeeded) {
1727
- // The epoch we hold was SEEDED from persisted content and this server will
1728
- // not say what process it is. Unverifiable restored content is handled as a
1729
- // restart, because the alternative is presenting a previous run's output as
1730
- // live and then refusing the new session's low absolute indices — wrong,
1731
- // then permanently blank. Only ever reachable for a hydrated session: an
1732
- // epoch learned from an earlier ack is an observation, and a server that
1733
- // reports none is ordinary operation for it.
1734
- epochChanged = true;
1735
- resetSessionAfterRestart(st);
1736
- st.lastServerEpoch = null;
1737
- st.epochSeeded = false;
1361
+ cb.onMessage(msg);
1362
+ return;
1738
1363
  }
1739
- // Capability, read from the ack's length-gated flags tail. An absent tail
1740
- // (a server older than it) reads the same as an unset bit: no paging,
1741
- // nothing ever sent, and the store keeps its compatibility tail cap.
1742
- history.paging = msg.historyPaging === true;
1743
- history.acked = true;
1744
- // The other two capabilities off the same tail, and the focus re-report
1745
- // for this socket. Placed here — with the capability handling, BEFORE the
1746
- // ledger-lost / session-forgotten early returns — for the same reason
1747
- // onResumeTransition is: a ledger loss is not a capability event, and the
1748
- // long-absence attach that loses its ledger still needs its focus state
1749
- // asserted on the new socket.
1750
- caps = {
1751
- ephemeralInput: msg.ephemeralInput === true,
1752
- serverFocus: msg.serverFocus === true,
1753
- };
1754
- focusReported = null;
1755
- setClientFocus(clientFocused);
1756
- // The store's ONE ack transition, dispatched BEFORE the early returns
1757
- // below. A ledger loss is not a capability event, and the long-absence
1758
- // attach that loses its ledger is exactly the one carrying a replay jump
1759
- // — appending this after those returns would skip it precisely there
1760
- // (docs/paged-scrollback.md §4.5).
1761
- cb?.onResumeTransition?.({
1762
- epochChanged,
1763
- committed: typeof msg.committed === "number" ? msg.committed : null,
1764
- serverOldest: typeof msg.oldestIndex === "number" ? msg.oldestIndex : null,
1765
- paging: history.paging,
1766
- sentHaveThrough: history.sentHaveThrough,
1767
- sentReplayMax: history.sentReplayMax,
1768
- });
1769
- // Resync guard 8.2.2: hand the server's retained-history bounds to the
1770
- // consumer so it can surface a trim marker when history the client was
1771
- // missing is gone for good. (If the ledger-lost / session-forgotten
1772
- // paths below reset state, a fresh server's oldest=0 simply reads as
1773
- // "no trim".)
1774
- if (typeof msg.committed === "number" && typeof msg.oldestIndex === "number") {
1775
- cb?.onResumeBounds?.(msg.committed, msg.oldestIndex);
1364
+ if (typeof msg.inputAck === "number") {
1365
+ applyAck(st, msg.inputAck);
1776
1366
  }
1777
- // Explicit ledger-loss signal (servers with the >= 35-byte resumeAck
1778
- // tail): the resume key missed the server's registry while we claimed
1779
- // sent bytes — our ledger was reclaimed (idle GC / cap eviction). The
1780
- // server cannot vouch for ANY previously sent input, so replaying the
1781
- // outbox risks duplicate execution. Deterministic drop, plus a notify
1782
- // only when unacked bytes were actually dropped (resetForgottenLedger),
1783
- // covering the bytesAcked === 0 case the heuristic below cannot see
1784
- // (acks that never reached us before the disconnect).
1785
- if (msg.ledgerLost) {
1786
- resetForgottenLedger(st);
1367
+ // PRE-ACK CONTENT SUPPRESSION (docs/paged-scrollback.md §4.5): on a busy
1368
+ // session a live frame can arrive before this socket's resumeAck, and its
1369
+ // rows would mutate the store under a stale residency cap. Field-aware:
1370
+ // the inputAck was applied above, ED3 is a consumed one-shot the resume
1371
+ // batch hard-codes false and is forwarded as a rows-less clear, `bell`
1372
+ // announces a screen the batch is about to repaint and is dropped. The
1373
+ // rows are lossless by supersession: the replay re-delivers them.
1374
+ if (!history.acked && (msg.type === "screen" || msg.type === "scroll")) {
1375
+ if (msg.type === "screen" && msg.scrollbackCleared) {
1376
+ cb.onMessage({ ...msg, changed: [], rows: [], bell: false });
1377
+ }
1787
1378
  return;
1788
1379
  }
1789
- // Server-doesn't-recognize-this-session safeguard (old-server
1790
- // fallback, pre-ledgerLost tail): if the server returns received=0
1791
- // but the client already had bytesAcked > 0, the server has forgotten
1792
- // our session (idle GC kicked in, or sessionId persistence failed and
1793
- // a reload created a new one). Replaying the outbox would deliver
1794
- // every queued chunk again, causing the iOS tab-suspend
1795
- // duplicate-resend bug. Drop the outbox, and notify only if it held
1796
- // unacked bytes — input since the last successful ack is
1797
- // irrecoverable but at least not duplicated. Skip this branch when
1798
- // bytesSent = 0 (genuine first-connect; received=0 is correct).
1799
- if (msg.received === 0 && st.bytesAcked > 0) {
1800
- resetForgottenLedger(st);
1801
- return;
1380
+ if (msg.type === "scroll") {
1381
+ const { correlated, raiseFloorTo, contained } = correlateHistoryReply(msg);
1382
+ if (correlated) {
1383
+ renderer.handleHistoryReply(msg, raiseFloorTo);
1384
+ // The solicited window is the store's permission to admit lines below
1385
+ // its stale-re-send watermark, so it outlives the apply; left open with
1386
+ // no request in flight, a later duplicate frame in that range could
1387
+ // resurrect an evicted row. An OVERSPILLING reply keeps it: its
1388
+ // attempt is still open.
1389
+ if (contained) {
1390
+ renderer.clearSolicited();
1391
+ }
1392
+ return;
1393
+ }
1802
1394
  }
1803
- applyAck(st, msg.received);
1804
- retransmitOutbox(
1805
- sock,
1806
- connState.status === "connected" && connState.sock === sock && connState.upgraded,
1807
- st,
1808
- );
1809
- // The transport answers for itself here, as it does for focus: the resume is
1810
- // complete, `upgraded` and `caps` are both set, so this is the first instant
1811
- // a best-effort report can leave. A gesture still recorded from before the
1812
- // gap is cancelled now, or the application holds that button for the rest of
1813
- // the session. Inert until a consumer has called `mouse.init`.
1814
- mouse.resyncGesture();
1815
- return;
1395
+ cb.onMessage(msg);
1816
1396
  }
1817
- if (msg.type === "ackOnly") {
1818
- // Bare ack from the flush tick's sweep: input was applied but no
1819
- // content frame carried the new count (silent app — e.g. `read -s`).
1820
- // Trim the outbox and stop; transport-internal, never forwarded to
1821
- // onMessage.
1822
- applyAck(st, msg.inputAck);
1823
- return;
1824
- }
1825
- if (msg.type === "modes") {
1826
- // Single mirror writer (P3): cache the snapshot on the session this
1827
- // socket serves AND apply it to the active-session singleton. The two
1828
- // targets are the same session by construction — a superseded socket's
1829
- // listeners are aborted, so only the active session's socket delivers.
1830
- const snap: modes.ModeSnapshot = {
1831
- bracketedPaste: msg.bracketedPaste,
1832
- applicationCursor: msg.applicationCursor,
1833
- mouseSGR: msg.mouseSGR,
1834
- focusReporting: msg.focusReporting,
1835
- mouseMode: msg.mouseMode,
1836
- applicationKeypad: msg.applicationKeypad,
1837
- reverseVideo: msg.reverseVideo,
1838
- mousePixels: msg.mousePixels,
1839
- keyboardFlags: msg.keyboardFlags,
1840
- };
1841
- st.modes = snap;
1842
- modes.applySnapshot(snap);
1843
- if (typeof msg.inputAck === "number") {
1844
- applyAck(st, msg.inputAck);
1845
- }
1846
- // Notify the UI so it can react to mode changes (e.g. clear
1847
- // scrollback on alt-screen entry — handled by the caller).
1848
- cb?.onMessage(msg);
1397
+
1398
+ sock.addEventListener(
1399
+ "close",
1400
+ (ev: CloseEvent) => {
1401
+ // A superseded sock's listener is removed by its abort; the check
1402
+ // guards the window before the abort has propagated.
1403
+ if (connState.status !== "connecting" && connState.status !== "connected") {
1404
+ return;
1405
+ }
1406
+ if (connState.sock !== sock) {
1407
+ return;
1408
+ }
1409
+ stopHeartbeat();
1410
+ resetForNewSocket();
1411
+ renderer.clearSolicited();
1412
+ if (ev.code === WIRE_INCOMPATIBLE_CLOSE_CODE) {
1413
+ const reason =
1414
+ ev.reason ||
1415
+ "server rejected this client wire protocol; reload or upgrade the client/server";
1416
+ connState = { status: "incompatible" };
1417
+ cb.onWireIncompatible?.({
1418
+ source: "server-close",
1419
+ clientVersion: WIRE_PROTOCOL_VERSION,
1420
+ minimumServerVersion: MIN_SUPPORTED_SERVER_WIRE_VERSION,
1421
+ reason,
1422
+ });
1423
+ return;
1424
+ }
1425
+ connState = { status: "disconnected" };
1426
+ // A definitive close can only be collected again by a reconnect, an
1427
+ // endless churn that reads as a flapping "Reconnecting…" banner.
1428
+ if (
1429
+ (ev.code === PROCESS_EXITED_CLOSE_CODE || ev.code === SESSION_UNKNOWN_CLOSE_CODE) &&
1430
+ cb.onProcessExit
1431
+ ) {
1432
+ cb.onProcessExit();
1433
+ return;
1434
+ }
1435
+ cb.onClose();
1436
+ if (isDisposed()) {
1437
+ return;
1438
+ }
1439
+ scheduleReconnect();
1440
+ },
1441
+ { signal: connectAbort.signal },
1442
+ );
1443
+
1444
+ sock.addEventListener(
1445
+ "error",
1446
+ () => {
1447
+ /* no-op: prevents unhandled error */
1448
+ },
1449
+ { signal: connectAbort.signal },
1450
+ );
1451
+ }
1452
+
1453
+ // Close BEFORE abort: the abort listener calls a bare `close()`, which would
1454
+ // emit a code-less close and make the later `close(1000)` a no-op; close-first
1455
+ // emits 1000 and the bare close that follows is the no-op instead.
1456
+ function dispose(): void {
1457
+ if (isDisposed()) {
1849
1458
  return;
1850
1459
  }
1851
- if (typeof msg.inputAck === "number") {
1852
- applyAck(st, msg.inputAck);
1853
- }
1854
- // PRE-ACK CONTENT SUPPRESSION (docs/paged-scrollback.md §4.5). The server
1855
- // registers a socket and wakes its scheduler at accept, so on a busy session
1856
- // a live screen/scroll frame is the DESIGNED first delivery — it can arrive
1857
- // before this socket's resumeAck. Applying its rows would mutate the store
1858
- // under a stale residency cap, and on an already-flipped store one frame is
1859
- // enough to push the tail over and have ordinary eviction eat the very band
1860
- // the ack transition exists to protect.
1861
- //
1862
- // Suppression is FIELD-AWARE, not a blanket drop, because a screen frame is
1863
- // not only rows. Row and window mutation are held back; everything else
1864
- // about the frame is honored:
1865
- // - the piggybacked inputAck was applied just above (monotone, harmless);
1866
- // - ED3 (`scrollbackCleared`) is a CONSUMED one-shot the resume batch
1867
- // hard-codes false, so dropping it would leave the client displaying
1868
- // history the server has already discarded — it is forwarded as a
1869
- // rows-less clear;
1870
- // - `bell` is dropped by design: it announces a screen the user has not
1871
- // been shown yet, and the batch is about to repaint it;
1872
- // - every other message class (modes, title, clipboard, pong, ackOnly)
1873
- // returned earlier or is unaffected.
1874
- //
1875
- // The suppressed rows are LOSSLESS by supersession: the batch's own window
1876
- // frame carries the screen, and a frame's scroll lines are committed to the
1877
- // ring before dispatch, so the replay re-delivers them.
1878
- if (!history.acked && (msg.type === "screen" || msg.type === "scroll")) {
1879
- if (msg.type === "screen" && msg.scrollbackCleared) {
1880
- cb?.onMessage({ ...msg, changed: [], rows: [], bell: false });
1460
+ disposed = true;
1461
+ if (connState.status === "connecting" || connState.status === "connected") {
1462
+ const { sock, abort } = connState;
1463
+ try {
1464
+ sock.close(1000);
1465
+ } catch {
1466
+ /* ignore */
1881
1467
  }
1882
- return;
1468
+ abort.abort();
1883
1469
  }
1884
- if (msg.type === "scroll") {
1885
- // A scroll frame may be a correlated page reply rather than live output.
1886
- // Correlation is by window membership; containment then decides whether
1887
- // this frame COMPLETES the attempt (releasing single-flight and growing
1888
- // the adaptive budget) or merely contributes content.
1889
- const { correlated, raiseFloorTo, contained } = correlateHistoryReply(msg);
1890
- if (correlated) {
1891
- cb?.onHistoryReply?.(msg, raiseFloorTo);
1892
- // Close the store's solicited window AFTER the page is applied, and only
1893
- // for a reply that COMPLETED the attempt. The window is the store's
1894
- // permission to admit lines below its stale-re-send watermark, so it has
1895
- // to outlive the apply — but leaving it open once no request is in flight
1896
- // means a later duplicate or malformed frame in that same range keeps
1897
- // bypassing the guard and can resurrect an evicted row through the
1898
- // ordinary (tail-classifying) path. An OVERSPILLING reply deliberately
1899
- // keeps both the slot and the window: its attempt is still open.
1900
- if (contained) {
1901
- cb?.clearSolicited?.();
1902
- }
1903
- return;
1904
- }
1470
+ stopHeartbeat();
1471
+ clearHistoryTimers();
1472
+ renderer.clearSolicited();
1473
+ cancelScheduledReconnect();
1474
+ connState = { status: "disconnected" };
1475
+ for (const st of sessions.values()) {
1476
+ resetLedger(st);
1905
1477
  }
1906
- cb?.onMessage(msg);
1478
+ sessions.clear();
1479
+ activeId = null;
1480
+ cb = DISPOSED_CALLBACKS;
1907
1481
  }
1908
1482
 
1909
- sock.addEventListener(
1910
- "close",
1911
- (ev: CloseEvent) => {
1912
- // Only the active sock's close should drive reconnect logic; an
1913
- // already-superseded sock has been aborted and this listener
1914
- // wouldn't fire (signal removes it). The check stays as a belt-
1915
- // and-braces guard in case the abort hasn't propagated yet.
1916
- if (connState.status !== "connecting" && connState.status !== "connected") {
1917
- return;
1918
- }
1919
- if (connState.sock !== sock) {
1920
- return;
1921
- }
1922
- stopHeartbeat();
1923
- // The fetch state belonged to THIS socket: kill its timers so neither
1924
- // fires against a dead socket, and release the solicited window so the
1925
- // store stops admitting lines below its stale-re-send watermark.
1926
- resetForNewSocket();
1927
- cb?.clearSolicited?.();
1928
- if (ev.code === WIRE_INCOMPATIBLE_CLOSE_CODE) {
1929
- const reason =
1930
- ev.reason ||
1931
- "server rejected this client wire protocol; reload or upgrade the client/server";
1932
- connState = { status: "incompatible" };
1933
- cb?.onWireIncompatible?.({
1934
- source: "server-close",
1935
- clientVersion: WIRE_PROTOCOL_VERSION,
1936
- minimumServerVersion: MIN_SUPPORTED_SERVER_WIRE_VERSION,
1937
- reason,
1938
- });
1939
- return;
1940
- }
1941
- connState = { status: "disconnected" };
1942
- // A process-exited close (4001) is definitive: the child is gone, so a
1943
- // backoff reconnect can only replay the final screen and collect another
1944
- // 4001 — an endless, pointless churn that reads as a flapping
1945
- // "Reconnecting…" banner. An unknown-session close (4004) is equally
1946
- // definitive: the server does not know the id at all (reaped, closed
1947
- // elsewhere, restarted server), so reconnecting can only collect another
1948
- // 4004. Route both to onProcessExit (no reconnect, no onClose) when the
1949
- // consumer wired it; without the callback, keep the legacy transient
1950
- // treatment so existing consumers see no change.
1951
- if (
1952
- (ev.code === PROCESS_EXITED_CLOSE_CODE || ev.code === SESSION_UNKNOWN_CLOSE_CODE) &&
1953
- cb?.onProcessExit
1954
- ) {
1955
- cb.onProcessExit();
1956
- return;
1957
- }
1958
- cb?.onClose();
1959
- scheduleReconnect();
1960
- },
1961
- { signal: connectAbort.signal },
1962
- );
1963
-
1964
- sock.addEventListener(
1965
- "error",
1966
- () => {
1967
- /* no-op: prevents unhandled error */
1968
- },
1969
- { signal: connectAbort.signal },
1970
- );
1483
+ return {
1484
+ sendBinary,
1485
+ sendEphemeral,
1486
+ setClientFocus,
1487
+ sendResize,
1488
+ reconnectNow,
1489
+ setSession,
1490
+ forgetSession,
1491
+ adoptPersistedEpoch,
1492
+ historyBudget,
1493
+ requestHistory,
1494
+ serverEpochOf,
1495
+ currentSessionId,
1496
+ disconnect,
1497
+ connect,
1498
+ dispose,
1499
+ };
1971
1500
  }