summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBerke Güzel <wenekar1@gmail.com>2026-10-15 12:39:21 +0300
committerBerke Güzel <wenekar1@gmail.com>2026-10-15 12:39:21 +0300
commitca69844a243a6e335b0ddafad057f35e89e0a576 (patch)
tree7192c9f05e6500bb2ba550ca976ed9951b87c1f4
parent22345caf457e8d9a4bdb56b696cc5f3694812276 (diff)
parent4c72f7b02fbafd15d0cf843edece106714bcc5f7 (diff)
Merge branch 'wt/snapshot-atomic' into api-hardening
# Conflicts: # src/snapshot.c
-rw-r--r--docs/correctness-audit.md5
-rw-r--r--include/term/term.h2
-rw-r--r--src/snapshot.c283
-rw-r--r--tests/correctness_probe.c9
-rw-r--r--tests/snapshot_failure_test.c467
5 files changed, 563 insertions, 203 deletions
diff --git a/docs/correctness-audit.md b/docs/correctness-audit.md
index 4c9a14c..dd854a1 100644
--- a/docs/correctness-audit.md
+++ b/docs/correctness-audit.md
@@ -96,8 +96,9 @@ remain on row 3, and history should remain empty.
- Snapshot scroll accounting now counts discarded cluster records as dead,
allowing compaction to reclaim them.
-- Snapshot allocation failures now report false, empty the snapshot, and
- force a complete capture on retry.
+- Snapshot allocation failures now report false and leave the previous
+ snapshot unchanged. Every allocation happens before a field changes, so a
+ retry is an ordinary incremental update.
- Height shrink allocates the replacement screen before moving rows or
changing the cursor, preserving state if allocation fails.
diff --git a/include/term/term.h b/include/term/term.h
index d8758e7..380dfd3 100644
--- a/include/term/term.h
+++ b/include/term/term.h
@@ -417,7 +417,7 @@ TERM_API void term_snapshot_free(struct term_snapshot *s);
/* Copy the viewport of t into s. Keep rows with unchanged content keys.
* Update the history view index and refine counts for visible wide lines.
- * Return false when memory runs out. The snapshot is then empty. */
+ * Return false when memory runs out. The snapshot then keeps its previous content. */
TERM_API bool term_snapshot_update(struct term_snapshot *s, struct term *t);
/* Palette entries of struct term_view.palette after the 256 indexed colors. */
diff --git a/src/snapshot.c b/src/snapshot.c
index 0bbf545..8726265 100644
--- a/src/snapshot.c
+++ b/src/snapshot.c
@@ -46,6 +46,8 @@ void term_snapshot_free(struct term_snapshot *s)
free(s);
}
+/* Make room for cols x rows. A failed call keeps the old contents, so the
+ * snapshot stays readable. */
static bool reserve(struct term_snapshot *s, int cols, int rows)
{
size_t cells = (size_t)cols * (size_t)rows;
@@ -73,38 +75,13 @@ static bool reserve(struct term_snapshot *s, int cols, int rows)
return true;
}
-/* The public contract makes every failed update an empty, retryable snapshot. */
-static void invalidate(struct term_snapshot *s)
-{
- s->view = (struct term_view){ .palette = s->palette };
- s->source = 0;
- s->arena_len = 0;
- s->arena_dead = 0;
- if (s->row_cap > 0) {
- memset(s->keys, 0, (size_t)s->row_cap * sizeof(*s->keys));
- memset(s->row_clusters, 0, (size_t)s->row_cap * sizeof(*s->row_clusters));
- memset(s->selection, 0, (size_t)s->row_cap * sizeof(*s->selection));
- }
-}
-
-static uint32_t arena_add(struct term_snapshot *s, const struct gstore *g, uint32_t handle,
- bool *ok)
+/* Append a cluster record. The caller reserved room for it. */
+static uint32_t arena_add(struct term_snapshot *s, const struct gstore *g, uint32_t handle)
{
uint32_t cps[TERM_GRAPHEME_MAX_LEN];
int n = term_gstore_expand(g, handle, cps, TERM_GRAPHEME_MAX_LEN);
if (n <= 0)
return ' ';
- if (s->arena_len + (uint32_t)n + 1 > s->arena_cap) {
- uint32_t cap = term_grow_cap32(s->arena_cap ? s->arena_cap * 2 : 256,
- s->arena_len + (uint32_t)n + 1);
- uint32_t *a = realloc(s->arena, cap * sizeof(*a));
- if (!a) {
- *ok = false;
- return 0;
- }
- s->arena = a;
- s->arena_cap = cap;
- }
uint32_t off = s->arena_len;
s->arena[off] = (uint32_t)n;
memcpy(&s->arena[off + 1], cps, (size_t)n * sizeof(*cps));
@@ -112,10 +89,26 @@ static uint32_t arena_add(struct term_snapshot *s, const struct gstore *g, uint3
return TERM_TEXT_CLUSTER | off;
}
+/* Arena words that converting src adds. Counts what arena_add() writes. */
+static uint64_t row_words(const struct gstore *g, const struct cell *src, int cols)
+{
+ uint32_t cps[TERM_GRAPHEME_MAX_LEN];
+ uint64_t words = 0;
+ for (int x = 0; x < cols; x++) {
+ uint32_t text = src[x].c;
+ if (!term_grapheme_is_handle(text))
+ continue;
+ int n = term_gstore_expand(g, text, cps, TERM_GRAPHEME_MAX_LEN);
+ if (n > 0)
+ words += (uint64_t)n + 1;
+ }
+ return words;
+}
+
/* Convert one stored row; returns the number of arena words it added. */
static uint32_t convert_row(struct term_snapshot *s, const struct gstore *g,
struct term_cell *dst,
- const struct cell *src, int cols, bool *ok)
+ const struct cell *src, int cols)
{
uint32_t before = s->arena_len;
for (int x = 0; x < cols; x++) {
@@ -124,11 +117,8 @@ static uint32_t convert_row(struct term_snapshot *s, const struct gstore *g,
uint32_t text = c->c;
if (TERM_EXPECT(text < 0x20, 0))
text = text ? ' ' : 0;
- else if (TERM_EXPECT(term_grapheme_is_handle(text), 0)) {
- text = arena_add(s, g, text, ok);
- if (!*ok)
- return 0;
- }
+ else if (TERM_EXPECT(term_grapheme_is_handle(text), 0))
+ text = arena_add(s, g, text);
dst[x] = (struct term_cell){
.text = text,
.fg = c->fg,
@@ -140,43 +130,129 @@ static uint32_t convert_row(struct term_snapshot *s, const struct gstore *g,
return s->arena_len - before;
}
+/* What one update does. Phase 1 fills it in and changes no snapshot field. */
+struct plan {
+ bool all; /* rebuild every row and reset the arena */
+ int scrolled; /* rows the content moved up */
+ const uint64_t *keys; /* key of each new row */
+ const int *c0, *c1; /* selected columns of each row */
+};
+
+/* Phase 2: write the plan into the snapshot. Nothing here can fail. */
+static void apply(struct term_snapshot *s, const struct term *t, const struct plan *p)
+{
+ int cols = t->cols, rows = t->rows;
+ int k = p->scrolled;
+ if (p->all) {
+ s->arena_len = 0;
+ s->arena_dead = 0;
+ }
+
+ bool changed = p->all;
+ if (k > 0) {
+ for (int y = 0; y < k; y++)
+ s->arena_dead += s->row_clusters[y];
+ memmove(s->cells, s->cells + (size_t)k * cols,
+ (size_t)(rows - k) * cols * sizeof(struct term_cell));
+ memmove(s->row_clusters, s->row_clusters + k,
+ (size_t)(rows - k) * sizeof(uint32_t));
+ memmove(s->keys, s->keys + k,
+ (size_t)(rows - k) * sizeof(uint64_t));
+ memset(s->keys + (rows - k), 0, (size_t)k * sizeof(uint64_t));
+ memset(s->row_clusters + (rows - k), 0, (size_t)k * sizeof(uint32_t));
+ }
+
+ for (int y = 0; y < rows; y++) {
+ uint64_t key = p->keys[y];
+ if (!p->all && s->keys[y] == key)
+ continue;
+ if (!p->all)
+ s->arena_dead += s->row_clusters[y];
+ const struct cell *src = term_screen_view_row(t, y);
+ s->row_clusters[y] = convert_row(s, t->gstore, s->cells + (size_t)y * cols,
+ src, cols);
+ s->keys[y] = key;
+ changed = true;
+ }
+
+ for (int y = 0; y < rows; y++) {
+ struct term_span span = p->c1[y] >= p->c0[y] ? (struct term_span){ p->c0[y], p->c1[y] + 1 }
+ : (struct term_span){ 0 };
+ if (p->all || span.begin != s->selection[y].begin || span.end != s->selection[y].end)
+ changed = true;
+ s->selection[y] = span;
+ }
+
+ struct term_cursor cursor = term_cursor(t);
+ int view_offset = term_view_offset(t);
+ cursor.pos.y += view_offset;
+ if (cursor.pos.y >= rows)
+ cursor.visible = false;
+ const struct term_cursor *old = &s->view.cursor;
+ if (cursor.pos.x != old->pos.x || cursor.pos.y != old->pos.y ||
+ cursor.style != old->style || cursor.visible != old->visible ||
+ cursor.blink != old->blink ||
+ s->view.scroll_offset != view_offset ||
+ s->view.reverse_video != t->reverse_video)
+ changed = true;
+ if (p->all || s->view.palette_version != t->palette.version) {
+ memcpy(s->palette, t->palette.colors, sizeof(s->palette));
+ s->view.palette_version = t->palette.version;
+ changed = true;
+ }
+
+ s->source = t->serial;
+ s->view.size = (struct term_vector2i){ .x = cols, .y = rows };
+ s->view.cursor = cursor;
+ s->view.scroll_offset = view_offset;
+ s->view.scrolled = k;
+ s->view.reverse_video = t->reverse_video;
+ if (changed || k)
+ s->view.version++;
+}
+
+/* Phase 1 for one update. Allocates and checks everything that can fail,
+ * then hands the plan to apply(). A false return leaves the content as it was. */
static bool term_snapshot_update_inner(struct term_snapshot *s, struct term *t)
{
if (!s || !t)
return false;
int cols = t->cols, rows = t->rows;
- if (!reserve(s, cols, rows)) {
- invalidate(s);
+ bool ok = false;
+ bool resized = s->view.size.x != cols || s->view.size.y != rows;
+ bool all = resized || s->source != t->serial;
+ int scrolled = 0;
+ uint64_t need = 0;
+
+ if (!reserve(s, cols, rows))
return false;
+ /* Scratch for rows above 256: new keys, then the selection spans. */
+ uint64_t stack_keys[256];
+ int stack[2][256];
+ uint64_t *new_keys = stack_keys;
+ int *c0 = stack[0], *c1 = stack[1];
+ void *heap = NULL;
+ if (rows > 256) {
+ heap = malloc((size_t)rows * (sizeof(*new_keys) + 2 * sizeof(*c0)));
+ if (!heap)
+ return false;
+ new_keys = heap;
+ c0 = (int *)(new_keys + rows);
+ c1 = c0 + rows;
}
+ struct plan p = { .keys = new_keys, .c0 = c0, .c1 = c1 };
- bool resized = s->view.size.x != cols || s->view.size.y != rows;
- bool all = resized || s->source != t->serial;
/* Reclaim cluster records once most of the arena is garbage. */
if (s->arena_dead > 4096 && s->arena_dead * 2 > s->arena_len)
all = true;
- if (all) {
- s->arena_len = 0;
- s->arena_dead = 0;
- }
- bool changed = all;
term_hist_prepare_rows(&t->hist, cols);
- if (!term_hist_view_sync(&t->view, &t->hist, cols)) {
- invalidate(s);
- return false;
- }
+ if (!term_hist_view_sync(&t->view, &t->hist, cols))
+ goto done;
term_hist_view_refine(&t->view, &t->hist, cols, rows);
- uint64_t stack_keys[256];
- uint64_t *new_keys = rows <= 256 ? stack_keys : malloc((size_t)rows * sizeof(uint64_t));
- if (!new_keys) {
- invalidate(s);
- return false;
- }
for (int y = 0; y < rows; y++)
new_keys[y] = term_screen_view_row_key(t, y);
- int scrolled = 0;
if (!all && rows > 1) {
int cand_k = 0;
/* Find candidate scroll offset k by checking where top rows moved. */
@@ -219,87 +295,38 @@ static bool term_snapshot_update_inner(struct term_snapshot *s, struct term *t)
}
}
- if (scrolled > 0) {
- int k = scrolled;
- for (int y = 0; y < k; y++)
- s->arena_dead += s->row_clusters[y];
- memmove(s->cells, s->cells + (size_t)k * cols,
- (size_t)(rows - k) * cols * sizeof(struct term_cell));
- memmove(s->row_clusters, s->row_clusters + k,
- (size_t)(rows - k) * sizeof(uint32_t));
- memmove(s->keys, s->keys + k,
- (size_t)(rows - k) * sizeof(uint64_t));
- memset(s->keys + (rows - k), 0, (size_t)k * sizeof(uint64_t));
- memset(s->row_clusters + (rows - k), 0, (size_t)k * sizeof(uint32_t));
- }
-
+ /* Arena words for the rows that apply() converts. Count them before
+ * anything changes, then grow the arena once so apply() cannot fail. */
+ need = all ? 0 : s->arena_len;
for (int y = 0; y < rows; y++) {
- uint64_t key = new_keys[y];
- if (!all && s->keys[y] == key)
+ uint64_t kept = s->keys[y];
+ if (scrolled)
+ kept = y + scrolled < rows ? s->keys[y + scrolled] : 0;
+ if (!all && kept == new_keys[y])
continue;
- if (!all)
- s->arena_dead += s->row_clusters[y];
- const struct cell *src = term_screen_view_row(t, y);
- bool converted = true;
- s->row_clusters[y] = convert_row(s, t->gstore, s->cells + (size_t)y * cols,
- src, cols, &converted);
- if (!converted) {
- if (new_keys != stack_keys)
- free(new_keys);
- invalidate(s);
- return false;
- }
- s->keys[y] = key;
- changed = true;
- }
- if (new_keys != stack_keys)
- free(new_keys);
-
- int stack[2][256];
- int *c0 = rows <= 256 ? stack[0] : malloc((size_t)rows * 2 * sizeof(int));
- if (!c0) {
- invalidate(s);
- return false;
+ need += row_words(t->gstore, term_screen_view_row(t, y), cols);
}
- int *c1 = rows <= 256 ? stack[1] : c0 + rows;
- term_selection_view_spans(t, c0, c1);
- for (int y = 0; y < rows; y++) {
- struct term_span span = c1[y] >= c0[y] ? (struct term_span){ c0[y], c1[y] + 1 }
- : (struct term_span){ 0 };
- if (all || span.begin != s->selection[y].begin || span.end != s->selection[y].end)
- changed = true;
- s->selection[y] = span;
+ /* Offsets must fit below TERM_TEXT_CLUSTER. */
+ if (need > TERM_TEXT_CLUSTER)
+ goto done;
+ if (need > s->arena_cap) {
+ uint32_t cap = term_grow_cap32(s->arena_cap ? s->arena_cap * 2 : 256, (uint32_t)need);
+ uint32_t *a = realloc(s->arena, cap * sizeof(*a));
+ if (!a)
+ goto done;
+ s->arena = a;
+ s->arena_cap = cap;
}
- if (c0 != stack[0])
- free(c0);
- struct term_cursor cursor = term_cursor(t);
- int view_offset = term_view_offset(t);
- cursor.pos.y += view_offset;
- if (cursor.pos.y >= rows)
- cursor.visible = false;
- const struct term_cursor *old = &s->view.cursor;
- if (cursor.pos.x != old->pos.x || cursor.pos.y != old->pos.y ||
- cursor.style != old->style || cursor.visible != old->visible ||
- cursor.blink != old->blink ||
- s->view.scroll_offset != view_offset ||
- s->view.reverse_video != t->reverse_video)
- changed = true;
- if (all || s->view.palette_version != t->palette.version) {
- memcpy(s->palette, t->palette.colors, sizeof(s->palette));
- s->view.palette_version = t->palette.version;
- changed = true;
- }
+ term_selection_view_spans(t, c0, c1);
+ p.all = all;
+ p.scrolled = scrolled;
+ apply(s, t, &p);
+ ok = true;
- s->source = t->serial;
- s->view.size = (struct term_vector2i){ .x = cols, .y = rows };
- s->view.cursor = cursor;
- s->view.scroll_offset = view_offset;
- s->view.scrolled = scrolled;
- s->view.reverse_video = t->reverse_video;
- if (changed || scrolled)
- s->view.version++;
- return true;
+done:
+ free(heap);
+ return ok;
}
bool term_snapshot_update(struct term_snapshot *s, struct term *t)
diff --git a/tests/correctness_probe.c b/tests/correctness_probe.c
index a7c15df..9ac74ca 100644
--- a/tests/correctness_probe.c
+++ b/tests/correctness_probe.c
@@ -36,12 +36,11 @@ int main(void)
fail_realloc = 1;
bool ok = term_snapshot_update(s, t);
uint32_t cps[16];
- const struct term_cell *row = term_snapshot_row(s, 0);
- int n = row ? term_snapshot_cluster(s, row[0].text, cps, 16) : 0;
- printf("snapshot arena allocation failure: update=%d cluster_length=%d (expected false and empty)\n", ok, n);
- failed |= ok || term_snapshot_view(s)->size.x != 0 || term_snapshot_view(s)->size.y != 0;
+ printf("snapshot arena allocation failure: update=%d size=%dx%d (expected false and the previous 8x3 view)\n",
+ ok, term_snapshot_view(s)->size.x, term_snapshot_view(s)->size.y);
+ failed |= ok || term_snapshot_view(s)->size.x != 8 || term_snapshot_view(s)->size.y != 3;
assert(term_snapshot_update(s, t));
- n = term_snapshot_cluster(s, term_snapshot_row(s, 0)[0].text, cps, 16);
+ int n = term_snapshot_cluster(s, term_snapshot_row(s, 0)[0].text, cps, 16);
printf("retry after allocation recovers: cluster_length=%d (expected 2)\n", n);
failed |= n != 2 || cps[0] != 'e' || cps[1] != 0x301;
term_snapshot_free(s);
diff --git a/tests/snapshot_failure_test.c b/tests/snapshot_failure_test.c
index a516ec2..6ce8f7b 100644
--- a/tests/snapshot_failure_test.c
+++ b/tests/snapshot_failure_test.c
@@ -3,91 +3,424 @@
#include <assert.h>
#include <stdbool.h>
#include <stddef.h>
+#include <stdint.h>
#include <stdlib.h>
+#include <string.h>
/* --wrap redirects calls after LTO has analyzed their side effects. Keep
- * these accesses observable so it cannot fold away the wrapper's changes. */
-static volatile bool fail_next_realloc;
-static volatile int malloc_failure_countdown = -1;
-static volatile bool malloc_was_failed;
+ * these accesses observable so it cannot fold away the wrapper's changes.
+ * The countdown fails the allocation after it reaches zero. malloc and
+ * realloc share it, so a sweep over the countdown hits every site. */
+static volatile int alloc_countdown = -1;
+static volatile bool alloc_was_failed;
+
+static bool alloc_should_fail(void)
+{
+ if (alloc_countdown < 0)
+ return false;
+ if (alloc_countdown > 0) {
+ alloc_countdown--;
+ return false;
+ }
+ alloc_countdown = -1;
+ alloc_was_failed = true;
+ return true;
+}
+
void *__real_malloc(size_t);
void *__wrap_malloc(size_t size)
{
- if (malloc_failure_countdown >= 0) {
- if (malloc_failure_countdown-- == 0) {
- malloc_failure_countdown = -1;
- malloc_was_failed = true;
- return NULL;
- }
- }
+ if (alloc_should_fail())
+ return NULL;
return __real_malloc(size);
}
void *__real_realloc(void *, size_t);
void *__wrap_realloc(void *p, size_t size)
{
- if (fail_next_realloc) {
- fail_next_realloc = false;
+ if (alloc_should_fail())
return NULL;
- }
return __real_realloc(p, size);
}
-int main(void)
+/* Code points of a cluster cell are copied up to this many. */
+#define CPS_MAX 32
+#define CPS_SLOT (CPS_MAX + 1)
+
+/* A copy of everything a renderer can read from a snapshot. */
+struct frame {
+ struct term_view view;
+ uint32_t palette[TERM_PALETTE_SIZE];
+ int cols, rows;
+ struct term_cell *cells; /* rows * cols */
+ uint32_t *cps; /* per cell: count, then code points */
+ uint64_t *keys; /* per row */
+ struct term_span *sel; /* per row */
+};
+
+static void frame_capture(const struct term_snapshot *s, struct frame *f)
+{
+ const struct term_view *v = term_snapshot_view(s);
+ f->view = *v;
+ memcpy(f->palette, v->palette, sizeof(f->palette));
+ f->cols = v->size.x;
+ f->rows = v->size.y;
+ size_t n = (size_t)f->cols * (size_t)f->rows;
+ f->cells = calloc(n + 1, sizeof(*f->cells));
+ f->cps = calloc(n + 1, CPS_SLOT * sizeof(*f->cps));
+ f->keys = calloc((size_t)f->rows + 1, sizeof(*f->keys));
+ f->sel = calloc((size_t)f->rows + 1, sizeof(*f->sel));
+ assert(f->cells && f->cps && f->keys && f->sel);
+ for (int y = 0; y < f->rows; y++) {
+ const struct term_cell *row = term_snapshot_row(s, y);
+ assert(row);
+ f->keys[y] = term_snapshot_row_key(s, y);
+ f->sel[y] = term_snapshot_row_selection(s, y);
+ for (int x = 0; x < f->cols; x++) {
+ size_t i = (size_t)y * f->cols + x;
+ f->cells[i] = row[x];
+ uint32_t *cps = f->cps + i * CPS_SLOT;
+ cps[0] = 0;
+ if (row[x].text & TERM_TEXT_CLUSTER)
+ cps[0] = (uint32_t)term_snapshot_cluster(s, row[x].text, cps + 1, CPS_MAX);
+ }
+ }
+}
+
+static void frame_free(struct frame *f)
+{
+ free(f->cells);
+ free(f->cps);
+ free(f->keys);
+ free(f->sel);
+}
+
+static bool same_view(const struct frame *a, const struct frame *b)
+{
+ const struct term_view *x = &a->view, *y = &b->view;
+ return x->size.x == y->size.x && x->size.y == y->size.y &&
+ x->cursor.pos.x == y->cursor.pos.x && x->cursor.pos.y == y->cursor.pos.y &&
+ x->cursor.style == y->cursor.style && x->cursor.visible == y->cursor.visible &&
+ x->cursor.blink == y->cursor.blink &&
+ x->scroll_offset == y->scroll_offset && x->scrolled == y->scrolled &&
+ x->reverse_video == y->reverse_video && x->version == y->version &&
+ x->palette_version == y->palette_version &&
+ memcmp(a->palette, b->palette, sizeof(a->palette)) == 0;
+}
+
+/* The same state as before a call: view, cells, keys, selection, and the
+ * arena records behind the cluster cells. */
+static bool frame_same(const struct frame *a, const struct frame *b)
+{
+ if (!same_view(a, b))
+ return false;
+ size_t n = (size_t)a->cols * (size_t)a->rows;
+ return memcmp(a->cells, b->cells, n * sizeof(*a->cells)) == 0 &&
+ memcmp(a->cps, b->cps, n * CPS_SLOT * sizeof(*a->cps)) == 0 &&
+ memcmp(a->keys, b->keys, (size_t)a->rows * sizeof(*a->keys)) == 0 &&
+ memcmp(a->sel, b->sel, (size_t)a->rows * sizeof(*a->sel)) == 0;
+}
+
+/* What a renderer paints. Arena offsets, keys, and version numbers may differ
+ * between an incremental snapshot and a fresh one. */
+static bool frame_shows_same(const struct frame *a, const struct frame *b)
+{
+ const struct term_view *x = &a->view, *y = &b->view;
+ if (a->cols != b->cols || a->rows != b->rows || x->cursor.pos.x != y->cursor.pos.x ||
+ x->cursor.pos.y != y->cursor.pos.y || x->cursor.visible != y->cursor.visible ||
+ x->scroll_offset != y->scroll_offset || x->reverse_video != y->reverse_video ||
+ memcmp(a->palette, b->palette, sizeof(a->palette)) != 0)
+ return false;
+ for (int r = 0; r < a->rows; r++) {
+ if (memcmp(&a->sel[r], &b->sel[r], sizeof(a->sel[r])) != 0)
+ return false;
+ for (int c = 0; c < a->cols; c++) {
+ size_t i = (size_t)r * a->cols + c;
+ const struct term_cell *p = &a->cells[i], *q = &b->cells[i];
+ const uint32_t *cp = a->cps + i * CPS_SLOT, *cq = b->cps + i * CPS_SLOT;
+ if (p->fg != q->fg || p->bg != q->bg || p->attrs != q->attrs || p->width != q->width)
+ return false;
+ if (p->text & TERM_TEXT_CLUSTER) {
+ if (cp[0] != cq[0] || memcmp(cp + 1, cq + 1, cp[0] * sizeof(*cp)) != 0)
+ return false;
+ } else if (p->text != q->text) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+/* One update with the k-th allocation failing, from the state the caller
+ * built. A failed update must leave s as it was, and the retry must succeed.
+ * Returns true when an allocation was failed. */
+static bool update_failing_at(struct term_snapshot *s, struct term *t, int k)
+{
+ struct frame before, after;
+ frame_capture(s, &before);
+ alloc_was_failed = false;
+ alloc_countdown = k;
+ bool ok = term_snapshot_update(s, t);
+ alloc_countdown = -1;
+ bool injected = alloc_was_failed;
+ if (!ok) {
+ assert(injected);
+ frame_capture(s, &after);
+ assert(frame_same(&before, &after));
+ frame_free(&after);
+ assert(term_snapshot_update(s, t));
+ }
+ frame_free(&before);
+ return injected;
+}
+
+/* Run attempt(k) for k = 0, 1, ... until one injects no failure. Each
+ * attempt builds its own state, so every site fails from the same start.
+ * Returns the number of failed updates. */
+static int sweep(bool (*attempt)(int k))
+{
+ int k = 0;
+ while (attempt(k)) {
+ k++;
+ assert(k < 1000);
+ }
+ return k;
+}
+
+/* Update s from t with the k-th allocation failing, for k = 0, 1, ... until
+ * an update succeeds. Every failed attempt must leave s as it was. Progress
+ * from earlier attempts stays, so this covers the later steps of a long run
+ * rather than each site from one start. Returns the number of failures. */
+static int update_each_failure(struct term_snapshot *s, struct term *t)
+{
+ struct frame before, after;
+ frame_capture(s, &before);
+ int failures = 0;
+ bool ok = false;
+ for (int k = 0; !ok; k++) {
+ assert(k < 1000);
+ alloc_was_failed = false;
+ alloc_countdown = k;
+ ok = term_snapshot_update(s, t);
+ alloc_countdown = -1;
+ if (ok)
+ break;
+ assert(alloc_was_failed);
+ failures++;
+ frame_capture(s, &after);
+ assert(frame_same(&before, &after));
+ frame_free(&after);
+ }
+ frame_free(&before);
+ return failures;
+}
+
+/* A renderer sees the same picture as a fresh capture of t. */
+static void expect_matches_fresh(struct term *t, const struct term_snapshot *s)
+{
+ struct term_snapshot *fresh = term_snapshot_new();
+ assert(fresh && term_snapshot_update(fresh, t));
+ struct frame a, b;
+ frame_capture(s, &a);
+ frame_capture(fresh, &b);
+ assert(frame_shows_same(&a, &b));
+ frame_free(&a);
+ frame_free(&b);
+ term_snapshot_free(fresh);
+}
+
+/* A US flag and a ZWJ family: two regional indicators, and five code points. */
+static const char flag_us[] = "\xf0\x9f\x87\xba\xf0\x9f\x87\xb8";
+static const char family[] = "\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7";
+
+static uint32_t cluster_cps(const struct term_snapshot *s, const struct term_cell *cell,
+ uint32_t *cps, int cap)
+{
+ return (uint32_t)term_snapshot_cluster(s, cell->text, cps, cap);
+}
+
+/* A new terminal and an empty snapshot. The first update grows every buffer
+ * and the arena, so each allocation site in the update is hit. */
+static bool first_update_at(int k)
{
struct term_options options = { .size = { 8, 3 }, .scrollback = 5 };
- struct term *term = term_new(&options);
- struct term_snapshot *snapshot = term_snapshot_new();
- assert(term && snapshot && term_snapshot_update(snapshot, term));
-
- const char accented[] = "e\xcc\x81";
- term_feed(term, accented, sizeof(accented) - 1, 0);
- fail_next_realloc = true;
- assert(!term_snapshot_update(snapshot, term));
- assert(!fail_next_realloc);
- assert(term_snapshot_view(snapshot)->size.x == 0);
- assert(term_snapshot_view(snapshot)->size.y == 0);
- assert(term_snapshot_row(snapshot, 0) == NULL);
-
- assert(term_snapshot_update(snapshot, term));
- const struct term_cell *row = term_snapshot_row(snapshot, 0);
- assert(row);
+ struct term *t = term_new(&options);
+ struct term_snapshot *s = term_snapshot_new();
+ assert(t && s);
+
+ const char accented[] = "e\xcc\x81 ";
+ term_feed(t, accented, sizeof(accented) - 1, 0);
+ term_feed(t, flag_us, sizeof(flag_us) - 1, 0);
+
+ bool injected = update_failing_at(s, t, k);
+
+ const struct term_cell *row = term_snapshot_row(s, 0);
+ assert(row && (row[0].text & TERM_TEXT_CLUSTER));
+ uint32_t cps[8];
+ assert(cluster_cps(s, &row[0], cps, 8) == 2 && cps[0] == 'e' && cps[1] == 0x301);
+ assert(cluster_cps(s, &row[2], cps, 8) == 2 && cps[0] == 0x1F1FA && cps[1] == 0x1F1F8);
+ expect_matches_fresh(t, s);
+
+ term_snapshot_free(s);
+ term_free(t);
+ return injected;
+}
+
+/* A 32 by 16 screen full of clusters. The arena needs more than its first
+ * 256 words, so the arena growth is one of the failing sites. */
+static bool full_cluster_screen_at(int k)
+{
+ struct term_options options = { .size = { 32, 16 }, .scrollback = 5 };
+ struct term *t = term_new(&options);
+ struct term_snapshot *s = term_snapshot_new();
+ assert(t && s);
+
+ for (int y = 0; y < 16; y++) {
+ for (int i = 0; i < 16; i++) {
+ if (y % 2)
+ term_feed(t, flag_us, sizeof(flag_us) - 1, 0);
+ else
+ term_feed(t, family, sizeof(family) - 1, 0);
+ }
+ if (y < 15)
+ term_feed(t, "\r\n", 2, 0);
+ }
+
+ bool injected = update_failing_at(s, t, k);
+
+ uint32_t cps[8];
+ const struct term_cell *row = term_snapshot_row(s, 0);
+ assert(cluster_cps(s, &row[0], cps, 8) == 5 && cps[0] == 0x1F468 && cps[1] == 0x200D);
+ row = term_snapshot_row(s, 1);
+ assert(cluster_cps(s, &row[0], cps, 8) == 2 && cps[0] == 0x1F1FA);
+ expect_matches_fresh(t, s);
+
+ term_snapshot_free(s);
+ term_free(t);
+ return injected;
+}
+
+/* The screen grows, so cells, keys, row clusters, and selection all reserve. */
+static bool resize_at(int k)
+{
+ struct term_options options = { .size = { 8, 3 }, .scrollback = 5 };
+ struct term *t = term_new(&options);
+ struct term_snapshot *s = term_snapshot_new();
+ assert(t && s);
+ term_feed(t, "old", 3, 0);
+ assert(term_snapshot_update(s, t));
+
+ assert(term_resize(t, (struct term_vector2i){ 32, 16 }));
+ term_feed(t, "\x1b[H\x1b[2J", 7, 0);
+ term_feed(t, family, sizeof(family) - 1, 0);
+
+ bool injected = update_failing_at(s, t, k);
+
+ assert(term_snapshot_view(s)->size.x == 32 && term_snapshot_view(s)->size.y == 16);
+ uint32_t cps[8];
+ const struct term_cell *row = term_snapshot_row(s, 0);
+ assert(cluster_cps(s, &row[0], cps, 8) == 5 && cps[0] == 0x1F468);
+ expect_matches_fresh(t, s);
+
+ term_snapshot_free(s);
+ term_free(t);
+ return injected;
+}
+
+/* More than 256 rows moves the scratch keys and spans off the stack. The
+ * selection is left out: its own row list falls back to 256 rows when its
+ * allocation fails, which is outside the snapshot. */
+static bool tall_screen_at(int k)
+{
+ struct term_options options = { .size = { 8, 300 }, .scrollback = 5 };
+ struct term *t = term_new(&options);
+ struct term_snapshot *s = term_snapshot_new();
+ assert(t && s);
+ for (int y = 0; y < 299; y++)
+ term_feed(t, "\r\n", 2, 0);
+ term_feed(t, "e\xcc\x81yes", 5, 0);
+
+ bool injected = update_failing_at(s, t, k);
+
+ assert(term_snapshot_view(s)->size.y == 300);
+ const struct term_cell *row = term_snapshot_row(s, 299);
uint32_t cps[4];
- assert(term_snapshot_cluster(snapshot, row[0].text, cps, 4) == 2);
- assert(cps[0] == 'e' && cps[1] == 0x301);
-
- term_snapshot_free(snapshot);
- term_free(term);
-
- /* More than 256 rows moves both temporary arrays off the stack. */
- options.size.y = 300;
- term = term_new(&options);
- snapshot = term_snapshot_new();
- assert(term && snapshot && term_snapshot_update(snapshot, term));
-
- /* Fail new_keys allocation, then make sure retry fills all rows. */
- malloc_was_failed = false;
- malloc_failure_countdown = 0;
- assert(!term_snapshot_update(snapshot, term));
- assert(malloc_was_failed);
- assert(term_snapshot_view(snapshot)->size.y == 0);
- assert(term_snapshot_row(snapshot, 0) == NULL);
- assert(term_snapshot_update(snapshot, term));
- assert(term_snapshot_view(snapshot)->size.y == 300);
- assert(term_snapshot_row(snapshot, 299));
-
- /* The first malloc is new_keys; fail the following selection-span array. */
- malloc_was_failed = false;
- malloc_failure_countdown = 1;
- assert(!term_snapshot_update(snapshot, term));
- assert(malloc_was_failed);
- assert(term_snapshot_view(snapshot)->size.y == 0);
- assert(term_snapshot_row(snapshot, 0) == NULL);
- assert(term_snapshot_update(snapshot, term));
- assert(term_snapshot_view(snapshot)->size.y == 300);
- assert(term_snapshot_row(snapshot, 299));
-
- term_snapshot_free(snapshot);
- term_free(term);
+ assert(cluster_cps(s, &row[0], cps, 4) == 2 && cps[0] == 'e' && cps[1] == 0x301);
+ expect_matches_fresh(t, s);
+
+ term_snapshot_free(s);
+ term_free(t);
+ return injected;
+}
+
+/* Without failures, the selection of a tall screen reaches the last row. */
+static void tall_selection_is_kept(void)
+{
+ struct term_options options = { .size = { 8, 300 }, .scrollback = 5 };
+ struct term *t = term_new(&options);
+ struct term_snapshot *s = term_snapshot_new();
+ assert(t && s);
+ for (int y = 0; y < 299; y++)
+ term_feed(t, "\r\n", 2, 0);
+ term_feed(t, "e\xcc\x81yes", 5, 0);
+ term_select_start(t, (struct term_vector2i){ .x = 0, .y = 299 }, false, TERM_SELECT_CELL);
+ term_select_extend(t, (struct term_vector2i){ .x = 3, .y = 299 }, true);
+
+ assert(term_snapshot_update(s, t));
+ struct term_span span = term_snapshot_row_selection(s, 299);
+ assert(span.begin == 0 && span.end == 4);
+ expect_matches_fresh(t, s);
+
+ term_snapshot_free(s);
+ term_free(t);
+}
+
+/* Scroll with clusters in most lines. Each step fails every allocation in
+ * turn, then retries, and must match a fresh capture. */
+static void scrolling_fails_cleanly(void)
+{
+ struct term_options options = { .size = { 8, 3 }, .scrollback = 5 };
+ struct term *t = term_new(&options);
+ struct term_snapshot *s = term_snapshot_new();
+ assert(t && s);
+
+ int failures = 0;
+ for (int i = 0; i < 300; i++) {
+ term_feed(t, "e\xcc\x81", 3, 0);
+ if (i % 3 == 0)
+ term_feed(t, flag_us, sizeof(flag_us) - 1, 0);
+ if (i % 5 == 0)
+ term_feed(t, family, sizeof(family) - 1, 0);
+ term_feed(t, "\r\n", 2, 0);
+ if (i == 150)
+ term_select_start(t, (struct term_vector2i){ .x = 0, .y = 1 }, false, TERM_SELECT_CELL);
+ if (i == 151)
+ term_select_extend(t, (struct term_vector2i){ .x = 4, .y = 2 }, true);
+ failures += update_each_failure(s, t);
+ expect_matches_fresh(t, s);
+ }
+ assert(failures > 0);
+ assert(term_history_rows(t) == 5);
+
+ term_snapshot_free(s);
+ term_free(t);
+}
+
+int main(void)
+{
+ /* Four reserve() buffers and the arena. */
+ int failures = sweep(first_update_at);
+ assert(failures >= 5);
+ /* Four reserve() buffers and the arena, with a larger arena. */
+ failures = sweep(full_cluster_screen_at);
+ assert(failures >= 5);
+ /* Four reserve() buffers and the arena, after the screen grows. */
+ failures = sweep(resize_at);
+ assert(failures >= 5);
+ /* Four reserve() buffers, the scratch array, and the arena. */
+ failures = sweep(tall_screen_at);
+ assert(failures >= 6);
+ tall_selection_is_kept();
+ scrolling_fails_cleanly();
return 0;
}