diff options
| author | user <user@clank> | 2026-09-29 14:26:06 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-09-29 14:26:06 +0200 |
| commit | e779c1fafa556330752b2c5f43c56415f655ff24 (patch) | |
| tree | b87d335587fbfb68696569c5a41d912435d67140 | |
| parent | mouse: encode pixel reports against the cell size we advertise (diff) | |
| download | slosh-e779c1fafa556330752b2c5f43c56415f655ff24.tar.gz slosh-e779c1fafa556330752b2c5f43c56415f655ff24.tar.xz slosh-e779c1fafa556330752b2c5f43c56415f655ff24.zip | |
panes: $SLOSH_PANE, so a program knows which pane it is in
A program in a pane could name every pane but its own. `panes` says which
one is *focused*, and focus belongs to the human, so anything that inferred
"where do I live" from it did its work in whichever tab somebody else
happened to be looking at -- and lost a race with anybody else asking the
same question.
So export the id into the child, the way tmux has $TMUX_PANE and zellij
$ZELLIJ_PANE_ID. The id is taken before the pane is made, because pane_new
spawns the program and assigning it afterwards is one instruction too late;
it lives on the pane rather than the layout, so a rerun keeps it -- an
identity that changed under a rerun would be no identity at all. Unset, not
zero, when there is none: a stale value inherited from an outer session
would read as this one's.
`panes` also reports each pane's pid now, which is the same question
answered for anything that cannot read its own environment: walk your
parents up until one of them is on that list.
| -rw-r--r-- | include/slosh.h | 18 | ||||
| -rw-r--r-- | src/app.c | 15 | ||||
| -rw-r--r-- | src/pane.c | 19 | ||||
| -rw-r--r-- | src/pty.c | 15 | ||||
| -rw-r--r-- | src/pty_win.c | 15 | ||||
| -rw-r--r-- | tests/test_own_pane.py | 256 |
6 files changed, 325 insertions, 13 deletions
diff --git a/include/slosh.h b/include/slosh.h index b8c8849..78972b8 100644 --- a/include/slosh.h +++ b/include/slosh.h @@ -169,8 +169,16 @@ typedef struct { * success). Panes do this on their own when the entry is missing * everywhere; `slosh --install-terminfo` is the by-hand spelling. */ int pty_terminfo_install(void); +/* `pane_id` is the id the session knows this pane by, exported into the child + * as $SLOSH_PANE. A program in a pane has to be able to name the pane it is + * *in* -- `panes` says which pane is focused, and focus is the human's, not the + * caller's, so anything that inferred "where do I live" from it spawned its + * work in whichever tab somebody else happened to be looking at. tmux answers + * this with $TMUX_PANE and zellij with $ZELLIJ_PANE_ID; this is ours. 0 means + * "no id", and then the variable is left unset rather than set to a lie. */ int pty_spawn(pty_t *p, const char *const argv[], uint16_t cols, uint16_t rows, - const char *cwd, uint16_t cell_w, uint16_t cell_h); + const char *cwd, uint16_t cell_w, uint16_t cell_h, + uint32_t pane_id); int pty_resize(pty_t *p, uint16_t cols, uint16_t rows, uint16_t cell_w, uint16_t cell_h); void pty_close(pty_t *p); @@ -194,12 +202,16 @@ typedef void (*pane_clip_fn)(pane_t *p, char *text, void *ud); typedef void (*pane_notify_fn)(pane_t *p, const char *title, const char *body, void *ud); +/* `id` is what the session will know this pane by: it reaches the program as + * $SLOSH_PANE, and every respawn of this pane (start/rerun) carries the same + * one, so a pane's identity outlives the process that had it. */ pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows, - const char *cwd); + const char *cwd, uint32_t id); /* `suspended`: create the pane but run nothing until pane_start(). `label` is * what to show in the meantime (usually the command line). */ pane_t *pane_new_ex(const char *const argv[], uint16_t cols, uint16_t rows, - const char *cwd, bool suspended, const char *label); + const char *cwd, bool suspended, const char *label, + uint32_t id); bool pane_suspended(const pane_t *p); /* A pane opened to do one thing, rather than one the session is made of: an * editor you popped open, not the dev server a layout declared. It runs a @@ -830,13 +830,18 @@ const char *app_clipboard(const app_t *a) { return a->clipboard; } node_t *leaf_new_ex(app_t *a, const char *const argv[], const char *cwd, bool suspended, const char *label) { - pane_t *p = pane_new_ex(argv, 1, 1, cwd, suspended, label); + /* The id is taken *before* the pane is made, because the pane's program is + * spawned by pane_new and has to be told which pane it is ($SLOSH_PANE). + * Assigning it afterwards, as this used to, is one instruction too late: + * the child is already running without it. */ + uint32_t id = ++a->next_id; + pane_t *p = pane_new_ex(argv, 1, 1, cwd, suspended, label, id); if (!p) return NULL; node_t *n = calloc(1, sizeof *n); n->kind = NODE_LEAF; n->weight = WEIGHT_UNIT; n->pane = p; - n->id = ++a->next_id; + n->id = id; pane_set_osc_handler(p, on_pane_osc, a); pane_set_clipboard_handler(p, on_pane_clipboard, a); pane_set_notify_handler(p, on_pane_notify, a); @@ -2137,6 +2142,12 @@ static void panes_cb(node_t *n, void *ud) { * false with a status is one whose program is over and which is waiting to * be re-run or closed. `exit_code` is -1 when there is no status to give. */ json_bool(j, "alive", pane_alive(n->pane)); + /* The pid of the program in the pane, so a process can work out which pane + * it is in by walking its own parents up to one of these -- the answer for + * anything that cannot read $SLOSH_PANE from its environment, which is + * every tool run with a scrubbed env and every process with no controlling + * tty. -1 when there is nothing running (a suspended or dead pane). */ + json_int(j, "pid", (long long)pane_pid(n->pane)); { int code = 0; bool sig = false; @@ -30,6 +30,11 @@ struct pane { GhosttyMouseEncoder menc; GhosttyMouseEvent mev; uint16_t cols, rows_n; + /* The id the session knows this pane by, handed to every spawn of it as + * $SLOSH_PANE. Kept on the pane rather than looked up from the layout + * because `rerun` respawns from here, and an identity that changed under a + * rerun would be no identity at all. */ + uint32_t id; /* The client's cell size in pixels. A default rather than a zero, because * zero is what makes an image vanish: a placement given no explicit cell * count is sized from the image's pixels and this, and 0 covers no cells. @@ -321,7 +326,7 @@ bool pane_start(pane_t *p) { if (!p->suspended) return false; p->suspended = false; if (pty_spawn(&p->pty, (const char *const *)p->argv, p->cols, p->rows_n, - p->cwd, p->cell_w, p->cell_h) != 0) { + p->cwd, p->cell_w, p->cell_h, p->id) != 0) { p->alive = false; return false; } @@ -339,8 +344,9 @@ static char **argv_dup(const char *const argv[]) { } pane_t *pane_new_ex(const char *const argv[], uint16_t cols, uint16_t rows, - const char *cwd, bool suspended, const char *label) { - pane_t *p = pane_new(argv, cols, rows, cwd); + const char *cwd, bool suspended, const char *label, + uint32_t id) { + pane_t *p = pane_new(argv, cols, rows, cwd, id); if (!p) return NULL; snprintf(p->label, sizeof p->label, "%s", label ? label : ""); if (suspended) { @@ -355,8 +361,9 @@ pane_t *pane_new_ex(const char *const argv[], uint16_t cols, uint16_t rows, } pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows, - const char *cwd) { + const char *cwd, uint32_t id) { pane_t *p = calloc(1, sizeof *p); + p->id = id; p->cols = cols; p->rows_n = rows; p->cell_w = 8; @@ -414,7 +421,7 @@ pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows, p->argv = argv_dup(argv); p->cwd = cwd ? strdup(cwd) : NULL; - if (pty_spawn(&p->pty, argv, cols, rows, cwd, p->cell_w, p->cell_h) != 0) + if (pty_spawn(&p->pty, argv, cols, rows, cwd, p->cell_w, p->cell_h, id) != 0) goto fail; p->alive = true; p->dirty = true; @@ -715,7 +722,7 @@ bool pane_restart(pane_t *p) { p->exit_code = 0; if (pty_spawn(&p->pty, (const char *const *)p->argv, p->cols, p->rows_n, - p->cwd, p->cell_w, p->cell_h) != 0) + p->cwd, p->cell_w, p->cell_h, p->id) != 0) return false; /* The terminal is deliberately not cleared: the run that ended, and the * line saying it ended, stay above this one in the scrollback. That is the @@ -124,7 +124,8 @@ static const char *pty_term(void) { } int pty_spawn(pty_t *p, const char *const argv[], uint16_t cols, uint16_t rows, - const char *cwd, uint16_t cell_w, uint16_t cell_h) { + const char *cwd, uint16_t cell_w, uint16_t cell_h, + uint32_t pane_id) { int master = posix_openpt(O_RDWR | O_NOCTTY); if (master < 0) return -1; if (grantpt(master) < 0 || unlockpt(master) < 0) { @@ -206,6 +207,18 @@ int pty_spawn(pty_t *p, const char *const argv[], uint16_t cols, uint16_t rows, * same contract inward. xterm-ghostty is what libghostty-vt implements. */ setenv("TERM", pty_term(), 1); setenv("SLOSH", "1", 1); + /* Which pane this is, so a program can act on its own pane instead of on + * whichever one has focus. Set in the child, after the fork, because it is + * per-pane: setting it in the parent would leak one pane's id into the + * next spawn. Unset when there is no id, so a stale inherited value from + * an outer session cannot be read as this one's. */ + if (pane_id) { + char idbuf[16]; + snprintf(idbuf, sizeof idbuf, "%u", pane_id); + setenv("SLOSH_PANE", idbuf, 1); + } else { + unsetenv("SLOSH_PANE"); + } unsetenv("ZELLIJ"); signal(SIGPIPE, SIG_DFL); diff --git a/src/pty_win.c b/src/pty_win.c index d4386ed..b33c7fb 100644 --- a/src/pty_win.c +++ b/src/pty_win.c @@ -179,7 +179,8 @@ static void build_cmdline(const char *const argv[], char *out, size_t cap) { } int pty_spawn(pty_t *p, const char *const argv[], uint16_t cols, uint16_t rows, - const char *cwd, uint16_t cell_w, uint16_t cell_h) { + const char *cwd, uint16_t cell_w, uint16_t cell_h, + uint32_t pane_id) { (void)cell_w; (void)cell_h; /* ConPTY carries no pixel geometry */ if (!p || !argv || !argv[0]) return -1; @@ -235,6 +236,18 @@ int pty_spawn(pty_t *p, const char *const argv[], uint16_t cols, uint16_t rows, /* Same contract as the POSIX child: we say what terminal this is. */ SetEnvironmentVariableA("TERM", "xterm-ghostty"); SetEnvironmentVariableA("SLOSH", "1"); + { + /* Same contract as the POSIX child: which pane this is. See pty_spawn in + * slosh.h -- a program must be able to name its own pane rather than + * infer it from whoever has focus. */ + char idbuf[16]; + if (pane_id) { + snprintf(idbuf, sizeof idbuf, "%u", pane_id); + SetEnvironmentVariableA("SLOSH_PANE", idbuf); + } else { + SetEnvironmentVariableA("SLOSH_PANE", NULL); + } + } /* A child attached to a pseudoconsole is supposed to take its stdio from * that console -- but CreateProcess copies the *parent's* standard handles diff --git a/tests/test_own_pane.py b/tests/test_own_pane.py new file mode 100644 index 0000000..31e7faa --- /dev/null +++ b/tests/test_own_pane.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""A program in a pane can act on *its own* pane, without using focus. + +The bug this pins: everything a script could ask about a pane was answered +relative to focus, and focus is the human's. A tool spawning work "beside +itself" had nothing else to go on, so it read `panes` for whichever pane was +focused, and landed its work in whatever tab somebody else happened to be +looking at -- in a session with several agents in it, reliably the wrong one. +Worse, applying a layout jumped the view to the tab it built, so the *next* +caller read a focus that the previous spawn had moved. + +So four things are checked here, which together are "background work lands where +the caller lives and nobody's view moves": + + * $SLOSH_PANE tells a program which pane it is in (and survives a rerun). + * `panes` reports each pane's `pid`, so a process with no environment to read + -- no controlling tty, a scrubbed env -- can still find itself by walking + its own parents. + * `apply-layout focus:false` builds tabs without going to them, and the panes + it builds are still given a real size (a pane nobody looks at used to stay + 1x1, which was invisible only because applying a layout always jumped). + * `move-pane beside:<id> focus:false` lands a pane next to a named pane and + leaves focus, and the view, alone. + * `raw id:<id>` and `capture id:<id>` drive and read a pane in another tab + with no focus change and no select-tab. +""" + +import os +import sys + +from harness import Session, check, report + +# A pane that reports its own identity into a file, then waits. This is the +# question under test: what does a program in a pane know about its own pane? +WHOAMI = "/tmp/slosh-test-whoami.txt" +SHELL = ["/bin/sh", "-c", f'echo "pane=$SLOSH_PANE pid=$$" > {WHOAMI}; read x'] +# Something with visible output, for the capture checks. +TALKER = ["/bin/sh", "-c", "echo MARKER-ONE; read x"] + + +def layout(kdl): + import tempfile + + f = tempfile.NamedTemporaryFile("w", suffix=".layout", delete=False) + f.write(kdl) + f.close() + return f.name + + +def test_a_pane_is_told_which_pane_it_is(): + if os.path.exists(WHOAMI): + os.unlink(WHOAMI) + with Session(SHELL, cols=60, rows=12) as s: + s.settle(200) + pane = s.pane(0) + said = open(WHOAMI).read().strip() if os.path.exists(WHOAMI) else "<nothing>" + check( + "$SLOSH_PANE is the pane's own id", + f"pane={pane['id']} " in said + " ", + f"pane id {pane['id']}, program said {said!r}", + ) + check( + "panes reports the pid of the program in the pane", + f"pid={pane['pid']}" in said, + f"panes says pid {pane['pid']}, program said {said!r}", + ) + + +def test_the_id_survives_a_rerun(): + """A pane's identity is the pane's, not the process's: `rerun` keeps it. + + Otherwise a tool that recorded "my pane is 3" would be talking about + somebody else after the pane it meant was run again.""" + log = "/tmp/slosh-test-rerun.txt" + if os.path.exists(log): + os.unlink(log) + # A *commanded* pane, because only those keep a husk to rerun -- a pane + # that is just a shell goes when its shell does (see test_dead.py). + lay = layout( + 'layout {\n tab {\n pane command="echo pane=$SLOSH_PANE >> %s"\n pane\n }\n}\n' + % log + ) + with Session(["/bin/sh", "-c", "read x"], cols=60, rows=12, layout=lay) as s: + s.until(lambda sn: not s.pane(0)["alive"]) + pane_id = s.pane(0)["id"] + s.api("rerun", id=pane_id) + s.until(lambda sn: len(open(log).read().splitlines()) >= 2) + said = open(log).read().split() + check( + "the same pane reports the same id after a rerun", + said == [f"pane={pane_id}", f"pane={pane_id}"], + f"pane {pane_id} logged {said}", + ) + + +def test_a_layout_can_be_applied_without_stealing_the_view(): + with Session(TALKER, cols=60, rows=12) as s: + s.settle(100) + s.api("new-tab", name="human-tab") + s.api("select-tab", index=2) + s.settle(50) + before = [t["id"] for t in s.tabs() if t["active"]] + + s.api( + "apply-layout", + focus=False, + kdl='layout { tab name="bg" { pane purpose="task:bg" command="sh -c \'echo MARKER-BG; read x\'" } }', + ) + s.settle(200) + after = [t["id"] for t in s.tabs() if t["active"]] + check( + "the view stays on the tab it was on", + before == after, + f"was viewing {before}, now viewing {after}", + ) + + built = [p for p in s.panes() if p["purpose"] == "task:bg"] + check("the layout still built its pane", len(built) == 1, str(s.panes())) + if built: + # The whole reason background panes were never seen to be broken: + # the view used to jump to them, and the jump is what sized them. + check( + "a pane in a tab nobody looked at still has a real size", + built[0]["w"] > 2 and built[0]["h"] > 2, + f"{built[0]['w']}x{built[0]['h']}", + ) + check( + "and its program ran at that size, so its output is readable", + "MARKER-BG" in s.api("capture", id=built[0]["id"])["text"], + repr(s.api("capture", id=built[0]["id"])["text"][:120]), + ) + + +def test_a_pane_lands_beside_the_pane_that_asked_not_beside_focus(): + """The reported bug, in one test. + + Pane 1 is "the agent". The human is in another tab, so the only focused + pane in the session is theirs. The agent asks for a pane beside *itself* + by id, and it has to arrive in the agent's tab with nobody's view moving. + """ + with Session(TALKER, cols=80, rows=24) as s: + s.settle(100) + agent = s.pane(0)["id"] + agent_tab = s.pane(0)["tab_id"] + s.api("new-tab", name="human-tab") + s.api("select-tab", index=2) + s.settle(50) + human = s.focused() + + s.api( + "apply-layout", + focus=False, + kdl='layout { tab name="work" { pane purpose="task:x" command="sh -c \'echo MARKER-TWO; read x\'" } }', + ) + s.settle(150) + new = [p for p in s.panes() if p["purpose"] == "task:x"][0]["id"] + + reply = s.api("move-pane", id=new, tab=agent_tab, beside=agent, focus=False) + check("the move is accepted", reply.get("ok"), str(reply)) + s.settle(50) + + rows = {p["id"]: p for p in s.panes()} + check( + "the pane lands in the tab of the pane that asked for it", + rows[new]["tab_id"] == agent_tab, + f"wanted tab {agent_tab}, got {rows[new]['tab_id']}", + ) + check( + "focus does not move to it", + not rows[new]["focused"], + "the new pane took focus", + ) + check( + "the human keeps focus", + rows[human["id"]]["focused"], + f"focus went to {[p['id'] for p in s.panes() if p['focused']]}", + ) + check( + "and keeps the tab they were looking at", + [t["id"] for t in s.tabs() if t["active"]] == [human["tab_id"]], + str(s.tabs()), + ) + + # `beside` has to be in the destination tab, or the caller has the + # wrong idea of where things are and should hear so. + bad = s.api("move-pane", id=new, tab=human["tab_id"], beside=agent) + check("a `beside` in the wrong tab is refused", not bad.get("ok"), str(bad)) + + +def test_a_pane_elsewhere_can_be_read_and_driven_without_focus(): + with Session(TALKER, cols=80, rows=24) as s: + s.settle(100) + mine = s.pane(0)["id"] + s.api("new-tab", name="elsewhere") + s.api("select-tab", index=2) + s.settle(50) + viewing = [t["id"] for t in s.tabs() if t["active"]] + + cap = s.api("capture", id=mine) + check( + "capture reads a pane in a tab that is not on screen", + "MARKER-ONE" in cap["text"], + repr(cap["text"][:120]), + ) + check( + "capture does not change which tab is on screen", + [t["id"] for t in s.tabs() if t["active"]] == viewing, + str(s.tabs()), + ) + check( + "capture does not move focus", + s.focused()["tab_id"] != s.pane(0)["tab_id"] or len(s.panes()) == 1, + str(s.focused()), + ) + + # raw into a named pane: the pane's program is `read x`, so a line ends + # it -- observable as the pane no longer being alive. + # raw into a named pane: its program is `read x`, so a line ends it. + # A real newline in `data`: the JSON form carries bytes, only the + # bare-verb form unescapes (`raw q\n`). + s.api("raw", id=mine, data="q\n") + + def ended(_): + rows = [p for p in s.panes() if p["id"] == mine] + # The session's own shell pane leaves no husk when it ends, so + # "gone" and "not alive" are both the line having been delivered. + return not rows or not rows[0]["alive"] + + s.until(ended) + check( + "raw id:<pane> reaches a pane that is not focused", + ended(None), + str([p for p in s.panes() if p["id"] == mine]), + ) + check( + "and still did not move the view", + [t["id"] for t in s.tabs() if t["active"]] == viewing, + str(s.tabs()), + ) + + check( + "raw at an id that does not exist is refused", + not s.api("raw", id=99999, data="x").get("ok"), + "a write to a dead id was accepted", + ) + check( + "capture at an id that does not exist is refused", + not s.api("capture", id=99999).get("ok"), + "a read of a dead id was accepted", + ) + + +for name, fn in sorted(list(globals().items())): + if name.startswith("test_"): + fn() +sys.exit(report()) |
