@cplieger/web-terminal-engine 5.0.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "5.0.0",
3
+ "version": "5.0.1",
4
4
  "license": "MPL-2.0",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "5.0.0",
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.10",
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.10"
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 currently holds, or
447
- * -1 if it holds nothing. Sent as `haveThrough` on resume so the server
448
- * replays only the lines missed (e.g. printed while the device slept).
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
@@ -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. This is the
591
- * resume `haveThrough` value (it replaces the old DOM-row count). Exposed so
592
- * the connection layer can request only the lines the client is missing.
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/store.ts CHANGED
@@ -39,6 +39,15 @@ const MAX_LINES = COMPATIBILITY_TAIL_CAP;
39
39
  * anything that does not match, so an older snapshot is discarded rather than
40
40
  * misread. There is deliberately no migration path: a discarded snapshot costs
41
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.
42
51
  */
43
52
  const SNAPSHOT_VERSION = 1;
44
53
 
@@ -91,6 +100,24 @@ export interface StoreSnapshot {
91
100
  oldest: number;
92
101
  /** Highest absolute index in `lines`. Write-side metadata; see `oldest`. */
93
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;
94
121
  /**
95
122
  * The retained lines as [absoluteIndex, runs] pairs, ascending. Pairs rather
96
123
  * than an object keyed by index, because object keys stringify and absolute
@@ -468,11 +495,68 @@ export class LineStore {
468
495
  return Math.max(this.effectiveTailCap, this.win.height);
469
496
  }
470
497
 
471
- /** Highest absolute index held, or -1 if empty. Used as resume `haveThrough`. */
498
+ /** Highest absolute index held, or -1 if empty. */
472
499
  highestIndex(): number {
473
500
  return this.highest;
474
501
  }
475
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
+
476
560
  /** Lowest absolute index held, or -1 if empty. */
477
561
  oldestIndex(): number {
478
562
  return this.oldest;
@@ -599,6 +683,12 @@ export class LineStore {
599
683
  }
600
684
  this.exitAltIfNeeded();
601
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);
602
692
  for (const y of msg.changed) {
603
693
  const row = msg.rows[y];
604
694
  if (row !== undefined) {
@@ -641,6 +731,11 @@ export class LineStore {
641
731
  this.applyLine(abs, row);
642
732
  }
643
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));
644
739
  return;
645
740
  }
646
741
  for (let i = 0; i < msg.lines.length; i++) {
@@ -649,6 +744,75 @@ export class LineStore {
649
744
  this.applyLine(msg.firstIndex + i, row);
650
745
  }
651
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
+ }
652
816
  }
653
817
 
654
818
  /**
@@ -1120,6 +1284,23 @@ export class LineStore {
1120
1284
  * descriptor, because the batch's own window frame re-establishes it at the
1121
1285
  * new base — those rows stop being window rows the moment it lands.
1122
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
+ *
1123
1304
  * The retirement leaves the same transition's later steps reading a window of
1124
1305
  * height 0, so state precisely what that costs rather than forbidding it: the
1125
1306
  * only window-derived bound they touch is `enforceCap`'s, which loses the live
@@ -1142,7 +1323,19 @@ export class LineStore {
1142
1323
  predicted = Math.max(predicted, committed - sentReplayMax);
1143
1324
  }
1144
1325
  if (predicted <= base || this.oldest < 0 || sentHaveThrough < this.oldest) {
1145
- // No jump, or an empty/fresh store where the predicate fires vacuously.
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.
1146
1339
  return false;
1147
1340
  }
1148
1341
  // The stranded band is every retained key at or below what this socket told
@@ -1251,6 +1444,7 @@ export class LineStore {
1251
1444
  this.highest = -1;
1252
1445
  this.everEvictedThrough = -1;
1253
1446
  this.erasedThrough = -1;
1447
+ this.unconfirmedFrom = Number.POSITIVE_INFINITY;
1254
1448
  this.paging = false;
1255
1449
  this.serverOldest = -1;
1256
1450
  this.win = emptyWindow();
@@ -1351,6 +1545,11 @@ export class LineStore {
1351
1545
  oldest: first,
1352
1546
  highest: last,
1353
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 } : {}),
1354
1553
  };
1355
1554
  }
1356
1555
 
@@ -1447,6 +1646,15 @@ export class LineStore {
1447
1646
  store.highest = highest;
1448
1647
  // Everything below the tail is gone as far as this store is concerned.
1449
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
+ }
1450
1658
  // The renderer must build every hydrated row: nothing is on screen yet.
1451
1659
  for (const abs of store.lines.keys()) {
1452
1660
  store.dirty.add(abs);
@@ -1576,6 +1784,15 @@ export class LineStore {
1576
1784
  private applyScrollbackCleared(base: number): void {
1577
1785
  this.solicitedPending = null;
1578
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
+ }
1579
1796
  if (this.oldest < 0 || this.oldest >= base) {
1580
1797
  return;
1581
1798
  }