aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-29 21:31:41 +0200
committeruser <user@clank>2026-08-29 21:31:41 +0200
commit8a8b5e1c7acd269d4a639a40712412a84480ad18 (patch)
treedd73377a2b0a8b424dc8f151ced00f702812edca
parentdocs: less wordy (diff)
downloadslosh-8a8b5e1c7acd269d4a639a40712412a84480ad18.tar.gz
slosh-8a8b5e1c7acd269d4a639a40712412a84480ad18.tar.xz
slosh-8a8b5e1c7acd269d4a639a40712412a84480ad18.zip
graphics: a frame the client never got must be said again
A pane image is re-emitted to the client as decoded RGBA, which for one screenshot is ~4MB of base64 in a single indivisible message -- and conn_send silently dropped anything that would overflow the 4MB outbox, while push_graphics ignored the refusal and the model advanced anyway. Everything stateful in that dropped frame was lost for good: the image was marked sent (so its placements drew nothing, forever), and the deletions rode out with it (so a placement the client had was never revoked). Reported as screenshots in a recovered pi transcript parked at a fixed spot on the screen, immune to scrolling and pane swaps -- those were placements whose deletion left with a dropped frame. The 1000x800 one could never be delivered at all: its transmission alone was bigger than the cap. So the frame is now transactional. Deletions queue and are repeated every flush until gfx_commit() confirms the client heard them; transmissions recorded in a frame are unwound by an undelivered commit so they are sent again. A flush nobody commits counts as undelivered at the next gfx_begin(), which is what the `graphics` control command is: bytes rendered for a CLI, not the client. The server reports the verdict and keeps the paint pending until it lands. The outbox cap now bounds the backlog rather than backlog-plus-message, so one big transmission on a drained queue always fits -- and that surfaced a second way to lose: a queued image legitimately holds the backlog over the cap until the client swallows it, and push_frame read that as a dead client and closed the connection. The session auto-detached every time a screenshot crossed the wire. A full outbox is a client that is *behind*, not gone: push_frame now marks the screen for a full repaint (the dropped diff means the client's cells are unknown) and retries as POLLOUT drains the queue; a client that truly left still shows up as a write error there. The headless suite pins the owed-until-committed contract; the new tests/live_graphics.py drives the real socket with a bigger-than-the-cap image and a reader that stalls, and would have caught both bugs: the flood must not detach, the transmission must arrive whole, and a placement scrolled away must be deleted -- then come back on scroll without a second copy of the pixels.
-rw-r--r--Makefile4
-rw-r--r--docs/scripting.md2
-rw-r--r--include/app.h3
-rw-r--r--include/graphics.h10
-rw-r--r--src/app_draw.c4
-rw-r--r--src/graphics.c70
-rw-r--r--src/server.c76
-rw-r--r--tests/live_graphics.py169
-rw-r--r--tests/test_graphics.py45
9 files changed, 357 insertions, 26 deletions
diff --git a/Makefile b/Makefile
index 958cf11..f8b638b 100644
--- a/Makefile
+++ b/Makefile
@@ -212,6 +212,8 @@ test-live: $(BIN) ## the checks that need a real tty (slow)
python3 tests/test_session.py
@echo
python3 tests/live_reload.py
+ @echo
+ python3 tests/live_graphics.py
test-all: test test-live ## everything
@@ -279,7 +281,7 @@ coverage: $(COV_DIR)/slosh $(COV_UNITS) ## how much of the code the tests run
(cd tests && SLOSH_BIN=../$(COV_DIR)/slosh timeout 300 python3 $$t >/dev/null 2>&1) \
|| echo " (failed: $$t)"; \
done
- @for t in test_session.py live_m0.py live_input.py live_reload.py; do \
+ @for t in test_session.py live_m0.py live_input.py live_reload.py live_graphics.py; do \
(cd tests && SLOSH_BIN=../$(COV_DIR)/slosh timeout 300 python3 $$t >/dev/null 2>&1) \
|| echo " (failed: $$t)"; \
done
diff --git a/docs/scripting.md b/docs/scripting.md
index 54c41ce..5ae448f 100644
--- a/docs/scripting.md
+++ b/docs/scripting.md
@@ -42,7 +42,7 @@ Panes and tabs are addressed by **id**, so a background tab is scriptable.
| `close-workspace` | `name` or `purpose`; answers `closed`, how many tabs went |
| `save-workspace` | write this tab as the project's layout: `tab` (0 for the current one), `path` for a tab that is not a workspace yet, `suspend`, and `force` to overwrite a layout the project already has; answers `path` `purpose` `panes` `suspended` `replaced` |
| `notify` | put a line in the session's status area |
-| `graphics` | the kitty placements on screen, or the bytes sent for them |
+| `graphics` | the kitty placements on screen, or the bytes sent for them. The bytes are rendered for *you*, not the client, so anything stateful in them (image transmissions, deletions) is re-sent to the client on its next frame |
| `clipboard` | what the session has copied |
| `reload` | re-read the config; answers `{"ok":true,"warning":...}` if it had a complaint |
| `splash` | replay the attach greeting; `fx` and `motion` pick the colour effect and the assembly by index, for a deterministic one |
diff --git a/include/app.h b/include/app.h
index 3333d6d..a203858 100644
--- a/include/app.h
+++ b/include/app.h
@@ -23,6 +23,9 @@ void app_event(app_t *a, const input_event_t *ev);
* the same thing as JSON for tests. Bytes are borrowed until the next call. */
const char *app_graphics(app_t *a, size_t *len);
void app_graphics_reset(app_t *a);
+/* Did the last app_graphics() stream reach the client? Undelivered frames
+ * are regenerated: transmissions repeat and deletions stay owed. */
+void app_graphics_commit(app_t *a, bool delivered);
char *app_graphics_json(app_t *a);
/* Transient announcements, drawn bottom-right and expiring on their own. */
diff --git a/include/graphics.h b/include/graphics.h
index b3cd6ec..6a338df 100644
--- a/include/graphics.h
+++ b/include/graphics.h
@@ -74,8 +74,16 @@ typedef struct {
/* Record a visible placement, transmitting the image if the client has not
* seen it yet. `data` may be NULL when the image is already transmitted. */
void gfx_place(graphics_t *g, const gfx_req_t *req);
-/* End a frame: emit transmits, placements and deletions. Caller frees. */
+/* End a frame: emit transmits, placements and deletions. Borrowed until the
+ * next gfx_begin(). The frame is provisional until gfx_commit() says whether
+ * the bytes reached the client. */
char *gfx_flush(graphics_t *g, size_t *out_len);
+/* The verdict on the last flushed frame. Delivered: the client is caught up.
+ * Not delivered: transmissions are forgotten so they are re-sent, and
+ * deletions stay owed -- a deletion the client never hears is an image
+ * parked on its screen that no scrolling will ever move. A flush nobody
+ * commits counts as undelivered at the next gfx_begin(). */
+void gfx_commit(graphics_t *g, bool delivered);
/* Forget what the client has seen (it is a different client now). */
void gfx_reset(graphics_t *g);
diff --git a/src/app_draw.c b/src/app_draw.c
index 1a46a46..85f20fd 100644
--- a/src/app_draw.c
+++ b/src/app_draw.c
@@ -2435,6 +2435,10 @@ const char *app_graphics(app_t *a, size_t *len) {
void app_graphics_reset(app_t *a) { gfx_reset(a->gfx); }
+void app_graphics_commit(app_t *a, bool delivered) {
+ gfx_commit(a->gfx, delivered);
+}
+
char *app_graphics_json(app_t *a) {
size_t len = 0;
app_graphics(a, &len); /* refresh the model; the bytes are the caller's job */
diff --git a/src/graphics.c b/src/graphics.c
index 26391fd..ac8c244 100644
--- a/src/graphics.c
+++ b/src/graphics.c
@@ -11,6 +11,16 @@
#define OUT_ID_BASE 0x51000000u
#define CHUNK 3072 /* bytes of base64 per escape, kitty's documented ceiling */
+/* A placement the client was shown and must now be told to forget. Kept in
+ * its own queue rather than deleted inline because the bytes of a frame can
+ * fail to reach the client (the server's outbox has a ceiling): a deletion
+ * that goes out with a dropped frame is a placement the client draws forever,
+ * pinned to a spot on the screen no scrolling will move. The queue survives
+ * until gfx_commit() says the client actually heard it. */
+typedef struct {
+ uint32_t out_id, place_id;
+} gfx_dead_t;
+
struct graphics {
gfx_image_t *imgs;
size_t nimgs, imgcap;
@@ -18,6 +28,11 @@ struct graphics {
size_t nplaces, placecap;
uint32_t next_out_id;
+ gfx_dead_t *dead; /* deletions owed to the client */
+ size_t ndead, deadcap;
+ size_t *txed; /* imgs[] indices transmitted in the frame being built */
+ size_t ntxed, txedcap;
+
char *out;
size_t len, cap;
};
@@ -73,6 +88,8 @@ void gfx_free(graphics_t *g) {
if (!g) return;
free(g->imgs);
free(g->places);
+ free(g->dead);
+ free(g->txed);
free(g->out);
free(g);
}
@@ -80,6 +97,9 @@ void gfx_free(graphics_t *g) {
void gfx_reset(graphics_t *g) {
for (size_t i = 0; i < g->nimgs; i++) g->imgs[i].sent = false;
g->nplaces = 0;
+ /* A fresh client has no placements to delete and has seen no frame. */
+ g->ndead = 0;
+ g->ntxed = 0;
}
void gfx_forget_pane(graphics_t *g, uint32_t pane) {
@@ -90,6 +110,10 @@ void gfx_forget_pane(graphics_t *g, uint32_t pane) {
}
void gfx_begin(graphics_t *g) {
+ /* A flushed frame nobody committed was never delivered: the `graphics`
+ * control command renders the stream for a CLI, not for the client, and
+ * anything it claimed to transmit the client still has not seen. */
+ if (g->ntxed) gfx_commit(g, false);
for (size_t i = 0; i < g->nplaces; i++) g->places[i].live = false;
g->len = 0;
if (g->out) g->out[0] = 0;
@@ -145,6 +169,15 @@ static void transmit(graphics_t *g, gfx_image_t *img, uint32_t px_w,
out_str(g, "\x1b\\");
}
img->sent = true;
+ /* Provisionally: gfx_commit() unwinds this if the frame never arrives,
+ * because `sent` on an image the client does not have means every future
+ * placement of it silently draws nothing. Indices, not pointers -- the
+ * array reallocates as the same frame records more images. */
+ if (g->ntxed == g->txedcap) {
+ g->txedcap = g->txedcap ? g->txedcap * 2 : 8;
+ g->txed = realloc(g->txed, g->txedcap * sizeof *g->txed);
+ }
+ g->txed[g->ntxed++] = (size_t)(img - g->imgs);
}
void gfx_place(graphics_t *g, const gfx_req_t *req) {
@@ -218,7 +251,10 @@ char *gfx_flush(graphics_t *g, size_t *out_len) {
const size_t before = 2; /* the DECSC gfx_begin() wrote */
/* Placements that were on screen last frame and are not now must be told to
- * go away; a terminal will happily keep drawing an image nobody owns. */
+ * go away; a terminal will happily keep drawing an image nobody owns. They
+ * queue rather than emit-and-forget: the model prunes them here whether or
+ * not the bytes ever reach the client, so the deletion must survive a
+ * dropped frame and be said again until gfx_commit() confirms delivery. */
size_t keep = 0;
for (size_t i = 0; i < g->nplaces; i++) {
gfx_place_t *p = &g->places[i];
@@ -226,10 +262,26 @@ char *gfx_flush(graphics_t *g, size_t *out_len) {
g->places[keep++] = *p;
continue;
}
- out_fmt(g, "\x1b_Ga=d,d=i,q=2,i=%u,p=%u\x1b\\", p->out_id, p->place_id);
+ bool queued = false;
+ for (size_t j = 0; j < g->ndead; j++)
+ if (g->dead[j].out_id == p->out_id && g->dead[j].place_id == p->place_id)
+ queued = true;
+ if (!queued) {
+ if (g->ndead == g->deadcap) {
+ g->deadcap = g->deadcap ? g->deadcap * 2 : 8;
+ g->dead = realloc(g->dead, g->deadcap * sizeof *g->dead);
+ }
+ g->dead[g->ndead++] = (gfx_dead_t){p->out_id, p->place_id};
+ }
}
g->nplaces = keep;
+ /* Deletions before placements: a placement that died undelivered and then
+ * came back is deleted and re-placed in the same stream, which nets out. */
+ for (size_t i = 0; i < g->ndead; i++)
+ out_fmt(g, "\x1b_Ga=d,d=i,q=2,i=%u,p=%u\x1b\\", g->dead[i].out_id,
+ g->dead[i].place_id);
+
/* Then (re)place everything that is. C=1 keeps the cursor where the text
* renderer left it, which matters because we are interleaving with a diff. */
for (size_t i = 0; i < g->nplaces; i++) {
@@ -268,6 +320,20 @@ char *gfx_flush(graphics_t *g, size_t *out_len) {
return g->out; /* borrowed: valid until the next gfx_begin */
}
+void gfx_commit(graphics_t *g, bool delivered) {
+ if (delivered) {
+ g->ndead = 0; /* the client heard the deletions; stop repeating them */
+ } else {
+ /* The transmissions in that frame never happened as far as the client
+ * is concerned; forget we made them so the next frame sends them again.
+ * Deletions stay queued for the same reason. Placements need nothing:
+ * every live one is re-emitted every frame anyway. */
+ for (size_t i = 0; i < g->ntxed; i++)
+ if (g->txed[i] < g->nimgs) g->imgs[g->txed[i]].sent = false;
+ }
+ g->ntxed = 0;
+}
+
size_t gfx_placements(const graphics_t *g, const gfx_place_t **out) {
*out = g->places;
return g->nplaces;
diff --git a/src/server.c b/src/server.c
index c6a7720..96e79cd 100644
--- a/src/server.c
+++ b/src/server.c
@@ -74,9 +74,13 @@ static int64_t now_ms(void) {
*
* So output is queued and flushed when the socket says it can take more. The
* cap is what makes it bounded: past it we are not buffering a slow reader any
- * more, we are storing a screen nobody is looking at, and the connection is
- * dropped. A display that comes back reattaches and gets a full repaint, which
- * is the same thing that happens after any detach. */
+ * more, and new frames are *dropped* rather than queued -- the model remembers
+ * what it owes (force_full for cells, gfx_commit for images) and says it again
+ * once the backlog drains. Dropping used to mean closing the connection, but a
+ * backlog over the cap is not a dead client: one kitty image retransmission is
+ * megabytes in a single indivisible message, so the queue sits over the cap
+ * for exactly as long as a healthy client needs to swallow it -- and closing
+ * there detached the session every time a screenshot crossed the wire. */
#define MAX_OUTBOX (4u * 1024 * 1024)
typedef struct {
@@ -134,8 +138,14 @@ static bool conn_flush(conn_t *c) {
/* Frame a message onto the queue, then try to get rid of it immediately: the
* common case is a socket with room, and queueing is only the fallback. */
static bool conn_send(conn_t *c, uint8_t type, const void *data, size_t len) {
+ /* The cap bounds the *backlog*, not the message: a queue already MAX_OUTBOX
+ * deep is a client that stopped reading, but a single big message on an
+ * empty queue is just a big message. A kitty image retransmission is
+ * megabytes of base64 in one indivisible stream -- capping the message
+ * itself made every screenshot larger than the cap silently undeliverable,
+ * forever. Memory stays bounded at MAX_OUTBOX plus one message. */
+ if (c->out_len - c->out_off > MAX_OUTBOX) return false;
size_t need = c->out_len + MSG_HDR + len;
- if (need - c->out_off > MAX_OUTBOX) return false;
if (c->out_off && need > c->out_cap) { /* reclaim what has already gone */
memmove(c->out, c->out + c->out_off, c->out_len - c->out_off);
c->out_len -= c->out_off;
@@ -210,13 +220,27 @@ static char *b64(const char *in, size_t len, size_t *out_len) {
}
/* Images go out after the cell diff, so a repainted cell cannot land on top
- * of a placement we just made. */
-static void push_graphics(server_t *s) {
+ * of a placement we just made.
+ *
+ * Returns whether the client is caught up. The stream is indivisible -- half
+ * a frame is a dangling APC that eats whatever follows it -- so a full outbox
+ * drops the whole frame, the model is told (deletions stay owed, transmits
+ * are re-sent), and the caller keeps a repaint pending until it lands. Losing
+ * a frame here silently was how a scrolled-away screenshot stayed parked on
+ * the screen: its deletion left with the dropped bytes and was never owed
+ * again. */
+static bool push_graphics(server_t *s) {
conn_t *c = display_conn(s);
- if (!c) return;
+ if (!c) return true;
size_t len = 0;
const char *bytes = app_graphics(s->app, &len);
- if (len) conn_send(c, MSG_OUTPUT, bytes, len);
+ if (!len) {
+ app_graphics_commit(s->app, true);
+ return true;
+ }
+ bool ok = conn_send(c, MSG_OUTPUT, bytes, len);
+ app_graphics_commit(s->app, ok);
+ return ok;
}
/* The clipboard lives on the client's machine, not ours, so a copy travels as
@@ -239,17 +263,22 @@ static void push_clipboard(server_t *s) {
}
/* Send whatever the last compose produced to the display client. */
-static void push_frame(server_t *s) {
+/* Returns whether the client has the frame (or there is none to owe it to).
+ * A refused send is a full outbox, not a dead client: the diff that was not
+ * queued is lost, so the client's cells are unknown from here on and the next
+ * attempt must be a full repaint. The caller keeps the paint pending, and the
+ * outbox drains on POLLOUT between frames; a client that truly went away
+ * shows up as a write error there, not here. */
+static bool push_frame(server_t *s) {
conn_t *c = display_conn(s);
- if (!c) return;
+ if (!c) return true;
screen_render(&s->screen);
- if (!s->screen.out_len) return;
- if (!conn_send(c, MSG_OUTPUT, s->screen.out, s->screen.out_len))
- for (size_t i = 0; i < s->nconns; i++)
- if (&s->conns[i] == c) {
- conn_close(s, i);
- break;
- }
+ if (!s->screen.out_len) return true;
+ if (!conn_send(c, MSG_OUTPUT, s->screen.out, s->screen.out_len)) {
+ s->screen.force_full = true;
+ return false;
+ }
+ return true;
}
static void drop_display(server_t *s, uint8_t reason) {
@@ -743,11 +772,16 @@ int server_run(const char *name, const char *const argv[], uint16_t cols,
if (pending_paint && now_ms() >= next_frame) {
app_compose(s.app, &s.screen);
- push_frame(&s);
- push_graphics(&s);
+ /* Either push can find the outbox full and drop its frame. The model
+ * remembers what the client is owed (a full repaint, the graphics
+ * deletions and transmissions), so the retry that this keeps pending
+ * is what eventually says it -- the outbox drains on POLLOUT between
+ * frames. */
+ bool frame_ok = push_frame(&s);
+ bool gfx_ok = push_graphics(&s);
push_clipboard(&s);
- pending_paint = false;
- next_frame = now_ms();
+ pending_paint = !(frame_ok && gfx_ok);
+ next_frame = now_ms() + (pending_paint ? FRAME_MS : 0);
}
}
diff --git a/tests/live_graphics.py b/tests/live_graphics.py
new file mode 100644
index 0000000..97dbdd4
--- /dev/null
+++ b/tests/live_graphics.py
@@ -0,0 +1,169 @@
+#!/usr/bin/env python3
+"""Big kitty images over the real socket, with a reader that stalls.
+
+The headless suite proves the graphics *model*; this proves the delivery path
+it cannot see: a pane image is re-emitted to the client as decoded RGBA, which
+is megabytes of base64 in one indivisible message. Two bugs lived there, both
+invisible headless:
+
+ - the outbox cap was applied to backlog *plus message*, so a message bigger
+ than the cap was silently undeliverable forever -- and the model advanced
+ anyway, losing the deletions that rode in the dropped frame. The visible
+ symptom was a screenshot parked at a fixed spot on the screen, immune to
+ scrolling, forever.
+ - once the cap bounded only the backlog, a queued image legitimately held
+ the backlog over the cap until it drained -- and push_frame read that as
+ a dead client and closed the connection. The visible symptom was the
+ session auto-detaching whenever an image crossed the wire.
+
+So: a pane draws an image whose transmission exceeds MAX_OUTBOX, while the
+client reads nothing. The session must stay attached, the whole transmission
+must arrive once the reader comes back, and scrolling the image away must
+deliver its deletion.
+"""
+
+import fcntl
+import os
+import pty
+import re
+import select
+import struct
+import sys
+import termios
+import time
+import uuid
+
+BIN = os.environ.get(
+ "SLOSH_BIN", os.path.join(os.path.dirname(__file__), "..", "build", "slosh")
+)
+SESSION = "test-" + uuid.uuid4().hex[:8]
+
+# 1100x1000 RGB is 3.3MB raw, 4.4MB as base64: bigger than the server's 4MB
+# outbox cap, so delivery *requires* the cap to bound backlog, not messages.
+CHILD = r"""
+import base64, sys, time
+w, h = 1100, 1000
+img = base64.b64encode(bytes([200, 60, 60]) * (w * h)).decode()
+sys.stdout.write("\x1b_Ga=T,f=24,q=2,s=%d,v=%d,i=7,c=40,r=10,m=1;\x1b\\" % (w, h))
+for off in range(0, len(img), 4096):
+ last = off + 4096 >= len(img)
+ sys.stdout.write("\x1b_Gm=%d;%s\x1b\\" % (0 if last else 1, img[off:off+4096]))
+sys.stdout.write("\r\nIMG-SENT\r\n")
+sys.stdout.flush()
+time.sleep(2) # visible for a few frames, like a screenshot you read past
+for i in range(200):
+ sys.stdout.write("line %d\r\n" % i) # ...and then scrolled away
+sys.stdout.flush()
+time.sleep(30)
+"""
+
+
+def spawn(cols=80, rows=24):
+ pid, fd = pty.fork()
+ if pid == 0:
+ os.environ["TERM"] = "xterm-ghostty"
+ os.environ["SLOSH_CONFIG"] = "/nonexistent/slosh.kdl"
+ os.execv(BIN, [BIN, "-s", SESSION, "--", "python3", "-c", CHILD])
+ os._exit(127)
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
+ return pid, fd
+
+
+def drain(fd, idle=0.4, limit=8.0):
+ buf, last, start = b"", time.time(), time.time()
+ while time.time() - start < limit:
+ r, _, _ = select.select([fd], [], [], 0.05)
+ if r:
+ try:
+ chunk = os.read(fd, 65536)
+ except OSError:
+ break
+ if not chunk:
+ break
+ buf += chunk
+ last = time.time()
+ elif time.time() - last > idle:
+ break
+ return buf
+
+
+def check(name, cond, detail=""):
+ print(f"{'ok ' if cond else 'FAIL'} {name}{'' if cond else ' <- ' + detail}")
+ return cond
+
+
+def gfx_commands(buf):
+ """(controls, payload_len) for every _G escape in the stream."""
+ out = []
+ for m in re.finditer(rb"\x1b_G([^\x1b]*)\x1b", buf):
+ ctl, _, payload = m.group(1).partition(b";")
+ out.append((ctl, len(payload)))
+ return out
+
+
+def main():
+ ok = True
+ pid, fd = spawn()
+
+ # Say nothing, hear nothing: the child pours the image into its pty while
+ # the client's own output backs up unread, which is what a slow terminal
+ # over ssh looks like from the server's side.
+ time.sleep(4.0)
+ buf = drain(fd, idle=0.6, limit=30.0)
+
+ ok &= check(
+ "the display stayed attached through the flood",
+ os.waitpid(pid, os.WNOHANG) == (0, 0),
+ repr(buf[-120:]),
+ )
+
+ sent = 0
+ for ctl, n in gfx_commands(buf):
+ if b"a=t" in ctl or (b"a=" not in ctl and b"m=" in ctl):
+ sent += n
+ ok &= check(
+ "a transmission bigger than the outbox cap arrives whole",
+ sent >= (1100 * 1000 * 3 + 2) // 3 * 4,
+ f"{sent} bytes of base64 reached the client",
+ )
+ cmds = gfx_commands(buf)
+ placed = any(ctl.startswith(b"a=p") for ctl, _ in cmds)
+ ok &= check("and the image is placed while visible", placed, repr(buf[-300:]))
+
+ # The child's flood scrolled it away while nothing was being read: the
+ # deletion rode a frame the full outbox may well have dropped, and it must
+ # be said again until it lands -- or the client draws the placement at a
+ # fixed spot forever, which is exactly how this was reported.
+ deleted = any(ctl.startswith(b"a=d,d=i") for ctl, _ in cmds)
+ ok &= check("scrolled away, the placement is deleted", deleted, repr(buf[-300:]))
+
+ # Scrolling back up must re-place it, and from the transmission the client
+ # already has: no second copy of the pixels.
+ buf = b""
+ for _ in range(20):
+ os.write(fd, b"\x01\x1b[5~") # C-a PageUp
+ buf += drain(fd, idle=0.1, limit=3.0)
+ buf += drain(fd, idle=0.4, limit=8.0)
+ back = [ctl for ctl, _ in gfx_commands(buf)]
+ ok &= check(
+ "scrolled back, it is placed again",
+ any(c.startswith(b"a=p") for c in back),
+ repr(buf[-300:]),
+ )
+ ok &= check(
+ "without retransmitting the pixels",
+ not any(c.startswith(b"a=t") for c in back),
+ str(back[:6]),
+ )
+
+ os.write(fd, b"\x01q")
+ drain(fd, idle=0.2, limit=2.0)
+ try:
+ os.kill(pid, 9)
+ except ProcessLookupError:
+ pass
+ return 0 if ok else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/test_graphics.py b/tests/test_graphics.py
index 245a0bd..f8d7a0d 100644
--- a/tests/test_graphics.py
+++ b/tests/test_graphics.py
@@ -532,6 +532,49 @@ def test_a_png_that_is_not_one_places_nothing():
check("and the session survived it", s.pane() is not None)
+def test_an_undelivered_frame_stays_owed():
+ """A frame's graphics bytes can fail to reach the client -- the server's
+ outbox has a ceiling, and one screenshot retransmission is megabytes of
+ base64. The model used to advance anyway: the image was marked sent (so it
+ was never transmitted again) and the deletions went out with the dropped
+ bytes (so they were never said again). The visible half of that bug was a
+ screenshot scrolled out of a pi transcript staying parked on the screen,
+ at a fixed position, immune to scrolling and pane swaps, forever.
+
+ A flush nobody commits counts as undelivered, which is also what the
+ `graphics` control command is: bytes rendered for a CLI, not the client.
+ So the contract is observable right here: everything stateful about a
+ frame -- transmissions and deletions -- must show up again in the next
+ one, until a delivery is confirmed."""
+ prog = (
+ "stty raw -echo; "
+ f'printf "\\033_Ga=T,f=24,s=4,v=2,i=7,q=2,c=6,r=2;{PX}\\033\\\\"; '
+ "head -c1 >/dev/null; seq 1 200; cat"
+ )
+ with Session(["/bin/sh", "-c", prog], cols=44, rows=10) as s:
+ s.until(lambda _: len(places(s)) == 1)
+
+ raw = s.api("graphics", format="bytes")["bytes"]
+ check("the image is transmitted", "_Ga=t" in raw, repr(raw[:80]))
+ raw = s.api("graphics", format="bytes")["bytes"]
+ check(
+ "and transmitted again: the last frame was never delivered",
+ "_Ga=t" in raw,
+ repr(raw[:80]),
+ )
+
+ s.send("x") # the seq scrolls the image out of the viewport
+ s.until(lambda _: places(s) == [])
+ raw = s.api("graphics", format="bytes")["bytes"]
+ check("scrolling it away owes a deletion", "_Ga=d,d=i" in raw, repr(raw))
+ raw = s.api("graphics", format="bytes")["bytes"]
+ check(
+ "which is still owed next frame, not fire-and-forget",
+ "_Ga=d,d=i" in raw,
+ repr(raw),
+ )
+
+
def test_scrolled_away_placements_are_dropped():
prog = (
"stty raw -echo; "
@@ -629,5 +672,7 @@ if __name__ == "__main__":
test_partial_visibility_crops_rather_than_squashes()
test_hidden_panes_place_nothing()
test_placement_goes_away_with_its_pane()
+ test_scrolled_away_placements_are_dropped()
+ test_an_undelivered_frame_stays_owed()
test_a_float_occludes_a_placement()
sys.exit(report())