@cplieger/web-terminal-engine 3.5.0 → 3.7.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 +2 -2
- package/jsr.json +1 -1
- package/package.json +3 -3
- package/src/connection.ts +647 -9
- package/src/index.ts +2 -1
- package/src/intervals.ts +81 -0
- package/src/render.ts +899 -48
- package/src/scroll.ts +229 -25
- package/src/store.ts +1443 -68
- package/src/types.ts +28 -0
- package/src/wire-binary.ts +9 -1
package/src/connection.ts
CHANGED
|
@@ -35,7 +35,14 @@ import {
|
|
|
35
35
|
type WireIncompatibility,
|
|
36
36
|
} from "./wire-compatibility.js";
|
|
37
37
|
import * as modes from "./modes.js";
|
|
38
|
-
|
|
38
|
+
// ONE page size for the whole feature. The transport used to declare its own
|
|
39
|
+
// identical literal, and nothing held the two equal: the fetch trigger anchors a
|
|
40
|
+
// request from the STORE's value while the transport clamps the length to its
|
|
41
|
+
// own, so tuning either one alone produces a shrunken page with a full-size
|
|
42
|
+
// anchor — the shape that leaves the rows under the reader blank while the far
|
|
43
|
+
// end of the gap fills.
|
|
44
|
+
import { PAGE_SIZE } from "./store.js";
|
|
45
|
+
import type { ControlMessage, ScrollMessage, ServerMessage } from "./types.js";
|
|
39
46
|
import { INITIAL_DELAY_MS, nextBackoffDelay } from "./reconnect.js";
|
|
40
47
|
|
|
41
48
|
/**
|
|
@@ -98,6 +105,13 @@ interface ResumeState {
|
|
|
98
105
|
outbox: Uint8Array[]; // unacked chunks (sum of lengths = bytesSent - bytesAcked)
|
|
99
106
|
outboxBytes: number; // running sum of outbox chunk lengths; keeps applyAck O(n) not O(n²)
|
|
100
107
|
lastServerEpoch: number | null; // process-start nanos last seen for this session
|
|
108
|
+
// Whether lastServerEpoch was SEEDED from persisted content rather than learned
|
|
109
|
+
// from a resumeAck. A seeded epoch is a claim about content restored from
|
|
110
|
+
// storage, and it is only worth anything if a server confirms or contradicts it —
|
|
111
|
+
// so a resume that reports no epoch at all leaves it unverifiable, which is
|
|
112
|
+
// handled as a restart. Learned epochs must not get that treatment: an ack
|
|
113
|
+
// without an epoch from a server that never reports one is ordinary operation.
|
|
114
|
+
epochSeeded: boolean;
|
|
101
115
|
/** The session's last-announced DEC-mode state (P3: per-session mode
|
|
102
116
|
* mirror). Written on every inbound modes frame; restored synchronously
|
|
103
117
|
* into the modes singleton by setSession, so a keystroke in the switch
|
|
@@ -158,6 +172,7 @@ function newResumeState(id: string): ResumeState {
|
|
|
158
172
|
outbox: [],
|
|
159
173
|
outboxBytes: 0,
|
|
160
174
|
lastServerEpoch: null,
|
|
175
|
+
epochSeeded: false,
|
|
161
176
|
modes: { ...modes.POWER_ON_MODES },
|
|
162
177
|
};
|
|
163
178
|
}
|
|
@@ -205,6 +220,131 @@ const IDLE_BEFORE_PROBE_MS = 10_000;
|
|
|
205
220
|
/** How long an unanswered probe is tolerated before declaring the socket stale. */
|
|
206
221
|
const PONG_TIMEOUT_MS = 7_000;
|
|
207
222
|
|
|
223
|
+
// --- demand-paged scrollback: per-socket fetch state (docs/paged-scrollback.md) ---
|
|
224
|
+
|
|
225
|
+
/** Requests served back-to-back before the client's own bucket throttles. */
|
|
226
|
+
const HISTORY_BURST = 4;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Client-side refill interval. Deliberately SLOWER than the server's floor
|
|
230
|
+
* (1.5 s): independent clocks and latency jitter compress arrival spacing, so
|
|
231
|
+
* identical constants would let a healthy client trip the server's silent drop.
|
|
232
|
+
* The slack absorbs that by construction rather than by coincidence.
|
|
233
|
+
*/
|
|
234
|
+
const HISTORY_REFILL_MS = 2_000;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* How long to wait for a page before releasing single-flight and retrying. It
|
|
238
|
+
* fires FIRST by design: the server's write context is 10 s, and in
|
|
239
|
+
* coder/websocket a write-deadline expiry CLOSES the socket, so a server that
|
|
240
|
+
* gave up first would turn every slow reply into a reconnect and make this
|
|
241
|
+
* retry path unreachable.
|
|
242
|
+
*/
|
|
243
|
+
const HISTORY_DATA_TIMEOUT_MS = 8_000;
|
|
244
|
+
|
|
245
|
+
/** The smallest request the adaptive budget will shrink to. */
|
|
246
|
+
const HISTORY_MIN_PAGE = 125;
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The server's clamp on `replayMax`, mirrored here so the client sends the
|
|
250
|
+
* value the server will actually honor. The equality is load-bearing: the
|
|
251
|
+
* replay-jump prediction computes `committed - sentReplayMax`, so a server
|
|
252
|
+
* honoring something smaller would place the real replay start above the
|
|
253
|
+
* prediction and leave a genuine jump undetected (§4.5).
|
|
254
|
+
*/
|
|
255
|
+
export const MAX_REPLAY_LINES = 2000;
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Everything the fetch controller owns for ONE socket. Held in the socket's
|
|
259
|
+
* closure lifetime — replaced wholesale on reconnect — because every field is
|
|
260
|
+
* a statement about that socket: a surviving empty bucket would stall against a
|
|
261
|
+
* fresh server bucket, a carried-over capability would page against a server
|
|
262
|
+
* that never declared it, and a carried-over 125-line budget would punish a new
|
|
263
|
+
* link for the old one's congestion.
|
|
264
|
+
*/
|
|
265
|
+
interface HistoryState {
|
|
266
|
+
/** The server declared paging on this socket's resume ack. */
|
|
267
|
+
paging: boolean;
|
|
268
|
+
/** An ack has been processed; before that, content frames are suppressed. */
|
|
269
|
+
acked: boolean;
|
|
270
|
+
/** The in-flight request's window, or null when nothing is outstanding. */
|
|
271
|
+
inFlight: { fromAbs: number; end: number } | null;
|
|
272
|
+
/** Token bucket fill and its last refill instant. */
|
|
273
|
+
tokens: number;
|
|
274
|
+
lastRefill: number;
|
|
275
|
+
/** The adaptive request budget, and the recovery ceiling above it. */
|
|
276
|
+
effMax: number;
|
|
277
|
+
budgetCeiling: number;
|
|
278
|
+
/** Timers: the in-flight data timeout, and the coalesced pending demand. */
|
|
279
|
+
dataTimer: ReturnType<typeof setTimeout> | null;
|
|
280
|
+
demandTimer: ReturnType<typeof setTimeout> | null;
|
|
281
|
+
/** The values this socket SENT on its resume, which its reply answers. */
|
|
282
|
+
sentHaveThrough: number;
|
|
283
|
+
sentReplayMax: number | null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function newHistoryState(): HistoryState {
|
|
287
|
+
return {
|
|
288
|
+
paging: false,
|
|
289
|
+
acked: false,
|
|
290
|
+
inFlight: null,
|
|
291
|
+
// The bucket starts FULL so a fresh socket can burst; the server's bucket
|
|
292
|
+
// does the same, so the two agree on the first few requests.
|
|
293
|
+
tokens: HISTORY_BURST,
|
|
294
|
+
lastRefill: Date.now(),
|
|
295
|
+
effMax: PAGE_SIZE,
|
|
296
|
+
budgetCeiling: PAGE_SIZE,
|
|
297
|
+
dataTimer: null,
|
|
298
|
+
demandTimer: null,
|
|
299
|
+
sentHaveThrough: -1,
|
|
300
|
+
sentReplayMax: null,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let history: HistoryState = newHistoryState();
|
|
305
|
+
|
|
306
|
+
/** Cancel both timers and release single-flight. */
|
|
307
|
+
function clearHistoryTimers(): void {
|
|
308
|
+
if (history.dataTimer !== null) {
|
|
309
|
+
clearTimeout(history.dataTimer);
|
|
310
|
+
history.dataTimer = null;
|
|
311
|
+
}
|
|
312
|
+
if (history.demandTimer !== null) {
|
|
313
|
+
clearTimeout(history.demandTimer);
|
|
314
|
+
history.demandTimer = null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Reset the fetch state for a new socket: capability, single-flight, pacing and
|
|
320
|
+
* the adaptive budget all go together, atomically. Splitting them is how a
|
|
321
|
+
* client ends up paging against a server that never declared it, or bursting
|
|
322
|
+
* into a depleted server bucket.
|
|
323
|
+
*/
|
|
324
|
+
function resetHistoryForSocket(): void {
|
|
325
|
+
clearHistoryTimers();
|
|
326
|
+
history = newHistoryState();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Spend a pacing token, returning the wait in ms when the bucket is empty (so
|
|
331
|
+
* the caller can arm the coalesced pending demand for exactly that instant)
|
|
332
|
+
* or 0 when a token was granted.
|
|
333
|
+
*/
|
|
334
|
+
function takeHistoryToken(): number {
|
|
335
|
+
const now = Date.now();
|
|
336
|
+
history.tokens = Math.min(
|
|
337
|
+
HISTORY_BURST,
|
|
338
|
+
history.tokens + (now - history.lastRefill) / HISTORY_REFILL_MS,
|
|
339
|
+
);
|
|
340
|
+
history.lastRefill = now;
|
|
341
|
+
if (history.tokens < 1) {
|
|
342
|
+
return Math.ceil((1 - history.tokens) * HISTORY_REFILL_MS);
|
|
343
|
+
}
|
|
344
|
+
history.tokens -= 1;
|
|
345
|
+
return 0;
|
|
346
|
+
}
|
|
347
|
+
|
|
208
348
|
/**
|
|
209
349
|
* Maximum bytes we keep in the outbox before refusing new input. 1
|
|
210
350
|
* MiB at typical typing rates is hours of held keys; fast enough to
|
|
@@ -314,6 +454,66 @@ export interface Callbacks {
|
|
|
314
454
|
* can tell a genuine history trim (the server evicted lines the client was
|
|
315
455
|
* missing) from a still-loading state. Resync guard 8.2.2. */
|
|
316
456
|
onResumeBounds?(committed: number, oldest: number): void;
|
|
457
|
+
/**
|
|
458
|
+
* How many lines of resume replay this client wants at most — a REFINEMENT of
|
|
459
|
+
* the protocol ceiling, never an opt-out: the server bounds the replay to
|
|
460
|
+
* MAX_REPLAY_LINES whether or not this is wired, and the client sends that
|
|
461
|
+
* ceiling when it is absent so both sides predict the same replay start.
|
|
462
|
+
* Return the depth the consumer intends to keep resident.
|
|
463
|
+
*
|
|
464
|
+
* The value is clamped to MAX_REPLAY_LINES before it is sent, because the
|
|
465
|
+
* client's replay-jump prediction is only exact while the SENT value equals the
|
|
466
|
+
* honored one (docs/paged-scrollback.md §4.5).
|
|
467
|
+
*/
|
|
468
|
+
getReplayMax?(): number | null;
|
|
469
|
+
/** Fired when a correlated history page arrives, carrying the reply and how
|
|
470
|
+
* the client should read it. The consumer forwards `msg` to the store's
|
|
471
|
+
* `applyHistoryScroll` (which classifies it as browse cache), and applies
|
|
472
|
+
* `raiseFloorTo` when present — a clamped or empty reply is the server
|
|
473
|
+
* proving nothing at or below that index survives. */
|
|
474
|
+
onHistoryReply?(msg: ScrollMessage, raiseFloorTo: number | null): void;
|
|
475
|
+
/** Fired once per resume ack with everything the store's single ack
|
|
476
|
+
* transition needs, including the values this socket SENT (which the
|
|
477
|
+
* server's reply is a function of) and the capability the server declared.
|
|
478
|
+
* The consumer builds a closure over its store and renderer viewport and
|
|
479
|
+
* calls `store.applyResumeAck(...)`; see docs/paged-scrollback.md §4.5 for
|
|
480
|
+
* why the five steps must not be split across separate callbacks. */
|
|
481
|
+
onResumeTransition?(ack: {
|
|
482
|
+
epochChanged: boolean;
|
|
483
|
+
committed: number | null;
|
|
484
|
+
serverOldest: number | null;
|
|
485
|
+
paging: boolean;
|
|
486
|
+
sentHaveThrough: number;
|
|
487
|
+
sentReplayMax: number | null;
|
|
488
|
+
}): void;
|
|
489
|
+
/** The store port's two solicited-window methods. `connection` is store-blind
|
|
490
|
+
* by design, but the events that open and close a solicited window all
|
|
491
|
+
* originate here (send, data timeout, socket close), so the store needs a
|
|
492
|
+
* bridge rather than a reach-in. */
|
|
493
|
+
noteSolicited?(fromAbs: number, end: number): void;
|
|
494
|
+
clearSolicited?(): void;
|
|
495
|
+
/** Re-run the fetch trigger: the coalesced pending demand fired (a paced
|
|
496
|
+
* denial's refill instant, or a data timeout's retry). The controller
|
|
497
|
+
* re-evaluates its full guard set rather than replaying a stale range. */
|
|
498
|
+
onHistoryRetry?(): void;
|
|
499
|
+
/** The size to announce to the server BEFORE asking to resume, or null when
|
|
500
|
+
* the client cannot yet measure itself trustworthily (web fonts still
|
|
501
|
+
* loading, a viewport transition in flight).
|
|
502
|
+
*
|
|
503
|
+
* Establishing the size first is what makes a resume snapshot arrive at the
|
|
504
|
+
* client's own geometry: the server reflows, and only then computes the
|
|
505
|
+
* window and the replay. Without it the client is answered at whatever size
|
|
506
|
+
* the session last held, repaints, and then its resize lands afterwards —
|
|
507
|
+
* which on a program that redraws on SIGWINCH means the redraw interleaves
|
|
508
|
+
* with the replay it just applied.
|
|
509
|
+
*
|
|
510
|
+
* Returning null is the explicit "not yet" answer and is byte-identical to
|
|
511
|
+
* omitting the callback: nothing is sent, the server keeps its current size,
|
|
512
|
+
* and the consumer's own later sendResize() (typically once the viewport
|
|
513
|
+
* settles) establishes it. Announcing an untrustworthy measurement is worse
|
|
514
|
+
* than announcing none, because it costs a second resize and therefore a
|
|
515
|
+
* second redraw. */
|
|
516
|
+
initialSize?(): { cols: number; rows: number } | null;
|
|
317
517
|
/** Optional WebSocket endpoint path (default "/ws"). vibekit serves
|
|
318
518
|
* the shell at "/api/shell/ws"; web-terminal-kiro at "/ws". */
|
|
319
519
|
wsPath?: string;
|
|
@@ -406,6 +606,40 @@ function sendControl(msg: ControlMessage): void {
|
|
|
406
606
|
connState.sock.send(controlFrame(msg));
|
|
407
607
|
}
|
|
408
608
|
|
|
609
|
+
/**
|
|
610
|
+
* The consumer's announced bootstrap size, validated, or null when there is
|
|
611
|
+
* none to announce.
|
|
612
|
+
*
|
|
613
|
+
* Isolated for two reasons. The validation: the server FLOORS an out-of-range
|
|
614
|
+
* resize rather than dropping it (a 0 becomes the minimum size), so a malformed
|
|
615
|
+
* value must be rejected here rather than silently resizing the session. And the
|
|
616
|
+
* try/catch: this runs inside the socket's open handler after the connect
|
|
617
|
+
* timeout has been cleared and BEFORE the resume is sent, so a consumer callback
|
|
618
|
+
* that throws would otherwise abort the handler, leave the resume unsent and the
|
|
619
|
+
* connection state stuck at "connecting" with no timeout left to rescue it. A
|
|
620
|
+
* broken provider degrades to "announce nothing", which is the documented
|
|
621
|
+
* behavior of omitting it.
|
|
622
|
+
*/
|
|
623
|
+
function readInitialSize(): { cols: number; rows: number } | null {
|
|
624
|
+
let size: { cols: number; rows: number } | null;
|
|
625
|
+
try {
|
|
626
|
+
size = cb?.initialSize?.() ?? null;
|
|
627
|
+
} catch (err) {
|
|
628
|
+
console.warn("vterm: initialSize provider threw; announcing no size", err);
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
if (
|
|
632
|
+
size === null ||
|
|
633
|
+
!Number.isInteger(size.cols) ||
|
|
634
|
+
!Number.isInteger(size.rows) ||
|
|
635
|
+
size.cols <= 0 ||
|
|
636
|
+
size.rows <= 0
|
|
637
|
+
) {
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
return { cols: size.cols, rows: size.rows };
|
|
641
|
+
}
|
|
642
|
+
|
|
409
643
|
export function sendResize(): void {
|
|
410
644
|
if (connState.status !== "connected" || !cb) {
|
|
411
645
|
return;
|
|
@@ -590,6 +824,8 @@ function teardown(): void {
|
|
|
590
824
|
}
|
|
591
825
|
}
|
|
592
826
|
stopHeartbeat();
|
|
827
|
+
resetHistoryForSocket();
|
|
828
|
+
cb?.clearSolicited?.();
|
|
593
829
|
cancelScheduledReconnect();
|
|
594
830
|
connState = { status: "disconnected" };
|
|
595
831
|
}
|
|
@@ -652,6 +888,271 @@ export function forgetSession(id: string): void {
|
|
|
652
888
|
}
|
|
653
889
|
}
|
|
654
890
|
|
|
891
|
+
/**
|
|
892
|
+
* Seed the server boot epoch a session's PERSISTED content belongs to, before
|
|
893
|
+
* connecting.
|
|
894
|
+
*
|
|
895
|
+
* Restart detection is otherwise in-memory: the first resumeAck of a page load
|
|
896
|
+
* records the epoch with nothing to compare against, and only a LATER mismatch
|
|
897
|
+
* fires onServerRestart. That is correct for a store built during this page's
|
|
898
|
+
* lifetime, and actively wrong for one hydrated from disk — a restarted server
|
|
899
|
+
* begins its absolute indices again at 0, so a hydrated store would be presented
|
|
900
|
+
* as live while the new session's low-index output was silently refused by the
|
|
901
|
+
* store's staleness guard.
|
|
902
|
+
*
|
|
903
|
+
* Seeding gives the first resumeAck something to compare against, so the
|
|
904
|
+
* existing restart path fires and resets the stale content exactly as it would
|
|
905
|
+
* mid-session. A consumer that hydrates a store MUST call this with the
|
|
906
|
+
* snapshot's `serverEpoch` (see StoreSnapshot in store.ts).
|
|
907
|
+
*
|
|
908
|
+
* Ignores a zero or non-finite epoch: those mean "no epoch was ever recorded",
|
|
909
|
+
* which is indistinguishable from a version-silent server, and seeding a
|
|
910
|
+
* sentinel would make the next real epoch look like a restart.
|
|
911
|
+
*
|
|
912
|
+
* Also ignores a session that already knows its epoch. "Seed" is the whole
|
|
913
|
+
* contract: this exists to give the FIRST resumeAck something to compare against,
|
|
914
|
+
* and overwriting a value a server already reported would make a genuine restart
|
|
915
|
+
* look like agreement, or a live session look restarted. A caller that reaches
|
|
916
|
+
* here twice, or after connecting, has a bug the library can simply refuse.
|
|
917
|
+
*/
|
|
918
|
+
export function adoptPersistedEpoch(sessionId: string, epoch: number): void {
|
|
919
|
+
if (!Number.isFinite(epoch) || epoch === 0) {
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
const st = ensureState(sessionId);
|
|
923
|
+
if (st.lastServerEpoch !== null) {
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
st.lastServerEpoch = epoch;
|
|
927
|
+
// Marked as a CLAIM about restored content, not an observation. A resume that
|
|
928
|
+
// reports no epoch cannot confirm it, and unverifiable restored content is
|
|
929
|
+
// handled as a restart — see handleResumeAck. This is the one hole the epoch
|
|
930
|
+
// comparison alone leaves: a server downgraded to reporting no epoch between the
|
|
931
|
+
// save and the load never contradicts the seeded value, so the stale store would
|
|
932
|
+
// otherwise be presented as live and then refuse the new session's low indices.
|
|
933
|
+
st.epochSeeded = true;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* The current adaptive request budget: how many lines the next page request
|
|
938
|
+
* should ask for. The fetch trigger lives in the renderer (the only layer that
|
|
939
|
+
* maps scroll position to absolute indices) while this budget is per-SOCKET
|
|
940
|
+
* state owned here, and this accessor is the only channel between them.
|
|
941
|
+
*
|
|
942
|
+
* The trigger must read it for BOTH the request's length and its ANCHOR. Using
|
|
943
|
+
* it for the length alone is a real defect, not a nicety: an anchor computed
|
|
944
|
+
* from the full page size while the length is shrunken serves a range that ends
|
|
945
|
+
* far from the reader, so a slow link heals the far end of a gap and leaves the
|
|
946
|
+
* rows under the viewport blank (docs/paged-scrollback.md §4.2).
|
|
947
|
+
*/
|
|
948
|
+
export function historyBudget(): number {
|
|
949
|
+
return history.effMax;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/** Whether this socket's server declared demand-paged scrollback. */
|
|
953
|
+
export function historyPagingAvailable(): boolean {
|
|
954
|
+
return history.paging && history.acked;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** Whether a page request is currently outstanding (single-flight). */
|
|
958
|
+
export function historyRequestInFlight(): boolean {
|
|
959
|
+
return history.inFlight !== null;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Request a page of history: at most `maxLines` lines starting at `fromAbs`.
|
|
964
|
+
*
|
|
965
|
+
* Returns true when the request went out. It can decline for four reasons, all
|
|
966
|
+
* of them normal: the server never declared paging, an ack has not arrived yet,
|
|
967
|
+
* a request is already in flight (single-flight), or the pacing bucket is empty.
|
|
968
|
+
* The last case ARMS the coalesced pending demand — a denied trigger is not
|
|
969
|
+
* merely dropped, because on an idle session no future scroll or flush event
|
|
970
|
+
* would ever re-fire it and a byte-short continuation would stall forever.
|
|
971
|
+
*
|
|
972
|
+
* The caller (the renderer's controller) owns the geometry; this owns the
|
|
973
|
+
* transport, single-flight, pacing, the timers and the adaptive budget.
|
|
974
|
+
*/
|
|
975
|
+
export function requestHistory(fromAbs: number, maxLines: number): boolean {
|
|
976
|
+
if (!history.paging || !history.acked) {
|
|
977
|
+
return false;
|
|
978
|
+
}
|
|
979
|
+
if (history.inFlight !== null) {
|
|
980
|
+
return false;
|
|
981
|
+
}
|
|
982
|
+
if (
|
|
983
|
+
!Number.isSafeInteger(fromAbs) ||
|
|
984
|
+
!Number.isSafeInteger(maxLines) ||
|
|
985
|
+
fromAbs < 0 ||
|
|
986
|
+
maxLines < 1 ||
|
|
987
|
+
// The subtraction form: the addition form is itself the overflow it would
|
|
988
|
+
// exist to reject (§4.1).
|
|
989
|
+
fromAbs > Number.MAX_SAFE_INTEGER - maxLines
|
|
990
|
+
) {
|
|
991
|
+
return false;
|
|
992
|
+
}
|
|
993
|
+
if (connState.status !== "connected" || !connState.upgraded) {
|
|
994
|
+
return false;
|
|
995
|
+
}
|
|
996
|
+
const wait = takeHistoryToken();
|
|
997
|
+
if (wait > 0) {
|
|
998
|
+
armPendingDemand(wait);
|
|
999
|
+
return false;
|
|
1000
|
+
}
|
|
1001
|
+
const bounded = Math.min(maxLines, history.effMax);
|
|
1002
|
+
const end = fromAbs + bounded;
|
|
1003
|
+
history.inFlight = { fromAbs, end };
|
|
1004
|
+
cb?.noteSolicited?.(fromAbs, end);
|
|
1005
|
+
// Through sendControl, which owns the ENCODING decision. Sending
|
|
1006
|
+
// `controlFrame` directly here was a defect with three simultaneous
|
|
1007
|
+
// consequences, none of them visible in this repo's tests: this function
|
|
1008
|
+
// refuses to run unless the socket is `upgraded`, and a post-upgrade server
|
|
1009
|
+
// parses the v3 0x00-sentinel only while UNLATCHED — so every request was
|
|
1010
|
+
// written straight to the PTY instead (terminal.go's handleBinaryFrame). No
|
|
1011
|
+
// page reply ever arrived (the 8 s data timeout halved the budget and retried
|
|
1012
|
+
// forever, so paging was inert); the control's JSON was TYPED INTO THE USER'S
|
|
1013
|
+
// SHELL, and fed to the session-title deriver; and its bytes advanced the
|
|
1014
|
+
// server's received-byte ledger while the client never counted them, so
|
|
1015
|
+
// applyAck's min(received, bytesSent) clamp trimmed genuinely-unacked
|
|
1016
|
+
// keystrokes out of the outbox.
|
|
1017
|
+
sendControl({ type: "history", fromAbs, maxLines: bounded });
|
|
1018
|
+
history.dataTimer = setTimeout(onHistoryDataTimeout, HISTORY_DATA_TIMEOUT_MS);
|
|
1019
|
+
return true;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Arm the coalesced pending demand: ONE timer that re-runs the full trigger
|
|
1024
|
+
* when the bucket refills. Coalesced because a burst of denied triggers should
|
|
1025
|
+
* cost one retry, not one each; and re-running the FULL guard set rather than
|
|
1026
|
+
* replaying the denied request, because the world may have changed (the gap may
|
|
1027
|
+
* have healed, the session may have entered alt).
|
|
1028
|
+
*/
|
|
1029
|
+
function armPendingDemand(waitMs: number): void {
|
|
1030
|
+
if (history.demandTimer !== null) {
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
history.demandTimer = setTimeout(() => {
|
|
1034
|
+
history.demandTimer = null;
|
|
1035
|
+
cb?.onHistoryRetry?.();
|
|
1036
|
+
}, waitMs);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* The in-flight request did not answer in time. Release single-flight so the
|
|
1041
|
+
* controller can try again, HALVE the budget, and remember the size that failed
|
|
1042
|
+
* as the recovery ceiling.
|
|
1043
|
+
*
|
|
1044
|
+
* The ceiling is RFC 5681's `ssthresh` role — a growth target, not a cap below
|
|
1045
|
+
* the current value. Halving alone would oscillate (a link that carries 500 but
|
|
1046
|
+
* not 1000 would time out on every other request forever); dropping to the
|
|
1047
|
+
* floor and climbing back toward half the failed size converges on the largest
|
|
1048
|
+
* size the link actually carries, approaching it from below.
|
|
1049
|
+
*/
|
|
1050
|
+
function onHistoryDataTimeout(): void {
|
|
1051
|
+
const failed = history.inFlight;
|
|
1052
|
+
history.dataTimer = null;
|
|
1053
|
+
history.inFlight = null;
|
|
1054
|
+
cb?.clearSolicited?.();
|
|
1055
|
+
if (failed !== null) {
|
|
1056
|
+
const size = failed.end - failed.fromAbs;
|
|
1057
|
+
history.budgetCeiling = Math.max(HISTORY_MIN_PAGE, Math.floor(size / 2));
|
|
1058
|
+
history.effMax = HISTORY_MIN_PAGE;
|
|
1059
|
+
}
|
|
1060
|
+
// Retry through the same coalescing path a denied trigger uses, so the
|
|
1061
|
+
// controller re-evaluates its guards rather than replaying a stale range.
|
|
1062
|
+
armPendingDemand(0);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Correlate an inbound scroll frame against the in-flight request.
|
|
1067
|
+
*
|
|
1068
|
+
* A frame is the reply iff its `firstIndex` lies inside the request window.
|
|
1069
|
+
* CONTAINMENT then decides the CONTROL effects separately from the content: only
|
|
1070
|
+
* a reply that fits entirely inside the window releases single-flight and grows
|
|
1071
|
+
* the budget. A correlated frame extending BEYOND the window — a timed-out
|
|
1072
|
+
* larger reply sharing a retry's `fromAbs` — has its in-window intersection
|
|
1073
|
+
* applied by the store and changes no control state, so the attempt's own reply
|
|
1074
|
+
* still gets to complete it (§4.3).
|
|
1075
|
+
*
|
|
1076
|
+
* Returns the floor to raise to when the reply proves history is gone
|
|
1077
|
+
* (`firstIndex > fromAbs` is the CLAMP signal; an empty reply means nothing in
|
|
1078
|
+
* the whole window survives), or null.
|
|
1079
|
+
*/
|
|
1080
|
+
function correlateHistoryReply(msg: ScrollMessage): {
|
|
1081
|
+
correlated: boolean;
|
|
1082
|
+
raiseFloorTo: number | null;
|
|
1083
|
+
contained: boolean;
|
|
1084
|
+
} {
|
|
1085
|
+
const req = history.inFlight;
|
|
1086
|
+
if (req === null) {
|
|
1087
|
+
return { correlated: false, raiseFloorTo: null, contained: false };
|
|
1088
|
+
}
|
|
1089
|
+
if (msg.firstIndex < req.fromAbs || msg.firstIndex >= req.end) {
|
|
1090
|
+
return { correlated: false, raiseFloorTo: null, contained: false };
|
|
1091
|
+
}
|
|
1092
|
+
const count = msg.lines.length;
|
|
1093
|
+
let raiseFloorTo: number | null = null;
|
|
1094
|
+
if (count === 0) {
|
|
1095
|
+
// Nothing in [fromAbs, end) is retained: the whole window is condemned.
|
|
1096
|
+
raiseFloorTo = req.end;
|
|
1097
|
+
} else if (msg.firstIndex > req.fromAbs) {
|
|
1098
|
+
// The server served from higher up than asked: everything below the served
|
|
1099
|
+
// start is evicted server-side, permanently.
|
|
1100
|
+
raiseFloorTo = msg.firstIndex;
|
|
1101
|
+
}
|
|
1102
|
+
const contained = msg.firstIndex + count <= req.end;
|
|
1103
|
+
if (contained) {
|
|
1104
|
+
if (history.dataTimer !== null) {
|
|
1105
|
+
clearTimeout(history.dataTimer);
|
|
1106
|
+
history.dataTimer = null;
|
|
1107
|
+
}
|
|
1108
|
+
history.inFlight = null;
|
|
1109
|
+
// Recovery: climb toward the remembered ceiling, never past it.
|
|
1110
|
+
history.effMax = Math.min(history.effMax * 2, history.budgetCeiling);
|
|
1111
|
+
}
|
|
1112
|
+
return { correlated: true, raiseFloorTo, contained };
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* The server boot epoch last observed for a session, or 0 when none is known —
|
|
1117
|
+
* the read half of adoptPersistedEpoch.
|
|
1118
|
+
*
|
|
1119
|
+
* A consumer that persists a session's content must record WHICH server process
|
|
1120
|
+
* the absolute indices in it belong to, and this is the only place that fact
|
|
1121
|
+
* exists: it arrives in a resumeAck and is otherwise kept privately for restart
|
|
1122
|
+
* detection. Pass the result to `LineStore.snapshot(serverEpoch)`.
|
|
1123
|
+
*
|
|
1124
|
+
* 0 means "never learned one", which happens before the first resumeAck and
|
|
1125
|
+
* against a version-silent server that sends no epoch at all. It is deliberately
|
|
1126
|
+
* the same value `adoptPersistedEpoch` ignores, so a snapshot taken without an
|
|
1127
|
+
* epoch cannot later be mistaken for one taken under a known epoch.
|
|
1128
|
+
*/
|
|
1129
|
+
export function serverEpochOf(sessionId: string): number {
|
|
1130
|
+
return sessions.get(sessionId)?.lastServerEpoch ?? 0;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* The session id this module's socket serves, resolving the UNMANAGED id if no
|
|
1135
|
+
* session has been set.
|
|
1136
|
+
*
|
|
1137
|
+
* A managed consumer (one calling setSession) already holds its own ids and does
|
|
1138
|
+
* not need this. The unmanaged single-terminal path does: its identity is a
|
|
1139
|
+
* per-tab, sessionStorage-backed id minted inside this module, so a consumer
|
|
1140
|
+
* that wants to key anything per-session — persisted scrollback, per-session
|
|
1141
|
+
* scroll memory — has no id to key it by, and any id it invented would be wrong
|
|
1142
|
+
* in one direction or the other (localStorage would make two tabs share one
|
|
1143
|
+
* terminal's state; a fresh random one would never match across a reload).
|
|
1144
|
+
*
|
|
1145
|
+
* Resolving means the same lazy load-or-mint the first send/connect performs, so
|
|
1146
|
+
* calling this before connecting simply moves that by a few milliseconds. The
|
|
1147
|
+
* semantics that make it the right persistence key are the ones sessionStorage
|
|
1148
|
+
* already gives: stable across a reload and an iOS tab restore, fresh in a
|
|
1149
|
+
* genuinely new tab.
|
|
1150
|
+
*/
|
|
1151
|
+
export function currentSessionId(): string {
|
|
1152
|
+
activeId ??= loadOrCreateSessionId();
|
|
1153
|
+
return activeId;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
655
1156
|
/**
|
|
656
1157
|
* disconnect tears down the live socket without reconnecting. Per-session resume
|
|
657
1158
|
* state is kept, so a later setSession/connect resumes cleanly. Used when the
|
|
@@ -744,11 +1245,56 @@ export function connect(): void {
|
|
|
744
1245
|
"open",
|
|
745
1246
|
() => {
|
|
746
1247
|
clearTimeout(timeoutId);
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
//
|
|
1248
|
+
// A new socket has no size on record with the server yet, so the
|
|
1249
|
+
// deduplication baseline resets before anything is announced below.
|
|
1250
|
+
lastSentCols = 0;
|
|
1251
|
+
lastSentRows = 0;
|
|
1252
|
+
// Announce the size BEFORE asking to resume, when the consumer can
|
|
1253
|
+
// measure trustworthily (Callbacks.initialSize). Order matters and is the
|
|
1254
|
+
// whole point: the server applies a resize control the moment it decodes
|
|
1255
|
+
// it, so a resize that precedes the resume makes the resume's screen
|
|
1256
|
+
// snapshot and history replay come back at THIS client's geometry, with
|
|
1257
|
+
// the app's SIGWINCH redraw (if any) landing after a coherent snapshot
|
|
1258
|
+
// instead of interleaved with the replay.
|
|
1259
|
+
//
|
|
1260
|
+
// This does not weaken the "resume is message one" invariant, which
|
|
1261
|
+
// exists for two reasons, neither of which a resize touches: the server
|
|
1262
|
+
// must see the resume's protocolVersion before any TEXT frame arrives
|
|
1263
|
+
// (a resize does not arm typed framing), and PTY INPUT must not precede
|
|
1264
|
+
// session resolution (input sent before the resume reaches the process
|
|
1265
|
+
// but is skipped by the server's received-byte ledger, desyncing the
|
|
1266
|
+
// outbox accounting). That is also why cb.onOpen() still runs after the
|
|
1267
|
+
// resume: a consumer may send input from it.
|
|
1268
|
+
const size = readInitialSize();
|
|
1269
|
+
if (size !== null) {
|
|
1270
|
+
sock.send(controlFrame({ type: "resize", cols: size.cols, rows: size.rows }));
|
|
1271
|
+
lastSentCols = size.cols;
|
|
1272
|
+
lastSentRows = size.rows;
|
|
1273
|
+
}
|
|
1274
|
+
// Bootstrap resume, on the captured socket, before consumer callbacks can
|
|
1275
|
+
// run: always binary-sentinel encoded, understood by every server
|
|
1276
|
+
// revision (the v4 negotiation bootstrap, design §4). An onOpen callback
|
|
1277
|
+
// that calls sendResize()/sendBinary() therefore always queues AFTER it.
|
|
1278
|
+
// Capture the resume's two history-relevant inputs BEFORE sending, and
|
|
1279
|
+
// remember them on the socket. The server's reply is a function of these
|
|
1280
|
+
// exact values, so the replay-jump prediction must be computed from them
|
|
1281
|
+
// — never from store state, which a frame arriving between send and ack
|
|
1282
|
+
// could have moved, masking a real jump (docs/paged-scrollback.md §4.5).
|
|
1283
|
+
resetHistoryForSocket();
|
|
1284
|
+
const sentHaveThrough = cb?.getHaveThrough?.() ?? -1;
|
|
1285
|
+
// ALWAYS a number. A consumer may ask for fewer lines than the protocol
|
|
1286
|
+
// ceiling, but it cannot opt OUT of the bound: the server clamps to the
|
|
1287
|
+
// same ceiling unconditionally, and a client that sent nothing while the
|
|
1288
|
+
// server bounded anyway would predict no replay jump where one happened —
|
|
1289
|
+
// leaving the stranded band classified as live tail for enforceCap to eat
|
|
1290
|
+
// silently. The two sides agree by construction instead of by wiring.
|
|
1291
|
+
const rawReplayMax = cb?.getReplayMax?.() ?? MAX_REPLAY_LINES;
|
|
1292
|
+
const sentReplayMax =
|
|
1293
|
+
Number.isSafeInteger(rawReplayMax) && rawReplayMax >= 1
|
|
1294
|
+
? Math.min(rawReplayMax, MAX_REPLAY_LINES)
|
|
1295
|
+
: MAX_REPLAY_LINES;
|
|
1296
|
+
history.sentHaveThrough = sentHaveThrough;
|
|
1297
|
+
history.sentReplayMax = sentReplayMax;
|
|
752
1298
|
sock.send(
|
|
753
1299
|
controlFrame({
|
|
754
1300
|
type: "resume",
|
|
@@ -763,7 +1309,10 @@ export function connect(): void {
|
|
|
763
1309
|
// duplication because applying a line by absolute index is
|
|
764
1310
|
// idempotent. Falls back to -1 (full retained replay) if the
|
|
765
1311
|
// consumer wired no getHaveThrough.
|
|
766
|
-
haveThrough:
|
|
1312
|
+
haveThrough: sentHaveThrough,
|
|
1313
|
+
// The resume replay bound, already clamped to what the server will
|
|
1314
|
+
// honor, and always present (see above).
|
|
1315
|
+
replayMax: sentReplayMax,
|
|
767
1316
|
// Lets the server detect a client built against a different wire
|
|
768
1317
|
// revision (e.g. a stale cached bundle) and warn rather than
|
|
769
1318
|
// silently mis-decode; >= 4 also ARMS the connection for the
|
|
@@ -775,8 +1324,6 @@ export function connect(): void {
|
|
|
775
1324
|
// decides whether the typed-framing upgrade happens (design §4).
|
|
776
1325
|
connState = { status: "connected", sock, abort: connectAbort, upgraded: false };
|
|
777
1326
|
reconnectDelay = INITIAL_DELAY_MS;
|
|
778
|
-
lastSentCols = 0;
|
|
779
|
-
lastSentRows = 0;
|
|
780
1327
|
cb?.onOpen();
|
|
781
1328
|
|
|
782
1329
|
// Begin client-side liveness probing for this socket. Idempotent
|
|
@@ -920,12 +1467,46 @@ export function connect(): void {
|
|
|
920
1467
|
// bytesSent/bytesAcked accounting (the new server has no record
|
|
921
1468
|
// of the previous boot's input). Reset state and notify the UI.
|
|
922
1469
|
const epoch = msg.serverEpoch;
|
|
1470
|
+
let epochChanged = false;
|
|
923
1471
|
if (epoch !== undefined && epoch !== 0) {
|
|
924
1472
|
if (st.lastServerEpoch !== null && st.lastServerEpoch !== epoch) {
|
|
1473
|
+
epochChanged = true;
|
|
925
1474
|
resetSessionAfterRestart(st);
|
|
926
1475
|
}
|
|
927
1476
|
st.lastServerEpoch = epoch;
|
|
1477
|
+
// Confirmed by a server: no longer a claim about restored content.
|
|
1478
|
+
st.epochSeeded = false;
|
|
1479
|
+
} else if (st.epochSeeded) {
|
|
1480
|
+
// The epoch we hold was SEEDED from persisted content and this server will
|
|
1481
|
+
// not say what process it is. Unverifiable restored content is handled as a
|
|
1482
|
+
// restart, because the alternative is presenting a previous run's output as
|
|
1483
|
+
// live and then refusing the new session's low absolute indices — wrong,
|
|
1484
|
+
// then permanently blank. Only ever reachable for a hydrated session: an
|
|
1485
|
+
// epoch learned from an earlier ack is an observation, and a server that
|
|
1486
|
+
// reports none is ordinary operation for it.
|
|
1487
|
+
epochChanged = true;
|
|
1488
|
+
resetSessionAfterRestart(st);
|
|
1489
|
+
st.lastServerEpoch = null;
|
|
1490
|
+
st.epochSeeded = false;
|
|
928
1491
|
}
|
|
1492
|
+
// Capability, read from the ack's length-gated flags tail. An absent tail
|
|
1493
|
+
// (a server older than it) reads the same as an unset bit: no paging,
|
|
1494
|
+
// nothing ever sent, and the store keeps its compatibility tail cap.
|
|
1495
|
+
history.paging = msg.historyPaging === true;
|
|
1496
|
+
history.acked = true;
|
|
1497
|
+
// The store's ONE ack transition, dispatched BEFORE the early returns
|
|
1498
|
+
// below. A ledger loss is not a capability event, and the long-absence
|
|
1499
|
+
// attach that loses its ledger is exactly the one carrying a replay jump
|
|
1500
|
+
// — appending this after those returns would skip it precisely there
|
|
1501
|
+
// (docs/paged-scrollback.md §4.5).
|
|
1502
|
+
cb?.onResumeTransition?.({
|
|
1503
|
+
epochChanged,
|
|
1504
|
+
committed: typeof msg.committed === "number" ? msg.committed : null,
|
|
1505
|
+
serverOldest: typeof msg.oldestIndex === "number" ? msg.oldestIndex : null,
|
|
1506
|
+
paging: history.paging,
|
|
1507
|
+
sentHaveThrough: history.sentHaveThrough,
|
|
1508
|
+
sentReplayMax: history.sentReplayMax,
|
|
1509
|
+
});
|
|
929
1510
|
// Resync guard 8.2.2: hand the server's retained-history bounds to the
|
|
930
1511
|
// consumer so it can surface a trim marker when history the client was
|
|
931
1512
|
// missing is gone for good. (If the ledger-lost / session-forgotten
|
|
@@ -1005,6 +1586,58 @@ export function connect(): void {
|
|
|
1005
1586
|
if (typeof msg.inputAck === "number") {
|
|
1006
1587
|
applyAck(st, msg.inputAck);
|
|
1007
1588
|
}
|
|
1589
|
+
// PRE-ACK CONTENT SUPPRESSION (docs/paged-scrollback.md §4.5). The server
|
|
1590
|
+
// registers a socket and wakes its scheduler at accept, so on a busy session
|
|
1591
|
+
// a live screen/scroll frame is the DESIGNED first delivery — it can arrive
|
|
1592
|
+
// before this socket's resumeAck. Applying its rows would mutate the store
|
|
1593
|
+
// under a stale residency cap, and on an already-flipped store one frame is
|
|
1594
|
+
// enough to push the tail over and have ordinary eviction eat the very band
|
|
1595
|
+
// the ack transition exists to protect.
|
|
1596
|
+
//
|
|
1597
|
+
// Suppression is FIELD-AWARE, not a blanket drop, because a screen frame is
|
|
1598
|
+
// not only rows. Row and window mutation are held back; everything else
|
|
1599
|
+
// about the frame is honored:
|
|
1600
|
+
// - the piggybacked inputAck was applied just above (monotone, harmless);
|
|
1601
|
+
// - ED3 (`scrollbackCleared`) is a CONSUMED one-shot the resume batch
|
|
1602
|
+
// hard-codes false, so dropping it would leave the client displaying
|
|
1603
|
+
// history the server has already discarded — it is forwarded as a
|
|
1604
|
+
// rows-less clear;
|
|
1605
|
+
// - `bell` is dropped by design: it announces a screen the user has not
|
|
1606
|
+
// been shown yet, and the batch is about to repaint it;
|
|
1607
|
+
// - every other message class (modes, title, clipboard, pong, ackOnly)
|
|
1608
|
+
// returned earlier or is unaffected.
|
|
1609
|
+
//
|
|
1610
|
+
// The suppressed rows are LOSSLESS by supersession: the batch's own window
|
|
1611
|
+
// frame carries the screen, and a frame's scroll lines are committed to the
|
|
1612
|
+
// ring before dispatch, so the replay re-delivers them.
|
|
1613
|
+
if (!history.acked && (msg.type === "screen" || msg.type === "scroll")) {
|
|
1614
|
+
if (msg.type === "screen" && msg.scrollbackCleared) {
|
|
1615
|
+
cb?.onMessage({ ...msg, changed: [], rows: [], bell: false });
|
|
1616
|
+
}
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
if (msg.type === "scroll") {
|
|
1620
|
+
// A scroll frame may be a correlated page reply rather than live output.
|
|
1621
|
+
// Correlation is by window membership; containment then decides whether
|
|
1622
|
+
// this frame COMPLETES the attempt (releasing single-flight and growing
|
|
1623
|
+
// the adaptive budget) or merely contributes content.
|
|
1624
|
+
const { correlated, raiseFloorTo, contained } = correlateHistoryReply(msg);
|
|
1625
|
+
if (correlated) {
|
|
1626
|
+
cb?.onHistoryReply?.(msg, raiseFloorTo);
|
|
1627
|
+
// Close the store's solicited window AFTER the page is applied, and only
|
|
1628
|
+
// for a reply that COMPLETED the attempt. The window is the store's
|
|
1629
|
+
// permission to admit lines below its stale-re-send watermark, so it has
|
|
1630
|
+
// to outlive the apply — but leaving it open once no request is in flight
|
|
1631
|
+
// means a later duplicate or malformed frame in that same range keeps
|
|
1632
|
+
// bypassing the guard and can resurrect an evicted row through the
|
|
1633
|
+
// ordinary (tail-classifying) path. An OVERSPILLING reply deliberately
|
|
1634
|
+
// keeps both the slot and the window: its attempt is still open.
|
|
1635
|
+
if (contained) {
|
|
1636
|
+
cb?.clearSolicited?.();
|
|
1637
|
+
}
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1008
1641
|
cb?.onMessage(msg);
|
|
1009
1642
|
}
|
|
1010
1643
|
|
|
@@ -1022,6 +1655,11 @@ export function connect(): void {
|
|
|
1022
1655
|
return;
|
|
1023
1656
|
}
|
|
1024
1657
|
stopHeartbeat();
|
|
1658
|
+
// The fetch state belonged to THIS socket: kill its timers so neither
|
|
1659
|
+
// fires against a dead socket, and release the solicited window so the
|
|
1660
|
+
// store stops admitting lines below its stale-re-send watermark.
|
|
1661
|
+
resetHistoryForSocket();
|
|
1662
|
+
cb?.clearSolicited?.();
|
|
1025
1663
|
if (ev.code === WIRE_INCOMPATIBLE_CLOSE_CODE) {
|
|
1026
1664
|
const reason =
|
|
1027
1665
|
ev.reason ||
|