@cplieger/web-terminal-engine 2.2.1 → 2.3.3

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/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "2.2.1",
3
+ "version": "2.3.3",
4
4
  "exports": {
5
5
  ".": "./src/index.ts"
6
6
  },
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "2.2.1",
3
+ "version": "2.3.3",
4
4
  "type": "module",
5
5
  "scripts": {
6
- "typecheck": "tsgo -p tsconfig.json",
7
- "typecheck:tests": "tsgo -p tsconfig.test.json",
6
+ "typecheck": "tsc -p tsconfig.json",
7
+ "typecheck:tests": "tsc -p tsconfig.test.json",
8
8
  "test": "vitest --run",
9
9
  "test:e2e": "playwright test"
10
10
  },
@@ -12,16 +12,17 @@
12
12
  "@eslint/js": "10.0.1",
13
13
  "@playwright/test": "1.61.1",
14
14
  "@types/pngjs": "6.0.5",
15
- "@vitest/coverage-v8": "4.1.9",
15
+ "@typescript/native": "npm:typescript@7.0.2",
16
+ "@vitest/coverage-v8": "4.1.10",
16
17
  "esbuild": "0.28.1",
17
- "eslint": "10.6.0",
18
- "fast-check": "4.8.0",
18
+ "eslint": "10.7.0",
19
+ "fast-check": "4.9.0",
19
20
  "happy-dom": "20.10.6",
20
21
  "pngjs": "7.0.0",
21
- "prettier": "3.9.4",
22
- "typescript": "6.0.3",
23
- "typescript-eslint": "8.62.1",
24
- "vitest": "4.1.9"
22
+ "prettier": "3.9.5",
23
+ "typescript": "npm:@typescript/typescript6@6.0.2",
24
+ "typescript-eslint": "8.63.0",
25
+ "vitest": "4.1.10"
25
26
  },
26
27
  "description": "Browser terminal engine: VT500 screen buffer, DOM renderer, and binary wire protocol",
27
28
  "license": "GPL-3.0-or-later",
package/src/connection.ts CHANGED
@@ -199,7 +199,7 @@ export interface Callbacks {
199
199
  * missing) from a still-loading state. Resync guard 8.2.2. */
200
200
  onResumeBounds?(committed: number, oldest: number): void;
201
201
  /** Optional WebSocket endpoint path (default "/ws"). vibekit serves
202
- * the shell at "/api/shell/ws"; vibecli at "/ws". */
202
+ * the shell at "/api/shell/ws"; web-terminal-kiro at "/ws". */
203
203
  wsPath?: string;
204
204
  }
205
205
 
@@ -257,6 +257,18 @@ export function sendResize(): void {
257
257
  sendControl({ type: "resize", cols, rows });
258
258
  }
259
259
 
260
+ // resetSessionAfterRestart clears a session's reliable-input accounting and
261
+ // notifies the UI. Called when the server's boot epoch changes (restart) or when
262
+ // the server no longer recognizes the session (received=0 with prior acks) -- both
263
+ // invalidate the local bytesSent/bytesAcked/outbox state.
264
+ function resetSessionAfterRestart(st: ResumeState): void {
265
+ st.bytesSent = 0;
266
+ st.bytesAcked = 0;
267
+ st.outbox.length = 0;
268
+ st.outboxBytes = 0;
269
+ cb?.onServerRestart?.();
270
+ }
271
+
260
272
  // applyAck drops chunks from the front of the session's outbox until the
261
273
  // running total of unacked bytes matches (bytesSent - newAck). Runs in
262
274
  // O(chunks_dropped) by tracking outboxBytes incrementally rather than
@@ -491,7 +503,19 @@ export function connect(): void {
491
503
  // removed atomically and can't fire again.
492
504
  const connectAbort = new AbortController();
493
505
  const timeoutId = setTimeout(() => {
506
+ // Aborting detaches every listener registered with connectAbort.signal
507
+ // (abort algorithms run BEFORE the abort event fires), INCLUDING the
508
+ // "close" listener that normally schedules the reconnect. A connect that
509
+ // never opens (SYN dropped by a firewall / an overloaded server) would
510
+ // otherwise leave connState pinned at "connecting" with no auto-retry.
511
+ // Drive the reconnect explicitly, mirroring the close handler.
494
512
  connectAbort.abort();
513
+ if (connState.status === "connecting" && connState.sock === sock) {
514
+ stopHeartbeat();
515
+ connState = { status: "disconnected" };
516
+ cb?.onClose();
517
+ scheduleReconnect();
518
+ }
495
519
  }, 10_000);
496
520
  connectAbort.signal.addEventListener("abort", () => {
497
521
  clearTimeout(timeoutId);
@@ -560,16 +584,35 @@ export function connect(): void {
560
584
  // even decode it. A malformed frame that decodes to null still counts.
561
585
  markActivity();
562
586
  if (ev.data instanceof ArrayBuffer) {
563
- handleDecoded(decodeWireBinary(ev.data));
587
+ try {
588
+ handleDecoded(decodeWireBinary(ev.data));
589
+ } catch (err) {
590
+ // Mirror the Blob branch below: a throw here (a consumer onMessage
591
+ // callback, or the documented re-throw of a non-RangeError from
592
+ // decodeWireBinary) is logged with engine context instead of
593
+ // surfacing as a bare uncaught exception, so field observability is
594
+ // the same across ArrayBuffer (non-iOS) and Blob (iOS Safari) frames.
595
+ console.error("vterm: dropped binary frame", err);
596
+ }
564
597
  return;
565
598
  }
566
599
  if (ev.data instanceof Blob) {
567
600
  const blob = ev.data;
568
- blobChain = blobChain.then(() =>
569
- blob.arrayBuffer().then((ab) => {
601
+ blobChain = blobChain
602
+ .then(() => blob.arrayBuffer())
603
+ .then((ab) => {
570
604
  handleDecoded(decodeWireBinary(ab));
571
- }),
572
- );
605
+ })
606
+ .catch((err: unknown) => {
607
+ // A throw here (typically a consumer onMessage callback) must NOT
608
+ // poison the chain: without this catch blobChain stays rejected and
609
+ // every later Blob frame's .then is skipped, silently dropping all
610
+ // binary frames until reconnect. iOS Safari delivers binary WS
611
+ // frames as Blob, and markActivity() already ran on arrival, so the
612
+ // liveness probe never fires -> the tab looks connected but renders
613
+ // nothing. Log and continue; arrival order is preserved.
614
+ console.error("vterm: dropped binary (blob) frame", err);
615
+ });
573
616
  return;
574
617
  }
575
618
  if (typeof ev.data === "string") {
@@ -596,11 +639,7 @@ export function connect(): void {
596
639
  const epoch = msg.serverEpoch;
597
640
  if (epoch !== undefined && epoch !== 0) {
598
641
  if (st.lastServerEpoch !== null && st.lastServerEpoch !== epoch) {
599
- st.bytesSent = 0;
600
- st.bytesAcked = 0;
601
- st.outbox.length = 0;
602
- st.outboxBytes = 0;
603
- cb?.onServerRestart?.();
642
+ resetSessionAfterRestart(st);
604
643
  }
605
644
  st.lastServerEpoch = epoch;
606
645
  }
@@ -622,11 +661,7 @@ export function connect(): void {
622
661
  // not duplicated. Skip this branch when bytesSent = 0 (genuine
623
662
  // first-connect; received=0 is correct).
624
663
  if (msg.received === 0 && st.bytesAcked > 0) {
625
- st.bytesSent = 0;
626
- st.bytesAcked = 0;
627
- st.outbox.length = 0;
628
- st.outboxBytes = 0;
629
- cb?.onServerRestart?.();
664
+ resetSessionAfterRestart(st);
630
665
  return;
631
666
  }
632
667
  applyAck(st, msg.received);
package/src/keyboard.ts CHANGED
@@ -722,7 +722,7 @@ export interface MobileToolbarController {
722
722
  /**
723
723
  * Wire a mobile / touch toolbar of on-screen keyboard buttons (Ctrl,
724
724
  * arrows, Tab, Enter, Esc, plus a collapse toggle) to a vterm send
725
- * sink. This was duplicated across vibekit and vibecli — same wire
725
+ * sink. This was duplicated across vibekit and web-terminal-kiro — same wire
726
726
  * sequences, same sticky-Ctrl semantics, same DECCKM nuance — so it
727
727
  * lives here.
728
728
  *
package/src/render.ts CHANGED
@@ -162,6 +162,22 @@ let defaultSpacing = 0;
162
162
  let onCursorMove: (() => void) | null = null;
163
163
  let pendingFrame: number | undefined;
164
164
 
165
+ // Bounded error-path reschedule (l-f28 / d-u4-1). The drain loop deletes a
166
+ // queued row only AFTER upsertRow succeeds, so a row whose build throws stays
167
+ // queued. flushRender's catch reschedules to finish a partial drain and to
168
+ // retry a transient throw (a font/measureText race), but a row that throws
169
+ // deterministically would otherwise turn catch -> rAF -> throw into a ~60fps
170
+ // busy loop (CPU/battery burn + per-frame console spam) that never stops, even
171
+ // on an idle session. `flushDrainedThisPass` records forward progress (queued
172
+ // rows actually built this pass; reset at each flushRenderInner entry, and
173
+ // visible to the catch because a mid-drain throw leaves it at the count so
174
+ // far). `renderNoProgressStreak` counts consecutive passes that threw with zero
175
+ // progress; once it passes the cap the catch stops rescheduling and lets the
176
+ // next inbound frame retry (the pre-l-f28 behavior for a stuck row).
177
+ let flushDrainedThisPass = 0;
178
+ let renderNoProgressStreak = 0;
179
+ const MAX_RENDER_NO_PROGRESS_RETRIES = 3;
180
+
165
181
  /**
166
182
  * Initialize the renderer by attaching it to a pair of DOM elements: the
167
183
  * scrollable terminal wrapper and the inner output container that receives
@@ -197,6 +213,8 @@ export function init(opts: {
197
213
  cancelAnimationFrame(pendingFrame);
198
214
  pendingFrame = undefined;
199
215
  }
216
+ flushDrainedThisPass = 0;
217
+ renderNoProgressStreak = 0;
200
218
  startCursorBlink();
201
219
  }
202
220
 
@@ -239,6 +257,37 @@ export function boundStore(): LineStore {
239
257
  return store;
240
258
  }
241
259
 
260
+ /**
261
+ * Queue every retained line for building, viewport-first: the live window rows
262
+ * (what the user sees) in ascending order first, then scrollback newest->oldest
263
+ * so rows adjacent to the viewport fill before deep history. Iterates the
264
+ * retained key set (forEachLine), NOT the integer range [oldest, highest], so a
265
+ * sparse store (a frame whose base jumped far from a retained index) never
266
+ * freezes the drain. Shared by the two wipe-and-rebuild-from-store paths
267
+ * (rebuild() and the alt-exit branch in flushRenderInner) so both order rows
268
+ * identically; the per-frame budget then spreads a large backlog across frames
269
+ * without janking.
270
+ */
271
+ function queueRowsViewportFirst(): void {
272
+ const winBase = store.getWindow().base;
273
+ const inWindow: number[] = [];
274
+ const belowWindow: number[] = [];
275
+ store.forEachLine((abs) => {
276
+ if (abs >= winBase) {
277
+ inWindow.push(abs);
278
+ } else {
279
+ belowWindow.push(abs);
280
+ }
281
+ });
282
+ for (const abs of inWindow) {
283
+ renderQueue.add(abs);
284
+ }
285
+ for (let i = belowWindow.length - 1; i >= 0; i--) {
286
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index in range
287
+ renderQueue.add(belowWindow[i]!);
288
+ }
289
+ }
290
+
242
291
  /**
243
292
  * Wipe the DOM and rebuild it from the current store, viewport-first and
244
293
  * budgeted. The live window (what the user sees) builds first, then scrollback
@@ -257,27 +306,13 @@ export function rebuild(): void {
257
306
  prevCursorCol = -1;
258
307
  prevCursorHidden = false;
259
308
  prevCursorStyleVal = -1;
309
+ // Fresh surface: a stale give-up streak from a prior store must not deny the
310
+ // rebuilt surface its full transient-retry budget.
311
+ renderNoProgressStreak = 0;
260
312
  // Alt screen paints from the ephemeral grid in the flush, so it needs no
261
313
  // absolute-index queueing here.
262
314
  if (!store.isAlt()) {
263
- const oldest = store.oldestIndex();
264
- const highest = store.highestIndex();
265
- if (highest >= 0) {
266
- const winStart = Math.max(store.getWindow().base, oldest);
267
- // Live window first (viewport), ascending.
268
- for (let abs = winStart; abs <= highest; abs++) {
269
- if (store.getLine(abs) !== undefined) {
270
- renderQueue.add(abs);
271
- }
272
- }
273
- // Then scrollback, newest to oldest, so rows nearest the viewport fill in
274
- // before deep history.
275
- for (let abs = winStart - 1; abs >= oldest; abs--) {
276
- if (store.getLine(abs) !== undefined) {
277
- renderQueue.add(abs);
278
- }
279
- }
280
- }
315
+ queueRowsViewportFirst();
281
316
  }
282
317
  scheduleFlush();
283
318
  }
@@ -554,7 +589,7 @@ function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HT
554
589
  const a = document.createElement("a");
555
590
  a.href = href;
556
591
  a.target = "_blank";
557
- a.rel = "noopener";
592
+ a.rel = "noopener noreferrer";
558
593
  // OSC 8 app hyperlink: base `.term-link`. Auto-detected URLs additionally
559
594
  // get `.term-autolink` (see linkifySpans).
560
595
  a.className = "term-link";
@@ -620,14 +655,46 @@ function flushRender(): void {
620
655
  pendingFrame = undefined;
621
656
  try {
622
657
  flushRenderInner();
658
+ // A clean pass means the error condition (if any) has cleared, so give a
659
+ // later transient error its full retry budget again. Any backlog reschedule
660
+ // was already issued from flushRenderInner's own end-of-body.
661
+ renderNoProgressStreak = 0;
623
662
  } catch (err) {
624
663
  console.error("vterm: render error", err);
664
+ // flushRenderInner threw mid-drain, skipping its own end-of-body
665
+ // "if (renderQueue.size > 0) scheduleFlush()" reschedule, so rows still
666
+ // queued would strand until the next external scheduleFlush() (a new
667
+ // frame). Reschedule here to finish the drain and to retry a transient
668
+ // throw (l-f28) -- but BOUND it. The drain loop deletes a row only after
669
+ // upsertRow succeeds, so a row that throws deterministically stays queued;
670
+ // an unconditional catch -> rAF -> throw reschedule is then a ~60fps busy
671
+ // loop that never stops, even on an idle session. Reschedule while the pass
672
+ // made forward progress (the backlog is shrinking) or the consecutive
673
+ // no-progress streak is under the cap (covers a transient font/measureText
674
+ // race that clears within a frame or two); once passes throw with zero
675
+ // progress past the cap, stop and let the next inbound frame (drainChanges)
676
+ // retry -- the pre-l-f28 behavior for a permanently stuck row.
677
+ if (renderQueue.size === 0) {
678
+ renderNoProgressStreak = 0;
679
+ } else if (flushDrainedThisPass > 0) {
680
+ renderNoProgressStreak = 0;
681
+ scheduleFlush();
682
+ } else if (renderNoProgressStreak < MAX_RENDER_NO_PROGRESS_RETRIES) {
683
+ renderNoProgressStreak++;
684
+ scheduleFlush();
685
+ } else {
686
+ console.error("vterm: giving up render retry after repeated no-progress errors");
687
+ }
625
688
  }
626
689
  // Single auto-follow invariant, applied after every DOM mutation.
627
690
  stickToBottomIfFollowing();
628
691
  }
629
692
 
630
693
  function flushRenderInner(): void {
694
+ // Forward-progress accounting for the bounded error-path reschedule. Reset at
695
+ // entry, incremented per drained row below; a mid-drain throw leaves it at
696
+ // the count-so-far for flushRender's catch to read.
697
+ flushDrainedThisPass = 0;
631
698
  const ch = store.drainChanges();
632
699
 
633
700
  if (ch.fullReset) {
@@ -666,13 +733,15 @@ function flushRenderInner(): void {
666
733
  return;
667
734
  }
668
735
  if (altRendered) {
669
- // Just exited alt: drop the ephemeral rows and rebuild from the store.
736
+ // Just exited alt: drop the ephemeral rows and rebuild from the store,
737
+ // viewport-first (shared with rebuild()) so the visible viewport fills
738
+ // before deep scrollback on a large-history alt-exit.
670
739
  altRendered = false;
671
740
  output.replaceChildren();
672
741
  rowEls.clear();
673
742
  renderQueue.clear();
674
743
  trimMarkerEl = null;
675
- store.forEachLine((abs) => renderQueue.add(abs));
744
+ queueRowsViewportFirst();
676
745
  }
677
746
 
678
747
  // Queue this frame's changed rows for building.
@@ -705,14 +774,16 @@ function flushRenderInner(): void {
705
774
 
706
775
  // Drain up to MAX_ROWS_PER_FRAME queued rows this frame; the rest carry over
707
776
  // to the next frame (scheduled below) so one big burst never blocks paint.
708
- let built = 0;
777
+ // flushDrainedThisPass doubles as the per-frame budget counter and the
778
+ // forward-progress signal the error-path reschedule reads (it was reset to 0
779
+ // at entry and nothing between there and here touches it).
709
780
  for (const abs of renderQueue) {
710
- if (built >= MAX_ROWS_PER_FRAME) {
781
+ if (flushDrainedThisPass >= MAX_ROWS_PER_FRAME) {
711
782
  break;
712
783
  }
713
784
  upsertRow(abs);
714
785
  renderQueue.delete(abs);
715
- built++;
786
+ flushDrainedThisPass++;
716
787
  }
717
788
 
718
789
  updateTrimMarker();
package/src/scroll.ts CHANGED
@@ -2,11 +2,11 @@
2
2
  //
3
3
  // One piece of state: `following`. The user is "following" when the viewport is
4
4
  // at (or within a small tolerance of) the bottom; otherwise they have scrolled
5
- // up to read and are "holding". The state is derived purely from scroll
6
- // position on every scroll event — there is no debounce window, no suppress
7
- // timer, and no programmatic-vs-user flag. That heuristic soup (a 100px
8
- // tolerance, a 150ms debounce, a 60-second touch window) was the source of the
9
- // view-jumping and scroll-interruption bugs (the legacy heuristic-soup
5
+ // up to read and are "holding". The state is derived purely from the scroll
6
+ // events — position plus movement direction — with no debounce window, no
7
+ // suppress timer, and no programmatic-vs-user flag. That heuristic soup (a
8
+ // 100px tolerance, a 150ms debounce, a 60-second touch window) was the source
9
+ // of the view-jumping and scroll-interruption bugs (the legacy heuristic-soup
10
10
  // failure mode; see the #web-terminal-engine steering doc, "Design rationale").
11
11
  //
12
12
  // The renderer calls stickToBottom() once after each flush: if following, pin
@@ -15,13 +15,32 @@
15
15
  // Appending content at the bottom does not fire a scroll event, so following
16
16
  // stays true across new output and the post-flush pin lands correctly. Pinning
17
17
  // to the bottom produces a scroll event whose recomputation yields
18
- // following=true again (no churn). Scrolling up past the tolerance flips to
19
- // holding; scrolling back flips to following.
18
+ // following=true again (no churn; the pin only ever moves DOWN). Scrolling
19
+ // back to within the tolerance of the bottom re-engages following.
20
+ //
21
+ // Disengaging is direction-based, not tolerance-based: ANY upward movement
22
+ // that leaves a real gap below flips to holding at once. A tolerance-only
23
+ // rule lost a race under heavy streaming — the renderer flushes (and pins)
24
+ // every frame, so each frame's upward scroll increment restarted from the
25
+ // bottom the previous pin reset it to, and unless a single frame's delta
26
+ // exceeded the tolerance the user was yanked back down every few milliseconds
27
+ // (the "scroll up fights me during output" bug). Per the HTML event loop the
28
+ // scroll steps run before that frame's rAF flush, so the first upward tick
29
+ // flips holding before the next pin can fire. The nothing-below guard keeps
30
+ // one non-user case following: when content SHRINKS (top-row eviction, a
31
+ // clear), the browser clamps scrollTop DOWN to the new maximum — an upward
32
+ // move that lands exactly at the bottom, which must not break auto-follow.
20
33
 
21
34
  const BOTTOM_TOLERANCE_PX = 24;
35
+ // An upward move only disengages follow when a real gap is left below it.
36
+ // Bigger than 0 to absorb fractional-layout rounding in the shrink-clamp case
37
+ // (a clamp lands at the bottom, but scrollTop can be subpixel-off); far below
38
+ // any real one-frame user scroll increment.
39
+ const CLAMP_EPSILON_PX = 1;
22
40
 
23
41
  let scrollEl: HTMLElement | null = null;
24
42
  let following = true;
43
+ let lastScrollTop = 0;
25
44
  let onFollowChange: ((scrolledUp: boolean) => void) | null = null;
26
45
  let scrollHandler: (() => void) | null = null;
27
46
 
@@ -65,8 +84,24 @@ export function init(opts: {
65
84
  scrollEl = opts.scrollEl;
66
85
  onFollowChange = opts.onUserScrollChange ?? null;
67
86
  following = true;
87
+ lastScrollTop = scrollEl.scrollTop;
68
88
  scrollHandler = () => {
69
- setFollowing(atBottom());
89
+ if (!scrollEl) {
90
+ return;
91
+ }
92
+ const top = scrollEl.scrollTop;
93
+ const movedUp = top < lastScrollTop;
94
+ lastScrollTop = top;
95
+ // Upward movement with content still below = the user pulling away from
96
+ // the live tail: hold immediately, however small the move (see the header
97
+ // comment for why the tolerance alone lost the streaming race). An upward
98
+ // move that lands AT the bottom is the browser clamping after a content
99
+ // shrink, not the user; everything else derives from position as before.
100
+ if (movedUp && distanceFromBottom() > CLAMP_EPSILON_PX) {
101
+ setFollowing(false);
102
+ } else {
103
+ setFollowing(atBottom());
104
+ }
70
105
  };
71
106
  scrollEl.addEventListener("scroll", scrollHandler, { passive: true });
72
107
  }
@@ -5,12 +5,18 @@
5
5
  // (re)open so the consumer can trigger it. Pairs with the Go
6
6
  // terminal.SessionManager EventsHandler.
7
7
 
8
+ import { nextBackoffDelay } from "./reconnect.js";
9
+
8
10
  /** SessionStatus is one session's current status as carried on the stream. It
9
11
  * mirrors the server's status event. */
10
12
  export interface SessionStatus {
11
13
  readonly id: string;
12
14
  readonly status: "working" | "idle" | "input" | "done" | "exited";
13
15
  readonly title: string;
16
+ /** the raw client-set title (before the OSC fallback baked into `title`); a
17
+ * consumer that treats the program's OSC window title as unreliable reads
18
+ * this instead of `title`. */
19
+ readonly clientTitle?: string;
14
20
  readonly createdAt: string;
15
21
  /** true when the session is gone (closed or reaped); the consumer drops it. */
16
22
  readonly removed?: boolean;
@@ -30,7 +36,9 @@ export interface StatusStreamCallbacks {
30
36
  /** Called on every (re)open, including auto-reconnects, so the consumer can
31
37
  * resync after a gap (the stream only carries future changes). */
32
38
  onOpen?: () => void;
33
- /** Called when the stream errors; the EventSource then auto-reconnects. */
39
+ /** Called when the stream errors. A transient drop is auto-reconnected by
40
+ * EventSource; a permanent close (non-2xx / wrong content-type) is
41
+ * re-established by this module with capped backoff. */
34
42
  onError?: () => void;
35
43
  }
36
44
 
@@ -39,6 +47,10 @@ export interface StatusStreamCallbacks {
39
47
  export interface EventSourceLike {
40
48
  addEventListener: (type: string, listener: (event: MessageEvent) => void) => void;
41
49
  close: () => void;
50
+ /** EventSource.readyState: 0 CONNECTING, 1 OPEN, 2 CLOSED. A permanent close
51
+ * (non-2xx response / wrong content-type / auth failure) sets CLOSED and
52
+ * native auto-reconnect never fires; the module re-establishes on CLOSED. */
53
+ readonly readyState: number;
42
54
  }
43
55
 
44
56
  /** EventSourceFactory builds an EventSourceLike for a URL. */
@@ -58,6 +70,9 @@ const defaultFactory: EventSourceFactory = (url) => {
58
70
  close: () => {
59
71
  es.close();
60
72
  },
73
+ get readyState() {
74
+ return es.readyState;
75
+ },
61
76
  };
62
77
  };
63
78
 
@@ -68,33 +83,62 @@ export function connectStatusStream(
68
83
  cb: StatusStreamCallbacks,
69
84
  make: EventSourceFactory = defaultFactory,
70
85
  ): StatusStream {
71
- const es = make(path);
86
+ let es: EventSourceLike;
87
+ let closed = false;
88
+ let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
89
+ let backoffMs = 500;
72
90
  const { onOpen, onError } = cb;
73
- if (onOpen) {
91
+ const open = (): void => {
92
+ es = make(path);
74
93
  es.addEventListener("open", () => {
75
- onOpen();
94
+ backoffMs = 500; // reset backoff on a successful (re)open
95
+ if (onOpen) {
96
+ onOpen();
97
+ }
98
+ });
99
+ es.addEventListener("message", (event) => {
100
+ const raw: unknown = event.data;
101
+ if (typeof raw !== "string") {
102
+ return; // non-text frame, ignore
103
+ }
104
+ let status: SessionStatus;
105
+ try {
106
+ status = JSON.parse(raw) as SessionStatus;
107
+ } catch {
108
+ console.warn("vterm: dropped malformed status-stream frame");
109
+ return; // skip a malformed frame, keep the stream
110
+ }
111
+ cb.onStatus(status);
76
112
  });
77
- }
78
- es.addEventListener("message", (event) => {
79
- const raw: unknown = event.data;
80
- if (typeof raw !== "string") {
81
- return; // non-text frame, ignore
82
- }
83
- let status: SessionStatus;
84
- try {
85
- status = JSON.parse(raw) as SessionStatus;
86
- } catch {
87
- return; // skip a malformed frame, keep the stream
88
- }
89
- cb.onStatus(status);
90
- });
91
- if (onError) {
92
113
  es.addEventListener("error", () => {
93
- onError();
114
+ if (onError) {
115
+ onError();
116
+ }
117
+ // Native EventSource auto-reconnect covers a transient drop but NOT a
118
+ // permanent close (server restart -> proxy 502/non-2xx, auth 401/403,
119
+ // wrong content-type), which sets readyState CLOSED and never retries.
120
+ // Re-establish so per-tab status doesn't freeze until a page reload while
121
+ // the terminal WS recovers on its own backoff. onOpen fires on the reopen,
122
+ // so the consumer's resync still runs.
123
+ if (es.readyState === 2 && !closed && reconnectTimer === undefined) {
124
+ const step = nextBackoffDelay(backoffMs);
125
+ reconnectTimer = setTimeout(() => {
126
+ reconnectTimer = undefined;
127
+ if (!closed) {
128
+ open();
129
+ }
130
+ }, step.scheduledMs);
131
+ backoffMs = step.nextBaseMs;
132
+ }
94
133
  });
95
- }
134
+ };
135
+ open();
96
136
  return {
97
137
  close: () => {
138
+ closed = true;
139
+ if (reconnectTimer !== undefined) {
140
+ clearTimeout(reconnectTimer);
141
+ }
98
142
  es.close();
99
143
  },
100
144
  };
package/src/store.ts CHANGED
@@ -151,7 +151,12 @@ export class LineStore {
151
151
  if (this.oldest < 0) {
152
152
  return;
153
153
  }
154
- for (let abs = this.oldest; abs <= this.highest; abs++) {
154
+ // Iterate the retained keys (bounded by maxLines), sorted ascending, not the
155
+ // integer range [oldest, highest]: a frame whose base jumps far from a
156
+ // retained index makes that range ~2^53 wide and freezes the tab (the DoS
157
+ // enforceCap is written to avoid).
158
+ const keys = [...this.lines.keys()].sort((a, b) => a - b);
159
+ for (const abs of keys) {
155
160
  const runs = this.lines.get(abs);
156
161
  if (runs !== undefined) {
157
162
  cb(abs, runs);
@@ -316,8 +321,10 @@ export class LineStore {
316
321
  if (this.oldest < 0 || this.oldest >= base) {
317
322
  return;
318
323
  }
319
- for (let abs = this.oldest; abs < base; abs++) {
320
- if (this.lines.delete(abs)) {
324
+ // Bounded key-scan (like enforceCap), not an integer walk over [oldest, base).
325
+ for (const abs of [...this.lines.keys()]) {
326
+ if (abs < base) {
327
+ this.lines.delete(abs);
321
328
  this.evicted.add(abs);
322
329
  this.dirty.delete(abs);
323
330
  }
@@ -349,19 +356,21 @@ export class LineStore {
349
356
  if (this.highest <= windowBottom) {
350
357
  return;
351
358
  }
352
- for (let abs = windowBottom + 1; abs <= this.highest; abs++) {
353
- if (this.lines.delete(abs)) {
359
+ // Bounded key-scan (like enforceCap), NOT an integer walk over
360
+ // [windowBottom+1, highest]: a frame whose base drops far below a high
361
+ // retained index would otherwise loop up to ~2^53 times and freeze the tab.
362
+ let newHighest = -1;
363
+ for (const abs of [...this.lines.keys()]) {
364
+ if (abs > windowBottom) {
365
+ this.lines.delete(abs);
354
366
  this.evicted.add(abs);
355
367
  this.dirty.delete(abs);
368
+ } else if (abs > newHighest) {
369
+ newHighest = abs;
356
370
  }
357
371
  }
358
- // New highest is the greatest retained index at or below the window bottom.
359
- let h = windowBottom;
360
- while (h >= this.oldest && !this.lines.has(h)) {
361
- h--;
362
- }
363
- if (h >= this.oldest) {
364
- this.highest = h;
372
+ if (newHighest >= 0) {
373
+ this.highest = newHighest;
365
374
  } else {
366
375
  this.highest = -1;
367
376
  this.oldest = -1;
@@ -43,6 +43,12 @@ const MODE_FLAG_APP_KEYPAD = 16;
43
43
  const MODE_FLAG_REVERSE_VIDEO = 32;
44
44
  const MODE_FLAG_MOUSE_PIXELS = 64;
45
45
 
46
+ // Shared across all one-shot decode() calls (no {stream:true} is used here), so
47
+ // a single module-level instance is safe and avoids allocating a fresh
48
+ // TextDecoder per styled run / OSC-8 URI on the hot decode path (a single
49
+ // scroll frame can carry thousands of runs).
50
+ const textDecoder = new TextDecoder();
51
+
46
52
  class Cursor {
47
53
  view: DataView;
48
54
  bytes: Uint8Array;
@@ -84,7 +90,7 @@ class Cursor {
84
90
  utf8(len: number): string {
85
91
  const slice = this.bytes.subarray(this.off, this.off + len);
86
92
  this.off += len;
87
- return new TextDecoder().decode(slice);
93
+ return textDecoder.decode(slice);
88
94
  }
89
95
  }
90
96
 
@@ -121,6 +127,7 @@ function readRowRuns(c: Cursor): WireRun[] {
121
127
  */
122
128
  export function decodeWireBinary(buf: ArrayBuffer): ServerMessage | null {
123
129
  if (buf.byteLength < 9) {
130
+ console.warn("vterm: dropped undersized wire frame", buf.byteLength);
124
131
  return null;
125
132
  }
126
133
  try {
@@ -131,6 +138,7 @@ export function decodeWireBinary(buf: ArrayBuffer): ServerMessage | null {
131
138
  // the caller skip this frame rather than crashing the decode loop.
132
139
  // The next flush will carry a complete snapshot anyway.
133
140
  if (err instanceof RangeError) {
141
+ console.warn("vterm: dropped malformed/truncated wire frame", buf.byteLength);
134
142
  return null;
135
143
  }
136
144
  throw err;
@@ -261,5 +269,6 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
261
269
  // here — returning null keeps it out of the onMessage path.
262
270
  return null;
263
271
  }
272
+ console.warn("vterm: unknown wire message type", msgType);
264
273
  return null;
265
274
  }