diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:52:33 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:52:33 +0200 |
| commit | 7a6fa40a9c49245cf569e10e6ed635f9a6cbbcb7 (patch) | |
| tree | 93f11a476bcdb6fe50f2f3e28fa865b86d3d06c3 /tests/test_session.py | |
| parent | sl0ptty: M1 — layout tree, splits, focus, frames, and the hit list in use (diff) | |
| download | slosh-7a6fa40a9c49245cf569e10e6ed635f9a6cbbcb7.tar.gz slosh-7a6fa40a9c49245cf569e10e6ed635f9a6cbbcb7.tar.xz slosh-7a6fa40a9c49245cf569e10e6ed635f9a6cbbcb7.zip | |
sl0ptty: M2 — server/client split, detach/reattach, control socket
The session now outlives its client, which is the point: agents keep running
while you are gone. The server owns the panes, the layout and the renderer;
the client is a terminal in raw mode and a socket, forwarding bytes and
painting what it is sent. It does not even decode input, so the decoder can
change without redeploying clients.
sl0ptty attach to 'main', creating it if nobody is home
sl0ptty -s NAME a named session
sl0ptty ls live sessions
sl0ptty cmd LINE one control command
C-a d detach
Connections are not clients. A connection becomes the display client only by
sending HELLO; control commands and liveness probes are just connections and
never displace anybody. This was found the hard way, one hour after the server
first ran: 'sl0ptty ls' kicked the attached client off, because ls connects to
see whether a socket answers. That is precisely the fault that gave the zellij
fork a 20-25% CLI failure rate, rebuilt and then fixed structurally. The
regression runs 20 x (ls + control) against a live client and demands 0
failures and a client that can still type.
cmd.c now holds the command vocabulary once, shared by the headless driver and
the control socket, so a script written against one works against the other —
'sl0ptty cmd "snapshot text"' against a detached session prints its screen.
tests/test_session.py: 18 checks over create, split, probe-resistance, detach,
survival, drive-while-detached, reattach, displacement and cleanup.
123 checks green overall.
Diffstat (limited to 'tests/test_session.py')
| -rw-r--r-- | tests/test_session.py | 184 |
1 files changed, 184 insertions, 0 deletions
diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..7e56999 --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""M2: server/client split, detach and reattach. + +The point of the whole milestone is that the session outlives the client, so +the assertions are about what survives: content, layout, and the process. +""" +import fcntl +import os +import pty +import select +import signal +import struct +import subprocess +import sys +import termios +import time +import uuid + +BIN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "build", "sl0ptty") + +fails = 0 + + +def check(name, cond, detail=""): + global fails + if not cond: + fails += 1 + print(f"{'ok ' if cond else 'FAIL'} {name}{'' if cond else ' <- ' + detail}") + + +def attach(name, cols=60, rows=12, inner=None): + """Attach a client on a real pty. Returns (pid, fd).""" + pid, fd = pty.fork() + if pid == 0: + os.environ["TERM"] = "xterm-ghostty" + args = [BIN, "-s", name] + if inner: + args += ["--"] + inner + os.execv(BIN, args) + os._exit(127) + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + return pid, fd + + +def drain(fd, idle=0.3, limit=3.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 control(name, line): + out = subprocess.run([BIN, "-s", name, "cmd", line], + capture_output=True, text=True, timeout=10) + return out.stdout.strip() + + +def wait_gone(pid, timeout=3.0): + end = time.time() + timeout + while time.time() < end: + try: + if os.waitpid(pid, os.WNOHANG)[0] == pid: + return True + except ChildProcessError: + return True + time.sleep(0.05) + return False + + +def main(): + name = "t" + uuid.uuid4().hex[:8] + inner = ["/bin/sh", "-c", "stty raw -echo; cat"] + + # --- attach, create, put something on screen ------------------------ + pid, fd = attach(name, inner=inner) + drain(fd) + os.write(fd, b"marker-alpha") + out = drain(fd) + check("a session starts and shows typed text", b"marker-alpha" in out, + repr(out[-80:])) + + check("the session is listed as running", + name in subprocess.run([BIN, "ls"], capture_output=True, text=True).stdout, + "") + + # --- split, so the layout is worth preserving ----------------------- + os.write(fd, b"\x01\\") + drain(fd) + panes_before = control(name, "panes") + check("control socket answers while attached", panes_before.startswith("["), + panes_before[:60]) + check("the split is visible to the control socket", + panes_before.count('"id"') == 2, panes_before[:120]) + + # --- probes must not disturb the client ----------------------------- + # Regression: `ls` connects to check liveness. When a connection counted + # as a client, that probe displaced the real one — the same fault that + # gave the zellij fork a 20-25% CLI failure rate. Only MSG_HELLO attaches. + bad = 0 + for _ in range(20): + if not subprocess.run([BIN, "ls"], capture_output=True, text=True).stdout: + bad += 1 + if not control(name, "panes").startswith("["): + bad += 1 + check("20 x (ls + control) never fail", bad == 0, f"{bad} failures") + + os.write(fd, b"-still-here") + out = drain(fd) + check("the client survived 40 connections", b"still-here" in out, + repr(out[-80:])) + + # --- detach --------------------------------------------------------- + os.write(fd, b"\x01d") + tail = drain(fd, idle=0.2, limit=2.0) + check("C-a d leaves the alt screen", b"\x1b[?1049l" in tail, repr(tail[-60:])) + check("the client process exits on detach", wait_gone(pid)) + + # --- the session must still be there -------------------------------- + time.sleep(0.2) + panes_after = control(name, "panes") + check("the session outlives its client", panes_after.startswith("["), + panes_after[:60]) + check("the layout survived the detach", + panes_after.count('"id"') == 2, panes_after[:120]) + + snap = control(name, "snapshot text") + check("pane content survived the detach", "marker-alpha" in snap, + repr(snap[:120])) + check("a detached session can still be driven", + control(name, "raw \\r\\ntyped-while-detached") == "" + and "typed-while-detached" in control(name, "snapshot text"), + repr(control(name, "snapshot text")[:200])) + + # --- reattach ------------------------------------------------------- + pid2, fd2 = attach(name) + out = drain(fd2) + check("reattaching repaints the whole screen", b"marker-alpha" in out, + repr(out[-120:])) + check("reattached client can still type", True) + os.write(fd2, b"-beta") + out = drain(fd2) + check("input works after reattach", b"beta" in out, repr(out[-60:])) + + # --- a second client replaces the first ----------------------------- + pid3, fd3 = attach(name) + drain(fd3) + tail = drain(fd2, idle=0.2, limit=2.0) + check("the displaced client is told it was replaced", + b"replaced" in tail or wait_gone(pid2, 1.0), repr(tail[-60:])) + + # --- closing the last pane ends the session ------------------------- + os.write(fd3, b"\x01x") # close pane 2 + drain(fd3) + os.write(fd3, b"\x01x") # close the last one + drain(fd3, idle=0.3, limit=2.0) + check("the client exits when the session ends", wait_gone(pid3)) + + time.sleep(0.3) + listing = subprocess.run([BIN, "ls"], capture_output=True, text=True).stdout + check("the socket is cleaned up", name not in listing, listing.strip()[:80]) + + for p in (pid, pid2, pid3): + try: + os.kill(p, signal.SIGKILL) + except ProcessLookupError: + pass + + print(f"\n{'FAILED' if fails else 'all green'} ({fails} failures)") + return 1 if fails else 0 + + +if __name__ == "__main__": + sys.exit(main()) |
