@cplieger/web-terminal-engine 3.5.0 → 3.8.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 +2 -2
- package/jsr.json +1 -1
- package/package.json +3 -3
- package/src/connection.ts +647 -9
- package/src/index.ts +2 -1
- package/src/intervals.ts +81 -0
- package/src/render.ts +899 -48
- package/src/scroll.ts +229 -25
- package/src/store.ts +1443 -68
- package/src/types.ts +28 -0
- package/src/wire-binary.ts +9 -1
package/src/store.ts
CHANGED
|
@@ -13,15 +13,219 @@
|
|
|
13
13
|
// from a zombie socket) incapable of duplicating a row.
|
|
14
14
|
|
|
15
15
|
import type { ScreenMessage, ScrollMessage, WireRun } from "./types.js";
|
|
16
|
+
import { interiorGaps, retainedIntervals, type Interval } from "./intervals.js";
|
|
16
17
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The tail cap held while paging is unconfirmed — the legacy default, so a
|
|
20
|
+
* pairing with a server that cannot serve history back never has its reachable
|
|
21
|
+
* history cut.
|
|
22
|
+
*
|
|
23
|
+
* This is the SOURCE of the default cap, not a second name for it: `MAX_LINES`
|
|
24
|
+
* derives from it below. The two were independent literals, so editing this one
|
|
25
|
+
* — the exported, documented, obviously-intended knob — changed nothing except
|
|
26
|
+
* which assertion failed.
|
|
27
|
+
*/
|
|
28
|
+
export const COMPATIBILITY_TAIL_CAP = 5000;
|
|
19
29
|
|
|
20
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Maximum lines retained client-side. Older lines are evicted from the top.
|
|
32
|
+
* Identical to the compatibility cap by construction: the depth a client holds
|
|
33
|
+
* when nothing has told it to hold less IS the pre-flip cap.
|
|
34
|
+
*/
|
|
35
|
+
const MAX_LINES = COMPATIBILITY_TAIL_CAP;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* SNAPSHOT_VERSION is the shape version of a persisted store snapshot. Bump it
|
|
39
|
+
* on any change to StoreSnapshot's fields or their meaning; fromSnapshot rejects
|
|
40
|
+
* anything that does not match, so an older snapshot is discarded rather than
|
|
41
|
+
* misread. There is deliberately no migration path: a discarded snapshot costs
|
|
42
|
+
* one full resume, which is exactly the behavior of not having persisted at all.
|
|
43
|
+
*/
|
|
44
|
+
const SNAPSHOT_VERSION = 1;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A persisted line store, as plain data: no class instances, no functions, no
|
|
48
|
+
* cycles, so it survives structuredClone and can go straight into IndexedDB
|
|
49
|
+
* without a bespoke serializer (WireRun is primitives only — see types.ts).
|
|
50
|
+
*
|
|
51
|
+
* What is deliberately NOT here, and must not be added without re-reading why:
|
|
52
|
+
*
|
|
53
|
+
* - The live window (`WindowState`). It is last session's geometry, and it is
|
|
54
|
+
* load-bearing in two places that would silently misbehave under a stale
|
|
55
|
+
* value: `inWindow()` is what exempts live-window rows from the staleness
|
|
56
|
+
* guard and from cap eviction. The first screen frame re-establishes it.
|
|
57
|
+
* - `serverOldest`. It is the server's retained-history bound from a resumeAck,
|
|
58
|
+
* and the next resumeAck supplies the current one. A stale value would make
|
|
59
|
+
* the "earlier output trimmed" marker report on a range that no longer exists.
|
|
60
|
+
* - The alternate screen (`alt`, `altRows`). Ephemeral by definition: an alt
|
|
61
|
+
* grid is not history and is never addressed by absolute index.
|
|
62
|
+
*/
|
|
63
|
+
export interface StoreSnapshot {
|
|
64
|
+
/** Shape version; must equal SNAPSHOT_VERSION or the snapshot is discarded. */
|
|
65
|
+
v: number;
|
|
66
|
+
/**
|
|
67
|
+
* The server boot epoch these lines belong to, as last reported by a
|
|
68
|
+
* resumeAck, or 0 when the client never learned one.
|
|
69
|
+
*
|
|
70
|
+
* This is the field that makes persistence safe rather than actively harmful.
|
|
71
|
+
* Absolute line indices are only meaningful within one server process: a
|
|
72
|
+
* restarted server begins again at 0. Restart detection is otherwise
|
|
73
|
+
* in-memory — the first resumeAck of a page load records the epoch with
|
|
74
|
+
* nothing to compare against — so a hydrated store plus a restarted server
|
|
75
|
+
* would present stale content as live AND then silently drop the new
|
|
76
|
+
* session's output, whose low indices fall at or below the hydrated
|
|
77
|
+
* everEvictedThrough and are refused by apply-line guard 2.
|
|
78
|
+
*
|
|
79
|
+
* The consumer must therefore hand this to the connection layer before
|
|
80
|
+
* connecting (see connection.adoptPersistedEpoch), so the first resumeAck
|
|
81
|
+
* has something to compare against and the existing restart path fires.
|
|
82
|
+
*/
|
|
83
|
+
serverEpoch: number;
|
|
84
|
+
/** Lowest absolute index in `lines`.
|
|
85
|
+
*
|
|
86
|
+
* Write-side metadata: `fromSnapshot` derives the bounds from the pairs it
|
|
87
|
+
* actually accepted rather than trusting these, so a corrupt value here changes
|
|
88
|
+
* nothing on the way back in. They are carried because a consumer's storage
|
|
89
|
+
* layer wants them without parsing the payload (the keeper records `highest` as
|
|
90
|
+
* its save watermark), and they are deliberately NOT validated against the pairs
|
|
91
|
+
* — a check with no failure mode behind it is worse than none. */
|
|
92
|
+
oldest: number;
|
|
93
|
+
/** Highest absolute index in `lines`. Write-side metadata; see `oldest`. */
|
|
94
|
+
highest: number;
|
|
95
|
+
/**
|
|
96
|
+
* The retained lines as [absoluteIndex, runs] pairs, ascending. Pairs rather
|
|
97
|
+
* than an object keyed by index, because object keys stringify and absolute
|
|
98
|
+
* indices are numbers; pairs clone as-is and read plainly in a debugger.
|
|
99
|
+
*/
|
|
100
|
+
lines: [number, WireRun[]][];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* isWireRunArray reports whether a value decoded from OUTSIDE this program is a
|
|
105
|
+
* usable run array.
|
|
106
|
+
*
|
|
107
|
+
* This is the only path into a store that admits externally-sourced run objects:
|
|
108
|
+
* the wire decoder constructs each WireRun field by field from typed cursor reads,
|
|
109
|
+
* so `t` is a string by construction and applyLine's own `Array.isArray(runs)`
|
|
110
|
+
* check inherits that guarantee. A snapshot inherits nothing, and the difference
|
|
111
|
+
* is not academic — a run whose `t` is not a string reaches the renderer's
|
|
112
|
+
* per-character loop and throws, the row stays queued because the drain only
|
|
113
|
+
* clears a row after a successful build, and every row above it is blocked. That
|
|
114
|
+
* failure is permanent and self-perpetuating (the store hydrated, so the entry is
|
|
115
|
+
* never dropped, so the next reload breaks the same way), where every other
|
|
116
|
+
* rejection here degrades to "nothing restored".
|
|
117
|
+
*
|
|
118
|
+
* Optional numeric fields are checked when present rather than defaulted: a
|
|
119
|
+
* string in `f` would flow into a colour lookup, and silently coercing it would
|
|
120
|
+
* hide a producer bug instead of rejecting a payload this store cannot trust.
|
|
121
|
+
*/
|
|
122
|
+
function isWireRunArray(value: unknown): value is WireRun[] {
|
|
123
|
+
if (!Array.isArray(value)) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
for (const run of value) {
|
|
127
|
+
if (typeof run !== "object" || run === null) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
const r = run as Record<string, unknown>;
|
|
131
|
+
if (typeof r["t"] !== "string") {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
for (const field of ["f", "b", "uc", "a"]) {
|
|
135
|
+
const v = r[field];
|
|
136
|
+
if (v !== undefined && (typeof v !== "number" || !Number.isFinite(v))) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (r["u"] !== undefined && typeof r["u"] !== "string") {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* evictionBatch returns how many lines an at-cap eviction pass NOMINALLY frees
|
|
149
|
+
* (the hysteresis band), derived from the cap so tiny test stores keep
|
|
150
|
+
* one-at-a-time eviction (a batch of 1 IS the pre-batching behavior) while the
|
|
151
|
+
* real 5000-line store frees 256 rows per pass. A pass can free fewer when the
|
|
152
|
+
* retained history above the cap is smaller than the batch: headroom is taken
|
|
153
|
+
* from evictable HISTORY only, never from the protected live window, so a cap
|
|
154
|
+
* close to the screen height degrades toward per-line eviction (see the
|
|
155
|
+
* operating-range note on the consumer option docs).
|
|
156
|
+
*
|
|
157
|
+
* Why evictions are batched at all: every evicted line removes a DOM row from
|
|
158
|
+
* the TOP of the scroller, which shifts every row below it and invalidates the
|
|
159
|
+
* whole scrolled-contents layer. At the cap, one-at-a-time eviction turns a
|
|
160
|
+
* streaming session into one whole-layer invalidation PER LINE — on WebKit
|
|
161
|
+
* (which re-rasterizes the resident tiles each time) that is the single largest
|
|
162
|
+
* compositor cost this client can trigger, and on a memory-pressured iPhone it
|
|
163
|
+
* is churn the page cannot afford (see the web-terminal-ui steering doc, "iOS
|
|
164
|
+
* memory ceiling"). Batching keeps appends eviction-free between passes, so the
|
|
165
|
+
* layer shifts once per batch instead of once per line. The retained count is
|
|
166
|
+
* hard-bounded at `max(maxLines, live-window height)` after every apply — the
|
|
167
|
+
* window is never evicted (a cap at or below the screen height keeps the full
|
|
168
|
+
* screen with zero scrollback), and everything else stays under the cap
|
|
169
|
+
* whatever shape the frames arrive in (enforceCap's skip-over-window walk
|
|
170
|
+
* also bounds content committed above a stale window, e.g. a resume replay).
|
|
171
|
+
*/
|
|
172
|
+
function evictionBatch(maxLines: number): number {
|
|
173
|
+
return Math.min(256, Math.max(1, Math.floor(maxLines / 16)));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Demand-paged-scrollback residency constants (docs/paged-scrollback.md §3).
|
|
178
|
+
*
|
|
179
|
+
* The store holds a small RESIDENT TAIL plus a disposable BROWSE CACHE of
|
|
180
|
+
* pages fetched on demand. The two budgets are enforced by separate mechanisms
|
|
181
|
+
* that can never touch each other's lines: live output cannot evict browse
|
|
182
|
+
* cache, and browse pressure cannot evict the tail.
|
|
183
|
+
*/
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The resident-tail target once the server has DECLARED it serves paging. The
|
|
187
|
+
* compatibility value below is what the tail holds until then.
|
|
188
|
+
*/
|
|
189
|
+
export const RESIDENT_TAIL_CAP = 1500;
|
|
190
|
+
|
|
191
|
+
/** Lines of paged-in history retained as disposable cache. Engine-internal. */
|
|
192
|
+
export const BROWSE_CACHE_CAP = 2500;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* How close the viewport must come to an absent range's edge before a fetch is
|
|
196
|
+
* triggered, and the radius of the eviction exemption around the reader. It is
|
|
197
|
+
* both because they are the same idea: the band worth prefetching is the band
|
|
198
|
+
* worth not evicting.
|
|
199
|
+
*/
|
|
200
|
+
export const PREFETCH_THRESHOLD = 500;
|
|
201
|
+
|
|
202
|
+
/** Lines requested per page, and the ceiling on one eviction pass's removals. */
|
|
203
|
+
export const PAGE_SIZE = 1000;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The viewport exemption can never pin browse membership above its cap. Checked
|
|
207
|
+
* statically here rather than left as a comment, because the eviction loop's
|
|
208
|
+
* termination argument at the page-apply target rests on it: if the exemption
|
|
209
|
+
* could cover the whole cache, a pass with work to do could find no victim.
|
|
210
|
+
*/
|
|
211
|
+
if (2 * PREFETCH_THRESHOLD + 1 >= BROWSE_CACHE_CAP) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`paged-scrollback invariant violated: the viewport exemption (${2 * PREFETCH_THRESHOLD + 1} lines) ` +
|
|
214
|
+
`must stay below browseCacheCap (${BROWSE_CACHE_CAP})`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The live screen window: a fixed `height`-row block at the tail of the buffer.
|
|
219
|
+
* Describes the MAIN screen even while the alternate screen is active: the alt
|
|
220
|
+
* grid's geometry lives in the store's altRows, so during an alt session that
|
|
221
|
+
* outlived a resize, `base`/`height` report the main region alt exit restores,
|
|
222
|
+
* not the current terminal size. */
|
|
21
223
|
export interface WindowState {
|
|
22
|
-
/** Absolute index of window row 0. */
|
|
224
|
+
/** Absolute index of window row 0 (the MAIN screen's; frozen during alt). */
|
|
23
225
|
base: number;
|
|
24
|
-
/** Number of rows in the window
|
|
226
|
+
/** Number of rows in the window — the MAIN screen's height, which equals
|
|
227
|
+
* the terminal height except during an alt session that resized (then the
|
|
228
|
+
* alt grid's height lives in altRows and this keeps the restore target). */
|
|
25
229
|
height: number;
|
|
26
230
|
/** Cursor row within the window (0..height-1). */
|
|
27
231
|
cursorRow: number;
|
|
@@ -98,11 +302,173 @@ export class LineStore {
|
|
|
98
302
|
private altDirty = false;
|
|
99
303
|
private resetPending = false;
|
|
100
304
|
|
|
305
|
+
// --- demand-paged scrollback (docs/paged-scrollback.md §5.1, §5.3) ---
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The RESIDENT TAIL cap, and the one deliberately mutable piece of residency
|
|
309
|
+
* policy: the capability the server declares in its resumeAck decides it at
|
|
310
|
+
* runtime. It starts at the compatibility value and flips ONE-WAY to the
|
|
311
|
+
* supported target through `applyResumeAck`. Every residency reader uses this
|
|
312
|
+
* value — the tail-budget gate, `evictionBatch`, and `snapshot`'s default
|
|
313
|
+
* bound — so a flip moves all of them together.
|
|
314
|
+
*/
|
|
315
|
+
private effectiveTailCap: number;
|
|
316
|
+
|
|
317
|
+
/** The post-flip target: what the tail shrinks to once paging is declared. */
|
|
318
|
+
private readonly supportedTarget: number;
|
|
319
|
+
|
|
320
|
+
/** The pre-flip cap: what the tail holds while paging is unconfirmed. */
|
|
321
|
+
private readonly compatibilityCap: number;
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The keys currently held as BROWSE CACHE — a subset of `lines`' keys, and
|
|
325
|
+
* the cache's whole model: membership, size and eviction candidates all read
|
|
326
|
+
* it directly. It drives cap accounting, eviction and snapshot exclusion. It
|
|
327
|
+
* is NOT a guard-2 bypass — re-delivery into a retained index passes only
|
|
328
|
+
* through applyLine's idempotence, or arrives under a fresh
|
|
329
|
+
* `solicitedPending`.
|
|
330
|
+
*
|
|
331
|
+
* A SET of keys rather than a set of intervals, and no companion counter.
|
|
332
|
+
* Both alternatives were built first and both leaked defects that this shape
|
|
333
|
+
* cannot express:
|
|
334
|
+
*
|
|
335
|
+
* - INTERVALS described ranges the store need not hold, so every question
|
|
336
|
+
* ("is the reader on cache?", "how many cache lines?", "which to evict?")
|
|
337
|
+
* became a walk over a numeric SPAN. The replay-jump band is a hull from
|
|
338
|
+
* `oldest` through the client's `haveThrough`, so that span is as wide as
|
|
339
|
+
* the client is behind: a 500k hull measured 2.3 s of main-thread work for
|
|
340
|
+
* ten real rows, and 80M measured 1.2 s in the one query that still walked
|
|
341
|
+
* it. Keys are bounded by residency, so every question here is O(cache).
|
|
342
|
+
* - A COMPANION COUNT (`browseCount`) had to be maintained by arithmetic at
|
|
343
|
+
* every mutation, and was wrong at four of them — it reached -96 on a third
|
|
344
|
+
* successive jump ack. `browse.size` cannot drift because it is not
|
|
345
|
+
* maintained at all.
|
|
346
|
+
*
|
|
347
|
+
* The one invariant is `browse ⊆ lines.keys()`, and it holds because `forget`
|
|
348
|
+
* is the sole deletion path and deletes from both.
|
|
349
|
+
*/
|
|
350
|
+
private browse = new Set<number>();
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The window of the request currently in flight, or null. Lines inside it are
|
|
354
|
+
* applyable even below prior evictions — paging is exactly a legitimate
|
|
355
|
+
* re-fetch below the stale-re-send watermark, which is why the watermark
|
|
356
|
+
* doctrine needed a solicited-range exception rather than a weaker guard.
|
|
357
|
+
* Exactly one slot, matching single-flight: there is nothing to expire.
|
|
358
|
+
*/
|
|
359
|
+
private solicitedPending: Interval | null = null;
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The lowest index still worth requesting. Raised by a clamped or empty
|
|
363
|
+
* reply (the server proving nothing at or below it survives), lowered only by
|
|
364
|
+
* a resumeAck reporting an older retained edge — which within one epoch is
|
|
365
|
+
* the repair path for a mis-correlated clamp.
|
|
366
|
+
*/
|
|
367
|
+
private pagingFloor = 0;
|
|
368
|
+
|
|
369
|
+
/** When browse cache was last created or read, for the consumer's TTL. */
|
|
370
|
+
private browseActivityMs = 0;
|
|
371
|
+
|
|
101
372
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
373
|
+
* Whether the last resumeAck declared paging. Kept because the RENDERER needs
|
|
374
|
+
* it and cannot derive it: §5.4's top-of-store marker says something different
|
|
375
|
+
* about the history above what is held depending on whether a fetch can bring
|
|
376
|
+
* it back.
|
|
377
|
+
*
|
|
378
|
+
* A BOOLEAN, false until an ack says otherwise, not a tri-state with a
|
|
379
|
+
* pre-ack "unknown". The pre-ack instant and a declared `unsupported` want the
|
|
380
|
+
* same answer — assert only what the client's own bookkeeping proves — so a
|
|
381
|
+
* third state would be a distinction nothing reads.
|
|
104
382
|
*/
|
|
105
|
-
|
|
383
|
+
private paging = false;
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The highest index the APPLICATION erased (ED3), or -1. A second watermark
|
|
387
|
+
* beside `everEvictedThrough` because the two answer different questions and
|
|
388
|
+
* only one of them is a trim.
|
|
389
|
+
*
|
|
390
|
+
* `everEvictedThrough` means "we dropped this to stay inside a memory budget",
|
|
391
|
+
* and it drives the "earlier output trimmed" affordance. ED3 is not that: the
|
|
392
|
+
* app discarded its own scrollback, so a marker would be permanent noise on
|
|
393
|
+
* kiro-cli (which clears on every resize redraw), which is why ED3 deliberately
|
|
394
|
+
* leaves it alone. That left NOTHING refusing an erased index, and a page reply
|
|
395
|
+
* whose data timeout had already released single-flight comes back
|
|
396
|
+
* UNCORRELATED — through the ordinary scroll path, classified TAIL, so outside
|
|
397
|
+
* the cache budget and the TTL both. It never leaves.
|
|
398
|
+
*
|
|
399
|
+
* Refusing an erased index is unconditionally safe within an epoch: the server's
|
|
400
|
+
* ring `Clear()` preserves `committed`, so an erased absolute index is never
|
|
401
|
+
* reused, and no legitimate later frame can carry one. The companion reprint an
|
|
402
|
+
* app emits in the same tick lands at NEW indices above everything the client
|
|
403
|
+
* held, which is why this watermark tracks what was actually DROPPED rather than
|
|
404
|
+
* the cleared bound — the bound would also refuse that reprint.
|
|
405
|
+
*/
|
|
406
|
+
private erasedThrough = -1;
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Memo for `retainedRanges()`, invalidated by every key-set mutation.
|
|
410
|
+
*
|
|
411
|
+
* Coalescing the keys costs a copy and a sort of the whole residency, and the
|
|
412
|
+
* scroll seam asks for it on EVERY scroll event (the fetch trigger reads the
|
|
413
|
+
* gaps near the viewport) while the renderer asks again on every flush (the
|
|
414
|
+
* gap markers are re-derived, not tracked). Measured at 4500 retained rows:
|
|
415
|
+
* 95 us per call, so 5.7 ms per 60-event scroll frame — a third of a frame
|
|
416
|
+
* budget, on the phone this whole feature exists for, during the one
|
|
417
|
+
* interaction it exists to serve. The key set changes far less often than it
|
|
418
|
+
* is asked about, so the answer is computed once per change instead of once
|
|
419
|
+
* per question.
|
|
420
|
+
*
|
|
421
|
+
* Invalidation is sound for the same reason the browse set is: insertion has
|
|
422
|
+
* one owner (`applyLine`) and deletion has one owner (`forget`), so there are
|
|
423
|
+
* exactly two places to clear it, plus `reset`. The snapshot hydrate writes
|
|
424
|
+
* keys directly and deliberately clears NOTHING: it builds a store whose memo
|
|
425
|
+
* is still unset, and an invalidation there is unreachable by construction
|
|
426
|
+
* (verified by mutation — the test that covers it fails only if a future edit
|
|
427
|
+
* reads the geometry before the keys land).
|
|
428
|
+
*/
|
|
429
|
+
private rangesMemo: Interval[] | null = null;
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* @param maxLines retained-line cap, or OMITTED for the engine's choice.
|
|
433
|
+
* Optional rather than defaulted, because the two are not
|
|
434
|
+
* the same statement: "omitted" selects the engine's own
|
|
435
|
+
* post-flip target, and expressing that as a default VALUE
|
|
436
|
+
* made the consumer's explicit `5000` indistinguishable from
|
|
437
|
+
* silence — a UI passing `scrollbackLines: 5000` had its cap
|
|
438
|
+
* silently replaced by the small target on the first paging
|
|
439
|
+
* ack. It also made every call site spell the distinction
|
|
440
|
+
* itself (`x !== undefined ? new LineStore(x) : new
|
|
441
|
+
* LineStore()`), which is the shape a sentinel forces. A
|
|
442
|
+
* HISTORY
|
|
443
|
+
* budget floored at the live screen: the current window's
|
|
444
|
+
* rows are never evicted, so a cap at or below the
|
|
445
|
+
* terminal height keeps the full screen and simply
|
|
446
|
+
* retains no scrollback. Injectable so eviction is
|
|
447
|
+
* testable without allocating thousands of rows, and
|
|
448
|
+
* consumer-tunable through the renderer/UI plumb.
|
|
449
|
+
*/
|
|
450
|
+
constructor(maxLines?: number) {
|
|
451
|
+
// Constructor-knob semantics (docs/paged-scrollback.md §5.3): an OMITTED
|
|
452
|
+
// cap means "engine's choice", so it holds the legacy value until paging is
|
|
453
|
+
// declared and then flips to the small target. A SUPPLIED cap is an
|
|
454
|
+
// explicit memory decision by the consumer and holds in EVERY capability
|
|
455
|
+
// state — the flip is then a no-op. Either way the tail never exceeds what
|
|
456
|
+
// the consumer asked for, and never drops below what a non-paging server
|
|
457
|
+
// can serve back.
|
|
458
|
+
this.compatibilityCap = maxLines ?? MAX_LINES;
|
|
459
|
+
this.supportedTarget = maxLines ?? RESIDENT_TAIL_CAP;
|
|
460
|
+
this.effectiveTailCap = this.compatibilityCap;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Lines held in the resident tail (everything not classified browse). */
|
|
464
|
+
private get tailCount(): number {
|
|
465
|
+
return this.lines.size - this.browse.size;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** The effective resident-tail cap, floored at the live window's height. */
|
|
469
|
+
private get tailBound(): number {
|
|
470
|
+
return Math.max(this.effectiveTailCap, this.win.height);
|
|
471
|
+
}
|
|
106
472
|
|
|
107
473
|
/** Highest absolute index held, or -1 if empty. Used as resume `haveThrough`. */
|
|
108
474
|
highestIndex(): number {
|
|
@@ -183,6 +549,44 @@ export class LineStore {
|
|
|
183
549
|
* changed window row at its absolute index (base + y).
|
|
184
550
|
*/
|
|
185
551
|
applyScreen(msg: ScreenMessage): void {
|
|
552
|
+
// ED3 FIRST, BEFORE the screen-mode dispatch. The signal is a property of the
|
|
553
|
+
// SESSION, not of the buffer the frame happens to describe. The server raises
|
|
554
|
+
// a pending flag in the PTY path and attaches it to whatever frame it builds
|
|
555
|
+
// next, with NO alt gate — and it force-appends a row so the flag can never be
|
|
556
|
+
// dropped, which on an alt tick forces an ALT payload carrying it.
|
|
557
|
+
//
|
|
558
|
+
// Handling it inside the main-screen branch made every alt-active ED3 a total
|
|
559
|
+
// no-op: not one of §5.5's three effects ran. `clear; vim foo` typed as one
|
|
560
|
+
// line is enough (clear's terminfo emits CSI 3 J, vim's smcup follows in the
|
|
561
|
+
// same PTY burst, both inside one flush interval), as is `reset` inside any
|
|
562
|
+
// full-screen app. The erased transcript stayed resident and — because the
|
|
563
|
+
// server's ring Clear() preserves `committed`, so post-ED3 lines keep
|
|
564
|
+
// numbering from where the erased ones stopped — it spliced CONTIGUOUSLY into
|
|
565
|
+
// later output with no index gap, no gap marker and no trim marker. The
|
|
566
|
+
// in-flight window also stayed open, so a reply already on the wire put the
|
|
567
|
+
// erased rows back through the one path exempt from guard 2.
|
|
568
|
+
//
|
|
569
|
+
// `msg.base` is the correct bound on either buffer: in alt it is
|
|
570
|
+
// `committedBefore` (alt accrues no history, so the base stays frozen), which
|
|
571
|
+
// is one past the newest committed line, so "below base" is the whole
|
|
572
|
+
// scrollback either way.
|
|
573
|
+
if (msg.scrollbackCleared) {
|
|
574
|
+
this.applyScrollbackCleared(msg.base);
|
|
575
|
+
}
|
|
576
|
+
// A ROWS-LESS screen frame is a SIGNAL, not geometry, and must not redefine
|
|
577
|
+
// any buffer's shape. The connection layer forwards a pre-ack ED3 exactly that
|
|
578
|
+
// way (its rows are suppressed because the resume batch will repaint them),
|
|
579
|
+
// and no real terminal has a zero-height screen. On the MAIN path, taking the
|
|
580
|
+
// window from it set `height = 0`, which put the window bottom at `base - 1`
|
|
581
|
+
// and handed truncateBelowWindow every retained row at or above `base` as
|
|
582
|
+
// stranded: measured wiping a 3024-line store to 0 rows, and silently, because
|
|
583
|
+
// neither that path nor the ED3 drop advances the eviction watermark that
|
|
584
|
+
// would show a trim marker. On the ALT path it reached `enterAltIfNeeded(0)`
|
|
585
|
+
// and collapsed the alt grid to zero rows until the next full frame. The
|
|
586
|
+
// signal above is the whole payload of such a frame.
|
|
587
|
+
if (msg.rows.length === 0) {
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
186
590
|
if (msg.altActive) {
|
|
187
591
|
this.enterAltIfNeeded(msg.rows.length);
|
|
188
592
|
for (const y of msg.changed) {
|
|
@@ -196,12 +600,6 @@ export class LineStore {
|
|
|
196
600
|
return;
|
|
197
601
|
}
|
|
198
602
|
this.exitAltIfNeeded();
|
|
199
|
-
if (msg.scrollbackCleared) {
|
|
200
|
-
// ED3 (erase scrollback): the app cleared its saved lines (kiro-cli does
|
|
201
|
-
// this on every resize redraw). Drop our matching history below the live
|
|
202
|
-
// window so the previous frame doesn't accumulate beneath the new one.
|
|
203
|
-
this.dropHistoryBelow(msg.base);
|
|
204
|
-
}
|
|
205
603
|
this.updateWindow(msg);
|
|
206
604
|
for (const y of msg.changed) {
|
|
207
605
|
const row = msg.rows[y];
|
|
@@ -210,13 +608,41 @@ export class LineStore {
|
|
|
210
608
|
}
|
|
211
609
|
}
|
|
212
610
|
this.truncateBelowWindow();
|
|
611
|
+
// Re-check the cap AFTER the window settled: a screen frame can LOWER the
|
|
612
|
+
// retention bound without applying a single line — a height shrink whose
|
|
613
|
+
// rows are byte-identical short-circuits applyLine on the idempotency
|
|
614
|
+
// guard, and applyLine is otherwise the only enforceCap caller. The R3
|
|
615
|
+
// adversarial rounds' interleaved property test failed on exactly this
|
|
616
|
+
// shape (~60% of seeds) until the bound was re-checked per FRAME, not
|
|
617
|
+
// only per content change.
|
|
618
|
+
this.enforceCap();
|
|
213
619
|
}
|
|
214
620
|
|
|
215
621
|
/** Apply a decoded scroll/history frame: each line at firstIndex + i. */
|
|
216
622
|
applyScroll(msg: ScrollMessage): void {
|
|
217
623
|
if (this.alt) {
|
|
218
|
-
//
|
|
219
|
-
// alternate screen is active
|
|
624
|
+
// History strictly below the frozen MAIN window is safe to store while
|
|
625
|
+
// the alternate screen is active: the alt display renders from altRows,
|
|
626
|
+
// never from the abs store, and lines below win.base can only be
|
|
627
|
+
// main-screen scrollback — a resume batch's replay, or a post-resume
|
|
628
|
+
// durable chunk that raced an alt flip (the 2026-08 write-ordering
|
|
629
|
+
// race's last residual, closed here). They surface at alt exit's store
|
|
630
|
+
// rebuild. Anything AT or ABOVE the frozen base still violates the
|
|
631
|
+
// protocol invariant (the server never emits live scroll during alt)
|
|
632
|
+
// and is dropped rather than corrupting the frozen window region. With
|
|
633
|
+
// no main window ever seen (a fresh tab attaching straight into an
|
|
634
|
+
// in-alt session) there is no region to protect, so everything is
|
|
635
|
+
// accepted exactly as on the main-screen path — the alt-exit full
|
|
636
|
+
// frame overwrites the window region either way. applyLine's guard
|
|
637
|
+
// set applies to whatever passes.
|
|
638
|
+
const base = this.win.height > 0 ? this.win.base : Number.POSITIVE_INFINITY;
|
|
639
|
+
for (let i = 0; i < msg.lines.length; i++) {
|
|
640
|
+
const abs = msg.firstIndex + i;
|
|
641
|
+
const row = msg.lines[i];
|
|
642
|
+
if (row !== undefined && abs < base) {
|
|
643
|
+
this.applyLine(abs, row);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
220
646
|
return;
|
|
221
647
|
}
|
|
222
648
|
for (let i = 0; i < msg.lines.length; i++) {
|
|
@@ -230,11 +656,589 @@ export class LineStore {
|
|
|
230
656
|
/**
|
|
231
657
|
* Record the server's retained-history bounds from a resumeAck so the
|
|
232
658
|
* renderer can tell a genuine trim from a still-loading state.
|
|
659
|
+
*
|
|
660
|
+
* `committed` is deliberately NOT stored. The replay-jump prediction needs
|
|
661
|
+
* it, but it takes it as an argument from the ack being processed — reading a
|
|
662
|
+
* stored copy would risk predicting from a previous ack's value, which is the
|
|
663
|
+
* same class of defect as predicting from a mutated `highest`.
|
|
233
664
|
*/
|
|
234
665
|
noteResumeBounds(_committed: number, oldestIndex: number): void {
|
|
235
666
|
if (Number.isInteger(oldestIndex) && oldestIndex >= 0) {
|
|
236
667
|
this.serverOldest = oldestIndex;
|
|
668
|
+
// Within one boot epoch a ring's oldest only RISES, so a report below the
|
|
669
|
+
// floor cannot be ordinary progress — it is the repair path for a floor
|
|
670
|
+
// raised by a mis-correlated clamp, and lowering it back is how a
|
|
671
|
+
// session that wrongly condemned its own history recovers.
|
|
672
|
+
if (oldestIndex < this.pagingFloor) {
|
|
673
|
+
this.pagingFloor = oldestIndex;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// --- demand-paged scrollback: public surface (docs/paged-scrollback.md) ---
|
|
679
|
+
|
|
680
|
+
/** Whether the last resumeAck declared demand paging (§5.4's `supported`). */
|
|
681
|
+
pagingDeclared(): boolean {
|
|
682
|
+
return this.paging;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** The oldest index the server reported retaining, or -1 if unknown. */
|
|
686
|
+
serverOldestIndex(): number {
|
|
687
|
+
return this.serverOldest;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** The lowest index still worth requesting (§4.5). */
|
|
691
|
+
pagingFloorIndex(): number {
|
|
692
|
+
return this.pagingFloor;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Lines currently classified as browse cache. */
|
|
696
|
+
browseCacheSize(): number {
|
|
697
|
+
return this.browse.size;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** The effective resident-tail cap (post-flip once paging is declared). */
|
|
701
|
+
tailCap(): number {
|
|
702
|
+
return this.effectiveTailCap;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** When browse cache was last created or refreshed, for the consumer's TTL. */
|
|
706
|
+
lastBrowseActivityMs(): number {
|
|
707
|
+
return this.browseActivityMs;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Record the window of a request about to go out, so its reply can apply
|
|
712
|
+
* below the stale-re-send watermark. Exactly one slot, matching
|
|
713
|
+
* single-flight — a second call replaces the first rather than accumulating.
|
|
714
|
+
*/
|
|
715
|
+
noteSolicited(fromAbs: number, end: number): void {
|
|
716
|
+
if (!Number.isSafeInteger(fromAbs) || !Number.isSafeInteger(end) || end <= fromAbs) {
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
this.solicitedPending = { lo: fromAbs, hi: end };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Release the in-flight window (reply applied, timed out, or socket gone). */
|
|
723
|
+
clearSolicited(): void {
|
|
724
|
+
this.solicitedPending = null;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* The retained ranges, coalesced from the KEY SET. This is the authority for
|
|
729
|
+
* gap geometry — see intervals.ts for why it cannot be the browse set.
|
|
730
|
+
*/
|
|
731
|
+
retainedRanges(): Interval[] {
|
|
732
|
+
this.rangesMemo ??= retainedIntervals(this.lines.keys());
|
|
733
|
+
// A COPY of the runs, not the memo itself: this is a public accessor and a
|
|
734
|
+
// caller that mutated the returned list would corrupt every later answer.
|
|
735
|
+
// The run count is small (one tail plus a cache run or two) where the key
|
|
736
|
+
// count is thousands, which is the whole reason the memo pays.
|
|
737
|
+
return this.rangesMemo.map((iv) => ({ ...iv }));
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* The absent edges within `threshold` lines of `abs`, nearest first: the
|
|
742
|
+
* trigger's input. Each entry is a gap the viewport is approaching, already
|
|
743
|
+
* clamped to what is worth requesting — an interior hole between two retained
|
|
744
|
+
* runs, or the bottom FRONTIER pseudo-gap `[pagingFloor, oldestHeld)`.
|
|
745
|
+
*
|
|
746
|
+
* The frontier is included only when there is something below to ask for:
|
|
747
|
+
* `oldestHeld > 0`, the floor does not already cover it, and the server still
|
|
748
|
+
* reports retaining something older. A gap whose high edge is at or below the
|
|
749
|
+
* floor is omitted, because the floor means the server has PROVEN nothing at
|
|
750
|
+
* or below it survives.
|
|
751
|
+
*/
|
|
752
|
+
absentEdgesNear(abs: number, threshold: number): Interval[] {
|
|
753
|
+
const runs = this.retainedRanges();
|
|
754
|
+
if (runs.length === 0) {
|
|
755
|
+
return [];
|
|
756
|
+
}
|
|
757
|
+
const candidates = interiorGaps(runs);
|
|
758
|
+
const lowest = runs[0]?.lo ?? 0;
|
|
759
|
+
if (
|
|
760
|
+
lowest > 0 &&
|
|
761
|
+
lowest > this.pagingFloor &&
|
|
762
|
+
(this.serverOldest < 0 || lowest > this.serverOldest)
|
|
763
|
+
) {
|
|
764
|
+
candidates.push({ lo: this.pagingFloor, hi: lowest });
|
|
765
|
+
}
|
|
766
|
+
// `gapHigh > pagingFloor`, deliberately NOT gapLow: after a ring exhaustion
|
|
767
|
+
// followed by a later tail trim, the frontier's low edge EQUALS the floor,
|
|
768
|
+
// and that reopened frontier must stay fetchable (§5.4).
|
|
769
|
+
const live = candidates.filter((g) => g.hi > this.pagingFloor && g.hi > g.lo);
|
|
770
|
+
const near = live.filter((g) => abs >= g.lo - threshold && abs <= g.hi + threshold);
|
|
771
|
+
near.sort((a, b) => Math.abs(abs - a.hi) - Math.abs(abs - b.hi));
|
|
772
|
+
return near;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Apply a correlated history page. The bulk entry point for paged-in lines,
|
|
777
|
+
* distinct from `applyScroll` in three ways that all matter: it classifies
|
|
778
|
+
* what it stores as BROWSE cache, it suppresses per-line cap enforcement in
|
|
779
|
+
* favour of one budget pass at the end, and it clips to the solicited window.
|
|
780
|
+
*
|
|
781
|
+
* `viewportAbs` comes from the renderer — the only layer that knows where the
|
|
782
|
+
* reader is — and decides which end of the cache is safe to evict. The store
|
|
783
|
+
* never guesses it.
|
|
784
|
+
*/
|
|
785
|
+
applyHistoryScroll(msg: ScrollMessage, viewportAbs: number): void {
|
|
786
|
+
// The ALT GATE first, identical to applyScroll's prologue: the fetch
|
|
787
|
+
// controller is scrollback-UI-only, but a reply can still land during an
|
|
788
|
+
// alt flip. History strictly below the frozen main base is safe to store
|
|
789
|
+
// (it surfaces at alt exit's rebuild); at or above it would corrupt the
|
|
790
|
+
// frozen window region.
|
|
791
|
+
const altBase = this.alt
|
|
792
|
+
? this.win.height > 0
|
|
793
|
+
? this.win.base
|
|
794
|
+
: Number.POSITIVE_INFINITY
|
|
795
|
+
: Number.POSITIVE_INFINITY;
|
|
796
|
+
|
|
797
|
+
// Only the INTERSECTION with the in-flight window gets solicited treatment.
|
|
798
|
+
// A correlated frame wider than the current slot — a timed-out larger reply
|
|
799
|
+
// racing a shrunken retry — has its out-of-window lines routed through the
|
|
800
|
+
// ordinary guards instead, where they are typically refused below the
|
|
801
|
+
// watermark (§5.1). Without the clip, a stale oversized reply would apply
|
|
802
|
+
// content the client did not ask for on this attempt.
|
|
803
|
+
const s = this.solicitedPending;
|
|
804
|
+
if (s === null) {
|
|
805
|
+
// The question was RETRACTED, so the answer is dropped whole. Every one of
|
|
806
|
+
// the bulk path's concessions — admission below the stale-re-send
|
|
807
|
+
// watermark, suppressed per-line cap enforcement, disposable-cache
|
|
808
|
+
// classification — is paid for by a request the client currently has out.
|
|
809
|
+
//
|
|
810
|
+
// Downgrading it to the ordinary scroll path instead is NOT equivalent and
|
|
811
|
+
// was tried first: at the store level a page reply is indistinguishable
|
|
812
|
+
// from legitimate new content at the same indices (after ED3 an app
|
|
813
|
+
// reprints its transcript below the new window base, and that must apply),
|
|
814
|
+
// so the ordinary path admits the stale rows too. Only the CALLER knows
|
|
815
|
+
// this frame was correlated against a window that no longer exists, so
|
|
816
|
+
// only here can it be refused. The three cancellers are ED3, an epoch
|
|
817
|
+
// reset, and a closed socket; if the range still matters the trigger asks
|
|
818
|
+
// again, subject to the floor.
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const applied: number[] = [];
|
|
822
|
+
for (let i = 0; i < msg.lines.length; i++) {
|
|
823
|
+
const abs = msg.firstIndex + i;
|
|
824
|
+
const row = msg.lines[i];
|
|
825
|
+
if (row === undefined || abs >= altBase) {
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
if (abs < s.lo || abs >= s.hi) {
|
|
829
|
+
// Outside the solicited window: ordinary guards, ordinary
|
|
830
|
+
// classification (it is not this request's content).
|
|
831
|
+
this.applyLine(abs, row);
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
this.applyLine(abs, row, true);
|
|
835
|
+
if (this.lines.has(abs)) {
|
|
836
|
+
applied.push(abs);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
if (applied.length === 0) {
|
|
840
|
+
// An empty or fully-refused reply changes no classification. The caller
|
|
841
|
+
// still owns the floor raise for an empty reply (§4.3).
|
|
842
|
+
this.enforceCap();
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
// Exactly the keys this reply put in the store join the cache. Re-fetching a
|
|
846
|
+
// range already cached is a no-op per key, so overlap cannot inflate the
|
|
847
|
+
// cache size the way a reply-length increment would.
|
|
848
|
+
this.classifyAsBrowse(applied);
|
|
849
|
+
// ONE budget pass, at the page-apply target. The transient between the
|
|
850
|
+
// apply above and this pass is the reason the invariants are documented as
|
|
851
|
+
// holding at OPERATION boundaries, not inside a bulk apply.
|
|
852
|
+
this.enforceBrowseBudget(BROWSE_CACHE_CAP, viewportAbs);
|
|
853
|
+
this.enforceCap();
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Move the given retained keys into the browse classification without
|
|
858
|
+
* touching a single line. This is a RECLASSIFICATION, not a trim: nothing is
|
|
859
|
+
* deleted, no DOM changes, and `everEvictedThrough` is untouched — which is
|
|
860
|
+
* what lets the cap flip and the replay jump hand a band to the cache budget
|
|
861
|
+
* instead of evicting it out from under the reader.
|
|
862
|
+
*
|
|
863
|
+
* It takes KEYS, not a range, and that is the point: every caller already
|
|
864
|
+
* knows exactly which indices it is reclassifying (the page's applied list,
|
|
865
|
+
* the flip's excess tail keys, the jump's keys below the send watermark), so
|
|
866
|
+
* no span loop exists anywhere in the cache model. The range-taking version
|
|
867
|
+
* this replaced had to walk `[lo, hi)` to find the retained members, and its
|
|
868
|
+
* band is routinely a hull tens of millions of indices wide.
|
|
869
|
+
*
|
|
870
|
+
* Unheld keys are skipped, so the subset invariant survives a caller passing
|
|
871
|
+
* an index the store does not have.
|
|
872
|
+
*/
|
|
873
|
+
private classifyAsBrowse(keys: Iterable<number>): number {
|
|
874
|
+
let added = 0;
|
|
875
|
+
for (const abs of keys) {
|
|
876
|
+
if (this.lines.has(abs) && !this.browse.has(abs)) {
|
|
877
|
+
this.browse.add(abs);
|
|
878
|
+
added++;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
if (added > 0) {
|
|
882
|
+
this.browseActivityMs = Date.now();
|
|
883
|
+
}
|
|
884
|
+
return added;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Evict browse cache down to `target`, far edge first.
|
|
889
|
+
*
|
|
890
|
+
* Removal is EXACTLY the overflow, capped at one page per step: a one-line
|
|
891
|
+
* overflow removes one line, not a page, because anything larger thrashes
|
|
892
|
+
* fetch/evict cycles at the cap. Victims are taken from whichever END of the
|
|
893
|
+
* cache is FARTHER from `viewportAbs`, and every line within
|
|
894
|
+
* `PREFETCH_THRESHOLD` of it is EXEMPT — an eviction must never create a hole
|
|
895
|
+
* the trigger immediately re-fetches, nor blank the rows under the reader.
|
|
896
|
+
*
|
|
897
|
+
* Direction is computed from the cache's own extremes, never from a range's
|
|
898
|
+
* edges. The version this replaced asked whether an interval began at or above
|
|
899
|
+
* the viewport to find its far side, which is false whenever the reader is
|
|
900
|
+
* INSIDE the cache — the normal deep-scroll shape — so it always took the low
|
|
901
|
+
* end: the direction an up-scrolling reader is heading, while the pages behind
|
|
902
|
+
* them were never freed.
|
|
903
|
+
*
|
|
904
|
+
* Termination: the exemption band is one contiguous range, so if any victim is
|
|
905
|
+
* available at the far end the pass makes progress, and every removal shrinks
|
|
906
|
+
* `browse`. When the reader's own neighbourhood is all that remains the pass
|
|
907
|
+
* stops and accepts the bounded overshoot rather than spinning — that stop
|
|
908
|
+
* rule is target-agnostic, which is what makes a smaller reclassify target
|
|
909
|
+
* safe too.
|
|
910
|
+
*/
|
|
911
|
+
private enforceBrowseBudget(target: number, viewportAbs: number): void {
|
|
912
|
+
// §5.3's viewport exemption, and it is REACHABLE — an earlier comment here
|
|
913
|
+
// claimed arithmetic made it dead, which was measurably wrong.
|
|
914
|
+
//
|
|
915
|
+
// The shape that reaches it: the resume-ack pass uses the small
|
|
916
|
+
// PREFETCH_THRESHOLD target, and the band is 2 * PREFETCH_THRESHOLD + 1 wide,
|
|
917
|
+
// so a cache SPANNING the reader but smaller than the band is entirely
|
|
918
|
+
// exempt while still exceeding the target. Two retained runs either side of
|
|
919
|
+
// the reader's position do it: 400 rows at [0,400), 400 at [600,1000), the
|
|
920
|
+
// reader in the hole at 500, and a jump ack classifies all 800 against a
|
|
921
|
+
// target of 500. Nothing is evictable, the pass stops, and the store keeps
|
|
922
|
+
// 800 — the bounded overshoot the design prefers to blanking the rows under
|
|
923
|
+
// the reader. Under BROWSE_CACHE_CAP the same arithmetic never bites, which
|
|
924
|
+
// is what the dead-code claim was really about.
|
|
925
|
+
const exemptLo = viewportAbs - PREFETCH_THRESHOLD;
|
|
926
|
+
const exemptHi = viewportAbs + PREFETCH_THRESHOLD + 1;
|
|
927
|
+
const exempt = (abs: number) => abs >= exemptLo && abs < exemptHi;
|
|
928
|
+
|
|
929
|
+
while (this.browse.size > target) {
|
|
930
|
+
// The victim pool IS the browse set: bounded by the cache, not by
|
|
931
|
+
// residency and not by any numeric span. `size` is the count the loop
|
|
932
|
+
// tests, so the gate and the pool cannot disagree.
|
|
933
|
+
const keys = [...this.browse].sort((a, b) => a - b);
|
|
934
|
+
const budget = Math.min(this.browse.size - target, PAGE_SIZE);
|
|
935
|
+
// Distance from the reader decides which end goes; on a tie the older
|
|
936
|
+
// (lower) end goes, matching the tail cap's oldest-first bias. `keys` is
|
|
937
|
+
// non-empty because `size > target >= 0`.
|
|
938
|
+
const lowest = keys[0] ?? 0;
|
|
939
|
+
const highest = keys[keys.length - 1] ?? 0;
|
|
940
|
+
const fromTop = Math.abs(highest - viewportAbs) > Math.abs(viewportAbs - lowest);
|
|
941
|
+
const victims: number[] = [];
|
|
942
|
+
const walk = fromTop ? keys.reverse() : keys;
|
|
943
|
+
for (const abs of walk) {
|
|
944
|
+
if (victims.length >= budget || exempt(abs)) {
|
|
945
|
+
break; // the exemption is contiguous, so the rest of this end is too
|
|
946
|
+
}
|
|
947
|
+
victims.push(abs);
|
|
948
|
+
}
|
|
949
|
+
if (victims.length === 0) {
|
|
950
|
+
break; // the reader's own neighbourhood is all that is left: overshoot
|
|
951
|
+
}
|
|
952
|
+
for (const abs of victims) {
|
|
953
|
+
// forget() drops the line AND its classification. A cache drop is NOT a
|
|
954
|
+
// permanent trim, so everEvictedThrough deliberately stays where it is
|
|
955
|
+
// and the same index can be re-fetched later.
|
|
956
|
+
this.forget(abs);
|
|
957
|
+
}
|
|
958
|
+
this.recomputeBounds();
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* The resume-ack transition: ONE store operation carrying every decision the
|
|
964
|
+
* ack implies, in a fixed order (docs/paged-scrollback.md §4.5).
|
|
965
|
+
*
|
|
966
|
+
* It is one call rather than four because the order is load-bearing and the
|
|
967
|
+
* inputs live in three different layers — the connection decodes the ack, the
|
|
968
|
+
* store owns residency, the renderer owns the viewport. Split across
|
|
969
|
+
* callbacks, an implementer can interleave them; here they cannot.
|
|
970
|
+
*
|
|
971
|
+
* `committed`/`serverOldest` are NULLABLE AS A PAIR: a server too old to
|
|
972
|
+
* carry the ack's length-gated bounds tail sends neither, and inventing zeros
|
|
973
|
+
* would lower the floor and forge a jump. Such an ack still runs the epoch
|
|
974
|
+
* reset and the capability read, and skips the two steps that have no inputs.
|
|
975
|
+
*/
|
|
976
|
+
applyResumeAck(ack: {
|
|
977
|
+
/** The boot epoch changed: everything retained is from another server. */
|
|
978
|
+
epochChanged: boolean;
|
|
979
|
+
/** One past the server's newest committed line, or null on an old ack. */
|
|
980
|
+
committed: number | null;
|
|
981
|
+
/** The server's oldest retained index, or null on an old ack. */
|
|
982
|
+
serverOldest: number | null;
|
|
983
|
+
/** The server DECLARED demand paging (ackFlags bit1). */
|
|
984
|
+
paging: boolean;
|
|
985
|
+
/** The `haveThrough` this socket SENT, which the reply is a function of. */
|
|
986
|
+
sentHaveThrough: number;
|
|
987
|
+
/** The `replayMax` this socket SENT (already clamped), or null. */
|
|
988
|
+
sentReplayMax: number | null;
|
|
989
|
+
/** Where the reader is, from the renderer. */
|
|
990
|
+
viewportAbs: number;
|
|
991
|
+
/**
|
|
992
|
+
* Whether the reader is at the live tail, from the renderer — which KNOWS,
|
|
993
|
+
* where the store could only ever guess.
|
|
994
|
+
*
|
|
995
|
+
* The store used to derive it from `viewportAbs >= win.base`, and every
|
|
996
|
+
* failure of that derivation was a real defect. It read a window descriptor
|
|
997
|
+
* that step 4 of this very transition RETIRES, so the answer was wrong for
|
|
998
|
+
* every predicted replay jump (r2's confirmed high, patched by capturing the
|
|
999
|
+
* value earlier — a fix that left the hazard in place). With no descriptor at
|
|
1000
|
+
* all, which is the DOMINANT shape here (this ack is the first frame of its
|
|
1001
|
+
* batch, so it precedes the window frame, and a hydrated store persists no
|
|
1002
|
+
* window), the fallback was wrong in both directions: "not following" strands
|
|
1003
|
+
* ~2000 lines over budget on the first attach over a snapshot, and
|
|
1004
|
+
* "following" would drain a reader who is demonstrably deep in history.
|
|
1005
|
+
*
|
|
1006
|
+
* The renderer has one unambiguous source (`scroll.isUserScrolledUp()`, plus
|
|
1007
|
+
* an armed restore, which means "in history at the anchor"), so it answers and
|
|
1008
|
+
* the store stops inferring. Retiring the window descriptor mid-transition can
|
|
1009
|
+
* no longer corrupt anything.
|
|
1010
|
+
*/
|
|
1011
|
+
following: boolean;
|
|
1012
|
+
}): void {
|
|
1013
|
+
// (1) An epoch change resets everything, cap included: a different server
|
|
1014
|
+
// process shares no absolute indices with the old one.
|
|
1015
|
+
if (ack.epochChanged) {
|
|
1016
|
+
this.reset();
|
|
1017
|
+
}
|
|
1018
|
+
// (2) The capability, recorded for the renderer before anything reads it, and
|
|
1019
|
+
// stated in BOTH directions: an ack is the only thing that knows, so an
|
|
1020
|
+
// unsupported pairing must actively restore the compatibility cap rather than
|
|
1021
|
+
// relying on some earlier reset to have cleared it. (When paging IS declared,
|
|
1022
|
+
// step 3's flip owns the cap.)
|
|
1023
|
+
this.paging = ack.paging;
|
|
1024
|
+
if (!ack.paging) {
|
|
1025
|
+
this.effectiveTailCap = this.compatibilityCap;
|
|
1026
|
+
}
|
|
1027
|
+
// (2b) Bounds, when the ack carries them.
|
|
1028
|
+
if (ack.committed !== null && ack.serverOldest !== null) {
|
|
1029
|
+
this.noteResumeBounds(ack.committed, ack.serverOldest);
|
|
1030
|
+
}
|
|
1031
|
+
// (3) Capability: the cap flip, bookkeeping only.
|
|
1032
|
+
let reclassified = false;
|
|
1033
|
+
if (ack.paging) {
|
|
1034
|
+
reclassified = this.confirmPaging();
|
|
1035
|
+
}
|
|
1036
|
+
// (4) The replay JUMP, predicted from what this socket SENT — never from
|
|
1037
|
+
// the store's current `highest`, which a frame arriving between send and
|
|
1038
|
+
// ack could have moved, masking the jump and leaving the stranded band for
|
|
1039
|
+
// enforceCap to eat. Gated on `paging`: under an unsupported pairing the
|
|
1040
|
+
// band stays TAIL, which is today's behavior and bounded by the
|
|
1041
|
+
// compatibility cap. Reclassifying it there would hand disposable-cache
|
|
1042
|
+
// semantics to history no fetch can restore.
|
|
1043
|
+
if (ack.paging && ack.committed !== null && ack.serverOldest !== null) {
|
|
1044
|
+
const jumped = this.predictReplayJump(
|
|
1045
|
+
ack.committed,
|
|
1046
|
+
ack.serverOldest,
|
|
1047
|
+
ack.sentHaveThrough,
|
|
1048
|
+
ack.sentReplayMax,
|
|
1049
|
+
);
|
|
1050
|
+
reclassified = reclassified || jumped;
|
|
1051
|
+
}
|
|
1052
|
+
// (5) ONE budget pass, if either step moved anything into the cache. The two
|
|
1053
|
+
// steps used to be compared as bands to pick the wider one; with membership
|
|
1054
|
+
// as a key set there is nothing to compare — the containment test below
|
|
1055
|
+
// reads the set itself and has one answer regardless of which step grew it.
|
|
1056
|
+
if (reclassified) {
|
|
1057
|
+
// CONTAINMENT: a FOLLOWING viewport is outside every reclassified band by
|
|
1058
|
+
// definition — it is looking at the live tail, not at cache — so the
|
|
1059
|
+
// disposable band drains to the small target. A reader in HISTORY keeps the
|
|
1060
|
+
// full cache instead, and the TTL cleans up later.
|
|
1061
|
+
//
|
|
1062
|
+
// Following is the WHOLE question, and the store asked a second one twice,
|
|
1063
|
+
// in two different structures. It tested whether the row UNDER the reader
|
|
1064
|
+
// was cache — a proxy that fails in exactly the shape
|
|
1065
|
+
// paging exists for: a reader whose position is an index the store does not
|
|
1066
|
+
// currently hold. Both doors to that shape are ordinary — a hole inside the
|
|
1067
|
+
// cache, and an ARMED RESTORE anchor (§7.2/§7.3 of
|
|
1068
|
+
// scroll-position-fidelity.md — the renderer deliberately passes the position
|
|
1069
|
+
// the reader is REGAINING, whose row may since have been dropped). Either
|
|
1070
|
+
// way the reader is unambiguously reading history and the proxy answered
|
|
1071
|
+
// "no", draining their depth to the small target: measured 801 rows kept
|
|
1072
|
+
// instead of 2420. The range version got that case right by accident (a hull
|
|
1073
|
+
// spans holes) and the key version got it wrong by accident; asking only
|
|
1074
|
+
// what the design means removes both.
|
|
1075
|
+
this.enforceBrowseBudget(
|
|
1076
|
+
ack.following ? PREFETCH_THRESHOLD : BROWSE_CACHE_CAP,
|
|
1077
|
+
ack.viewportAbs,
|
|
1078
|
+
);
|
|
1079
|
+
this.enforceCap();
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* The cap flip: set the effective tail cap to the supported target and
|
|
1085
|
+
* reclassify the excess tail as browse cache. Returns whether anything moved.
|
|
1086
|
+
*
|
|
1087
|
+
* The band keeps the newest `max(supportedTarget, windowHeight)` tail lines
|
|
1088
|
+
* and NEVER crosses `win.base` — a LIVE window row is never reclassified,
|
|
1089
|
+
* because the browse budget may evict it and the window must not be
|
|
1090
|
+
* evictable. (§5.2's replay-jump band deliberately DOES include the old
|
|
1091
|
+
* window rows, but only because it retires the descriptor first.)
|
|
1092
|
+
*/
|
|
1093
|
+
private confirmPaging(): boolean {
|
|
1094
|
+
this.effectiveTailCap = this.supportedTarget;
|
|
1095
|
+
const keep = Math.max(this.supportedTarget, this.win.height);
|
|
1096
|
+
if (this.tailCount <= keep || this.oldest < 0) {
|
|
1097
|
+
return false;
|
|
1098
|
+
}
|
|
1099
|
+
// Walk the tail keys oldest-first and take all but the newest `keep`.
|
|
1100
|
+
const tailKeys = [...this.lines.keys()]
|
|
1101
|
+
.filter((k) => !this.browse.has(k))
|
|
1102
|
+
.sort((a, b) => a - b);
|
|
1103
|
+
const excess = tailKeys.length - keep;
|
|
1104
|
+
if (excess <= 0) {
|
|
1105
|
+
return false;
|
|
1106
|
+
}
|
|
1107
|
+
const winFloor = this.win.height > 0 ? this.win.base : Number.POSITIVE_INFINITY;
|
|
1108
|
+
return this.classifyAsBrowse(tailKeys.slice(0, excess).filter((k) => k < winFloor)) > 0;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Predict where the incoming replay will START, and if it lands above what
|
|
1113
|
+
* this socket told the server it had, reclassify the stranded band as browse
|
|
1114
|
+
* cache before any frame of the batch applies.
|
|
1115
|
+
*
|
|
1116
|
+
* Two causes produce the same shape and both are covered: the new bounded
|
|
1117
|
+
* replay's clamp, and the plain eviction gap EVERY server produces when its
|
|
1118
|
+
* ring has moved past the client's `haveThrough`. Scoping detection to the
|
|
1119
|
+
* clamp alone would miss the shape that already ships.
|
|
1120
|
+
*
|
|
1121
|
+
* The band deliberately includes the OLD window rows and RETIRES the window
|
|
1122
|
+
* descriptor, because the batch's own window frame re-establishes it at the
|
|
1123
|
+
* new base — those rows stop being window rows the moment it lands.
|
|
1124
|
+
*
|
|
1125
|
+
* The retirement leaves the same transition's later steps reading a window of
|
|
1126
|
+
* height 0, so state precisely what that costs rather than forbidding it: the
|
|
1127
|
+
* only window-derived bound they touch is `enforceCap`'s, which loses the live
|
|
1128
|
+
* screen's eviction protection for that one pass. It is harmless HERE and not
|
|
1129
|
+
* by luck — after the jump the tail holds only the keys above
|
|
1130
|
+
* `sentHaveThrough`, so `tailCount` is far below the bound and the pass returns
|
|
1131
|
+
* at its first gate; and were it to run, the walk is oldest-first while the
|
|
1132
|
+
* screen rows are the newest. A future step that evaluates a window bound with
|
|
1133
|
+
* consequences must re-establish the descriptor first.
|
|
1134
|
+
*/
|
|
1135
|
+
private predictReplayJump(
|
|
1136
|
+
committed: number,
|
|
1137
|
+
serverOldest: number,
|
|
1138
|
+
sentHaveThrough: number,
|
|
1139
|
+
sentReplayMax: number | null,
|
|
1140
|
+
): boolean {
|
|
1141
|
+
const base = sentHaveThrough + 1;
|
|
1142
|
+
let predicted = Math.max(base, serverOldest);
|
|
1143
|
+
if (sentReplayMax !== null) {
|
|
1144
|
+
predicted = Math.max(predicted, committed - sentReplayMax);
|
|
237
1145
|
}
|
|
1146
|
+
if (predicted <= base || this.oldest < 0 || sentHaveThrough < this.oldest) {
|
|
1147
|
+
// No jump, or an empty/fresh store where the predicate fires vacuously.
|
|
1148
|
+
return false;
|
|
1149
|
+
}
|
|
1150
|
+
// The stranded band is every retained key at or below what this socket told
|
|
1151
|
+
// the server it had. Taken from the KEY SET, so a client reconnecting far
|
|
1152
|
+
// behind costs its residency, not the width of the range it is missing.
|
|
1153
|
+
const stranded: number[] = [];
|
|
1154
|
+
for (const abs of this.lines.keys()) {
|
|
1155
|
+
if (abs <= sentHaveThrough) {
|
|
1156
|
+
stranded.push(abs);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
this.classifyAsBrowse(stranded);
|
|
1160
|
+
this.win = emptyWindow();
|
|
1161
|
+
this.windowDirty = true;
|
|
1162
|
+
return true;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* Raise the paging floor: the server proved nothing at or below `end`
|
|
1167
|
+
* survives, so no further request below it can succeed. Called for a clamped
|
|
1168
|
+
* reply (`firstIndex > fromAbs`) and for an empty one.
|
|
1169
|
+
*/
|
|
1170
|
+
raisePagingFloor(end: number): void {
|
|
1171
|
+
if (Number.isSafeInteger(end) && end > this.pagingFloor) {
|
|
1172
|
+
this.pagingFloor = end;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Drop the browse cache. The consumer owns the TTL that calls this (the
|
|
1178
|
+
* engine has no notion of tabs or visibility) and supplies both inputs.
|
|
1179
|
+
*
|
|
1180
|
+
* A VISIBLE page whose reader sits on browse rows SKIPS the drop: the TTL is
|
|
1181
|
+
* an inactivity signal, and a reader parked on a long stack trace is inactive
|
|
1182
|
+
* while looking straight at cache content — wiping it and re-fetching one RTT
|
|
1183
|
+
* later serves nobody. A HIDDEN page has no reader, so it drops
|
|
1184
|
+
* unconditionally; without that condition the skip would retain exactly the
|
|
1185
|
+
* deep-scrolled cache the hidden-page TTL exists to free.
|
|
1186
|
+
*/
|
|
1187
|
+
dropBrowseCache(viewportAbs: number, pageVisible: boolean): void {
|
|
1188
|
+
if (this.browse.size === 0) {
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
if (pageVisible && this.browse.has(viewportAbs)) {
|
|
1192
|
+
return; // skip and re-arm; the consumer's timer retries
|
|
1193
|
+
}
|
|
1194
|
+
for (const abs of [...this.browse]) {
|
|
1195
|
+
this.forget(abs);
|
|
1196
|
+
}
|
|
1197
|
+
this.recomputeBounds();
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* Remove one retained line and its browse classification. The single owner of
|
|
1202
|
+
* every deletion in this store.
|
|
1203
|
+
*
|
|
1204
|
+
* It exists because classification lives in a second structure keyed by the
|
|
1205
|
+
* same indices, and a second structure is only as correct as its
|
|
1206
|
+
* least-maintained mutation path. Two paths did not maintain it — ED3's
|
|
1207
|
+
* `applyScrollbackCleared` and the resize `truncateBelowWindow`, the latter firing
|
|
1208
|
+
* on every soft-keyboard open — so deleting a browse-classified line left the
|
|
1209
|
+
* cache accounting too high, `tailCount` understated, and the tail cap
|
|
1210
|
+
* under-enforcing for the rest of the epoch. Funnelling every removal through
|
|
1211
|
+
* here removes the CLASS rather than those two instances, and is what makes
|
|
1212
|
+
* `browse ⊆ lines.keys()` a fact rather than a hope.
|
|
1213
|
+
*
|
|
1214
|
+
* Returns whether a line was actually held (mirroring Map.delete), so callers
|
|
1215
|
+
* can count real removals.
|
|
1216
|
+
*/
|
|
1217
|
+
private forget(abs: number): boolean {
|
|
1218
|
+
const held = this.lines.delete(abs);
|
|
1219
|
+
if (held) {
|
|
1220
|
+
this.rangesMemo = null;
|
|
1221
|
+
this.evicted.add(abs);
|
|
1222
|
+
this.dirty.delete(abs);
|
|
1223
|
+
}
|
|
1224
|
+
this.browse.delete(abs);
|
|
1225
|
+
return held;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/** Recompute oldest/highest after a bulk removal. Bounded by map size. */
|
|
1229
|
+
private recomputeBounds(): void {
|
|
1230
|
+
let min = -1;
|
|
1231
|
+
let max = -1;
|
|
1232
|
+
for (const k of this.lines.keys()) {
|
|
1233
|
+
if (min < 0 || k < min) {
|
|
1234
|
+
min = k;
|
|
1235
|
+
}
|
|
1236
|
+
if (k > max) {
|
|
1237
|
+
max = k;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
this.oldest = min;
|
|
1241
|
+
this.highest = max;
|
|
238
1242
|
}
|
|
239
1243
|
|
|
240
1244
|
/**
|
|
@@ -244,9 +1248,12 @@ export class LineStore {
|
|
|
244
1248
|
*/
|
|
245
1249
|
reset(): void {
|
|
246
1250
|
this.lines.clear();
|
|
1251
|
+
this.rangesMemo = null;
|
|
247
1252
|
this.oldest = -1;
|
|
248
1253
|
this.highest = -1;
|
|
249
1254
|
this.everEvictedThrough = -1;
|
|
1255
|
+
this.erasedThrough = -1;
|
|
1256
|
+
this.paging = false;
|
|
250
1257
|
this.serverOldest = -1;
|
|
251
1258
|
this.win = emptyWindow();
|
|
252
1259
|
this.alt = false;
|
|
@@ -256,6 +1263,197 @@ export class LineStore {
|
|
|
256
1263
|
this.windowDirty = true;
|
|
257
1264
|
this.altDirty = true;
|
|
258
1265
|
this.resetPending = true;
|
|
1266
|
+
// CONTENT-derived paging state goes with the content: a new boot epoch shares
|
|
1267
|
+
// no absolute indices with the old one, so the cache, the in-flight window and
|
|
1268
|
+
// the floor are all meaningless at once (docs/paged-scrollback.md §5.5).
|
|
1269
|
+
this.browse.clear();
|
|
1270
|
+
this.solicitedPending = null;
|
|
1271
|
+
this.pagingFloor = 0;
|
|
1272
|
+
this.browseActivityMs = 0;
|
|
1273
|
+
// CAPABILITY is deliberately NOT reset here. It describes the SERVER on the
|
|
1274
|
+
// other end of the socket, not the content, and only a resumeAck can restate
|
|
1275
|
+
// it — so a reset that cleared it left the store waiting for an event that
|
|
1276
|
+
// may never come. That was safe for the documented caller (applyResumeAck's
|
|
1277
|
+
// epoch step, whose very next step re-reads the ack), and wrong for the two
|
|
1278
|
+
// public ones that predate paging: `render.resetScreen()` and
|
|
1279
|
+
// `render.resetScrollback()`, which a consumer is explicitly invited to call
|
|
1280
|
+
// on alt-screen entry, nowhere near a resume. Until the next reconnect that
|
|
1281
|
+
// left `pagingDeclared()` false while the transport was still perfectly able
|
|
1282
|
+
// to page, so §5.4's top marker asserted "earlier output trimmed" about
|
|
1283
|
+
// history the trigger was about to fetch, and the tail cap reverted to the
|
|
1284
|
+
// compatibility value — discarding the residency reduction that is the whole
|
|
1285
|
+
// point of the feature on the device it exists for. `applyResumeAck` now
|
|
1286
|
+
// states the capability explicitly instead of relying on a reset to clear it.
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/**
|
|
1290
|
+
* Serialize the newest retained lines as plain, structuredClone-safe data, for
|
|
1291
|
+
* a consumer that persists scrollback across a page discard (see
|
|
1292
|
+
* StoreSnapshot for what is deliberately excluded and why).
|
|
1293
|
+
*
|
|
1294
|
+
* `serverEpoch` is the boot epoch the caller last saw in a resumeAck; pass 0
|
|
1295
|
+
* if none is known. It is required rather than optional because a snapshot
|
|
1296
|
+
* without it cannot be hydrated safely, and an optional parameter is a
|
|
1297
|
+
* corruption bug waiting for the one caller who omits it.
|
|
1298
|
+
*
|
|
1299
|
+
* `maxLines` bounds the tail, defaulting to the store's own cap. A smaller
|
|
1300
|
+
* bound is the normal choice: the screen plus recent history is what a
|
|
1301
|
+
* returning user needs, and the cost of persisting is a repeated serialize on
|
|
1302
|
+
* a device that may already be under memory pressure. The depth that is not
|
|
1303
|
+
* kept is reported honestly after hydration, via the trim marker.
|
|
1304
|
+
*
|
|
1305
|
+
* Returns null when there is nothing worth persisting (an empty store), so a
|
|
1306
|
+
* caller cannot accidentally store an empty snapshot over a good one.
|
|
1307
|
+
*/
|
|
1308
|
+
snapshot(serverEpoch: number, maxLines: number = this.effectiveTailCap): StoreSnapshot | null {
|
|
1309
|
+
if (this.oldest < 0 || this.highest < 0) {
|
|
1310
|
+
return null;
|
|
1311
|
+
}
|
|
1312
|
+
const cap = Number.isInteger(maxLines) && maxLines > 0 ? maxLines : this.effectiveTailCap;
|
|
1313
|
+
// Persist the LIVE TAIL only, excluding browse cache BY CLASSIFICATION
|
|
1314
|
+
// rather than by contiguity (docs/paged-scrollback.md §5.2). Walk down from
|
|
1315
|
+
// `highest` and stop at the first index that is absent OR a browse member:
|
|
1316
|
+
// a fetched page can sit FLUSH against the tail with no numeric gap, so a
|
|
1317
|
+
// contiguity test alone would serialize it. Excluding it is deliberate —
|
|
1318
|
+
// the cache is disposable by construction (recovery is one page fetch), and
|
|
1319
|
+
// persisting it would spend storage writes on data the design defines as
|
|
1320
|
+
// throwaway and hydrate interior holes into a fresh store.
|
|
1321
|
+
//
|
|
1322
|
+
// The result is that a hydrated store is always ONE contiguous tail, which
|
|
1323
|
+
// is what keeps its derived `everEvictedThrough = oldest - 1` honest.
|
|
1324
|
+
const tail: number[] = [];
|
|
1325
|
+
for (let abs = this.highest; abs >= 0 && tail.length < cap; abs--) {
|
|
1326
|
+
if (!this.lines.has(abs) || this.browse.has(abs)) {
|
|
1327
|
+
break;
|
|
1328
|
+
}
|
|
1329
|
+
tail.push(abs);
|
|
1330
|
+
}
|
|
1331
|
+
tail.reverse();
|
|
1332
|
+
const lines: [number, WireRun[]][] = [];
|
|
1333
|
+
let first = -1;
|
|
1334
|
+
let last = -1;
|
|
1335
|
+
for (const abs of tail) {
|
|
1336
|
+
const runs = this.lines.get(abs);
|
|
1337
|
+
if (runs !== undefined) {
|
|
1338
|
+
lines.push([abs, runs]);
|
|
1339
|
+
if (first < 0) {
|
|
1340
|
+
first = abs;
|
|
1341
|
+
}
|
|
1342
|
+
last = abs;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
if (lines.length === 0) {
|
|
1346
|
+
return null;
|
|
1347
|
+
}
|
|
1348
|
+
return {
|
|
1349
|
+
v: SNAPSHOT_VERSION,
|
|
1350
|
+
serverEpoch: Number.isFinite(serverEpoch) ? serverEpoch : 0,
|
|
1351
|
+
// Derived from what is actually being written, not from this.oldest: a
|
|
1352
|
+
// bounded tail starts above it.
|
|
1353
|
+
oldest: first,
|
|
1354
|
+
highest: last,
|
|
1355
|
+
lines,
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* Rehydrate a store from a snapshot, or return null when the snapshot is
|
|
1361
|
+
* missing, malformed, or of a different shape version.
|
|
1362
|
+
*
|
|
1363
|
+
* Returning null rather than throwing (or returning a half-built store) is the
|
|
1364
|
+
* point: every failure mode here — a truncated write, a hand-edited value, a
|
|
1365
|
+
* snapshot from an older release — has the same correct handling, which is to
|
|
1366
|
+
* start empty and take a full resume.
|
|
1367
|
+
*
|
|
1368
|
+
* The hydrated store reports the depth it does NOT have.
|
|
1369
|
+
* `everEvictedThrough` is set to `oldest - 1`, which is what the eviction
|
|
1370
|
+
* bookkeeping would say had this client trimmed those lines itself: the
|
|
1371
|
+
* staleness guard then refuses a late re-delivery of a line below the tail
|
|
1372
|
+
* (it is not wanted; the tail is deliberate), and `hasTrimmedHistory()`
|
|
1373
|
+
* reports true so the renderer shows its "earlier output trimmed" affordance
|
|
1374
|
+
* instead of implying the history is complete.
|
|
1375
|
+
*
|
|
1376
|
+
* `maxLines` is the hydrated store's retained-line cap, and it also BOUNDS the
|
|
1377
|
+
* restore: a snapshot larger than the cap is trimmed to its newest `maxLines`
|
|
1378
|
+
* lines, because the cap is a memory budget (the renderer builds one DOM row
|
|
1379
|
+
* per retained line) and a restore that ignored it would blow the budget until
|
|
1380
|
+
* eviction caught up. The trimmed depth is reported through the usual channel:
|
|
1381
|
+
* `hasTrimmedHistory()`.
|
|
1382
|
+
*
|
|
1383
|
+
* The caller MUST pass `snap.serverEpoch` to the connection layer before
|
|
1384
|
+
* connecting; see StoreSnapshot.serverEpoch for what goes wrong otherwise.
|
|
1385
|
+
*/
|
|
1386
|
+
static fromSnapshot(snap: unknown, maxLines?: number): LineStore | null {
|
|
1387
|
+
if (typeof snap !== "object" || snap === null) {
|
|
1388
|
+
return null;
|
|
1389
|
+
}
|
|
1390
|
+
const rec = snap as Record<string, unknown>;
|
|
1391
|
+
// Read the payload as unknown[] rather than through StoreSnapshot's typed
|
|
1392
|
+
// field: this data has been outside the program's memory, so its shape is an
|
|
1393
|
+
// assumption until checked, and typing it first would make the checks below
|
|
1394
|
+
// statically dead (the compiler would "know" each entry is a valid pair).
|
|
1395
|
+
if (rec["v"] !== SNAPSHOT_VERSION || !Array.isArray(rec["lines"])) {
|
|
1396
|
+
return null;
|
|
1397
|
+
}
|
|
1398
|
+
const raw = rec["lines"] as unknown[];
|
|
1399
|
+
if (raw.length === 0) {
|
|
1400
|
+
return null;
|
|
1401
|
+
}
|
|
1402
|
+
// Mirrors the constructor: an absent or invalid cap means "engine's choice"
|
|
1403
|
+
// and is FORWARDED as absent, not as a value. Resolved BEFORE the store is
|
|
1404
|
+
// constructed, because it has to be the store's own cap as well as the trim
|
|
1405
|
+
// bound — passing the raw value here gave `fromSnapshot(snap, 0)` a store
|
|
1406
|
+
// that trimmed at 5000 and then evicted almost everything on the next append.
|
|
1407
|
+
const cap =
|
|
1408
|
+
maxLines !== undefined && Number.isInteger(maxLines) && maxLines > 0 ? maxLines : undefined;
|
|
1409
|
+
const bound = cap ?? MAX_LINES;
|
|
1410
|
+
const store = new LineStore(cap);
|
|
1411
|
+
const pairs: [number, WireRun[]][] = [];
|
|
1412
|
+
let highest = -1;
|
|
1413
|
+
for (const entry of raw) {
|
|
1414
|
+
// One bad row must not poison the whole restore, and a partially applied
|
|
1415
|
+
// one is worse than none: bail out entirely and take a full resume.
|
|
1416
|
+
if (!Array.isArray(entry) || entry.length !== 2) {
|
|
1417
|
+
return null;
|
|
1418
|
+
}
|
|
1419
|
+
const abs: unknown = entry[0];
|
|
1420
|
+
const runs: unknown = entry[1];
|
|
1421
|
+
if (typeof abs !== "number" || !Number.isInteger(abs) || abs < 0) {
|
|
1422
|
+
return null;
|
|
1423
|
+
}
|
|
1424
|
+
if (!isWireRunArray(runs)) {
|
|
1425
|
+
return null;
|
|
1426
|
+
}
|
|
1427
|
+
if (abs <= highest) {
|
|
1428
|
+
return null; // not ascending, or duplicated: the index space is broken
|
|
1429
|
+
}
|
|
1430
|
+
pairs.push([abs, runs]);
|
|
1431
|
+
highest = abs;
|
|
1432
|
+
}
|
|
1433
|
+
// Bound the restore to the store's own cap. A snapshot may legitimately be
|
|
1434
|
+
// larger than the cap this client runs with (the same snapshot outlives a
|
|
1435
|
+
// consumer lowering its budget, and the persisted bound is a separate knob),
|
|
1436
|
+
// and hydrating over the cap would blow the memory budget the cap exists to
|
|
1437
|
+
// set — the renderer builds one DOM row per retained line. The whole payload
|
|
1438
|
+
// is validated first, so a corrupt head still rejects the restore rather
|
|
1439
|
+
// than being silently sliced off.
|
|
1440
|
+
const kept = pairs.length > bound ? pairs.slice(pairs.length - bound) : pairs;
|
|
1441
|
+
const oldest = kept[0]?.[0] ?? -1;
|
|
1442
|
+
if (oldest < 0) {
|
|
1443
|
+
return null;
|
|
1444
|
+
}
|
|
1445
|
+
for (const [abs, runs] of kept) {
|
|
1446
|
+
store.lines.set(abs, runs);
|
|
1447
|
+
}
|
|
1448
|
+
store.oldest = oldest;
|
|
1449
|
+
store.highest = highest;
|
|
1450
|
+
// Everything below the tail is gone as far as this store is concerned.
|
|
1451
|
+
store.everEvictedThrough = oldest - 1;
|
|
1452
|
+
// The renderer must build every hydrated row: nothing is on screen yet.
|
|
1453
|
+
for (const abs of store.lines.keys()) {
|
|
1454
|
+
store.dirty.add(abs);
|
|
1455
|
+
}
|
|
1456
|
+
return store;
|
|
259
1457
|
}
|
|
260
1458
|
|
|
261
1459
|
/** Drain accumulated changes for the renderer and clear the tracking sets. */
|
|
@@ -277,6 +1475,11 @@ export class LineStore {
|
|
|
277
1475
|
|
|
278
1476
|
// --- internals ---
|
|
279
1477
|
|
|
1478
|
+
/** Whether an absolute index falls inside the current live window. */
|
|
1479
|
+
private inWindow(abs: number): boolean {
|
|
1480
|
+
return this.win.height > 0 && abs >= this.win.base && abs < this.win.base + this.win.height;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
280
1483
|
/**
|
|
281
1484
|
* applyLine is the guarded core. It enforces the apply-line guard set
|
|
282
1485
|
* (see the #web-terminal-engine steering doc, "Design rationale"):
|
|
@@ -284,13 +1487,35 @@ export class LineStore {
|
|
|
284
1487
|
* cap-bounded. Returns nothing; effects are recorded in the dirty/evicted
|
|
285
1488
|
* sets for the next drain.
|
|
286
1489
|
*/
|
|
287
|
-
private applyLine(abs: number, runs: WireRun[]): void {
|
|
1490
|
+
private applyLine(abs: number, runs: WireRun[], bulk = false): void {
|
|
288
1491
|
// Guard 1: a valid, non-negative integer index.
|
|
289
1492
|
if (!Number.isInteger(abs) || abs < 0) {
|
|
290
1493
|
return;
|
|
291
1494
|
}
|
|
292
|
-
// Guard 2: not below what we have permanently evicted (stale re-send)
|
|
293
|
-
|
|
1495
|
+
// Guard 2: not below what we have permanently evicted (stale re-send) —
|
|
1496
|
+
// EXCEPT inside the current live window, and EXCEPT inside the window of a
|
|
1497
|
+
// request we currently have in flight. The window is the terminal's
|
|
1498
|
+
// writable region and is never stale: under the real protocol its base
|
|
1499
|
+
// only advances, so a window index at or below everEvictedThrough is
|
|
1500
|
+
// unreachable — but a malformed or hostile frame whose base RETREATS
|
|
1501
|
+
// must degrade to a drawn screen, not to window rows silently dropped
|
|
1502
|
+
// forever (the property test drives exactly that shape). Same doctrine
|
|
1503
|
+
// as enforceCap's "the live window is never evictable".
|
|
1504
|
+
//
|
|
1505
|
+
// The SOLICITED exception is what makes demand paging possible at all:
|
|
1506
|
+
// a page is by definition a re-fetch of history below the watermark, so
|
|
1507
|
+
// the watermark alone cannot decide staleness any more. What keeps it
|
|
1508
|
+
// safe is that the exception is scoped to one in-flight request window —
|
|
1509
|
+
// an UNSOLICITED line below the watermark is still refused, exactly as
|
|
1510
|
+
// before (docs/paged-scrollback.md §5.1).
|
|
1511
|
+
if (abs <= this.everEvictedThrough && !this.inWindow(abs) && !this.isSolicited(abs)) {
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
// Guard 2b: not an index the APPLICATION erased. Same two exemptions, and
|
|
1515
|
+
// `erasedThrough` is why it is separable from guard 2 above — see its
|
|
1516
|
+
// declaration for why ED3 cannot advance the trim watermark, and why
|
|
1517
|
+
// refusing an erased index can never refuse legitimate content.
|
|
1518
|
+
if (abs <= this.erasedThrough && !this.inWindow(abs) && !this.isSolicited(abs)) {
|
|
294
1519
|
return;
|
|
295
1520
|
}
|
|
296
1521
|
// Guard 3: a well-formed run array.
|
|
@@ -305,6 +1530,7 @@ export class LineStore {
|
|
|
305
1530
|
return;
|
|
306
1531
|
}
|
|
307
1532
|
this.lines.set(abs, runs);
|
|
1533
|
+
this.rangesMemo = null;
|
|
308
1534
|
this.evicted.delete(abs);
|
|
309
1535
|
this.dirty.add(abs);
|
|
310
1536
|
if (this.oldest < 0 || abs < this.oldest) {
|
|
@@ -313,36 +1539,61 @@ export class LineStore {
|
|
|
313
1539
|
if (abs > this.highest) {
|
|
314
1540
|
this.highest = abs;
|
|
315
1541
|
}
|
|
316
|
-
// Guard 10: enforce the cap by evicting from the oldest end.
|
|
317
|
-
|
|
1542
|
+
// Guard 10: enforce the cap by evicting from the oldest end. SUPPRESSED
|
|
1543
|
+
// during a bulk page apply, which runs one budget pass of its own at the
|
|
1544
|
+
// end: per-line enforcement there would trim the tail against a count the
|
|
1545
|
+
// apply is still in the middle of changing.
|
|
1546
|
+
if (!bulk) {
|
|
1547
|
+
this.enforceCap();
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
/** Whether `abs` lies inside the request currently in flight. */
|
|
1552
|
+
private isSolicited(abs: number): boolean {
|
|
1553
|
+
const s = this.solicitedPending;
|
|
1554
|
+
return s !== null && abs >= s.lo && abs < s.hi;
|
|
318
1555
|
}
|
|
319
1556
|
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
|
|
1557
|
+
// applyScrollbackCleared handles ED3 (erase scrollback) in full: the app told
|
|
1558
|
+
// the terminal to discard its saved lines, and an inline TUI (kiro-cli) does it
|
|
1559
|
+
// on every resize redraw. Window rows (>= base) are kept and refreshed by the
|
|
1560
|
+
// frame carrying the signal. everEvictedThrough is left untouched: the app
|
|
1561
|
+
// discarded the lines deliberately (not a cap trim), so no "earlier output
|
|
1562
|
+
// trimmed" marker fits.
|
|
1563
|
+
//
|
|
1564
|
+
// Dropping the lines is only ONE of the three effects §5.5 requires, and the
|
|
1565
|
+
// other two are what stop the erased history coming back:
|
|
1566
|
+
//
|
|
1567
|
+
// - The in-flight request window is CANCELLED. It is the store's standing
|
|
1568
|
+
// permission to admit lines below its stale-re-send watermark, so a reply
|
|
1569
|
+
// already in flight when the app erased its scrollback would otherwise
|
|
1570
|
+
// re-apply the very rows the app just discarded — through the one path that
|
|
1571
|
+
// is exempt from the staleness guard.
|
|
1572
|
+
// - The paging FLOOR snaps to the cleared bound: nothing at or below it is
|
|
1573
|
+
// worth requesting any more. Without it the trigger re-fetches the erased
|
|
1574
|
+
// range from a server that still holds it, and the user watches erased
|
|
1575
|
+
// output scroll back into view.
|
|
1576
|
+
//
|
|
1577
|
+
// Both run even when this client holds no line below `base`: an in-flight
|
|
1578
|
+
// request and a stale floor are not conditional on local residency.
|
|
1579
|
+
private applyScrollbackCleared(base: number): void {
|
|
1580
|
+
this.solicitedPending = null;
|
|
1581
|
+
this.raisePagingFloor(base);
|
|
329
1582
|
if (this.oldest < 0 || this.oldest >= base) {
|
|
330
1583
|
return;
|
|
331
1584
|
}
|
|
332
1585
|
// Bounded key-scan (like enforceCap), not an integer walk over [oldest, base).
|
|
1586
|
+
// Through forget() so a browse-classified line takes its classification with
|
|
1587
|
+
// it: an application clearing its scrollback deletes cache as readily as tail.
|
|
333
1588
|
for (const abs of [...this.lines.keys()]) {
|
|
334
1589
|
if (abs < base) {
|
|
335
|
-
this.
|
|
336
|
-
|
|
337
|
-
|
|
1590
|
+
this.forget(abs);
|
|
1591
|
+
// The watermark tracks what was actually DROPPED, never the cleared
|
|
1592
|
+
// bound: the gap between them is where the app's own reprint lands.
|
|
1593
|
+
this.erasedThrough = Math.max(this.erasedThrough, abs);
|
|
338
1594
|
}
|
|
339
1595
|
}
|
|
340
|
-
|
|
341
|
-
this.oldest = -1;
|
|
342
|
-
this.highest = -1;
|
|
343
|
-
} else {
|
|
344
|
-
this.oldest = base;
|
|
345
|
-
}
|
|
1596
|
+
this.recomputeBounds();
|
|
346
1597
|
}
|
|
347
1598
|
|
|
348
1599
|
// truncateBelowWindow evicts every retained line past the window's bottom row.
|
|
@@ -370,9 +1621,7 @@ export class LineStore {
|
|
|
370
1621
|
let newHighest = -1;
|
|
371
1622
|
for (const abs of [...this.lines.keys()]) {
|
|
372
1623
|
if (abs > windowBottom) {
|
|
373
|
-
this.
|
|
374
|
-
this.evicted.add(abs);
|
|
375
|
-
this.dirty.delete(abs);
|
|
1624
|
+
this.forget(abs);
|
|
376
1625
|
} else if (abs > newHighest) {
|
|
377
1626
|
newHighest = abs;
|
|
378
1627
|
}
|
|
@@ -386,38 +1635,157 @@ export class LineStore {
|
|
|
386
1635
|
}
|
|
387
1636
|
|
|
388
1637
|
private enforceCap(): void {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
1638
|
+
// AMENDMENT 1 (docs/paged-scrollback.md §5.3): the gate and the victim
|
|
1639
|
+
// target read `tailCount`, not `lines.size`. Browse cache lives under its
|
|
1640
|
+
// OWN budget, enforced at page-apply time, so live output must not be able
|
|
1641
|
+
// to evict a page the reader is browsing — at the default batch that would
|
|
1642
|
+
// be up to 93 cache lines per applied line.
|
|
1643
|
+
if (this.tailCount <= this.tailBound) {
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
const target = this.tailBound - evictionBatch(this.tailBound) + 1;
|
|
1647
|
+
// ONE oldest-first eviction walk that SKIPS the live window. The window
|
|
1648
|
+
// is never evictable — the screen is documented as a fixed,
|
|
1649
|
+
// always-present block, so the cap is a HISTORY budget floored at the
|
|
1650
|
+
// live screen (a cap at or below the terminal height keeps the full
|
|
1651
|
+
// screen with zero scrollback). Content ABOVE the window (a resume
|
|
1652
|
+
// replay delivered before its window frame; a malformed stream that
|
|
1653
|
+
// never advances the base) is ordinary evictable history too: the
|
|
1654
|
+
// cursor hops over the window and keeps walking, so the bound
|
|
1655
|
+
// size <= max(maxLines, window height) holds whatever shape the frames
|
|
1656
|
+
// arrive in. Evicting that band OLDEST-first keeps the NEWEST lines —
|
|
1657
|
+
// the ones adjacent to the incoming window — so after the window
|
|
1658
|
+
// advances the retained set converges back to one contiguous block
|
|
1659
|
+
// (R3 adversarial finding: the earlier newest-first tail trim parked a
|
|
1660
|
+
// permanent interior hole under the live screen that resume, which
|
|
1661
|
+
// replays only above haveThrough, could never backfill).
|
|
1662
|
+
// everEvictedThrough advances over every TAIL eviction, including the
|
|
1663
|
+
// hopped band — safe for the window frame that follows a replay because
|
|
1664
|
+
// apply-line guard 2 exempts the live window (see applyLine).
|
|
1665
|
+
const winBottom = this.win.height > 0 ? this.win.base + this.win.height - 1 : -1;
|
|
1666
|
+
const hasWindow = this.win.height > 0;
|
|
1667
|
+
let cursor = this.oldest;
|
|
1668
|
+
let hopped = false;
|
|
1669
|
+
// The `cursor < highest` half of the guard keeps the NEWEST line alive in
|
|
1670
|
+
// a scroll-only store (a cap-1 store must still show something). With a
|
|
1671
|
+
// protected window retained there is always content to show, so a line
|
|
1672
|
+
// ABOVE the window bottom — the tail invariant broken by a scroll frame,
|
|
1673
|
+
// a shape only a malformed stream produces — is evictable even when it is
|
|
1674
|
+
// the highest key; otherwise one such line parks the store at bound+1
|
|
1675
|
+
// (found by the interleaved property test).
|
|
1676
|
+
while (
|
|
1677
|
+
this.tailCount > target &&
|
|
1678
|
+
cursor >= 0 &&
|
|
1679
|
+
(cursor < this.highest || (hasWindow && cursor > winBottom))
|
|
1680
|
+
) {
|
|
1681
|
+
if (hasWindow && cursor >= this.win.base && cursor <= winBottom) {
|
|
1682
|
+
// Hop the cursor over the protected window to the first retained key
|
|
1683
|
+
// above it. O(1) contiguous probe first; the scan fallback is
|
|
1684
|
+
// bounded by map size (the DoS note below applies here too).
|
|
1685
|
+
if (winBottom >= this.highest) {
|
|
1686
|
+
break; // the window is the tail: nothing evictable above it
|
|
1687
|
+
}
|
|
1688
|
+
cursor = this.lines.has(winBottom + 1) ? winBottom + 1 : this.minKeyAbove(winBottom);
|
|
1689
|
+
hopped = true;
|
|
1690
|
+
if (cursor < 0) {
|
|
1691
|
+
break;
|
|
1692
|
+
}
|
|
1693
|
+
continue;
|
|
1694
|
+
}
|
|
1695
|
+
// AMENDMENT 2: the hop predicate extends to BROWSE intervals. The walk
|
|
1696
|
+
// steps over them exactly the way it steps over the window, which is
|
|
1697
|
+
// what keeps `this.oldest` correct: it stays the GLOBAL minimum retained
|
|
1698
|
+
// key (browse included), and hopping arms the once-per-pass bounds
|
|
1699
|
+
// recompute below. Without this the cursor would delete cache lines as
|
|
1700
|
+
// if they were tail, and `oldestIndex()` — which the frontier and the
|
|
1701
|
+
// gap geometry both read — would go stale on the first trim of any
|
|
1702
|
+
// browse episode.
|
|
1703
|
+
if (this.browse.has(cursor)) {
|
|
1704
|
+
const next = this.minKeyAboveNonBrowse(cursor);
|
|
1705
|
+
hopped = true;
|
|
1706
|
+
if (next < 0) {
|
|
1707
|
+
break;
|
|
412
1708
|
}
|
|
413
|
-
|
|
1709
|
+
cursor = next;
|
|
1710
|
+
continue;
|
|
1711
|
+
}
|
|
1712
|
+
const victim = cursor;
|
|
1713
|
+
if (this.forget(victim)) {
|
|
1714
|
+
// AMENDMENT 3: only TAIL victims advance everEvictedThrough. A browse
|
|
1715
|
+
// eviction is a cache drop, not a permanent trim, and must not teach
|
|
1716
|
+
// guard 2 to refuse a later re-fetch of the same index. (Browse lines
|
|
1717
|
+
// never reach here anyway — amendment 2 hops them — so this is the
|
|
1718
|
+
// invariant stated where a future edit would break it, and forget()
|
|
1719
|
+
// keeps the classification consistent if one ever does.)
|
|
1720
|
+
this.everEvictedThrough = Math.max(this.everEvictedThrough, victim);
|
|
414
1721
|
}
|
|
415
1722
|
if (this.lines.size === 0) {
|
|
416
1723
|
this.oldest = -1;
|
|
417
1724
|
this.highest = -1;
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
// Advance to the lowest remaining key above the victim. Contiguous
|
|
1728
|
+
// history (the common case: live scroll-off, resume replay, a
|
|
1729
|
+
// cat-bigfile burst) leaves victim + 1 present -- an O(1) probe. Only a
|
|
1730
|
+
// hole at the boundary falls back to a scan, and that scan is bounded
|
|
1731
|
+
// by map size (<= the retention bound), NOT by the integer gap to the
|
|
1732
|
+
// next retained line, so a malformed/compromised frame whose base jumps
|
|
1733
|
+
// far ahead of a retained low index cannot make eviction walk billions
|
|
1734
|
+
// of indices (an algorithmic-complexity DoS that freezes the tab).
|
|
1735
|
+
cursor = this.lines.has(victim + 1) ? victim + 1 : this.minKeyAbove(victim);
|
|
1736
|
+
if (!hopped) {
|
|
1737
|
+
// Classic head eviction: the cursor IS the minimum retained key.
|
|
1738
|
+
this.oldest = cursor;
|
|
1739
|
+
}
|
|
1740
|
+
if (cursor < 0) {
|
|
418
1741
|
break;
|
|
419
1742
|
}
|
|
420
1743
|
}
|
|
1744
|
+
if (hopped) {
|
|
1745
|
+
// The cursor hopped the window or a browse interval, so the minimum
|
|
1746
|
+
// retained key is not the cursor: recompute both bounds once per pass
|
|
1747
|
+
// (bounded scan; once per PASS, never per victim — R3 perf finding).
|
|
1748
|
+
let min = -1;
|
|
1749
|
+
let max = -1;
|
|
1750
|
+
for (const k of this.lines.keys()) {
|
|
1751
|
+
if (min < 0 || k < min) {
|
|
1752
|
+
min = k;
|
|
1753
|
+
}
|
|
1754
|
+
if (k > max) {
|
|
1755
|
+
max = k;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
this.oldest = min;
|
|
1759
|
+
this.highest = max;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
/**
|
|
1764
|
+
* The lowest retained NON-BROWSE key strictly above k, or -1. The eviction
|
|
1765
|
+
* walk uses it to step past a whole browse interval in one move rather than
|
|
1766
|
+
* key by key: a 2500-line cache would otherwise cost 2500 iterations of the
|
|
1767
|
+
* hop branch on every tail trim taken while the reader is deep in history.
|
|
1768
|
+
* Bounded by map size, like minKeyAbove.
|
|
1769
|
+
*/
|
|
1770
|
+
private minKeyAboveNonBrowse(k: number): number {
|
|
1771
|
+
let min = -1;
|
|
1772
|
+
for (const key of this.lines.keys()) {
|
|
1773
|
+
if (key > k && !this.browse.has(key) && (min < 0 || key < min)) {
|
|
1774
|
+
min = key;
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
return min;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
/** The lowest retained key strictly above k, or -1. Bounded by map size. */
|
|
1781
|
+
private minKeyAbove(k: number): number {
|
|
1782
|
+
let min = -1;
|
|
1783
|
+
for (const key of this.lines.keys()) {
|
|
1784
|
+
if (key > k && (min < 0 || key < min)) {
|
|
1785
|
+
min = key;
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
return min;
|
|
421
1789
|
}
|
|
422
1790
|
|
|
423
1791
|
private updateWindow(msg: ScreenMessage): void {
|
|
@@ -437,10 +1805,17 @@ export class LineStore {
|
|
|
437
1805
|
}
|
|
438
1806
|
|
|
439
1807
|
private updateWindowCursor(msg: ScreenMessage): void {
|
|
440
|
-
// In the alt screen the base
|
|
1808
|
+
// In the alt screen the base AND height keep the MAIN window's values —
|
|
1809
|
+
// the alt grid's own height lives in altRows — and only the cursor tracks
|
|
1810
|
+
// the alt frame. This keeps the retention bound (max(maxLines, window
|
|
1811
|
+
// height)) and the guard-2 window exemption defined over the MAIN screen,
|
|
1812
|
+
// the region alt exit must restore, rather than over a phantom range
|
|
1813
|
+
// mixing the main base with the alt height (R4 review, all three models:
|
|
1814
|
+
// a smaller alt frame otherwise appeared to lower the bound below the
|
|
1815
|
+
// protected main rows, and the exemption described rows that were not the
|
|
1816
|
+
// writable screen).
|
|
441
1817
|
const next: WindowState = {
|
|
442
1818
|
...this.win,
|
|
443
|
-
height: msg.rows.length,
|
|
444
1819
|
cursorRow: msg.cursor[0],
|
|
445
1820
|
cursorCol: msg.cursor[1],
|
|
446
1821
|
cursorStyle: msg.cursorStyle ?? 0,
|