@cplieger/web-terminal-engine 3.7.0 → 3.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/jsr.json +2 -1
- package/package.json +2 -2
- package/src/render.ts +155 -0
- package/src/scroll.ts +9 -0
package/README.md
CHANGED
package/jsr.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-engine",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"typecheck": "tsc -p tsconfig.json",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"qs": "^6.15.2"
|
|
33
33
|
},
|
|
34
34
|
"description": "Browser terminal engine: VT500 screen buffer, DOM renderer, and binary wire protocol",
|
|
35
|
-
"license": "
|
|
35
|
+
"license": "MPL-2.0",
|
|
36
36
|
"exports": {
|
|
37
37
|
".": "./src/index.ts",
|
|
38
38
|
"./wire-compatibility.json": "./wire-compatibility.json"
|
package/src/render.ts
CHANGED
|
@@ -234,6 +234,10 @@ function termPadding(): { padL: number; padT: number } {
|
|
|
234
234
|
let flushDrainedThisPass = 0;
|
|
235
235
|
let renderNoProgressStreak = 0;
|
|
236
236
|
const MAX_RENDER_NO_PROGRESS_RETRIES = 3;
|
|
237
|
+
// The stall canary's latch (see the reschedule rule near flushRender). Reset by
|
|
238
|
+
// init, and by any flush that ends in a healthy state, so it suppresses a repeat
|
|
239
|
+
// of ONE stall rather than every stall for the life of the process.
|
|
240
|
+
let unnamedStallReported = false;
|
|
237
241
|
// Whether the CURRENT flush performed a full reset (server restart / epoch
|
|
238
242
|
// change). Read by restoreReadAnchor after the flush: a re-anchor across a
|
|
239
243
|
// reset would match unrelated content from the new index space (R2 review).
|
|
@@ -339,6 +343,11 @@ export function init(opts: {
|
|
|
339
343
|
}
|
|
340
344
|
flushDrainedThisPass = 0;
|
|
341
345
|
renderNoProgressStreak = 0;
|
|
346
|
+
// The stall canary latches so it cannot spam, and init is the attachment
|
|
347
|
+
// boundary, so the latch resets here with every other piece of pass state. A
|
|
348
|
+
// latch that survived would make the canary a once-per-PROCESS report, silent
|
|
349
|
+
// for every later attachment and for every genuinely new stall.
|
|
350
|
+
unnamedStallReported = false;
|
|
342
351
|
// Attachment boundary: a view restore armed against the PREVIOUS surface must
|
|
343
352
|
// not survive it, or its timer outlives teardown and writes scrollTop on a
|
|
344
353
|
// detached element. Same discipline scroll.init applies to its own arms.
|
|
@@ -959,6 +968,104 @@ function scheduleFlush(): void {
|
|
|
959
968
|
pendingFrame = requestAnimationFrame(flushRender);
|
|
960
969
|
}
|
|
961
970
|
|
|
971
|
+
/**
|
|
972
|
+
* The renderer's half of a scroll-position event. Wire this to
|
|
973
|
+
* `scroll.init({ onScrollPosition })` and nothing else: it is the ONE hook the
|
|
974
|
+
* renderer needs from a position change, so a consumer cannot half-wire it.
|
|
975
|
+
*
|
|
976
|
+
* Two jobs, in this order. Re-evaluate the demand-paging trigger, because the
|
|
977
|
+
* reader may have approached a gap. Then finish a drain that stopped with rows
|
|
978
|
+
* still queued, which is reachable on an idle session: the bounded error path in
|
|
979
|
+
* `flushRender`'s catch deliberately stops rescheduling, and no inbound frame
|
|
980
|
+
* arrives to restart it.
|
|
981
|
+
*
|
|
982
|
+
* It replaced a pair of exported calls (`maybeFetchHistory` plus a public
|
|
983
|
+
* `resumeDrain`) because that pair was a silent-omission trap of exactly the
|
|
984
|
+
* shape the paged-scrollback docs warn about: a consumer upgrading the engine and
|
|
985
|
+
* keeping its old one-line wiring got paging and no drain recovery, with no type
|
|
986
|
+
* error and no runtime complaint. `maybeFetchHistory` stays exported for the
|
|
987
|
+
* transport's own retry path, which is a different event.
|
|
988
|
+
*/
|
|
989
|
+
export function handleScrollPosition(): void {
|
|
990
|
+
maybeFetchHistory();
|
|
991
|
+
resumeDrain();
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Resume a drain that still has rows queued. Private: reached only through
|
|
996
|
+
* `handleScrollPosition`, so there is one wiring obligation rather than two.
|
|
997
|
+
*
|
|
998
|
+
* Three refusals, and each one is load-bearing rather than defensive:
|
|
999
|
+
*
|
|
1000
|
+
* **An empty queue.** `flushRender` runs `applyPendingRestore`,
|
|
1001
|
+
* `restoreReadAnchor` and `stickToBottomIfFollowing` unconditionally; only the
|
|
1002
|
+
* drain is queue-gated. So a flush scheduled from a scroll handler would run the
|
|
1003
|
+
* position invariants at moments the renderer never previously flushed at, and
|
|
1004
|
+
* because `atBottom` tolerates `BOTTOM_TOLERANCE_PX` while `stickToBottom` pins
|
|
1005
|
+
* on any non-zero gap, a downward scroll landing just short of the tail on an
|
|
1006
|
+
* IDLE session would snap to the bottom one frame later. It would also expire an
|
|
1007
|
+
* armed view restore early through `RESTORE_OWN_WRITE_EPSILON_PX`.
|
|
1008
|
+
*
|
|
1009
|
+
* **Alt screen.** Alt is a NAMED suspension of the drain (see the reschedule rule
|
|
1010
|
+
* below), and a resumption path that ignores it is not a resumption path. The
|
|
1011
|
+
* drain would survive anyway, because `flushRenderInner`'s alt branch returns
|
|
1012
|
+
* before the drain, but the flush around it would still call `renderAlt` (which
|
|
1013
|
+
* replaces the whole output subtree, destroying an in-alt selection) and run all
|
|
1014
|
+
* three position invariants. Leaning on the body of a function to make a
|
|
1015
|
+
* scheduling decision safe is how the next edit to that body becomes a bug. Alt
|
|
1016
|
+
* exit re-queues everything through `queueRowsViewportFirst`, so refusing costs
|
|
1017
|
+
* nothing.
|
|
1018
|
+
*
|
|
1019
|
+
* **A give-up already retried.** The catch's give-up exists so a deterministically
|
|
1020
|
+
* throwing row cannot become a 60fps loop. Retrying it from a scroll event would
|
|
1021
|
+
* move that loop outside the module rather than remove it: a flick is one scroll
|
|
1022
|
+
* event per frame, and `scroll.ts`'s position callback fires for the browser's own
|
|
1023
|
+
* clamps too, so a stuck row would log two errors per frame for the length of a
|
|
1024
|
+
* gesture. One retry per give-up is enough to serve the case this function exists
|
|
1025
|
+
* for (the reader scrolls, the surface completes) without reopening the loop the
|
|
1026
|
+
* cap closed. The retry is re-earned by anything that clears the streak: real
|
|
1027
|
+
* forward progress, an empty queue, a rebuild, or a re-attach. NOT by the give-up
|
|
1028
|
+
* log — a retry that throws gives up again, and re-arming there would restore the
|
|
1029
|
+
* per-gesture loop one indirection further out.
|
|
1030
|
+
*
|
|
1031
|
+
* Residual, accepted: `scroll.ts` fires its position seam before its own
|
|
1032
|
+
* announced-shrink early return, so a programmatic clamp (a cap eviction) can spend
|
|
1033
|
+
* the retry with no user action. The exposure is small because any successful flush
|
|
1034
|
+
* re-arms it, and the alternative (a time bound) adds a clock to a path whose whole
|
|
1035
|
+
* purpose is to be cheap.
|
|
1036
|
+
*/
|
|
1037
|
+
function resumeDrain(): void {
|
|
1038
|
+
if (renderQueue.size === 0 || store.isAlt()) {
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
// A frame is already coming, so there is nothing to resume and nothing to spend.
|
|
1042
|
+
// Without this the retry is burned in a state where it buys NOTHING: the catch
|
|
1043
|
+
// reaches `streak === MAX` from its own progress-less branch and schedules a frame
|
|
1044
|
+
// on the way out, so for one frame the streak is at the cap with a flush pending.
|
|
1045
|
+
// A scroll landing there would set the streak past the cap while `scheduleFlush`
|
|
1046
|
+
// no-ops on the occupied slot, and the real give-up one frame later would then
|
|
1047
|
+
// find its one retry already gone. The spend must be gated on the give-up having
|
|
1048
|
+
// HAPPENED, and an empty frame slot is what distinguishes that.
|
|
1049
|
+
if (pendingFrame !== undefined) {
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
if (renderNoProgressStreak >= MAX_RENDER_NO_PROGRESS_RETRIES) {
|
|
1053
|
+
if (renderNoProgressStreak > MAX_RENDER_NO_PROGRESS_RETRIES) {
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
// Spend the one retry by carrying it in the streak itself, rather than in a
|
|
1057
|
+
// second flag beside it. A flag was tried and it leaked: it had to be cleared
|
|
1058
|
+
// at every site that clears the streak, it was cleared at three of five, and
|
|
1059
|
+
// the two misses (`rebuild`, and a clean pass) are the common ones — so one
|
|
1060
|
+
// give-up plus one scroll disabled the resume for the rest of the attachment,
|
|
1061
|
+
// invisibly, because that state presents to the canary as a plain give-up. With
|
|
1062
|
+
// the count carrying it there is nothing to keep in sync: every existing
|
|
1063
|
+
// `renderNoProgressStreak = 0` re-arms the retry by construction.
|
|
1064
|
+
renderNoProgressStreak = MAX_RENDER_NO_PROGRESS_RETRIES + 1;
|
|
1065
|
+
}
|
|
1066
|
+
scheduleFlush();
|
|
1067
|
+
}
|
|
1068
|
+
|
|
962
1069
|
// --- Read-position anchoring (manual scroll anchoring) ---
|
|
963
1070
|
//
|
|
964
1071
|
// A flush can change the content height ABOVE the reading position: rows evicted
|
|
@@ -1241,6 +1348,54 @@ function flushRender(): void {
|
|
|
1241
1348
|
// beside them — so the flush is the other place the trigger must run
|
|
1242
1349
|
// (docs/paged-scrollback.md §5.4).
|
|
1243
1350
|
maybeFetchHistory();
|
|
1351
|
+
reportUnnamedDrainStall();
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// --- The reschedule rule ---
|
|
1355
|
+
//
|
|
1356
|
+
// After a flush, owed rows are in exactly one of four states. The set is
|
|
1357
|
+
// exhaustive by construction here rather than by review of each return site,
|
|
1358
|
+
// which is what a queue left non-empty with an empty `pendingFrame` slot costs:
|
|
1359
|
+
// `scheduleFlush` is a single slot, so every later request from every source is
|
|
1360
|
+
// silently dropped and nothing drains.
|
|
1361
|
+
//
|
|
1362
|
+
// 1. nothing owed - the queue is empty.
|
|
1363
|
+
// 2. scheduled - a frame will drain it.
|
|
1364
|
+
// 3. suspended, named edge - alt screen. `renderAlt` replaces the whole output
|
|
1365
|
+
// subtree, so continuing the drain would inject
|
|
1366
|
+
// main-buffer rows into the alt grid; the alt-exit
|
|
1367
|
+
// branch re-queues everything via
|
|
1368
|
+
// queueRowsViewportFirst, so nothing is lost. The
|
|
1369
|
+
// resumption edge is alt exit. A future third
|
|
1370
|
+
// suspension point must declare its edge here.
|
|
1371
|
+
// 4. stopped and reported - the bounded error path gave up after
|
|
1372
|
+
// MAX_RENDER_NO_PROGRESS_RETRIES and logged. It is a
|
|
1373
|
+
// guard on the process, so it refuses rather than
|
|
1374
|
+
// looping; the next inbound frame retries, or one
|
|
1375
|
+
// scroll does (resumeDrain, once per give-up).
|
|
1376
|
+
//
|
|
1377
|
+
// Anything else is a stall nobody owns. Reported once PER EPISODE: the latch
|
|
1378
|
+
// clears as soon as a flush ends in a healthy state, so a repeat of one stall
|
|
1379
|
+
// stays quiet while a genuinely new stall later is still reported. Log spam would
|
|
1380
|
+
// be its own defect, and so would a canary that goes deaf after the first report.
|
|
1381
|
+
function reportUnnamedDrainStall(): void {
|
|
1382
|
+
if (renderQueue.size === 0 || pendingFrame !== undefined || store.isAlt()) {
|
|
1383
|
+
// A healthy end: the episode (if any) is over, so re-arm the canary.
|
|
1384
|
+
unnamedStallReported = false;
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
if (renderNoProgressStreak >= MAX_RENDER_NO_PROGRESS_RETRIES) {
|
|
1388
|
+
// Named and already logged by the catch. Not a healthy state, so the latch is
|
|
1389
|
+
// left as it is rather than re-armed.
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
if (unnamedStallReported) {
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
unnamedStallReported = true;
|
|
1396
|
+
console.warn(
|
|
1397
|
+
`vterm: ${String(renderQueue.size)} rows queued with no scheduled frame and no named suspension`,
|
|
1398
|
+
);
|
|
1244
1399
|
}
|
|
1245
1400
|
|
|
1246
1401
|
function flushRenderInner(): void {
|
package/src/scroll.ts
CHANGED
|
@@ -265,6 +265,15 @@ function writePreservingFollow(next: number): void {
|
|
|
265
265
|
* absolutely-positioned overlay) — and a lingering arm swallows the next real
|
|
266
266
|
* gesture.
|
|
267
267
|
*
|
|
268
|
+
* That last case is live in this codebase rather than illustrative: the caret
|
|
269
|
+
* overlay, the predicted cursor, the IME composition view and the consumer's
|
|
270
|
+
* hidden textarea all sit inside the scroll container carrying a `top` in CONTENT
|
|
271
|
+
* coordinates. Nothing resets them on a wipe: the renderer's `init` drops the two
|
|
272
|
+
* it owns (the caret and the predicted cursor), a full consumer remount recreates
|
|
273
|
+
* the other two, and `rebuild` does neither. So a wipe can leave scrollHeight held
|
|
274
|
+
* above the built content by a stale overlay, the clamp never happens, and there
|
|
275
|
+
* is nothing for this arm to cover. See `docs/tab-switch-repaint.md` §6.2.
|
|
276
|
+
*
|
|
268
277
|
* A native-scroll-anchoring adjustment (Chrome/Firefox lowering scrollTop as
|
|
269
278
|
* rows are removed above the viewport) also moves the position and also arms
|
|
270
279
|
* this; that is correct rather than incidental, since it is likewise the
|