diff options
| author | user <user@clank> | 2026-08-29 21:31:41 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-29 21:31:41 +0200 |
| commit | 8a8b5e1c7acd269d4a639a40712412a84480ad18 (patch) | |
| tree | dd73377a2b0a8b424dc8f151ced00f702812edca | |
| parent | docs: less wordy (diff) | |
| download | slosh-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-- | Makefile | 4 | ||||
| -rw-r--r-- | docs/scripting.md | 2 | ||||
| -rw-r--r-- | include/app.h | 3 | ||||
| -rw-r--r-- | include/graphics.h | 10 | ||||
| -rw-r--r-- | src/app_draw.c | 4 | ||||
| -rw-r--r-- | src/graphics.c | 70 | ||||
| -rw-r--r-- | src/server.c | 76 | ||||
| -rw-r--r-- | tests/live_graphics.py | 169 | ||||
| -rw-r--r-- | tests/test_graphics.py | 45 |
9 files changed, 357 insertions, 26 deletions
@@ -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()) |
