From ca24dc178bfa22c3d79628414c1923df6fb61c94 Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Fri, 16 Oct 2026 14:09:28 +0100 Subject: [PATCH] Add a limit on image count and size. --- image-grid.c | 38 ++++++++++- image.c | 121 +++++++++++++++++++++++++++++++++- regress/image-sixel-limits.sh | 76 +++++++++++++++++++++ tmux-image.h | 2 + 4 files changed, 233 insertions(+), 4 deletions(-) create mode 100755 regress/image-sixel-limits.sh diff --git a/image-grid.c b/image-grid.c index 3ed6ff008..558c34454 100644 --- a/image-grid.c +++ b/image-grid.c @@ -59,8 +59,13 @@ TAILQ_HEAD(image_placements, image_placement); /* Placements owned by one grid. */ struct image_store { - struct image_placements placements; + struct image_placements placements; + TAILQ_ENTRY(image_store) entry; }; +TAILQ_HEAD(image_stores, image_store); + +/* All current placements. */ +static struct image_stores image_stores = TAILQ_HEAD_INITIALIZER(image_stores); /* A shared placements copied when duplicating lines. */ struct image_copy_map { @@ -175,6 +180,7 @@ image_draw_span(struct image_backend *backend, struct tty *tty, struct image_rect rect; rect.image = span->placement->image; + image_touch(rect.image); rect.src_x = span->source_x + start - span->x; rect.src_y = span->source_y; rect.sx = end - start; @@ -265,6 +271,7 @@ image_store_get(struct grid *gd) if (gd->images == NULL) { gd->images = xcalloc(1, sizeof *gd->images); TAILQ_INIT(&gd->images->placements); + TAILQ_INSERT_TAIL(&image_stores, gd->images, entry); } return (gd->images); } @@ -293,6 +300,7 @@ image_placement_create(struct grid *gd, struct image *im) TAILQ_INIT(&plmt->spans); TAILQ_INSERT_TAIL(&store->placements, plmt, entry); image_ref(im); + image_touch(im); return (plmt); } @@ -440,6 +448,32 @@ image_grid_remove(struct grid *gd, u_int x, u_int y, u_int sx, u_int sy) image_store_prune(gd->images); } +/* Remove a placement and all of its spans. */ +static void +image_remove_placement(struct image_placement *plmt) +{ + struct image_span *span, *span1; + + TAILQ_FOREACH_SAFE(span, &plmt->spans, placement_entry, span1) + image_span_free(span); + image_placement_free(plmt); +} + +/* Remove every placement for an image. */ +void +image_grid_remove_all(struct image *im) +{ + struct image_store *store; + struct image_placement *plmt, *next; + + TAILQ_FOREACH(store, &image_stores, entry) { + TAILQ_FOREACH_SAFE(plmt, &store->placements, entry, next) { + if (plmt->image == im) + image_remove_placement(plmt); + } + } +} + /* Free all spans on a grid line. */ void image_grid_free_line(struct grid *gd, struct grid_line *gl) @@ -464,6 +498,7 @@ image_grid_free(struct grid *gd) { if (gd->images != NULL) { image_store_prune(gd->images); + TAILQ_REMOVE(&image_stores, gd->images, entry); free(gd->images); gd->images = NULL; } @@ -973,6 +1008,7 @@ image_grid_get_at(struct grid *gd, u_int x, u_int y, struct image **im, if (found == NULL) return (-1); *im = found->placement->image; + image_touch(*im); *src_x = found->source_x + x - found->x; *src_y = found->source_y; return (1); diff --git a/image.c b/image.c index fdf64f533..19a312713 100644 --- a/image.c +++ b/image.c @@ -26,13 +26,19 @@ #include "tmux.h" +/* Maximum number of images and total bytes. */ +#define IMAGE_MAX_IMAGES 1000 +#define IMAGE_MAX_BYTES (1024 * 1024 * 1024) + /* An image. */ +TAILQ_HEAD(image_list, image); struct image { u_int id; u_int references; struct image_geom geom; struct image *parent; + struct image_list children; u_int order_id; /* @@ -46,10 +52,19 @@ struct image { u_char *pixels; RB_ENTRY(image) entry; + TAILQ_ENTRY(image) all_entry; + TAILQ_ENTRY(image) child_entry; }; RB_HEAD(images, image); static struct images images = RB_INITIALIZER(&images); +/* All images, most recently used first. */ +static struct image_list all_images = TAILQ_HEAD_INITIALIZER(all_images); + +/* Number of images and size. */ +static u_int image_count; +static size_t image_size; + /* Next image ID. */ static u_int image_next_id; @@ -65,6 +80,18 @@ image_cmp(struct image *a, struct image *b) } RB_GENERATE_STATIC(images, image, entry, image_cmp); +/* Record use of an image and its parents. */ +void +image_touch(struct image *im) +{ + for (; im != NULL; im = im->parent) { + if (TAILQ_FIRST(&all_images) != im) { + TAILQ_REMOVE(&all_images, im, all_entry); + TAILQ_INSERT_HEAD(&all_images, im, all_entry); + } + } +} + /* Find an image by its ID. */ struct image * image_find(u_int id) @@ -82,6 +109,76 @@ image_get_id(struct image *im) return (im->id); } +/* Remove an image and children. */ +static void +image_remove_all(struct image *im) +{ + struct image *child; + + image_ref(im); + while ((child = TAILQ_FIRST(&im->children)) != NULL) + image_remove_all(child); + image_grid_remove_all(im); + image_free(im); +} + +/* Find the oldest image. */ +static struct image * +image_find_oldest(struct image *keep, size_t size) +{ + struct image *im, *parent; + + TAILQ_FOREACH_REVERSE(im, &all_images, image_list, all_entry) { + if (image_size + size > IMAGE_MAX_BYTES && im->parent != NULL) + continue; + for (parent = keep; parent != NULL; parent = parent->parent) { + if (parent == im) + break; + } + if (parent == NULL) + return (im); + } + return (NULL); +} + +/* Is there enough space for this image? */ +static int +image_can_add(size_t size) +{ + if (image_count >= IMAGE_MAX_IMAGES) + return (0); + if (image_size + size > IMAGE_MAX_BYTES) + return (0); + return (1); +} + +/* Check limits and remove images if needed. */ +static int +image_check_limits(struct image *keep, size_t size) +{ + struct image *oldest; + struct client *c; + + if (size > IMAGE_MAX_BYTES) + return (-1); + if (image_can_add(size)) + return (0); + + TAILQ_FOREACH(c, &clients, entry) + image_tty_free(&c->tty); + while (!image_can_add(size)) { + oldest = image_find_oldest(keep, size); + if (oldest == NULL) + break; + image_remove_all(oldest); + } + TAILQ_FOREACH(c, &clients, entry) + server_redraw_client(c); + if (!image_can_add(size)) + return (-1); + return (0); +} + /* Allocate an image and assign a unique ID. */ static struct image * image_alloc(struct image_geom *ig, size_t stride, u_char *pixels) @@ -103,7 +200,9 @@ image_alloc(struct image_geom *ig, size_t stride, u_char *pixels) im->stride = stride; im->size = (ig->src_h - 1) * stride + ig->src_w * 4; im->pixels = pixels; + TAILQ_INIT(&im->children); RB_INSERT(images, &images, im); + image_count++; return (im); } @@ -122,6 +221,7 @@ image_create_with_parent(struct image *parent, struct image_geom *ig, size_t stride, u_char *pixels) { struct image *im; + size_t size = 0; if (ig->src_w == 0 || ig->src_h == 0) return (NULL); @@ -138,12 +238,22 @@ image_create_with_parent(struct image *parent, struct image_geom *ig, if (ig->scaled_width <= 0 || ig->scaled_height <= 0) return (NULL); + if (parent == NULL) + size = (ig->src_h - 1) * stride + ig->src_w * 4; + if (image_check_limits(parent, size) != 0) + return (NULL); + im = image_alloc(ig, stride, pixels); im->parent = parent; - if (parent != NULL) { + if (parent == NULL) + image_size += im->size; + else { im->order_id = parent->order_id; + TAILQ_INSERT_TAIL(&parent->children, im, child_entry); image_ref(parent); } + TAILQ_INSERT_HEAD(&all_images, im, all_entry); + image_touch(im); return (im); } @@ -164,10 +274,15 @@ image_free(struct image *im) fatalx("invalid image reference %u", im->id); if (--im->references == 0) { RB_REMOVE(images, &images, im); - if (im->parent == NULL) + TAILQ_REMOVE(&all_images, im, all_entry); + image_count--; + if (im->parent == NULL) { + image_size -= im->size; free(im->pixels); - else + } else { + TAILQ_REMOVE(&im->parent->children, im, child_entry); image_free(im->parent); + } free(im); } } diff --git a/regress/image-sixel-limits.sh b/regress/image-sixel-limits.sh new file mode 100755 index 000000000..30dcfc420 --- /dev/null +++ b/regress/image-sixel-limits.sh @@ -0,0 +1,76 @@ +#!/bin/sh + +# Check image quotas, last use order, copied grids and released image slots. + +PATH=/bin:/usr/bin +TERM=screen +export TERM +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +command -v python3 >/dev/null || exit 101 + +python3 -B - "$TEST_TMUX" <<'PY' +import sys +from image_test import image_test, sixel +from image_terminal import Terminal + + +def image(colour): + """Build one solid image cell which advances the cursor by one row.""" + return sixel(8, 16, [colour], [0] * (8 * 16)) + + +def snapshot(test, client, rows): + """Check the image cells in the first column after a full redraw.""" + test.redraw() + terminal = Terminal() + terminal.feed(client.data) + expected = {(x, y * 16 + py): colour for y, colour in rows.items() + for py in range(16) for x in range(8)} + terminal.check(expected) + + +red, green, blue = (255, 0, 0), (0, 255, 0), (0, 0, 255) +red_image = image((100, 0, 0)) +green_image = image((0, 100, 0)) +blue_image = image((0, 0, 100)) + +with image_test(sys.argv[1]) as test: + client = test.attach() + test.emit(red_image + green_image * 999) + test.emit(blue_image) + snapshot(test, client, dict.fromkeys(range(14), green) | {14: blue}) + # Images in scrollback count towards the quota; the oldest one is gone. + test.run('copy-mode') + test.run('send-keys', '-X', 'history-top') + snapshot(test, client, dict.fromkeys(range(1, 16), green)) + +with image_test(sys.argv[1]) as test: + client = test.attach() + test.emit(red_image + green_image * 999) + test.run('copy-mode') + test.run('send-keys', '-X', 'history-top') + snapshot(test, client, {0: red} | dict.fromkeys(range(1, 16), green)) + # The copied grid keeps these old images in use while a new window fills. + test.run('new-window', '-n', 'quota', '/bin/sh', '-c', + "stty raw -echo; printf '\033]2;QUOTA\007'; exec cat") + test.wait(lambda: test.run('display-message', '-p', + '#{pane_title}') == 'QUOTA') + test.emit(blue_image) + test.run('select-window', '-t', 'image:0') + snapshot(test, client, {0: red} | dict.fromkeys(range(1, 16), green)) + # The first image below the redrawn rows was evicted from both grids. + test.run('send-keys', '-X', 'scroll-down') + snapshot(test, client, dict.fromkeys(range(15), green)) + test.run('send-keys', '-X', 'cancel') + test.run('copy-mode') + test.run('send-keys', '-X', 'history-top') + test.run('send-keys', '-X', 'scroll-down') + snapshot(test, client, dict.fromkeys(range(15), green)) + +with image_test(sys.argv[1]) as test: + client = test.attach() + # Replacing an image releases its slot rather than exhausting the quota. + test.emit((b'\033[H' + green_image) * 1001) + test.emit(b'\033[3;1H' + blue_image) + snapshot(test, client, {0: green, 2: blue}) +PY diff --git a/tmux-image.h b/tmux-image.h index 2aa27d7cd..60d62069b 100644 --- a/tmux-image.h +++ b/tmux-image.h @@ -102,6 +102,7 @@ struct image *image_find(u_int); u_int image_get_id(struct image *); void image_ref(struct image *); void image_free(struct image *); +void image_touch(struct image *); struct image_geom *image_get_geometry(struct image *); u_char *image_get_pixels(struct image *, size_t *, size_t *); int image_get_bounds(struct image_geom *, u_int, u_int, u_int, @@ -118,6 +119,7 @@ void image_grid_add(struct grid *, struct image *, u_int, u_int, void image_grid_remove(struct grid *, u_int, u_int, u_int, u_int); void image_grid_free_line(struct grid *, struct grid_line *); void image_grid_free(struct grid *); +void image_grid_remove_all(struct image *); void image_grid_move_cells(struct grid *, u_int, u_int, u_int, u_int); void image_grid_duplicate_lines(struct grid *, u_int, struct grid *,