@cplieger/web-terminal-engine 4.0.3 → 5.0.1
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 +3 -3
- package/src/connection.ts +10 -3
- package/src/render.ts +19 -4
- package/src/scroll.ts +1 -1
- package/src/store.ts +221 -7
- package/src/types.ts +1 -2
package/jsr.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-engine",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"typecheck": "tsc -p tsconfig.json",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
18
18
|
"@types/pngjs": "6.0.5",
|
|
19
19
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
20
|
-
"@vitest/coverage-v8": "4.1.
|
|
20
|
+
"@vitest/coverage-v8": "4.1.11",
|
|
21
21
|
"esbuild": "0.28.2",
|
|
22
22
|
"eslint": "10.8.1",
|
|
23
23
|
"fast-check": "4.9.0",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"prettier": "3.9.6",
|
|
27
27
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
28
28
|
"typescript-eslint": "8.67.0",
|
|
29
|
-
"vitest": "4.1.
|
|
29
|
+
"vitest": "4.1.11"
|
|
30
30
|
},
|
|
31
31
|
"overrides": {
|
|
32
32
|
"qs": "^6.15.2"
|
package/src/connection.ts
CHANGED
|
@@ -443,9 +443,16 @@ export interface Callbacks {
|
|
|
443
443
|
* it. */
|
|
444
444
|
onWireIncompatible?(details: WireIncompatibility): void;
|
|
445
445
|
computeSize(): { cols: number; rows: number };
|
|
446
|
-
/** Returns the highest absolute line index the client
|
|
447
|
-
* -1
|
|
448
|
-
* replays only the lines missed (e.g. printed while
|
|
446
|
+
/** Returns the highest absolute line index the client will not ask the server
|
|
447
|
+
* to re-send, or -1 to request a full retained replay. Sent as `haveThrough`
|
|
448
|
+
* on resume so the server replays only the lines missed (e.g. printed while
|
|
449
|
+
* the device slept).
|
|
450
|
+
*
|
|
451
|
+
* NOT simply the highest index held. A client that holds a row PROVISIONALLY
|
|
452
|
+
* — a screen row the application drew, which the server has not committed at
|
|
453
|
+
* that absolute index — must not claim it, or the replay starts above it and
|
|
454
|
+
* the provisional copy is never corrected. Wire this to
|
|
455
|
+
* `render.getReplayBoundary`, which answers exactly that question.
|
|
449
456
|
* When absent, the client requests a full retained replay (-1). */
|
|
450
457
|
getHaveThrough?(): number;
|
|
451
458
|
/** Fired on resume with the server's retained-history bounds: `committed`
|
package/src/render.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// "scrollback": the live window is simply the last `height` absolute indices,
|
|
8
8
|
// and a line that scrolls into history just stops being updated. This is what
|
|
9
9
|
// removes the live/history reconciliation that caused the duplicate-rows and
|
|
10
|
-
// view-jumping bugs
|
|
10
|
+
// view-jumping bugs.
|
|
11
11
|
//
|
|
12
12
|
// Decode frames feed the store (handleScreen/handleScroll); a single
|
|
13
13
|
// requestAnimationFrame flush drains the store's change set and applies it:
|
|
@@ -587,14 +587,29 @@ function collapseContentSpaceOverlays(): void {
|
|
|
587
587
|
}
|
|
588
588
|
|
|
589
589
|
/**
|
|
590
|
-
* Highest absolute line index the client holds, or -1 if empty.
|
|
591
|
-
*
|
|
592
|
-
*
|
|
590
|
+
* Highest absolute line index the client holds, or -1 if empty. Exposed so a
|
|
591
|
+
* consumer can ask how far this store's content reaches; it is NOT the resume
|
|
592
|
+
* `haveThrough` (that is getReplayBoundary below, and the difference is a bug
|
|
593
|
+
* when the two are confused).
|
|
593
594
|
*/
|
|
594
595
|
export function getHighestIndex(): number {
|
|
595
596
|
return store.highestIndex();
|
|
596
597
|
}
|
|
597
598
|
|
|
599
|
+
/**
|
|
600
|
+
* The resume `haveThrough`: the highest index this client will not ask the
|
|
601
|
+
* server to re-send. Wire this to Callbacks.getHaveThrough, never
|
|
602
|
+
* getHighestIndex.
|
|
603
|
+
*
|
|
604
|
+
* The two differ by whatever the store holds provisionally — screen rows the
|
|
605
|
+
* application drew and the server has not committed at those indices — and
|
|
606
|
+
* claiming those on the wire is what leaves a stale copy of the last screen
|
|
607
|
+
* parked in scrollback after a reattach. See LineStore.replayBoundary.
|
|
608
|
+
*/
|
|
609
|
+
export function getReplayBoundary(): number {
|
|
610
|
+
return store.replayBoundary();
|
|
611
|
+
}
|
|
612
|
+
|
|
598
613
|
/**
|
|
599
614
|
* Record the server's retained-history bounds from a resumeAck (committed =
|
|
600
615
|
* one past the newest retained, oldest = oldest retained absolute index). The
|
package/src/scroll.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// suppress timer, and no programmatic-vs-user flag. That heuristic soup (a
|
|
8
8
|
// 100px tolerance, a 150ms debounce, a 60-second touch window) was the source
|
|
9
9
|
// of the view-jumping and scroll-interruption bugs (the legacy heuristic-soup
|
|
10
|
-
// failure mode
|
|
10
|
+
// failure mode).
|
|
11
11
|
//
|
|
12
12
|
// The renderer calls stickToBottom() once after each flush: if following, pin
|
|
13
13
|
// to the new bottom; if holding, do nothing. Holding the reading position when
|
package/src/store.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// Absolute-index line store: the client's authoritative model of the
|
|
2
2
|
// terminal. One buffer keyed by absolute line index, with the live screen
|
|
3
3
|
// window sliding along it. This is the data model that resolves the
|
|
4
|
-
// live/history split
|
|
5
|
-
// rationale"): there is no separate
|
|
4
|
+
// live/history split: there is no separate
|
|
6
5
|
// "live zone" and "scrollback" here, only lines addressed by absolute index,
|
|
7
6
|
// the last `height` of which happen to still be changing.
|
|
8
7
|
//
|
|
@@ -40,6 +39,15 @@ const MAX_LINES = COMPATIBILITY_TAIL_CAP;
|
|
|
40
39
|
* anything that does not match, so an older snapshot is discarded rather than
|
|
41
40
|
* misread. There is deliberately no migration path: a discarded snapshot costs
|
|
42
41
|
* one full resume, which is exactly the behavior of not having persisted at all.
|
|
42
|
+
*
|
|
43
|
+
* One carve-out, because the rule above exists to prevent MISREADING and this
|
|
44
|
+
* cannot be misread: adding an OPTIONAL field whose absence is defined to mean
|
|
45
|
+
* exactly the pre-existing behaviour needs no bump. `fromSnapshot` reads fields
|
|
46
|
+
* individually off an `unknown` record and validates each, so an older entry
|
|
47
|
+
* simply lacks the key and takes the documented absent path. The test is whether
|
|
48
|
+
* a v-matching entry written by the previous release still hydrates to the same
|
|
49
|
+
* store it would have before; if it does not, bump. `unconfirmedFrom` is the
|
|
50
|
+
* first field added under this carve-out.
|
|
43
51
|
*/
|
|
44
52
|
const SNAPSHOT_VERSION = 1;
|
|
45
53
|
|
|
@@ -92,6 +100,24 @@ export interface StoreSnapshot {
|
|
|
92
100
|
oldest: number;
|
|
93
101
|
/** Highest absolute index in `lines`. Write-side metadata; see `oldest`. */
|
|
94
102
|
highest: number;
|
|
103
|
+
/**
|
|
104
|
+
* The saving store's provisional floor: the lowest index at or above which its
|
|
105
|
+
* content had not been confirmed as committed history by the server. ABSENT
|
|
106
|
+
* when everything it held was confirmed, and absent in every snapshot written
|
|
107
|
+
* before this field existed, which is the same state and takes the same path.
|
|
108
|
+
*
|
|
109
|
+
* Carried because the alternative is a reloaded page telling the server "no
|
|
110
|
+
* need to re-send" about screen rows it only ever saw the application draw.
|
|
111
|
+
* The window descriptor cannot serve here (see above: a stale one is
|
|
112
|
+
* load-bearing in two places), and this is not geometry — it is one index,
|
|
113
|
+
* meaningless to interpret as anything else.
|
|
114
|
+
*
|
|
115
|
+
* `fromSnapshot` treats a malformed value as absent rather than rejecting the
|
|
116
|
+
* snapshot. Being wrong in that direction costs a replayed screenful; rejecting
|
|
117
|
+
* would throw away a whole session's history over a field that is an
|
|
118
|
+
* optimisation of correctness, not a correctness precondition.
|
|
119
|
+
*/
|
|
120
|
+
unconfirmedFrom?: number;
|
|
95
121
|
/**
|
|
96
122
|
* The retained lines as [absoluteIndex, runs] pairs, ascending. Pairs rather
|
|
97
123
|
* than an object keyed by index, because object keys stringify and absolute
|
|
@@ -160,8 +186,7 @@ function isWireRunArray(value: unknown): value is WireRun[] {
|
|
|
160
186
|
* streaming session into one whole-layer invalidation PER LINE — on WebKit
|
|
161
187
|
* (which re-rasterizes the resident tiles each time) that is the single largest
|
|
162
188
|
* compositor cost this client can trigger, and on a memory-pressured iPhone it
|
|
163
|
-
* is churn the page cannot afford
|
|
164
|
-
* memory ceiling"). Batching keeps appends eviction-free between passes, so the
|
|
189
|
+
* is churn the page cannot afford. Batching keeps appends eviction-free between passes, so the
|
|
165
190
|
* layer shifts once per batch instead of once per line. The retained count is
|
|
166
191
|
* hard-bounded at `max(maxLines, live-window height)` after every apply — the
|
|
167
192
|
* window is never evicted (a cap at or below the screen height keeps the full
|
|
@@ -470,11 +495,68 @@ export class LineStore {
|
|
|
470
495
|
return Math.max(this.effectiveTailCap, this.win.height);
|
|
471
496
|
}
|
|
472
497
|
|
|
473
|
-
/** Highest absolute index held, or -1 if empty.
|
|
498
|
+
/** Highest absolute index held, or -1 if empty. */
|
|
474
499
|
highestIndex(): number {
|
|
475
500
|
return this.highest;
|
|
476
501
|
}
|
|
477
502
|
|
|
503
|
+
/**
|
|
504
|
+
* The lowest absolute index at or above which this store's content has NOT
|
|
505
|
+
* been confirmed as committed history by the server, or +Infinity when
|
|
506
|
+
* everything held is confirmed.
|
|
507
|
+
*
|
|
508
|
+
* The distinction this tracks is real and the map cannot express it. A row
|
|
509
|
+
* arriving in a SCREEN frame lands at `base + y`, which is a screen POSITION
|
|
510
|
+
* the application repaints in place, so the content held at that absolute
|
|
511
|
+
* index is what the app most recently drew there — provisional until the row
|
|
512
|
+
* scrolls off and the server commits it. A row arriving in a SCROLL or history
|
|
513
|
+
* frame is the opposite: the server has committed it, at that index, for good.
|
|
514
|
+
*
|
|
515
|
+
* One number rather than a per-row flag or a second key set. The rows above
|
|
516
|
+
* this floor are always a contiguous suffix of the store, because a screen
|
|
517
|
+
* frame's window is contiguous and lowers the floor to its base, while a
|
|
518
|
+
* durable frame that reaches the floor from below raises it past the range it
|
|
519
|
+
* delivered. `paged-scrollback.md` §5.3 records why a parallel structure over
|
|
520
|
+
* the same map is the shape to avoid: the one that was tried, a maintained
|
|
521
|
+
* companion count, was wrong at four mutation sites and reached -96.
|
|
522
|
+
*
|
|
523
|
+
* Movement is deliberately asymmetric. Lowering is unconditional, because a
|
|
524
|
+
* screen frame is proof its window is provisional. Raising happens only when a
|
|
525
|
+
* durable frame reaches the floor from at or below it, so a chunk landing
|
|
526
|
+
* above a still-unconfirmed gap leaves the floor where it is. That direction
|
|
527
|
+
* is the safe one to be wrong in: too low costs a few replayed rows, too high
|
|
528
|
+
* is the defect this exists to prevent.
|
|
529
|
+
*/
|
|
530
|
+
private unconfirmedFrom = Number.POSITIVE_INFINITY;
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* The resume REPLAY-EXCLUSION BOUNDARY: the highest index this client will not
|
|
534
|
+
* ask the server to re-send. Send this as `haveThrough`, never `highestIndex()`.
|
|
535
|
+
*
|
|
536
|
+
* The two differ by whatever the store holds provisionally, and that
|
|
537
|
+
* difference is a correctness bug once it reaches the wire. While a store has
|
|
538
|
+
* no socket (a background tab) it observes no base advance, so its window rows
|
|
539
|
+
* keep the content the app drew there, and claiming down to the window's
|
|
540
|
+
* bottom row tells the server "no need to re-send those" about rows whose
|
|
541
|
+
* committed content this client has never seen. The server replays strictly
|
|
542
|
+
* above `haveThrough`, so the provisional copies are never corrected and
|
|
543
|
+
* surface as scrollback under the new window: measured as a frozen kiro-cli
|
|
544
|
+
* composer box parked above the live region after almost every tab switch.
|
|
545
|
+
*
|
|
546
|
+
* NOT a residency fact and NOT "the highest confirmed row": the index this
|
|
547
|
+
* returns may be one the store never held or has since evicted. The only
|
|
548
|
+
* guarantee is `replayBoundary() <= highestIndex()`, with equality whenever
|
|
549
|
+
* nothing held is provisional.
|
|
550
|
+
*/
|
|
551
|
+
replayBoundary(): number {
|
|
552
|
+
if (this.highest < 0) {
|
|
553
|
+
return this.highest;
|
|
554
|
+
}
|
|
555
|
+
// unconfirmedFrom is +Infinity when nothing is provisional, so the min is
|
|
556
|
+
// `highest` and this is a no-op for a store fed only by durable frames.
|
|
557
|
+
return Math.min(this.highest, this.unconfirmedFrom - 1);
|
|
558
|
+
}
|
|
559
|
+
|
|
478
560
|
/** Lowest absolute index held, or -1 if empty. */
|
|
479
561
|
oldestIndex(): number {
|
|
480
562
|
return this.oldest;
|
|
@@ -601,6 +683,12 @@ export class LineStore {
|
|
|
601
683
|
}
|
|
602
684
|
this.exitAltIfNeeded();
|
|
603
685
|
this.updateWindow(msg);
|
|
686
|
+
// Every index in this window is provisional: the app repaints these screen
|
|
687
|
+
// positions in place, so what the store holds there is the last thing drawn,
|
|
688
|
+
// not what the server will commit at that absolute index. Unconditional and
|
|
689
|
+
// independent of `changed`, because an unchanged row still holds provisional
|
|
690
|
+
// content from an earlier frame.
|
|
691
|
+
this.markUnconfirmedFrom(msg.base);
|
|
604
692
|
for (const y of msg.changed) {
|
|
605
693
|
const row = msg.rows[y];
|
|
606
694
|
if (row !== undefined) {
|
|
@@ -643,6 +731,11 @@ export class LineStore {
|
|
|
643
731
|
this.applyLine(abs, row);
|
|
644
732
|
}
|
|
645
733
|
}
|
|
734
|
+
// Confirm only the prefix the alt gate actually ACCEPTED. The rows at or
|
|
735
|
+
// above the frozen base were dropped, so they are not committed history as
|
|
736
|
+
// far as this store is concerned and must not raise the floor over
|
|
737
|
+
// themselves.
|
|
738
|
+
this.confirmRange(msg.firstIndex, Math.min(msg.lines.length, base - msg.firstIndex));
|
|
646
739
|
return;
|
|
647
740
|
}
|
|
648
741
|
for (let i = 0; i < msg.lines.length; i++) {
|
|
@@ -651,6 +744,75 @@ export class LineStore {
|
|
|
651
744
|
this.applyLine(msg.firstIndex + i, row);
|
|
652
745
|
}
|
|
653
746
|
}
|
|
747
|
+
this.confirmRange(msg.firstIndex, msg.lines.length);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Lower the provisional floor to `base`: everything from there up is content
|
|
752
|
+
* the application drew, not content the server committed. See
|
|
753
|
+
* `unconfirmedFrom` for why the floor moves in only one direction here.
|
|
754
|
+
*/
|
|
755
|
+
private markUnconfirmedFrom(base: number): void {
|
|
756
|
+
if (!Number.isInteger(base) || base < 0) {
|
|
757
|
+
return; // a malformed frame must not move the floor
|
|
758
|
+
}
|
|
759
|
+
if (base < this.unconfirmedFrom) {
|
|
760
|
+
this.unconfirmedFrom = base;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Raise the provisional floor past a range the server just committed, and heal
|
|
766
|
+
* it when the range proves the old floor is unreachable.
|
|
767
|
+
*
|
|
768
|
+
* The reach test is the correctness argument for the ordinary case. A chunk
|
|
769
|
+
* landing above the floor leaves rows between the floor and the chunk still
|
|
770
|
+
* provisional, so raising past it would claim exactly the rows this mechanism
|
|
771
|
+
* exists to exclude. Under the normal dispatch order the test passes: a screen
|
|
772
|
+
* frame lowers the floor to its new base and the scroll chunks carrying the rows
|
|
773
|
+
* that just scrolled off start at or below that floor. When those chunks do NOT
|
|
774
|
+
* arrive — the server writes each payload as its own message and ignores write
|
|
775
|
+
* errors, so a socket that dies mid-dispatch is ordinary — the floor stays put
|
|
776
|
+
* and the next resume asks for the range again.
|
|
777
|
+
*
|
|
778
|
+
* The HEAL is the second clause, and without it the floor wedges permanently.
|
|
779
|
+
* Three ordinary events deliver content far above the floor and leave nothing
|
|
780
|
+
* that can ever reach it: an ED3 (which drops every line below the new base,
|
|
781
|
+
* taking the floor's own neighbourhood with it), a clamped replay, and the ring
|
|
782
|
+
* eviction every server eventually performs. Measured before the heal existed: a
|
|
783
|
+
* store whose floor sat at 99 still reported 99 after fifteen healthy frames had
|
|
784
|
+
* carried `highest` to 297, and after a clamped replay took it to 5223. Every
|
|
785
|
+
* attach then asked for a maximal replay forever, and `snapshot()` persisted the
|
|
786
|
+
* wedge across reloads — strictly worse than the defect the floor exists to fix.
|
|
787
|
+
*
|
|
788
|
+
* So a range arriving entirely above `oldest` while the floor sits BELOW
|
|
789
|
+
* `oldest` heals it: nothing at or above the floor is held any more, so there is
|
|
790
|
+
* nothing left to protect, and holding the claim down protects only rows that no
|
|
791
|
+
* longer exist. This is safe in the one direction that matters — it never raises
|
|
792
|
+
* the floor over a row the store still holds provisionally, because such a row
|
|
793
|
+
* would be at or above `oldest` by definition.
|
|
794
|
+
*
|
|
795
|
+
* `applyHistoryScroll` deliberately does not call this. A demand-paged reply
|
|
796
|
+
* fetches history strictly below the resident tail; it can neither reach the
|
|
797
|
+
* floor nor witness the floor being orphaned.
|
|
798
|
+
*/
|
|
799
|
+
private confirmRange(firstIndex: number, count: number): void {
|
|
800
|
+
if (!Number.isInteger(firstIndex) || firstIndex < 0 || count <= 0) {
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
const end = firstIndex + count;
|
|
804
|
+
if (firstIndex > this.unconfirmedFrom) {
|
|
805
|
+
// The heal: the floor names an index the store no longer holds, so it is
|
|
806
|
+
// protecting nothing. Recorded through the same path as an ordinary raise so
|
|
807
|
+
// there is one writer, not two.
|
|
808
|
+
if (this.oldest >= 0 && this.unconfirmedFrom < this.oldest) {
|
|
809
|
+
this.unconfirmedFrom = Math.max(end, this.oldest);
|
|
810
|
+
}
|
|
811
|
+
return; // otherwise a gap below stays provisional
|
|
812
|
+
}
|
|
813
|
+
if (end > this.unconfirmedFrom) {
|
|
814
|
+
this.unconfirmedFrom = end;
|
|
815
|
+
}
|
|
654
816
|
}
|
|
655
817
|
|
|
656
818
|
/**
|
|
@@ -1122,6 +1284,23 @@ export class LineStore {
|
|
|
1122
1284
|
* descriptor, because the batch's own window frame re-establishes it at the
|
|
1123
1285
|
* new base — those rows stop being window rows the moment it lands.
|
|
1124
1286
|
*
|
|
1287
|
+
* The band top is `sentHaveThrough` and deliberately does NOT follow the wire
|
|
1288
|
+
* value down now that `replayBoundary()` supplies it. The two questions are
|
|
1289
|
+
* different: the wire value asks "what will I not ask for", the band asks "what
|
|
1290
|
+
* did I claim to hold before the server answered". Rows above the sent value
|
|
1291
|
+
* arrived from the server as committed content, so they are real history and
|
|
1292
|
+
* must not be reclassified as disposable cache; rows this store holds only
|
|
1293
|
+
* provisionally sit at or above the boundary and are covered by the replay,
|
|
1294
|
+
* which now starts at `boundary + 1` precisely because of it.
|
|
1295
|
+
*
|
|
1296
|
+
* One residual, deliberately not addressed here. When the replay start is
|
|
1297
|
+
* CLAMPED above the boundary (`committed - replayMax`, reachable when a
|
|
1298
|
+
* background tab printed more than roughly `tailCap` lines), the provisional
|
|
1299
|
+
* rows are neither replayed nor banded, so they stay tail-classified and lose
|
|
1300
|
+
* the browse budget's TTL sweep. Widening the band to cover them would change a
|
|
1301
|
+
* pinned invariant that two existing cases assert, so it is recorded in
|
|
1302
|
+
* docs/resume-watermark.md rather than changed in passing.
|
|
1303
|
+
*
|
|
1125
1304
|
* The retirement leaves the same transition's later steps reading a window of
|
|
1126
1305
|
* height 0, so state precisely what that costs rather than forbidding it: the
|
|
1127
1306
|
* only window-derived bound they touch is `enforceCap`'s, which loses the live
|
|
@@ -1144,7 +1323,19 @@ export class LineStore {
|
|
|
1144
1323
|
predicted = Math.max(predicted, committed - sentReplayMax);
|
|
1145
1324
|
}
|
|
1146
1325
|
if (predicted <= base || this.oldest < 0 || sentHaveThrough < this.oldest) {
|
|
1147
|
-
// No jump, or
|
|
1326
|
+
// No jump, or nothing this socket claimed is still held, so the stranded
|
|
1327
|
+
// band is empty and there is nothing to reclassify.
|
|
1328
|
+
//
|
|
1329
|
+
// The third clause used to mean only "an empty or fresh store", because the
|
|
1330
|
+
// wire value was the highest held index and so never sat below `oldest`.
|
|
1331
|
+
// The replay boundary can: a post-ED3 store has `oldest === win.base` and
|
|
1332
|
+
// claims `win.base - 1`. That is still the right answer here, for the
|
|
1333
|
+
// reason above rather than the original one — the band is `keys <=
|
|
1334
|
+
// sentHaveThrough`, which is empty exactly when this fires, so both the
|
|
1335
|
+
// reclassify and the budget pass it gates would be no-ops. The descriptor
|
|
1336
|
+
// is then not retired either, which is equally harmless: retirement exists
|
|
1337
|
+
// so a band containing old window rows survives the batch's own window
|
|
1338
|
+
// frame, and there is no band.
|
|
1148
1339
|
return false;
|
|
1149
1340
|
}
|
|
1150
1341
|
// The stranded band is every retained key at or below what this socket told
|
|
@@ -1253,6 +1444,7 @@ export class LineStore {
|
|
|
1253
1444
|
this.highest = -1;
|
|
1254
1445
|
this.everEvictedThrough = -1;
|
|
1255
1446
|
this.erasedThrough = -1;
|
|
1447
|
+
this.unconfirmedFrom = Number.POSITIVE_INFINITY;
|
|
1256
1448
|
this.paging = false;
|
|
1257
1449
|
this.serverOldest = -1;
|
|
1258
1450
|
this.win = emptyWindow();
|
|
@@ -1353,6 +1545,11 @@ export class LineStore {
|
|
|
1353
1545
|
oldest: first,
|
|
1354
1546
|
highest: last,
|
|
1355
1547
|
lines,
|
|
1548
|
+
// OMITTED when nothing held is provisional, which is also what an older
|
|
1549
|
+
// snapshot looks like, so absent and "all confirmed" are deliberately the
|
|
1550
|
+
// same state on the way back in. JSON has no Infinity, so a sentinel value
|
|
1551
|
+
// would have to be invented and then defended; absence needs neither.
|
|
1552
|
+
...(Number.isFinite(this.unconfirmedFrom) ? { unconfirmedFrom: this.unconfirmedFrom } : {}),
|
|
1356
1553
|
};
|
|
1357
1554
|
}
|
|
1358
1555
|
|
|
@@ -1449,6 +1646,15 @@ export class LineStore {
|
|
|
1449
1646
|
store.highest = highest;
|
|
1450
1647
|
// Everything below the tail is gone as far as this store is concerned.
|
|
1451
1648
|
store.everEvictedThrough = oldest - 1;
|
|
1649
|
+
// Restore the saving store's provisional floor. A malformed or absent value
|
|
1650
|
+
// leaves it at +Infinity, which is the pre-existing behaviour: the whole
|
|
1651
|
+
// hydrated tail reads as confirmed and the first resume claims all of it.
|
|
1652
|
+
// Read off the unknown record like every other field, and clamped into the
|
|
1653
|
+
// kept range because the tail may have been trimmed above the saved floor.
|
|
1654
|
+
const savedFloor: unknown = rec["unconfirmedFrom"];
|
|
1655
|
+
if (typeof savedFloor === "number" && Number.isInteger(savedFloor) && savedFloor >= 0) {
|
|
1656
|
+
store.unconfirmedFrom = savedFloor;
|
|
1657
|
+
}
|
|
1452
1658
|
// The renderer must build every hydrated row: nothing is on screen yet.
|
|
1453
1659
|
for (const abs of store.lines.keys()) {
|
|
1454
1660
|
store.dirty.add(abs);
|
|
@@ -1482,7 +1688,6 @@ export class LineStore {
|
|
|
1482
1688
|
|
|
1483
1689
|
/**
|
|
1484
1690
|
* applyLine is the guarded core. It enforces the apply-line guard set
|
|
1485
|
-
* (see the #web-terminal-engine steering doc, "Design rationale"):
|
|
1486
1691
|
* valid index, not stale, idempotent, and
|
|
1487
1692
|
* cap-bounded. Returns nothing; effects are recorded in the dirty/evicted
|
|
1488
1693
|
* sets for the next drain.
|
|
@@ -1579,6 +1784,15 @@ export class LineStore {
|
|
|
1579
1784
|
private applyScrollbackCleared(base: number): void {
|
|
1580
1785
|
this.solicitedPending = null;
|
|
1581
1786
|
this.raisePagingFloor(base);
|
|
1787
|
+
// The provisional floor moves up with the erase. Everything below `base` is
|
|
1788
|
+
// about to be dropped, so a floor down there would name rows the store no
|
|
1789
|
+
// longer holds and hold the resume claim below them forever: measured as a
|
|
1790
|
+
// floor stuck at 99 while `highest` climbed to 297 over fifteen healthy
|
|
1791
|
+
// frames, which turned every attach into a maximal replay. Never LOWERS the
|
|
1792
|
+
// floor, so a floor already above the erase bound keeps protecting its rows.
|
|
1793
|
+
if (base > this.unconfirmedFrom) {
|
|
1794
|
+
this.unconfirmedFrom = base;
|
|
1795
|
+
}
|
|
1582
1796
|
if (this.oldest < 0 || this.oldest >= base) {
|
|
1583
1797
|
return;
|
|
1584
1798
|
}
|
package/src/types.ts
CHANGED
|
@@ -37,8 +37,7 @@ export interface ScreenMessage {
|
|
|
37
37
|
* Absolute line index of the top screen row (row 0). A changed row at
|
|
38
38
|
* window index `y` has absolute index `base + y`. The client stores
|
|
39
39
|
* every line by absolute index, so applying a row is idempotent and
|
|
40
|
-
* never duplicates
|
|
41
|
-
* "Design rationale").
|
|
40
|
+
* never duplicates.
|
|
42
41
|
*/
|
|
43
42
|
base: number;
|
|
44
43
|
/** Cursor position as [row, col], zero-indexed within the window. */
|