@cplieger/web-terminal-engine 2.2.0 → 2.3.2

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.0",
3
+ "version": "2.3.2",
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.0",
3
+ "version": "2.3.2",
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
18
  "eslint": "10.6.0",
18
19
  "fast-check": "4.8.0",
19
20
  "happy-dom": "20.10.6",
20
21
  "pngjs": "7.0.0",
21
22
  "prettier": "3.9.4",
22
- "typescript": "6.0.3",
23
- "typescript-eslint": "8.62.1",
24
- "vitest": "4.1.9"
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
  }
@@ -346,7 +381,14 @@ function linkifySpans(
346
381
  a.href = match[0];
347
382
  a.target = "_blank";
348
383
  a.rel = "noopener noreferrer";
349
- a.className = "term-link";
384
+ // Auto-detected bare URLs are tightly scoped to the matched URL text
385
+ // (never a padded/bordered region), so `.term-autolink` keeps a persistent
386
+ // underline for discoverability. OSC 8 hyperlinks (buildRowSpans below) get
387
+ // only `.term-link`, which the UI underlines on hover — an app can attach a
388
+ // single OSC 8 link to a whole region (e.g. a URL wrapping inside a table
389
+ // cell, where the link stays open across the cell padding/borders), and a
390
+ // persistent underline would then bleed across the cell/row.
391
+ a.className = "term-link term-autolink";
350
392
  a.textContent = match[0];
351
393
  // Copy inline styles from the source span
352
394
  a.style.cssText = span.style.cssText;
@@ -364,6 +406,29 @@ function linkifySpans(
364
406
  return out;
365
407
  }
366
408
 
409
+ // A hyperlink run is "link text" only if it has at least one glyph that is not
410
+ // whitespace and not a box-drawing (U+2500–U+257F) or block-element
411
+ // (U+2580–U+259F) character. Terminal table structure (borders `│`, padding,
412
+ // empty columns, margins) is made of exactly those, and an app may keep an OSC 8
413
+ // hyperlink open across it while a URL wraps; such decorative runs are not
414
+ // anchored, so the link decoration hugs the actual text instead of bleeding
415
+ // across the cell/row.
416
+ function runHasLinkText(spans: (HTMLSpanElement | HTMLAnchorElement)[]): boolean {
417
+ for (const s of spans) {
418
+ for (const ch of s.textContent) {
419
+ if (/\s/.test(ch)) {
420
+ continue;
421
+ }
422
+ const cp = ch.codePointAt(0) ?? 0;
423
+ if (cp >= 0x2500 && cp <= 0x259f) {
424
+ continue;
425
+ }
426
+ return true;
427
+ }
428
+ }
429
+ return false;
430
+ }
431
+
367
432
  // --- Build row DOM ---
368
433
  function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HTMLAnchorElement)[] {
369
434
  const out: (HTMLSpanElement | HTMLAnchorElement)[] = [];
@@ -509,20 +574,33 @@ function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HT
509
574
  col++;
510
575
  }
511
576
  flush();
512
- // Wrap spans from this run in an <a> if it has a hyperlink URL.
577
+ // Wrap this run's spans in an <a> when it has a hyperlink URL — but only if
578
+ // the run actually contains link *text*. An OSC 8 hyperlink can stay open
579
+ // across a whole region the app never meant as the clickable "link": a URL
580
+ // that wraps inside a table cell keeps the link open across the cell padding,
581
+ // the borders `│` and the empty adjacent column. Anchoring those decorative
582
+ // cells makes the link's decoration (the underline) bleed across the cell and,
583
+ // on wrap, the full row. Skip them so the anchor — and its underline, at rest
584
+ // or on hover — hugs the visible link text (they stay as plain, non-clickable
585
+ // spans; the wrapped URL's text runs each still carry the full href).
513
586
  const href = run.u && /^https?:\/\//i.test(run.u) ? run.u : null;
514
- if (href) {
587
+ const runSpans = out.splice(runStartIdx);
588
+ if (href && runHasLinkText(runSpans)) {
515
589
  const a = document.createElement("a");
516
590
  a.href = href;
517
591
  a.target = "_blank";
518
- a.rel = "noopener";
592
+ a.rel = "noopener noreferrer";
593
+ // OSC 8 app hyperlink: base `.term-link`. Auto-detected URLs additionally
594
+ // get `.term-autolink` (see linkifySpans).
519
595
  a.className = "term-link";
520
- // Move spans from this run into the anchor.
521
- const runSpans = out.splice(runStartIdx);
522
596
  for (const s of runSpans) {
523
597
  a.appendChild(s);
524
598
  }
525
599
  out.push(a);
600
+ } else {
601
+ for (const s of runSpans) {
602
+ out.push(s);
603
+ }
526
604
  }
527
605
  }
528
606
  if (cursorAt >= 0 && col <= cursorAt) {
@@ -577,14 +655,46 @@ function flushRender(): void {
577
655
  pendingFrame = undefined;
578
656
  try {
579
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;
580
662
  } catch (err) {
581
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
+ }
582
688
  }
583
689
  // Single auto-follow invariant, applied after every DOM mutation.
584
690
  stickToBottomIfFollowing();
585
691
  }
586
692
 
587
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;
588
698
  const ch = store.drainChanges();
589
699
 
590
700
  if (ch.fullReset) {
@@ -623,13 +733,15 @@ function flushRenderInner(): void {
623
733
  return;
624
734
  }
625
735
  if (altRendered) {
626
- // 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.
627
739
  altRendered = false;
628
740
  output.replaceChildren();
629
741
  rowEls.clear();
630
742
  renderQueue.clear();
631
743
  trimMarkerEl = null;
632
- store.forEachLine((abs) => renderQueue.add(abs));
744
+ queueRowsViewportFirst();
633
745
  }
634
746
 
635
747
  // Queue this frame's changed rows for building.
@@ -662,14 +774,16 @@ function flushRenderInner(): void {
662
774
 
663
775
  // Drain up to MAX_ROWS_PER_FRAME queued rows this frame; the rest carry over
664
776
  // to the next frame (scheduled below) so one big burst never blocks paint.
665
- 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).
666
780
  for (const abs of renderQueue) {
667
- if (built >= MAX_ROWS_PER_FRAME) {
781
+ if (flushDrainedThisPass >= MAX_ROWS_PER_FRAME) {
668
782
  break;
669
783
  }
670
784
  upsertRow(abs);
671
785
  renderQueue.delete(abs);
672
- built++;
786
+ flushDrainedThisPass++;
673
787
  }
674
788
 
675
789
  updateTrimMarker();
@@ -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
  }