@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/README.md +121 -61
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/connection.ts +1205 -1676
- package/src/index.ts +20 -100
- package/src/keyboard.ts +56 -133
- package/src/modes.ts +92 -166
- package/src/mouse.ts +380 -413
- package/src/render.ts +1894 -2355
- package/src/scroll.ts +225 -476
- package/src/store.ts +1 -1
- package/src/terminal.ts +135 -0
- package/src/toolbar.ts +29 -43
package/src/connection.ts
CHANGED
|
@@ -1,28 +1,11 @@
|
|
|
1
|
-
// WebSocket lifecycle with reliable input delivery across reconnects
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
38
|
-
// ONE page size for the whole feature
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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
|
|
46
|
-
import
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
74
|
-
//
|
|
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
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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: { ...
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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
|
-
|
|
731
|
-
|
|
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
|
-
//
|
|
875
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
940
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
return;
|
|
505
|
+
|
|
506
|
+
function activeState(): ResumeState {
|
|
507
|
+
activeId ??= loadOrCreateSessionId();
|
|
508
|
+
return ensureState(activeId);
|
|
1009
509
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
-
//
|
|
1027
|
-
//
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
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
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
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
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
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
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
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
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
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
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
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
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
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
|
-
|
|
1211
|
-
|
|
738
|
+
|
|
739
|
+
function cancelScheduledReconnect(): void {
|
|
740
|
+
if (connState.status === "reconnecting") {
|
|
741
|
+
clearTimeout(connState.timer);
|
|
742
|
+
connState = { status: "disconnected" };
|
|
743
|
+
}
|
|
1212
744
|
}
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
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
|
-
|
|
1225
|
-
|
|
750
|
+
|
|
751
|
+
function startHeartbeat(): void {
|
|
752
|
+
stopHeartbeat();
|
|
753
|
+
markActivity();
|
|
754
|
+
heartbeatTimer = setInterval(heartbeatTick, HEARTBEAT_INTERVAL_MS);
|
|
1226
755
|
}
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
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
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
*/
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
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
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
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
|
-
|
|
1321
|
-
|
|
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
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
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
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
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
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
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
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
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
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
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
|
-
|
|
1417
|
-
//
|
|
1418
|
-
//
|
|
1419
|
-
//
|
|
1420
|
-
//
|
|
1421
|
-
//
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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
|
-
|
|
1431
|
-
//
|
|
1432
|
-
//
|
|
1433
|
-
|
|
1434
|
-
|
|
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
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
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
|
-
|
|
1034
|
+
cb.onConnecting?.();
|
|
1035
|
+
if (isDisposed()) {
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1490
1038
|
|
|
1491
|
-
|
|
1492
|
-
|
|
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
|
-
//
|
|
1496
|
-
//
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
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
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
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 (
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
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
|
-
|
|
1628
|
-
|
|
1629
|
-
.
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
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
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
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
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
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
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
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
|
-
|
|
1724
|
-
|
|
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
|
-
|
|
1740
|
-
|
|
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
|
-
//
|
|
1778
|
-
//
|
|
1779
|
-
//
|
|
1780
|
-
//
|
|
1781
|
-
//
|
|
1782
|
-
//
|
|
1783
|
-
//
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
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
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
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
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
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
|
-
|
|
1468
|
+
abort.abort();
|
|
1883
1469
|
}
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
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
|
-
|
|
1478
|
+
sessions.clear();
|
|
1479
|
+
activeId = null;
|
|
1480
|
+
cb = DISPOSED_CALLBACKS;
|
|
1907
1481
|
}
|
|
1908
1482
|
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
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
|
}
|