Commit37227f4eRecorded17 Jul 2026Repositorysigil-vt

fix: scrollback rows keep their push width (heap over-read); repair the gate

Message

A history row is allocated at the cols in effect when it scrolled off and is never re-widthed (xterm no-rewrap). Every reader used a GUESSED width instead of the row's own, so widening the terminal after content scrolled into history read past the allocation:

  nat_scrollback_row    guessed t->cols   wrong on any widening resize
  nat_scrollback_runs   guessed t->cols   wrong on any widening resize
  resize_grid pull      guessed ocols     wrong after TWO resizes (push@40 ->
                                          80 -> 132 reads 80 cells from a
                                          40-wide row)

Reproduced: a 40-col terminal with 26 history rows, resized to 132, returns a 132-element row from a 40-wide allocation; 45 of the 92 out-of-bounds cells hold invalid codepoints. That is ~1.4KB of adjacent heap per row, and it gets RENDERED: in Slate it either crashes the frame (integer->char rejects the garbage, so take-frame dies and the pane stops updating) or, on a runs-based renderer, silently paints adjacent heap into the DOM. Reachable by ordinary use: run something that scrolls, widen the pane, scroll back. Confined to the app's own wasm linear memory (verified: no wasm trap, no cross-origin reach), but a native consumer such as the planned sigil-tui has no such sandbox.

Root cause is one thing, not three. The interpreted reference is safe by construction: resize-row reads (vector-length row), because a Scheme vector carries its length. Porting rows to raw int32t* dropped that length, and each reader then had to invent it. Fix: store the push width per ring entry (sbw) and make every reader use it. The comment asserting ring rows "are only read during pull (min-copied) - safe" is corrected; it stopped being true when the render seam added readers, and the pull's own min-copy was wrong anyway.

test/vt-test.sgl: three regression tests covering both readers and the pull. Each was confirmed to FAIL against the unfixed core before being trusted. One of them initially passed on the bug because it asserted per-RUN text length, and over-read garbage splits into many short runs; it now asserts the TOTAL.

THE GATE ITSELF WAS BLIND, which is why this shipped. spike/fuzz.sh reported "5,000,000 iterations, ASan/UBSan clean" with NO ASan present: the pinned zig has no ASan runtime, so -fsanitize=address alone fails to link (undefined asanreportload4) and -fsanitize=address,undefined links while silently dropping it (zero asan symbols in the binary). A 1456-byte heap over-read goes undetected. UBSan was real and useful; ASan was never there. Two fixes:

  - vt-fuzz.c walked only sb[0] of a scrollback row (always in bounds). It now
    walks the full row at sb_get_w. Necessary but NOT sufficient: with the full
    walk pointed at t->cols, 150k iterations still passed on this toolchain,
    because the missing ASan is the real reason the bug survived.
  - fuzz.sh now self-tests BEFORE trusting itself: it compiles a known
    cross-function heap over-read with the same flags and refuses to run if the
    sanitizer fails to catch it. A naive self-test would pass on this broken
    toolchain, because an in-function malloc lets UBSan's object-size check
    fire and masquerade as ASan; the probe is deliberately opaque.
    SAN_UBSAN_ONLY=1 keeps the real UBSan coverage available, loudly labelled,
    never reportable as the M2 memory-safety gate.
Changed
 native/vt-fuzz.c           | 23 +++++++++++++++++++++--
 native/vt.c                | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++-----------
 spike/fuzz.sh              | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
 spike/sanitizer-selftest.c | 48 ++++++++++++++++++++++++++++++++++++++++++++++++
 test/vt-test.sgl           | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 5 files changed, 258 insertions(+), 19 deletions(-)
Diff
native/vt-fuzz.cmodified
@@ -60,8 +60,27 @@ static void touch_accessors(Vt *t) {
60
const int32_t *row = t->grid + (size_t)r * t->cols * 4;
61
for (int c = 0; c < t->cols; c++) g_sink ^= row[c * 4];
62
}
63
int32_t *sb = sb_get(t, rnd_below(t->sb_size ? t->sb_size + 1 : 1));
64
if (sb) g_sink ^= sb[0];
+63
/* WALK A SCROLLBACK ROW IN FULL, at the width the real readers use.
+64
* This line used to be `if (sb) g_sink ^= sb[0];` — one cell, which is
+65
* ALWAYS in bounds. That is why 5,000,000 ASan iterations never saw the
+66
* heap over-read in t-d4c7: the harness had the right shape and stopped one
+67
* cell short. A history row keeps its push width, so reading it at t->cols
+68
* after a widening resize runs off the allocation — exactly what a full
+69
* walk catches on the first widening.
+70
*
+71
* SCOPE, honestly: walking at sb_get_w exercises the ring's width
+72
* BOOKKEEPING — a stale or too-large sb_w faults here under ASan. It does
+73
* NOT catch a reader reverting to t->cols, because the Sigil-glue readers
+74
* (nat_scrollback_row / nat_scrollback_runs) are compiled out of this
+75
* build by VT_FUZZ. That regression is covered by the Scheme-level test in
+76
* test/vt-test.sgl ("scrollback row keeps its push width"), which calls the
+77
* real readers. Two gates, different halves. */
+78
int sbk = (int)rnd_below(t->sb_size ? t->sb_size + 1 : 1);
+79
int32_t *sb = sb_get(t, sbk);
+80
if (sb) {
+81
int w = sb_get_w(t, sbk);
+82
for (int c = 0; c < w; c++) g_sink ^= sb[c * 4];
+83
}
84
g_sink ^= color_256_rgb((int)rnd_below(300)); /* palette incl. out-of-range */
85
}
86
native/vt.cmodified
@@ -136,7 +136,21 @@ typedef struct {
136
int curstyle; /* DECSCUSR 0..6 */
137
138
/* scrollback ring: index 0 = newest */
139
int32_t **sb; /* each entry: cols*4 int32 */
+139
int32_t **sb; /* each entry: sb_w[i]*4 int32 */
+140
int *sb_w; /* WIDTH each entry was pushed at.
+141
* A history row keeps the width it
+142
* had when it scrolled off (xterm
+143
* no-rewrap), which is NOT t->cols
+144
* after a resize. The interpreted
+145
* reference got this for free —
+146
* its rows are Scheme vectors that
+147
* carry their own length. Porting
+148
* to raw int32_t* dropped it, so
+149
* every reader guessed, and a
+150
* widening resize read off the end
+151
* of the allocation. Never read an
+152
* entry with anything but its
+153
* sb_w. */
154
int sb_head; /* index of newest */
155
int sb_size;
156
int sb_cap; /* == sbmax */
@@ -242,12 +256,14 @@ static void sb_push(Vt *t, const int32_t *row) {
256
copy = t->sb[oldest];
257
copy = (int32_t *)realloc(copy, cells * sizeof(int32_t));
258
t->sb[oldest] = copy;
+259
t->sb_w[oldest] = t->cols;
260
t->sb_head = (t->sb_head + 1) % t->sb_cap;
261
/* size stays == cap */
262
} else {
263
copy = (int32_t *)malloc(cells * sizeof(int32_t));
264
t->sb_head = (t->sb_size == 0) ? 0 : (t->sb_head + 1) % t->sb_cap;
265
t->sb[t->sb_head] = copy;
+266
t->sb_w[t->sb_head] = t->cols;
267
t->sb_size++;
268
}
269
memcpy(copy, row, cells * sizeof(int32_t));
@@ -260,10 +276,18 @@ static int32_t *sb_get(Vt *t, int k) {
276
return t->sb[idx];
277
}
278
+279
/* the width entry k was pushed at — the ONLY safe extent for reading it */
+280
static int sb_get_w(Vt *t, int k) {
+281
if (k < 0 || k >= t->sb_size) return 0;
+282
int idx = (t->sb_head - k + t->sb_cap) % t->sb_cap;
+283
return t->sb_w[idx];
+284
}
+285
286
/* pop the newest entry (for resize grow); returns its buffer (caller frees) */
264
static int32_t *sb_pop(Vt *t) {
265
if (t->sb_size == 0) return NULL;
+287
static int32_t *sb_pop(Vt *t, int *out_w) {
+288
if (t->sb_size == 0) { if (out_w) *out_w = 0; return NULL; }
289
int32_t *row = t->sb[t->sb_head];
+290
if (out_w) *out_w = t->sb_w[t->sb_head];
291
t->sb[t->sb_head] = NULL;
292
t->sb_head = (t->sb_head - 1 + t->sb_cap) % t->sb_cap;
293
t->sb_size--;
@@ -1018,12 +1042,15 @@ static int32_t *resize_grid(Vt *t, int32_t *g, int old_rows, int cols, int rows,
1042
int pull = main_screen ? (need < t->sb_size ? need : t->sb_size) : 0;
1043
/* pull rows out of scrollback into the top */
1044
for (int k = 0; k < pull; k++) {
1021
int32_t *hist = sb_pop(t); /* newest first */
+1045
int hist_w = 0;
+1046
int32_t *hist = sb_pop(t, &hist_w); /* newest first */
1047
/* place newest adjacent to old top: slot (pull-1-k)?? term.sgl puts
1048
* the newest scrollback row at (pull-1) descending as it pops. */
1049
int slot = pull - 1 - k;
1050
int32_t *dst = ng + (size_t)slot * cols * 4;
1026
int copy = (ocols < cols) ? ocols : cols;
+1051
/* the history row's OWN width — NOT ocols. After two resizes
+1052
* (push@40 -> 80 -> 132) ocols is 80 and the row is 40 wide. */
+1053
int copy = (hist_w < cols) ? hist_w : cols;
1054
for (int c = 0; c < copy; c++)
1055
memcpy(dst + c * 4, hist + c * 4, 4 * sizeof(int32_t));
1056
free(hist);
@@ -1065,10 +1092,16 @@ static void term_resize(Vt *t, int cols, int rows) {
1092
if (cols < 1) cols = 1;
1093
if (rows < 1) rows = 1;
1094
1068
/* scrollback ring must be re-capacitied for new cols so sb_push copies the
1069
* right width; entries store the OLD width until resized through. We handle
1070
* width via the copy-min in resize_grid; ring buffers stay at old width but
1071
* are only read during pull (min-copied) — safe. */
+1095
/* Ring entries KEEP the width they were pushed at (xterm no-rewrap): they
+1096
* are not re-widthed here, and must never be read at t->cols. Each entry's
+1097
* width lives in sb_w — every reader uses it (nat_scrollback_row,
+1098
* nat_scrollback_runs, and the resize pull in resize_grid).
+1099
*
+1100
* This comment previously claimed ring rows "are only read during pull
+1101
* (min-copied) — safe". That stopped being true when the render seam added
+1102
* readers, and the min-copy in the pull was itself wrong after two resizes
+1103
* (it used ocols, not the row's width). Result: a heap over-read of up to
+1104
* (t->cols - push_width) cells, rendered into the terminal. See t-d4c7. */
1105
1106
if (t->alt_active) {
1107
int32_t *nmain = resize_grid(t, t->main, orows, cols, rows, 0, 1);
@@ -1186,6 +1219,7 @@ static Vt *vt_new(int cols, int rows, int sbmax) {
1219
t->title = NULL; t->title_len = 0; t->title_cap = 0;
1220
t->sb_cap = sbmax > 0 ? sbmax : 1000;
1221
t->sb = (int32_t **)calloc(t->sb_cap, sizeof(int32_t *));
+1222
t->sb_w = (int *)calloc(t->sb_cap, sizeof(int));
1223
t->sb_head = 0; t->sb_size = 0;
1224
t->dirty = (uint8_t *)calloc(rows, 1);
1225
t->alldirty = 1;
@@ -1208,6 +1242,7 @@ static void vt_free(void *data) {
1242
free(t->sb[idx]);
1243
}
1244
free(t->sb);
+1245
free(t->sb_w);
1246
for (int i = 0; i < t->nevents; i++) free(t->events[i].payload);
1247
free(t->events);
1248
free(t);
@@ -1465,7 +1500,10 @@ static Value nat_scrollback_row(SigilVM *vm, int argc, Value *args) {
1500
int k = (int)sigil_as_fixnum(args[1]);
1501
int32_t *r = sb_get(t, k);
1502
if (!r) return SIGIL_FALSE;
1468
int cols = t->cols;
+1503
/* the row's OWN width, not t->cols: a history row keeps the width it had
+1504
* when it scrolled off. The interpreted reference returns the stored vector
+1505
* verbatim, so its length IS the push width — match that exactly. */
+1506
int cols = sb_get_w(t, k);
1507
Value v = make_vec(vm, cols);
1508
sigil__gc_push_temp_root(vm, v);
1509
for (int c = 0; c < cols; c++) {
@@ -1594,7 +1632,9 @@ static Value nat_scrollback_runs(SigilVM *vm, int argc, Value *args) {
1632
if (!r) return SIGIL_EMPTY;
1633
int cursor_col = -1;
1634
if (argc >= 3 && sigil_is_fixnum(args[2])) cursor_col = (int)sigil_as_fixnum(args[2]);
1597
return row_runs_of(vm, r, t->cols, cursor_col);
+1635
/* the row's OWN width, not t->cols — see sb_w. Cells past it would be blank
+1636
* and right-trimmed away anyway, so this is byte-identical AND in-bounds. */
+1637
return row_runs_of(vm, r, sb_get_w(t, k), cursor_col);
1638
}
1639
1640
/* ---- palette ------------------------------------------------------------- */
spike/fuzz.shmodified
@@ -1,19 +1,91 @@
1
#!/usr/bin/env bash
2
# M2 GATE: build + run the sigil-vt fuzz driver under ASan + UBSan.
+2
# M2 GATE: build + run the sigil-vt fuzz driver under the sanitizers.
3
# The pure C core (native/vt.c under -DVT_FUZZ) is standalone — no libsigil.
4
set -euo pipefail
5
cd "$(dirname "$0")/.."
6
# Any zig with the sanitizers works. Override if zig is not on PATH, e.g. to use
7
# the Sigil monorepo's pinned toolchain: ZIG=../sigil/tools/zig/zig spike/fuzz.sh
+6
# Any toolchain with a REAL AddressSanitizer runtime. Override if zig is not on
+7
# PATH, e.g. the Sigil monorepo's pinned toolchain:
+8
# ZIG=../sigil/tools/zig/zig spike/fuzz.sh
+9
# NOTE: the pinned zig currently ships NO ASan runtime — see the self-test.
10
ZIG="${ZIG:-zig}"
11
ITERS="${1:-3000000}"
+12
SAN_FLAGS="-fsanitize=address,undefined -fno-sanitize-recover=all"
13
mkdir -p build
11
echo "== compiling vt-fuzz (ASan+UBSan) =="
+14
+15
# ---------------------------------------------------------------------------
+16
# STEP 0 — PROVE THE GATE CAN FIRE, BEFORE TRUSTING ANYTHING IT SAYS.
+17
#
+18
# t-d4c7: this gate reported "5,000,000 iterations, ASan/UBSan clean" while
+19
# ASan was NOT LINKED AT ALL, and a real heap over-read shipped through it.
+20
# `zig cc -fsanitize=address` alone fails to link (undefined __asan_report_*);
+21
# with `address,undefined` it links and SILENTLY DROPS ASan (the binary has
+22
# zero __asan symbols). The run still prints CLEAN — which is worse than having
+23
# no gate, because it reads as assurance.
+24
#
+25
# So: compile a KNOWN heap over-read with the SAME flags and require it to be
+26
# caught. A gate that cannot demonstrate it fires is not a gate.
+27
# ---------------------------------------------------------------------------
+28
echo "== sanitizer self-test (can this build detect a heap over-read?) =="
+29
"$ZIG" cc -std=c99 -O1 -g $SAN_FLAGS spike/sanitizer-selftest.c -o build/san-selftest
+30
set +e
+31
ASAN_OPTIONS=abort_on_error=1 UBSAN_OPTIONS=halt_on_error=1 \
+32
./build/san-selftest >/dev/null 2>&1
+33
rc=$?
+34
set -e
+35
if [ "$rc" -eq 0 ]; then
+36
echo " ok: a 1456-byte heap over-read was caught — sanitizer is real."
+37
elif [ "$rc" -eq 1 ]; then
+38
cat >&2 <<'MSG'
+39
+40
SANITIZER SELF-TEST FAILED — THIS BUILD IS BLIND.
+41
+42
A deliberate 1456-byte heap over-read was NOT detected, so this toolchain has
+43
no working AddressSanitizer. Any "FUZZ CLEAN" from it is meaningless: it
+44
cannot see heap-buffer-overflow, use-after-free, or leaks — the exact bug
+45
classes this gate exists to catch (the parser consumes UNTRUSTED pty bytes).
+46
+47
This is not hypothetical: it is how the t-d4c7 scrollback over-read shipped
+48
through 5,000,000 "clean" iterations.
+49
+50
Fix the toolchain; do NOT silence this check:
+51
- use a compiler with a real ASan runtime (system clang or gcc), e.g.
+52
ZIG=clang spike/fuzz.sh
+53
- verify with: nm build/san-selftest | grep __asan (must be non-empty)
+54
+55
Refusing to report a green from a gate that cannot fire.
+56
+57
If you only want the UBSan coverage (which IS real — it caught the CSI
+58
param int overflow), run: SAN_UBSAN_ONLY=1 spike/fuzz.sh
+59
That is NOT the M2 memory-safety gate and must never be reported as one.
+60
MSG
+61
if [ "${SAN_UBSAN_ONLY:-0}" = "1" ]; then
+62
echo "" >&2
+63
echo " SAN_UBSAN_ONLY=1 set — continuing WITHOUT memory-safety coverage." >&2
+64
echo " This run CANNOT satisfy the M2 gate. Report it as UBSAN-ONLY." >&2
+65
echo "" >&2
+66
UBSAN_ONLY=1
+67
else
+68
exit 1
+69
fi
+70
else
+71
echo " self-test exited $rc (crashed for an unexpected reason)" >&2
+72
exit 1
+73
fi
+74
+75
echo "== compiling vt-fuzz =="
76
"$ZIG" cc -std=c99 -O1 -g -DVT_FUZZ \
77
-Wall -Wextra -Wno-unused-parameter \
14
-fsanitize=address,undefined -fno-sanitize-recover=all \
+78
$SAN_FLAGS \
79
native/vt-fuzz.c -o build/vt-fuzz
80
echo "== running $ITERS iterations =="
81
ASAN_OPTIONS=detect_leaks=1:abort_on_error=1 \
82
UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 \
83
./build/vt-fuzz "$ITERS"
+84
+85
if [ "${UBSAN_ONLY:-0}" = "1" ]; then
+86
cat >&2 <<'MSG'
+87
+88
^ UBSAN-ONLY RUN. This did NOT check memory safety (no working ASan).
+89
It does not satisfy the M2 gate. Do not record it as "ASan/UBSan clean".
+90
MSG
+91
fi
spike/sanitizer-selftest.cadded
@@ -0,0 +1,48 @@
+1
/* Does the sanitizer build ACTUALLY detect a heap over-read?
+2
*
+3
* Exit 0 = the overflow was caught (the toolchain's ASan is real).
+4
* Exit 1 = NOT caught -> the "sanitizer" build is blind and any clean fuzz run
+5
* from it is meaningless.
+6
*
+7
* WHY THIS EXISTS (t-d4c7). The M2 gate reported "5,000,000 iterations,
+8
* ASan/UBSan clean" for months while ASan WAS NOT PRESENT AT ALL. The pinned
+9
* zig ships no ASan runtime: `zig cc -fsanitize=address` alone fails to link
+10
* (undefined __asan_report_load4), and `-fsanitize=address,undefined` links but
+11
* SILENTLY DROPS ASan — the binary contains zero __asan symbols. Only UBSan
+12
* survived. A real heap over-read shipped straight through that green.
+13
*
+14
* The trap that makes this hard to notice: a naive self-test PASSES anyway.
+15
* If the malloc is visible in the same function, UBSan's __builtin_object_size
+16
* check fires and you conclude "sanitizers work". They don't — you measured
+17
* UBSan. So the allocation here is deliberately behind a noinline function, out
+18
* of the compiler's static reach, exactly like sb_push. Then ONLY ASan's heap
+19
* redzones can catch it.
+20
*
+21
* Keep this shape. If you "simplify" the malloc back into main(), this file
+22
* silently starts passing on a broken toolchain again.
+23
*/
+24
#include <stdlib.h>
+25
#include <stdio.h>
+26
+27
/* Opaque to __builtin_object_size — mirrors sb_push allocating a row. */
+28
__attribute__((noinline)) static int *make_block(int n) {
+29
int *p = (int *)malloc((size_t)n * sizeof(int));
+30
if (!p) exit(2);
+31
for (int i = 0; i < n; i++) p[i] = i;
+32
return p;
+33
}
+34
+35
static long sink = 0;
+36
+37
int main(void) {
+38
/* 160 ints = 640 bytes: the same shape as a 40-column scrollback row. */
+39
int *p = make_block(160);
+40
/* Read at int index 524 (byte 2096) — 1456 bytes past the end. This is
+41
* precisely the t-d4c7 over-read: a 40-wide row read at 132 columns. */
+42
sink ^= p[524];
+43
/* Reaching here means the sanitizer did NOT catch a 1456-byte heap
+44
* over-read. Print, then fail loudly. */
+45
printf("sink=%ld\n", sink);
+46
free(p);
+47
return 1;
+48
}
test/vt-test.sglmodified
@@ -430,4 +430,64 @@
430
(assert-true (vector-ref (cadr runs) 4))
431
(assert-equal "e" (vector-ref (cadr runs) 0))))
432
(test "blank row -> no runs"
433
(assert-equal '() (vt-row-runs (vt-make 10 2) 1))))
+433
(assert-equal '() (vt-row-runs (vt-make 10 2) 1)))
+434
+435
;; ---- t-d4c7: a history row keeps the width it was PUSHED at -------------
+436
;; Ring rows are allocated at the cols in effect when they scrolled off and
+437
;; are never re-widthed (xterm no-rewrap). Reading one at t->cols after a
+438
;; WIDENING resize runs off the end of the allocation: a heap over-read whose
+439
;; garbage got rendered into the terminal (DoS via integer->char, plus
+440
;; disclosure of adjacent heap). These call the REAL readers — the fuzz
+441
;; harness cannot, since VT_FUZZ compiles the Sigil glue out.
+442
(test "scrollback row keeps its push width (no over-read on widen)"
+443
(let ((t (vt-make 40 3)))
+444
(vt-feed! t "aaaa\r\nbbbb\r\ncccc\r\ndddd\r\neeee\r\n")
+445
(assert-true (> (vt-scrollback-count t) 0))
+446
(vt-resize! t 132 3) ; widen; history stays 40 wide
+447
(assert-equal 132 (vt-cols t))
+448
;; the row is returned at ITS width, not the grid's
+449
(assert-equal 40 (vector-length (vt-scrollback-row t 0)))
+450
;; and every cell is a real codepoint, not heap garbage
+451
(let* ((row (vt-scrollback-row t 0))
+452
(n (vector-length row)))
+453
(let loop ((i 0))
+454
(when (< i n)
+455
(let ((cp (vt-cell-ch (vector-ref row i))))
+456
(assert-true (and (>= cp 0) (<= cp 1114111))))
+457
(loop (+ i 1)))))))
+458
+459
(test "scrollback runs after widen stay in-bounds"
+460
(let ((t (vt-make 40 3)))
+461
(vt-feed! t "hello\r\nworld\r\nagain\r\nmore1\r\nmore2\r\n")
+462
(vt-resize! t 132 3)
+463
(let ((runs (vt-scrollback-runs t 0)))
+464
(assert-true (pair? runs))
+465
;; TOTAL rendered width must not exceed the row's real width. Asserting
+466
;; per-RUN length instead would silently pass on the bug: over-read
+467
;; garbage has erratic attrs, so it splits into many SHORT runs that are
+468
;; each under the limit while the row as a whole runs far over.
+469
(let loop ((rs runs) (total 0))
+470
(if (null? rs)
+471
(assert-true (<= total 40))
+472
(loop (cdr rs) (+ total (string-length (vector-ref (car rs) 0)))))))))
+473
+474
;; The resize PULL had the same root cause with a different guess (ocols, not
+475
;; t->cols) — only wrong after TWO resizes, when ocols is neither the push
+476
;; width nor the new width.
+477
(test "resize pull uses the row's push width, not ocols"
+478
(let ((t (vt-make 40 3)))
+479
(vt-feed! t "aaaa\r\nbbbb\r\ncccc\r\ndddd\r\neeee\r\n")
+480
(vt-resize! t 80 3) ; ocols becomes 80...
+481
(vt-resize! t 132 8) ; ...but history rows are 40 wide
+482
(assert-equal 132 (vt-cols t))
+483
;; pulled-back rows must be real content, not garbage
+484
(let loop ((r 0))
+485
(when (< r 8)
+486
(let* ((row (vt-row-cells t r))
+487
(n (vector-length row)))
+488
(let loop2 ((i 0))
+489
(when (< i n)
+490
(let ((cp (vt-cell-ch (vector-ref row i))))
+491
(assert-true (and (>= cp 0) (<= cp 1114111))))
+492
(loop2 (+ i 1)))))
+493
(loop (+ r 1)))))))