aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-16 00:52:42 +0200
committeruser <user@clank>2026-08-16 00:52:42 +0200
commit40edef803e1b162ddc259e74aadd3688631ce1f2 (patch)
treedb86f33266d3f3e8085a5b9b69da8b3c4231b3f2
parentsl0ppty: one shader entry, one parser (diff)
downloadslosh-40edef803e1b162ddc259e74aadd3688631ce1f2.tar.gz
slosh-40edef803e1b162ddc259e74aadd3688631ce1f2.tar.xz
slosh-40edef803e1b162ddc259e74aadd3688631ce1f2.zip
sl0ppty: a reply is never a request
`hello` was answered with the verb `hello`. Any pane that echoes what it is sent -- `cat`, `tee`, a shell with echo on, a REPL waiting for a line -- therefore answered the answer, and the session answered that. Measured before the fix: a pane running `tee` was sent 4,303,832 bytes and 165,532 hellos in a second and a half. Afterwards, 32 bytes and one. The fix is the rule rather than the instance: every message the session sends *to* a program ends its verb in `-reply`, no request verb may, and `on_osc5577` drops one on sight instead of relying on no handler happening to exist. So this cannot come back when the next verb gets an answer -- which is the commit after this one, and how I found it. `hello`'s reply is now `hello-reply;sl0ppty;1`. That is our own extension to the fork's protocol, not one of the five compatible verbs, so nothing written against sl0ppi notices. The fork's `click` goes to a program too and keeps its name: it predates the rule and is safe for the weaker reason that nothing answers a click. The suite was hiding this. `emit()` builds its pane out of `cat -v`, so the existing `hello` test had a pane trading megabytes with the session while it asserted -- and passed, because the assertion was `in`, and the first reply was in there along with all the rest. It now counts. Carries `pane_touch`, two lines, unused until the next commit: a pane whose shaders changed has to be repainted, and nothing about its cells changed to say so.
-rw-r--r--src/osc5577.h7
-rw-r--r--src/pane.c20
-rw-r--r--src/sl0ppty.h3
-rw-r--r--tests/test_osc5577.py23
4 files changed, 51 insertions, 2 deletions
diff --git a/src/osc5577.h b/src/osc5577.h
index 7170c37..21b82a1 100644
--- a/src/osc5577.h
+++ b/src/osc5577.h
@@ -11,6 +11,13 @@
*
* \033]5577;1;click;approve\033\\
*
+ * A reply is never a request. Everything the session sends back to a program
+ * ends its verb in `-reply` (`hello-reply`, `shader-reply`), and no request verb
+ * may, because a pane that echoes what it is sent -- `cat`, a shell with echo
+ * on, a REPL waiting for a line -- would otherwise be answered into a loop. The
+ * fork's `click` predates the rule and is safe for the weaker reason that
+ * nothing answers a click.
+ *
* libghostty-vt's UNKNOWN_SEQUENCE effect reports APC only, not unknown OSC,
* so we scan the pty stream ourselves. The bytes still go to lib-vt, which
* discards an OSC it does not know — nothing is drawn, and we need no
diff --git a/src/pane.c b/src/pane.c
index ae0c1bf..aaad325 100644
--- a/src/pane.c
+++ b/src/pane.c
@@ -130,6 +130,19 @@ static void parse_buttons(pane_t *p, const char *payload) {
static void on_osc5577(const char *verb, const char *payload, void *ud) {
pane_t *p = ud;
+
+ /* A reply is never a request. Everything the session sends *to* a program ends
+ * its verb in `-reply`, and nothing here dispatches one, so a pane that echoes
+ * what it is sent -- `cat`, `tee`, a shell with echo on, a REPL waiting for a
+ * line -- cannot be answered into a loop.
+ *
+ * Found the hard way: `hello` used to answer with the verb `hello`, and a pane
+ * running `tee` traded four megabytes of them with the session in a second and
+ * a half. The fork's `click` (D1) goes to a program too and predates the rule;
+ * it is safe because nothing answers a click. */
+ size_t vlen = strlen(verb);
+ if (vlen >= 6 && strcmp(verb + vlen - 6, "-reply") == 0) return;
+
if (strcmp(verb, "status") == 0) {
snprintf(p->status, sizeof p->status, "%s", payload);
p->dirty = true;
@@ -142,7 +155,7 @@ static void on_osc5577(const char *verb, const char *payload, void *ud) {
} else if (strcmp(verb, "hello") == 0) {
/* Our addition to the fork's protocol, which is unversioned in practice:
* a program can ask what it is talking to before using anything new. */
- const char *reply = "\033]5577;1;hello;sl0ppty;1\033\\";
+ const char *reply = "\033]5577;1;hello-reply;sl0ppty;1\033\\";
pane_write(p, reply, strlen(reply));
} else if (p->osc_cb) {
p->osc_cb(p, verb, payload, p->osc_ud); /* purpose, and anything later */
@@ -400,6 +413,11 @@ void pane_clear_bell(pane_t *p) {
p->dirty = true;
}
+/* Nothing about the pane's cells changed, but what is drawn over them did.
+ * Composing is the only thing that can notice a new shader chain, and the loop
+ * only composes when something is dirty. */
+void pane_touch(pane_t *p) { p->dirty = true; }
+
void pane_set_name(pane_t *p, const char *name) {
if (!name) name = "";
snprintf(p->name, sizeof p->name, "%s", name);
diff --git a/src/sl0ppty.h b/src/sl0ppty.h
index 319fcba..037a050 100644
--- a/src/sl0ppty.h
+++ b/src/sl0ppty.h
@@ -179,6 +179,9 @@ void pane_resize(pane_t *p, uint16_t cols, uint16_t rows);
* is dropped, which is a picture that silently does not appear. */
void pane_set_cell_px(pane_t *p, uint16_t w, uint16_t h);
bool pane_dirty(pane_t *p);
+/* Ask for a repaint without having changed a cell: what is drawn *over* the
+ * pane changed, and composing is the only thing that can see that. */
+void pane_touch(pane_t *p);
/* Scrollback. Negative delta scrolls up (towards older output). */
void pane_scroll(pane_t *p, int delta);
void pane_scroll_edge(pane_t *p, bool top);
diff --git a/tests/test_osc5577.py b/tests/test_osc5577.py
index 3ccd4f7..e215fdd 100644
--- a/tests/test_osc5577.py
+++ b/tests/test_osc5577.py
@@ -6,6 +6,7 @@ for the existing pi extensions. The hostile cases matter as much as the happy
ones: a pane's own output is untrusted input.
"""
import sys
+import time
from harness import Session, check, report
@@ -248,7 +249,26 @@ def test_hello_handshake():
s.settle()
out = s.snapshot().pane_text(s.pane())
check("hello is answered with an implementation and a version",
- "5577;1;hello;sl0ppty;1" in out, repr(out[:120]))
+ "5577;1;hello-reply;sl0ppty;1" in out, repr(out[:120]))
+
+
+def test_a_reply_is_not_a_request():
+ """The pane in `emit()` runs `cat -v`, so everything the session sends it
+ goes straight back out again. That is the ordinary case -- a shell with echo
+ on, a REPL waiting for a line -- and it used to be a loop: `hello` was
+ answered with the verb `hello`, which parsed as another request. A pane
+ running `tee` traded four megabytes with the session in a second and a half.
+
+ So: exactly one answer, however loudly the pane echoes it.
+ """
+ with Session(emit(osc("1;hello;")), cols=60, rows=12) as s:
+ s.settle()
+ time.sleep(0.5) # real time: a loop needs none of our help to run
+ out = s.snapshot().pane_text(s.pane())
+ check("a reply the pane echoes back is not answered again",
+ out.count("hello-reply") == 1, repr(out[:200]))
+ check("and the pane is still usable afterwards",
+ len(out) < 400, "%d chars of screen" % len(out))
if __name__ == "__main__":
@@ -260,4 +280,5 @@ if __name__ == "__main__":
test_split_across_reads()
test_pi_extension_compat()
test_hello_handshake()
+ test_a_reply_is_not_a_request()
sys.exit(report())