@cplieger/web-terminal-engine 3.5.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/connection.ts CHANGED
@@ -35,7 +35,14 @@ import {
35
35
  type WireIncompatibility,
36
36
  } from "./wire-compatibility.js";
37
37
  import * as modes from "./modes.js";
38
- import type { ControlMessage, ServerMessage } from "./types.js";
38
+ // ONE page size for the whole feature. The transport used to declare its own
39
+ // identical literal, and nothing held the two equal: the fetch trigger anchors a
40
+ // request from the STORE's value while the transport clamps the length to its
41
+ // own, so tuning either one alone produces a shrunken page with a full-size
42
+ // anchor — the shape that leaves the rows under the reader blank while the far
43
+ // end of the gap fills.
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
- // Bootstrap resume FIRST, on the captured socket, before consumer
748
- // callbacks can run: it must be message one on every socket (the v4
749
- // negotiation bootstrap, design §4 — always binary-sentinel encoded,
750
- // understood by every server revision). An onOpen callback that calls
751
- // sendResize()/sendBinary() therefore always queues AFTER it.
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: cb?.getHaveThrough?.() ?? -1,
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 ||