@cplieger/web-terminal-engine 2.2.1 → 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 +1 -1
- package/package.json +8 -7
- package/src/connection.ts +51 -16
- package/src/keyboard.ts +1 -1
- package/src/render.ts +95 -24
- package/src/status-stream.ts +65 -21
- package/src/store.ts +21 -12
- package/src/wire-binary.ts +10 -1
package/jsr.json
CHANGED
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-engine",
|
|
3
|
-
"version": "2.2
|
|
3
|
+
"version": "2.3.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
|
-
"typecheck": "
|
|
7
|
-
"typecheck:tests": "
|
|
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
|
-
"@
|
|
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.
|
|
23
|
-
"typescript-eslint": "8.
|
|
24
|
-
"vitest": "4.1.
|
|
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";
|
|
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
|
-
|
|
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
|
|
569
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
781
|
+
if (flushDrainedThisPass >= MAX_ROWS_PER_FRAME) {
|
|
711
782
|
break;
|
|
712
783
|
}
|
|
713
784
|
upsertRow(abs);
|
|
714
785
|
renderQueue.delete(abs);
|
|
715
|
-
|
|
786
|
+
flushDrainedThisPass++;
|
|
716
787
|
}
|
|
717
788
|
|
|
718
789
|
updateTrimMarker();
|
package/src/status-stream.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
91
|
+
const open = (): void => {
|
|
92
|
+
es = make(path);
|
|
74
93
|
es.addEventListener("open", () => {
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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;
|
package/src/wire-binary.ts
CHANGED
|
@@ -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
|
|
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
|
}
|