aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorsl0p <sl0p@sl0p.foo>2026-08-13 22:52:33 +0200
committersl0p <sl0p@sl0p.foo>2026-08-13 22:52:33 +0200
commit7a6fa40a9c49245cf569e10e6ed635f9a6cbbcb7 (patch)
tree93f11a476bcdb6fe50f2f3e28fa865b86d3d06c3 /src
parentsl0ptty: M1 — layout tree, splits, focus, frames, and the hit list in use (diff)
downloadslosh-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 'src')
-rw-r--r--src/app.c4
-rw-r--r--src/app.h3
-rw-r--r--src/client.c181
-rw-r--r--src/cmd.c91
-rw-r--r--src/cmd.h13
-rw-r--r--src/headless.c133
-rw-r--r--src/main.c252
-rw-r--r--src/proto.c135
-rw-r--r--src/proto.h63
-rw-r--r--src/screen.c18
-rw-r--r--src/server.c348
-rw-r--r--src/server.h22
-rw-r--r--src/sl0ptty.h4
13 files changed, 973 insertions, 294 deletions
diff --git a/src/app.c b/src/app.c
index b668b62..19d3459 100644
--- a/src/app.c
+++ b/src/app.c
@@ -54,6 +54,7 @@ struct app {
uint16_t cols, rows;
bool prefix;
bool quit;
+ bool detach;
const char *const *argv;
/* the screen we last composed into: its hit list is what a click resolves
* against, so routing can never consult geometry the user never saw */
@@ -102,6 +103,8 @@ void app_free(app_t *a) {
}
bool app_should_quit(const app_t *a) { return a->quit || a->root == NULL; }
+bool app_detach_requested(const app_t *a) { return a->detach; }
+void app_clear_detach(app_t *a) { a->detach = false; }
/* ---- walking ------------------------------------------------------------ */
@@ -510,6 +513,7 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) {
static bool prefix_command(app_t *a, const input_event_t *ev) {
switch (ev->key) {
case GHOSTTY_KEY_Q: a->quit = true; return true;
+ case GHOSTTY_KEY_D: a->detach = true; return true;
case GHOSTTY_KEY_BACKSLASH: /* C-a \ or C-a | */
split_focus(a, SPLIT_COLS);
return true;
diff --git a/src/app.h b/src/app.h
index 79b7732..f9d8b58 100644
--- a/src/app.h
+++ b/src/app.h
@@ -33,6 +33,9 @@ void app_compose(app_t *a, screen_t *s);
/* Bytes straight into the focused pane's pty (the harness's `raw`). */
void app_write_focused(app_t *a, const void *buf, size_t len);
bool app_should_quit(const app_t *a);
+/* C-a d asks the client to leave while the session keeps running. */
+bool app_detach_requested(const app_t *a);
+void app_clear_detach(app_t *a);
size_t app_pane_count(const app_t *a);
/* Layout as JSON, for the harness: id, rect, focus, title. Caller frees. */
diff --git a/src/client.c b/src/client.c
new file mode 100644
index 0000000..f50db7b
--- /dev/null
+++ b/src/client.c
@@ -0,0 +1,181 @@
+/* The client: a terminal in raw mode and a socket. It decodes nothing and
+ * lays out nothing — it forwards bytes and paints what it is sent, so a bug
+ * here can cost you a frame, never a session. */
+#define _GNU_SOURCE
+#include "server.h"
+
+#include <errno.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <termios.h>
+#include <unistd.h>
+
+#include "proto.h"
+
+#define TERM_ENTER \
+ "\x1b[?1049h\x1b[0m\x1b[2J" \
+ "\x1b[?1002h\x1b[?1006h" \
+ "\x1b[?1004h" \
+ "\x1b[?2004h" \
+ "\x1b[>1u"
+#define TERM_LEAVE \
+ "\x1b[<u\x1b[?2004l\x1b[?1004l\x1b[?1006l\x1b[?1002l" \
+ "\x1b[?25h\x1b[0m\x1b[?1049l"
+
+static volatile sig_atomic_t g_winch = 0, g_quit = 0;
+static struct termios g_saved;
+static bool g_saved_ok = false;
+
+static void on_sig(int s) {
+ if (s == SIGWINCH) g_winch = 1;
+ else g_quit = 1;
+}
+
+static void restore(void) {
+ if (g_saved_ok) tcsetattr(STDIN_FILENO, TCSAFLUSH, &g_saved);
+ ssize_t r = write(STDOUT_FILENO, TERM_LEAVE, strlen(TERM_LEAVE));
+ (void)r;
+}
+
+static void term_size(uint16_t *cols, uint16_t *rows) {
+ struct winsize ws;
+ if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col && ws.ws_row) {
+ *cols = ws.ws_col;
+ *rows = ws.ws_row;
+ } else {
+ *cols = 80;
+ *rows = 24;
+ }
+}
+
+static void send_size(int fd, uint8_t type, uint16_t cols, uint16_t rows) {
+ uint8_t b[4] = {(uint8_t)(cols >> 8), (uint8_t)cols, (uint8_t)(rows >> 8),
+ (uint8_t)rows};
+ msg_send(fd, type, b, sizeof b);
+}
+
+static void write_all(int fd, const void *buf, size_t len) {
+ size_t off = 0;
+ while (off < len) {
+ ssize_t w = write(fd, (const char *)buf + off, len - off);
+ if (w <= 0) {
+ if (errno == EINTR) continue;
+ return;
+ }
+ off += (size_t)w;
+ }
+}
+
+int client_run(int fd) {
+ uint16_t cols, rows;
+ term_size(&cols, &rows);
+
+ if (tcgetattr(STDIN_FILENO, &g_saved) != 0) {
+ fprintf(stderr, "sl0ptty: not a terminal\n");
+ return 1;
+ }
+ g_saved_ok = true;
+ struct termios t = g_saved;
+ cfmakeraw(&t);
+ t.c_cc[VMIN] = 1;
+ t.c_cc[VTIME] = 0;
+ tcsetattr(STDIN_FILENO, TCSAFLUSH, &t);
+ atexit(restore);
+ write_all(STDOUT_FILENO, TERM_ENTER, strlen(TERM_ENTER));
+
+ struct sigaction sa = {.sa_handler = on_sig};
+ sigaction(SIGWINCH, &sa, NULL);
+ sigaction(SIGTERM, &sa, NULL);
+ sigaction(SIGHUP, &sa, NULL);
+ signal(SIGPIPE, SIG_IGN);
+
+ send_size(fd, MSG_HELLO, cols, rows);
+
+ msg_reader_t reader;
+ msg_reader_init(&reader);
+ int exit_reason = -1;
+
+ while (!g_quit) {
+ struct pollfd pfds[2] = {
+ {.fd = STDIN_FILENO, .events = POLLIN},
+ {.fd = fd, .events = POLLIN},
+ };
+ int n = poll(pfds, 2, -1);
+
+ if (g_winch) {
+ g_winch = 0;
+ term_size(&cols, &rows);
+ send_size(fd, MSG_RESIZE, cols, rows);
+ }
+ if (n < 0) {
+ if (errno == EINTR) continue;
+ break;
+ }
+
+ if (pfds[0].revents & POLLIN) {
+ uint8_t buf[65536];
+ ssize_t got = read(STDIN_FILENO, buf, sizeof buf);
+ if (got > 0 && msg_send(fd, MSG_INPUT, buf, (size_t)got) != 0) break;
+ if (got == 0) break;
+ }
+
+ if (pfds[1].revents & (POLLIN | POLLHUP)) {
+ uint8_t buf[65536];
+ ssize_t got = read(fd, buf, sizeof buf);
+ if (got <= 0) break;
+ msg_reader_feed(&reader, buf, (size_t)got);
+ msg_t m;
+ while (msg_reader_next(&reader, &m)) {
+ if (m.type == MSG_OUTPUT) {
+ write_all(STDOUT_FILENO, m.data, m.len);
+ } else if (m.type == MSG_EXIT) {
+ exit_reason = m.len ? m.data[0] : EXIT_SESSION_ENDED;
+ g_quit = 1;
+ break;
+ }
+ }
+ }
+ }
+
+ msg_reader_free(&reader);
+ close(fd);
+ restore();
+ g_saved_ok = false;
+
+ if (exit_reason == EXIT_DETACHED) fprintf(stderr, "[detached]\n");
+ else if (exit_reason == EXIT_REPLACED) fprintf(stderr, "[replaced by another client]\n");
+ return 0;
+}
+
+/* One-shot control request: connect, ask, print the answer. */
+int client_control(int fd, const char *json) {
+ if (msg_send(fd, MSG_CMD, json, strlen(json)) != 0) return 1;
+ msg_reader_t reader;
+ msg_reader_init(&reader);
+ uint8_t buf[65536];
+ int rc = 1;
+ for (;;) {
+ ssize_t got = read(fd, buf, sizeof buf);
+ if (got <= 0) break;
+ msg_reader_feed(&reader, buf, (size_t)got);
+ msg_t m;
+ bool done = false;
+ while (msg_reader_next(&reader, &m)) {
+ if (m.type == MSG_REPLY) {
+ fwrite(m.data, 1, m.len, stdout);
+ fputc('\n', stdout);
+ rc = 0;
+ done = true;
+ break;
+ }
+ }
+ if (done) break;
+ }
+ msg_reader_free(&reader);
+ close(fd);
+ return rc;
+}
diff --git a/src/cmd.c b/src/cmd.c
new file mode 100644
index 0000000..bfd1f08
--- /dev/null
+++ b/src/cmd.c
@@ -0,0 +1,91 @@
+/* One implementation of the command vocabulary, used by both the headless
+ * driver and the control socket. `settle` is the exception: it needs the
+ * caller's event loop, so the driver keeps it. */
+#define _GNU_SOURCE
+#include "cmd.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+/* \e \n \r \t \\ \0 \xHH -> bytes */
+size_t cmd_unescape(const char *in, uint8_t *out, size_t cap) {
+ size_t n = 0;
+ for (const char *p = in; *p && n < cap; p++) {
+ if (*p != '\\') {
+ out[n++] = (uint8_t)*p;
+ continue;
+ }
+ p++;
+ switch (*p) {
+ case 'e': out[n++] = 0x1b; break;
+ case 'n': out[n++] = '\n'; break;
+ case 'r': out[n++] = '\r'; break;
+ case 't': out[n++] = '\t'; break;
+ case '0': out[n++] = 0; break;
+ case '\\': out[n++] = '\\'; break;
+ case 'x': {
+ int hi = 0, lo = 0;
+ sscanf(p + 1, "%1x%1x", &hi, &lo);
+ out[n++] = (uint8_t)(hi * 16 + lo);
+ p += 2;
+ break;
+ }
+ case 0: return n;
+ default: out[n++] = (uint8_t)*p; break;
+ }
+ }
+ return n;
+}
+
+static void feed_event(const input_event_t *ev, void *ud) {
+ app_event((app_t *)ud, ev);
+}
+
+char *cmd_exec(app_t *a, screen_t *s, input_parser_t *in, const char *line,
+ bool *quit) {
+ char verb[32] = {0};
+ const char *arg = "";
+ const char *sp = strchr(line, ' ');
+ size_t vlen = sp ? (size_t)(sp - line) : strlen(line);
+ if (vlen >= sizeof verb) vlen = sizeof verb - 1;
+ memcpy(verb, line, vlen);
+ if (sp) arg = sp + 1;
+
+ if (strcmp(verb, "send") == 0) {
+ uint8_t buf[4096];
+ size_t n = cmd_unescape(arg, buf, sizeof buf);
+ input_feed(in, buf, n, feed_event, a);
+ if (input_pending(in)) input_timeout(in, feed_event, a);
+ return strdup("");
+ }
+ if (strcmp(verb, "raw") == 0) {
+ uint8_t buf[4096];
+ size_t n = cmd_unescape(arg, buf, sizeof buf);
+ app_write_focused(a, buf, n);
+ return strdup("");
+ }
+ if (strcmp(verb, "resize") == 0) {
+ int c = s->cols, r = s->rows;
+ sscanf(arg, "%d %d", &c, &r);
+ screen_resize(s, (uint16_t)c, (uint16_t)r);
+ app_resize(a, (uint16_t)c, (uint16_t)r);
+ return strdup("");
+ }
+ if (strcmp(verb, "snapshot") == 0) {
+ app_compose(a, s);
+ return strcmp(arg, "text") == 0 ? screen_dump(s) : screen_dump_json(s);
+ }
+ if (strcmp(verb, "panes") == 0) {
+ app_compose(a, s); /* layout is a function of the frame: compose first */
+ return app_panes_json(a);
+ }
+ if (strcmp(verb, "alive") == 0) {
+ return strdup(app_should_quit(a) ? "false" : "true");
+ }
+ if (strcmp(verb, "quit") == 0) {
+ if (quit) *quit = true;
+ return strdup("");
+ }
+ return NULL;
+}
diff --git a/src/cmd.h b/src/cmd.h
new file mode 100644
index 0000000..e70cb8e
--- /dev/null
+++ b/src/cmd.h
@@ -0,0 +1,13 @@
+/* The command vocabulary, shared by the headless driver and the control
+ * socket, so a script written against one works against the other. */
+#ifndef SL0PTTY_CMD_H
+#define SL0PTTY_CMD_H
+
+#include "app.h"
+
+/* Execute one command line. Returns a freshly allocated reply (possibly an
+ * empty string), or NULL if the command is unknown. Sets *quit for "quit". */
+char *cmd_exec(app_t *a, screen_t *s, input_parser_t *in, const char *line,
+ bool *quit);
+
+#endif /* SL0PTTY_CMD_H */
diff --git a/src/headless.c b/src/headless.c
index c38313f..8be9f87 100644
--- a/src/headless.c
+++ b/src/headless.c
@@ -1,20 +1,10 @@
/* The headless driver (DESIGN.md, "headless from day one").
*
- * The whole compositor with no tty: commands in on stdin, screen snapshots out
- * on stdout. Tests drive this instead of screen-scraping a pty, so they are
- * deterministic and take milliseconds.
+ * The whole compositor with no tty: commands in on stdin, replies out on
+ * stdout. The vocabulary lives in cmd.c and is shared with the control socket,
+ * so a script written against one works against the other.
*
- * The command set is deliberately the vocabulary the M2 control socket will
- * speak, so the harness moves over unchanged.
- *
- * send <escaped> bytes as if typed at the outer terminal (decoded first)
- * raw <escaped> bytes straight to the pane's pty, decoder bypassed
- * settle [ms] pump the pane until it has been quiet for ms
- * resize <c> <r>
- * snapshot [json|text]
- * quit
- *
- * <escaped> understands \e \n \r \t \\ \xHH, so a test can write \e[1;5A.
+ * `settle` is implemented here because it needs an event loop.
*/
#define _GNU_SOURCE
#include "sl0ptty.h"
@@ -27,10 +17,9 @@
#include <unistd.h>
#include "app.h"
+#include "cmd.h"
-static void hl_event(const input_event_t *ev, void *ud) {
- app_event((app_t *)ud, ev);
-}
+#define MAX_PANES 64
static int64_t ms_now(void) {
struct timespec ts;
@@ -38,52 +27,18 @@ static int64_t ms_now(void) {
return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
}
-/* \e \n \r \t \\ \xHH -> bytes */
-static size_t unescape(const char *in, uint8_t *out, size_t cap) {
- size_t n = 0;
- for (const char *p = in; *p && n < cap; p++) {
- if (*p != '\\') {
- out[n++] = (uint8_t)*p;
- continue;
- }
- p++;
- switch (*p) {
- case 'e': out[n++] = 0x1b; break;
- case 'n': out[n++] = '\n'; break;
- case 'r': out[n++] = '\r'; break;
- case 't': out[n++] = '\t'; break;
- case '0': out[n++] = 0; break;
- case '\\': out[n++] = '\\'; break;
- case 'x': {
- int hi = 0, lo = 0;
- sscanf(p + 1, "%1x%1x", &hi, &lo);
- out[n++] = (uint8_t)(hi * 16 + lo);
- p += 2;
- break;
- }
- case 0: return n;
- default: out[n++] = (uint8_t)*p; break;
- }
- }
- return n;
-}
-
/* Pump every pane until none has produced anything for `quiet` ms. */
static void settle(app_t *a, int quiet) {
int64_t last = ms_now();
int64_t deadline = last + 3000;
while (!app_should_quit(a) && ms_now() < deadline) {
- int fds[64];
- size_t n = app_fds(a, fds, 64);
+ int fds[MAX_PANES];
+ size_t n = app_fds(a, fds, MAX_PANES);
if (n == 0) break;
- struct pollfd pfds[64];
- for (size_t i = 0; i < n; i++) {
- pfds[i].fd = fds[i];
- pfds[i].events = POLLIN;
- pfds[i].revents = 0;
- }
- int r = poll(pfds, (nfds_t)n, 10);
- if (r > 0) {
+ struct pollfd pfds[MAX_PANES];
+ for (size_t i = 0; i < n; i++)
+ pfds[i] = (struct pollfd){.fd = fds[i], .events = POLLIN};
+ if (poll(pfds, (nfds_t)n, 10) > 0) {
for (size_t i = 0; i < n; i++)
if (pfds[i].revents) {
app_pump_fd(a, pfds[i].fd);
@@ -107,8 +62,7 @@ int run_headless(const char *const argv[], uint16_t cols, uint16_t rows,
screen_init(&s, cols, rows);
input_parser_t *in = input_new();
- /* one-shot: run it, let it settle, print the screen */
- if (!script) {
+ if (!script) { /* one-shot: run it, let it settle, print the screen */
settle(a, idle_ms);
app_compose(a, &s);
char *dump = screen_dump(&s);
@@ -118,53 +72,28 @@ int run_headless(const char *const argv[], uint16_t cols, uint16_t rows,
}
char line[4096];
- while (fgets(line, sizeof line, stdin)) {
+ bool quit = false;
+ while (!quit && fgets(line, sizeof line, stdin)) {
line[strcspn(line, "\r\n")] = 0;
- char *sp = strchr(line, ' ');
- const char *arg = sp ? sp + 1 : "";
- if (sp) *sp = 0;
+ if (!line[0]) continue;
- if (strcmp(line, "send") == 0) {
- uint8_t buf[4096];
- size_t n = unescape(arg, buf, sizeof buf);
- input_feed(in, buf, n, hl_event, a);
- if (input_pending(in)) input_timeout(in, hl_event, a);
- } else if (strcmp(line, "raw") == 0) {
- uint8_t buf[4096];
- size_t n = unescape(arg, buf, sizeof buf);
- app_write_focused(a, buf, n);
- } else if (strcmp(line, "settle") == 0) {
- settle(a, *arg ? atoi(arg) : idle_ms);
- } else if (strcmp(line, "resize") == 0) {
- int c = cols, r = rows;
- sscanf(arg, "%d %d", &c, &r);
- cols = (uint16_t)c;
- rows = (uint16_t)r;
- screen_resize(&s, cols, rows);
- app_resize(a, cols, rows);
- } else if (strcmp(line, "snapshot") == 0) {
- app_compose(a, &s);
- char *dump = strcmp(arg, "text") == 0 ? screen_dump(&s)
- : screen_dump_json(&s);
- fputs(dump, stdout);
- fputs("\n", stdout);
- free(dump);
- fflush(stdout);
- } else if (strcmp(line, "panes") == 0) {
- app_compose(a, &s); /* layout is a function of the frame; compose first */
- char *dump = app_panes_json(a);
- fputs(dump, stdout);
- fputs("\n", stdout);
- free(dump);
- fflush(stdout);
- } else if (strcmp(line, "alive") == 0) {
- printf("%s\n", app_should_quit(a) ? "false" : "true");
- fflush(stdout);
- } else if (strcmp(line, "quit") == 0) {
- break;
- } else if (line[0]) {
+ if (strncmp(line, "settle", 6) == 0) {
+ settle(a, line[6] == ' ' ? atoi(line + 7) : idle_ms);
+ continue;
+ }
+
+ char *reply = cmd_exec(a, &s, in, line, &quit);
+ if (!reply) {
fprintf(stderr, "sl0ptty: unknown command: %s\n", line);
+ continue;
+ }
+ /* Only commands that answer print a line, so the harness stays in step. */
+ if (*reply) {
+ fputs(reply, stdout);
+ fputc('\n', stdout);
+ fflush(stdout);
}
+ free(reply);
}
done:
diff --git a/src/main.c b/src/main.c
index 6d47d59..b36c524 100644
--- a/src/main.c
+++ b/src/main.c
@@ -1,70 +1,28 @@
-/* M0: one pty, one fullscreen pane, input passthrough, resize.
- * Plus a seed of the headless harness (M0.5) so this is testable without a tty.
+/* Entry point and argument parsing.
+ *
+ * sl0ptty attach to session "main", creating it if needed
+ * sl0ptty -s NAME ... a named session
+ * sl0ptty ls live sessions
+ * sl0ptty cmd LINE one control command against a session
+ * sl0ptty --server NAME run a session in the foreground (internal)
+ * sl0ptty --script the headless driver (no server, no tty)
+ * sl0ptty --headless run once, print the screen
*/
#define _GNU_SOURCE
#include "sl0ptty.h"
-#include "app.h"
-#include <ghostty/vt.h>
-
-#include <errno.h>
-#include <poll.h>
-#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
-#include <sys/wait.h>
#include <termios.h>
-#include <time.h>
#include <unistd.h>
-#define MAX_PANES 64 /* a screen cannot usefully hold more */
-#define FRAME_MS 8 /* ~120Hz cap; pty reads are coalesced into one paint */
-#define ESC_MS 40 /* how long a bare ESC waits to become a sequence */
-
-/* What we ask the outer terminal for. Kitty keyboard flag 1 (disambiguate)
- * costs nothing on terminals that ignore it, and everything a pane receives is
- * re-encoded for that pane anyway, so an inner app that has never heard of
- * kitty still gets legacy bytes. */
-#define TERM_ENTER \
- "\x1b[?1049h\x1b[0m\x1b[2J" /* alt screen */ \
- "\x1b[?1002h\x1b[?1006h" /* mouse: button+drag, SGR encoding */ \
- "\x1b[?1004h" /* focus in/out */ \
- "\x1b[?2004h" /* bracketed paste */ \
- "\x1b[>1u" /* kitty keyboard, pushed */
-#define TERM_LEAVE \
- "\x1b[<u\x1b[?2004l\x1b[?1004l\x1b[?1006l\x1b[?1002l" \
- "\x1b[?25h\x1b[0m\x1b[?1049l"
-
-static volatile sig_atomic_t g_winch = 0, g_quit = 0;
-static struct termios g_saved_tio;
-static bool g_tio_saved = false;
-
-static void on_signal(int sig) {
- if (sig == SIGWINCH) g_winch = 1;
- else g_quit = 1;
-}
-
-static void restore_tty(void) {
- if (g_tio_saved) tcsetattr(STDIN_FILENO, TCSAFLUSH, &g_saved_tio);
- ssize_t r = write(STDOUT_FILENO, TERM_LEAVE, strlen(TERM_LEAVE));
- (void)r;
-}
+#include "proto.h"
+#include "server.h"
-static int raw_mode(void) {
- if (tcgetattr(STDIN_FILENO, &g_saved_tio) != 0) return -1;
- g_tio_saved = true;
- struct termios t = g_saved_tio;
- cfmakeraw(&t);
- t.c_cc[VMIN] = 1;
- t.c_cc[VTIME] = 0;
- if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &t) != 0) return -1;
- atexit(restore_tty);
- ssize_t r = write(STDOUT_FILENO, TERM_ENTER, strlen(TERM_ENTER));
- (void)r;
- return 0;
-}
+int run_headless(const char *const argv[], uint16_t cols, uint16_t rows,
+ int idle_ms, bool script);
static void term_size(uint16_t *cols, uint16_t *rows) {
struct winsize ws;
@@ -77,138 +35,40 @@ static void term_size(uint16_t *cols, uint16_t *rows) {
}
}
-static int64_t now_ms(void) {
- struct timespec ts;
- clock_gettime(CLOCK_MONOTONIC, &ts);
- return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
-}
-
-/* headless lives in headless.c */
-int run_headless(const char *const argv[], uint16_t cols, uint16_t rows,
- int idle_ms, bool script);
-
-/* ---- interactive -------------------------------------------------------- */
-
-/* Every decoded event goes to the app, which owns the prefix, the layout and
- * what a key means. The client is only a decoder and a screen. */
-static void on_event(const input_event_t *ev, void *ud) {
- app_event((app_t *)ud, ev);
-}
-
-static int run_interactive(const char *const argv[]) {
- uint16_t cols, rows;
- term_size(&cols, &rows);
-
- if (raw_mode() != 0) {
- fprintf(stderr, "sl0ptty: stdin is not a terminal (try --headless)\n");
- return 1;
- }
-
- app_t *a = app_new(argv, cols, rows);
- if (!a) {
- fprintf(stderr, "sl0ptty: cannot spawn pane\n");
- return 1;
- }
- app_resize(a, cols, rows);
-
- screen_t s;
- screen_init(&s, cols, rows);
-
- input_parser_t *in = input_new();
-
- bool pending_paint = true;
- int64_t next_frame = 0;
- int64_t esc_due = 0;
-
- while (!g_quit && !app_should_quit(a)) {
- int timeout = -1;
- if (pending_paint) {
- int64_t due = next_frame - now_ms();
- timeout = due <= 0 ? 0 : (int)due;
- }
- if (input_pending(in)) {
- int64_t due = esc_due - now_ms();
- int t = due <= 0 ? 0 : (int)due;
- if (timeout < 0 || t < timeout) timeout = t;
- }
-
- int fds[MAX_PANES];
- size_t npanes = app_fds(a, fds, MAX_PANES);
- struct pollfd pfds[MAX_PANES + 1];
- pfds[0] = (struct pollfd){.fd = STDIN_FILENO, .events = POLLIN};
- for (size_t i = 0; i < npanes; i++)
- pfds[i + 1] = (struct pollfd){.fd = fds[i], .events = POLLIN};
- int n = poll(pfds, (nfds_t)(npanes + 1), timeout);
-
- if (g_winch) {
- g_winch = 0;
- term_size(&cols, &rows);
- screen_resize(&s, cols, rows);
- app_resize(a, cols, rows);
- pending_paint = true;
- next_frame = now_ms();
- }
-
- if (n < 0 && errno != EINTR) break;
-
- if (n > 0 && (pfds[0].revents & POLLIN)) {
- uint8_t buf[8192];
- ssize_t r = read(STDIN_FILENO, buf, sizeof buf);
- if (r > 0) {
- input_feed(in, buf, (size_t)r, on_event, a);
- esc_due = now_ms() + ESC_MS;
- pending_paint = true; /* focus, splits and closes are visible changes */
- if (!next_frame) next_frame = now_ms();
- }
- }
-
- /* A lone ESC is only the Escape key once nothing follows it. */
- if (input_pending(in) && now_ms() >= esc_due) input_timeout(in, on_event, a);
-
- for (size_t i = 0; i < npanes; i++) {
- if (n > 0 && (pfds[i + 1].revents & (POLLIN | POLLHUP))) {
- app_pump_fd(a, pfds[i + 1].fd);
- if (!pending_paint) {
- pending_paint = true;
- next_frame = now_ms() + FRAME_MS;
- }
- }
- }
- app_reap(a);
-
- if (pending_paint && now_ms() >= next_frame) {
- app_compose(a, &s);
- screen_flush(&s, STDOUT_FILENO);
- pending_paint = false;
- next_frame = 0;
- }
- }
-
- input_free(in);
- screen_free(&s);
- app_free(a);
- restore_tty();
- return 0;
+static void usage(void) {
+ fputs("usage: sl0ptty [-s NAME] [ls | cmd LINE | -- CMD...]\n", stderr);
}
int main(int argc, char **argv) {
- bool headless = false, script = false;
+ bool headless = false, script = false, server = false;
uint16_t cols = 80, rows = 24;
int idle_ms = 300;
+ const char *name = "main";
+ const char *cmd_line = NULL;
+ bool list = false;
const char *cmd_argv[64];
int cmd_n = 0;
for (int i = 1; i < argc; i++) {
- if (strcmp(argv[i], "--headless") == 0) headless = true;
- else if (strcmp(argv[i], "--script") == 0) headless = script = true;
- else if (strcmp(argv[i], "--cols") == 0 && i + 1 < argc) cols = (uint16_t)atoi(argv[++i]);
- else if (strcmp(argv[i], "--rows") == 0 && i + 1 < argc) rows = (uint16_t)atoi(argv[++i]);
- else if (strcmp(argv[i], "--idle-ms") == 0 && i + 1 < argc) idle_ms = atoi(argv[++i]);
- else if (strcmp(argv[i], "--") == 0) {
+ const char *a = argv[i];
+ if (strcmp(a, "--headless") == 0) headless = true;
+ else if (strcmp(a, "--script") == 0) headless = script = true;
+ else if (strcmp(a, "--server") == 0) server = true;
+ else if (strcmp(a, "-s") == 0 && i + 1 < argc) name = argv[++i];
+ else if (strcmp(a, "ls") == 0) list = true;
+ else if (strcmp(a, "cmd") == 0 && i + 1 < argc) cmd_line = argv[++i];
+ else if (strcmp(a, "--cols") == 0 && i + 1 < argc) cols = (uint16_t)atoi(argv[++i]);
+ else if (strcmp(a, "--rows") == 0 && i + 1 < argc) rows = (uint16_t)atoi(argv[++i]);
+ else if (strcmp(a, "--idle-ms") == 0 && i + 1 < argc) idle_ms = atoi(argv[++i]);
+ else if (strcmp(a, "-h") == 0 || strcmp(a, "--help") == 0) {
+ usage();
+ return 0;
+ } else if (strcmp(a, "--") == 0) {
for (int j = i + 1; j < argc && cmd_n < 63; j++) cmd_argv[cmd_n++] = argv[j];
break;
} else {
- fprintf(stderr, "sl0ptty: unknown argument: %s\n", argv[i]);
+ fprintf(stderr, "sl0ptty: unknown argument: %s\n", a);
+ usage();
return 2;
}
}
@@ -219,13 +79,39 @@ int main(int argc, char **argv) {
}
cmd_argv[cmd_n] = NULL;
- struct sigaction sa = {.sa_handler = on_signal};
- sigaction(SIGWINCH, &sa, NULL);
- sigaction(SIGTERM, &sa, NULL);
- sigaction(SIGHUP, &sa, NULL);
- signal(SIGPIPE, SIG_IGN);
- signal(SIGCHLD, SIG_IGN); /* no zombies; EOF on the pty is our signal */
+ if (list) {
+ char **names = NULL;
+ size_t n = session_list(&names);
+ for (size_t i = 0; i < n; i++) {
+ int fd = server_connect(names[i]);
+ printf("%-20s %s\n", names[i], fd >= 0 ? "running" : "stale");
+ if (fd >= 0) close(fd);
+ free(names[i]);
+ }
+ free(names);
+ return 0;
+ }
- return headless ? run_headless(cmd_argv, cols, rows, idle_ms, script)
- : run_interactive(cmd_argv);
+ if (cmd_line) {
+ int fd = server_connect(name);
+ if (fd < 0) {
+ fprintf(stderr, "sl0ptty: no session named %s\n", name);
+ return 1;
+ }
+ return client_control(fd, cmd_line);
+ }
+
+ if (headless) return run_headless(cmd_argv, cols, rows, idle_ms, script);
+
+ if (isatty(STDOUT_FILENO)) term_size(&cols, &rows);
+ if (server) return server_run(name, cmd_argv, cols, rows);
+
+ /* attach, creating the session if nobody is home */
+ int fd = server_connect(name);
+ if (fd < 0) fd = server_spawn(name, cmd_argv, cols, rows);
+ if (fd < 0) {
+ fprintf(stderr, "sl0ptty: cannot start session %s\n", name);
+ return 1;
+ }
+ return client_run(fd);
}
diff --git a/src/proto.c b/src/proto.c
new file mode 100644
index 0000000..f0c318d
--- /dev/null
+++ b/src/proto.c
@@ -0,0 +1,135 @@
+#define _GNU_SOURCE
+#include "proto.h"
+
+#include <dirent.h>
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/un.h>
+#include <unistd.h>
+
+#define HDR 5
+
+void msg_reader_init(msg_reader_t *r) {
+ memset(r, 0, sizeof *r);
+ r->cap = 65536;
+ r->buf = malloc(r->cap);
+}
+
+void msg_reader_free(msg_reader_t *r) {
+ free(r->buf);
+ free(r->msg);
+ memset(r, 0, sizeof *r);
+}
+
+void msg_reader_feed(msg_reader_t *r, const uint8_t *data, size_t len) {
+ if (r->len + len > r->cap) {
+ while (r->cap < r->len + len) r->cap *= 2;
+ r->buf = realloc(r->buf, r->cap);
+ }
+ memcpy(r->buf + r->len, data, len);
+ r->len += len;
+}
+
+bool msg_reader_next(msg_reader_t *r, msg_t *out) {
+ if (r->len < HDR) return false;
+ uint32_t len = (uint32_t)r->buf[1] << 24 | (uint32_t)r->buf[2] << 16 |
+ (uint32_t)r->buf[3] << 8 | r->buf[4];
+ if (r->len < HDR + (size_t)len) return false;
+
+ /* Lift the payload out before compacting the buffer, so the caller's pointer
+ * does not alias bytes we are about to move. Valid until the next call. */
+ if (len + 1 > r->msg_cap) {
+ r->msg_cap = len + 1;
+ r->msg = realloc(r->msg, r->msg_cap);
+ }
+ if (len) memcpy(r->msg, r->buf + HDR, len);
+ r->msg[len] = 0; /* JSON payloads want a terminator */
+
+ out->type = r->buf[0];
+ out->len = len;
+ out->data = r->msg;
+
+ size_t total = HDR + (size_t)len;
+ memmove(r->buf, r->buf + total, r->len - total);
+ r->len -= total;
+ return true;
+}
+
+int msg_send(int fd, uint8_t type, const void *data, size_t len) {
+ uint8_t hdr[HDR] = {type, (uint8_t)(len >> 24), (uint8_t)(len >> 16),
+ (uint8_t)(len >> 8), (uint8_t)len};
+ struct iovec iov[2] = {{hdr, HDR}, {(void *)data, len}};
+ size_t total = HDR + len, sent = 0;
+ while (sent < total) {
+ struct msghdr mh = {0};
+ struct iovec cur[2];
+ size_t n = 0;
+ size_t off = sent;
+ for (int i = 0; i < 2; i++) {
+ size_t l = iov[i].iov_len;
+ if (off >= l) {
+ off -= l;
+ continue;
+ }
+ cur[n].iov_base = (char *)iov[i].iov_base + off;
+ cur[n].iov_len = l - off;
+ off = 0;
+ n++;
+ }
+ mh.msg_iov = cur;
+ mh.msg_iovlen = n;
+ ssize_t w = sendmsg(fd, &mh, MSG_NOSIGNAL);
+ if (w < 0) {
+ if (errno == EINTR) continue;
+ return -1;
+ }
+ sent += (size_t)w;
+ }
+ return 0;
+}
+
+static int session_dir(char *out, size_t cap) {
+ const char *run = getenv("XDG_RUNTIME_DIR");
+ if (run && *run) snprintf(out, cap, "%s/sl0ptty", run);
+ else snprintf(out, cap, "/tmp/sl0ptty-%u", (unsigned)getuid());
+ if (mkdir(out, 0700) != 0 && errno != EEXIST) return -1;
+ return 0;
+}
+
+int session_socket_path(const char *name, char *out, size_t cap) {
+ char dir[512];
+ if (session_dir(dir, sizeof dir) != 0) return -1;
+ snprintf(out, cap, "%s/%s.sock", dir, name);
+ return 0;
+}
+
+int session_log_path(const char *name, char *out, size_t cap) {
+ char dir[512];
+ if (session_dir(dir, sizeof dir) != 0) return -1;
+ snprintf(out, cap, "%s/%s.log", dir, name);
+ return 0;
+}
+
+size_t session_list(char ***out_names) {
+ char dir[512];
+ if (session_dir(dir, sizeof dir) != 0) return 0;
+ DIR *d = opendir(dir);
+ if (!d) return 0;
+ char **names = NULL;
+ size_t n = 0;
+ struct dirent *e;
+ while ((e = readdir(d))) {
+ size_t l = strlen(e->d_name);
+ if (l < 6 || strcmp(e->d_name + l - 5, ".sock") != 0) continue;
+ names = realloc(names, (n + 1) * sizeof *names);
+ names[n] = strndup(e->d_name, l - 5);
+ n++;
+ }
+ closedir(d);
+ *out_names = names;
+ return n;
+}
diff --git a/src/proto.h b/src/proto.h
new file mode 100644
index 0000000..dc3ea3a
--- /dev/null
+++ b/src/proto.h
@@ -0,0 +1,63 @@
+/* The client/server wire, and where sessions live.
+ *
+ * Frames are `u8 type, u32 length (big endian), payload`. Deliberately dull:
+ * the interesting protocol is the JSON control channel (M3) that rides on
+ * MSG_CMD, and it speaks the same vocabulary as the headless driver.
+ */
+#ifndef SL0PTTY_PROTO_H
+#define SL0PTTY_PROTO_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+enum {
+ MSG_HELLO = 1, /* client -> server: u16 cols, u16 rows */
+ MSG_INPUT = 2, /* client -> server: raw bytes from the terminal */
+ MSG_RESIZE = 3, /* client -> server: u16 cols, u16 rows */
+ MSG_DETACH = 4, /* client -> server: leave, but keep running */
+ MSG_OUTPUT = 5, /* server -> client: bytes for the terminal */
+ MSG_EXIT = 6, /* server -> client: u8 reason */
+ MSG_CMD = 7, /* client -> server: one JSON line */
+ MSG_REPLY = 8, /* server -> client: one JSON line */
+};
+
+enum {
+ EXIT_SESSION_ENDED = 0, /* the last pane closed */
+ EXIT_DETACHED = 1, /* the user detached */
+ EXIT_REPLACED = 2, /* another client attached */
+};
+
+typedef struct {
+ uint8_t type;
+ uint8_t *data;
+ uint32_t len;
+} msg_t;
+
+/* Incremental framing: feed bytes, take whole messages out. */
+typedef struct {
+ uint8_t *buf;
+ size_t len, cap;
+ uint8_t *msg; /* the payload handed to the caller, lifted out of buf */
+ size_t msg_cap;
+} msg_reader_t;
+
+void msg_reader_init(msg_reader_t *r);
+void msg_reader_free(msg_reader_t *r);
+void msg_reader_feed(msg_reader_t *r, const uint8_t *data, size_t len);
+/* Returns true and fills `out` (data points into the reader's buffer, valid
+ * until the next call) when a whole message is available. */
+bool msg_reader_next(msg_reader_t *r, msg_t *out);
+
+/* Blocking send of one frame. Returns 0 on success, -1 if the peer is gone. */
+int msg_send(int fd, uint8_t type, const void *data, size_t len);
+
+/* $XDG_RUNTIME_DIR/sl0ptty/<name>.sock, or /tmp/sl0ptty-<uid>/<name>.sock.
+ * Creates the directory (0700). Returns 0 on success. */
+int session_socket_path(const char *name, char *out, size_t cap);
+int session_log_path(const char *name, char *out, size_t cap);
+/* Names of live sessions (sockets that answer). Caller frees each and the
+ * array. Returns the count. */
+size_t session_list(char ***out_names);
+
+#endif /* SL0PTTY_PROTO_H */
diff --git a/src/screen.c b/src/screen.c
index e36f461..ed7c343 100644
--- a/src/screen.c
+++ b/src/screen.c
@@ -182,7 +182,7 @@ static bool style_eq(const cell_t *a, const cell_t *b) {
return a->attrs == b->attrs && color_eq(a->fg, b->fg) && color_eq(a->bg, b->bg);
}
-void screen_flush(screen_t *s, int fd) {
+void screen_render(screen_t *s) {
s->out_len = 0;
/* The cursor is hidden lazily, on the first cell we actually repaint, so a
@@ -247,13 +247,15 @@ void screen_flush(screen_t *s, int fd) {
}
s->force_full = false;
- if (s->out_len) {
- size_t off = 0;
- while (off < s->out_len) {
- ssize_t n = write(fd, s->out + off, s->out_len - off);
- if (n <= 0) break;
- off += (size_t)n;
- }
+}
+
+void screen_flush(screen_t *s, int fd) {
+ screen_render(s);
+ size_t off = 0;
+ while (off < s->out_len) {
+ ssize_t n = write(fd, s->out + off, s->out_len - off);
+ if (n <= 0) break;
+ off += (size_t)n;
}
}
diff --git a/src/server.c b/src/server.c
new file mode 100644
index 0000000..4326fb2
--- /dev/null
+++ b/src/server.c
@@ -0,0 +1,348 @@
+/* The server: owns the panes and the layout, renders, and talks to at most one
+ * attached client (D10). It outlives every client, which is the entire point —
+ * agents keep running while you are gone. */
+#define _GNU_SOURCE
+#include "server.h"
+
+#include <errno.h>
+#include <fcntl.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/un.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "app.h"
+#include "cmd.h"
+#include "proto.h"
+
+#include <sys/wait.h>
+
+#define MAX_PANES 64
+#define FRAME_MS 8
+#define ESC_MS 40
+
+static volatile sig_atomic_t g_stop = 0;
+static void on_sig(int s) { g_stop = 1; }
+
+static int64_t now_ms(void) {
+ struct timespec ts;
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
+}
+
+/* Connections are not clients. A connection becomes *the* display client only
+ * by sending MSG_HELLO; anything else (a control command, a liveness probe
+ * from `ls`) is just a connection and never displaces anybody.
+ *
+ * This distinction is not fussiness. The zellij fork sl0ptty replaces had a
+ * 20-25%% CLI failure rate because every action probed liveness by connecting,
+ * that probe counted as a client, and its teardown removed the *real* one. */
+#define MAX_CONNS 16
+
+typedef struct {
+ int fd;
+ msg_reader_t reader;
+ bool display;
+} conn_t;
+
+typedef struct {
+ app_t *app;
+ screen_t screen;
+ input_parser_t *in;
+ conn_t conns[MAX_CONNS];
+ size_t nconns;
+} server_t;
+
+static conn_t *display_conn(server_t *s) {
+ for (size_t i = 0; i < s->nconns; i++)
+ if (s->conns[i].display) return &s->conns[i];
+ return NULL;
+}
+
+static void conn_close(server_t *s, size_t i) {
+ close(s->conns[i].fd);
+ msg_reader_free(&s->conns[i].reader);
+ s->conns[i] = s->conns[--s->nconns];
+}
+
+static void conn_drop(server_t *s, size_t i, uint8_t reason) {
+ msg_send(s->conns[i].fd, MSG_EXIT, &reason, 1);
+ conn_close(s, i);
+}
+
+static void srv_event(const input_event_t *ev, void *ud) {
+ app_event(((server_t *)ud)->app, ev);
+}
+
+static int listen_socket(const char *path) {
+ int fd = socket(AF_UNIX, SOCK_STREAM, 0);
+ if (fd < 0) return -1;
+ struct sockaddr_un addr = {.sun_family = AF_UNIX};
+ snprintf(addr.sun_path, sizeof addr.sun_path, "%s", path);
+ unlink(path);
+ if (bind(fd, (struct sockaddr *)&addr, sizeof addr) != 0) {
+ close(fd);
+ return -1;
+ }
+ chmod(path, 0600);
+ if (listen(fd, 4) != 0) {
+ close(fd);
+ return -1;
+ }
+ return fd;
+}
+
+/* Send whatever the last compose produced to the display client. */
+static void push_frame(server_t *s) {
+ conn_t *c = display_conn(s);
+ if (!c) return;
+ screen_render(&s->screen);
+ if (!s->screen.out_len) return;
+ if (msg_send(c->fd, MSG_OUTPUT, s->screen.out, s->screen.out_len) != 0)
+ for (size_t i = 0; i < s->nconns; i++)
+ if (&s->conns[i] == c) {
+ conn_close(s, i);
+ break;
+ }
+}
+
+static void drop_display(server_t *s, uint8_t reason) {
+ for (size_t i = 0; i < s->nconns; i++)
+ if (s->conns[i].display) {
+ conn_drop(s, i, reason);
+ return;
+ }
+}
+
+int server_run(const char *name, const char *const argv[], uint16_t cols,
+ uint16_t rows) {
+ char path[512];
+ if (session_socket_path(name, path, sizeof path) != 0) return 1;
+ int lfd = listen_socket(path);
+ if (lfd < 0) {
+ fprintf(stderr, "sl0ptty: cannot listen on %s: %s\n", path, strerror(errno));
+ return 1;
+ }
+
+ struct sigaction sa = {.sa_handler = on_sig};
+ sigaction(SIGTERM, &sa, NULL);
+ sigaction(SIGINT, &sa, NULL);
+ signal(SIGPIPE, SIG_IGN);
+ signal(SIGCHLD, SIG_IGN);
+
+ server_t s = {0};
+ s.app = app_new(argv, cols, rows);
+ if (!s.app) return 1;
+ app_resize(s.app, cols, rows);
+ screen_init(&s.screen, cols, rows);
+ s.in = input_new();
+
+ bool pending_paint = true;
+ int64_t next_frame = now_ms();
+ int64_t esc_due = 0;
+
+ while (!g_stop && !app_should_quit(s.app)) {
+ int fds[MAX_PANES];
+ size_t npanes = app_fds(s.app, fds, MAX_PANES);
+ struct pollfd pfds[MAX_PANES + MAX_CONNS + 1];
+ size_t n = 0;
+ pfds[n++] = (struct pollfd){.fd = lfd, .events = POLLIN};
+ size_t conn_slot = n;
+ for (size_t i = 0; i < s.nconns; i++)
+ pfds[n++] = (struct pollfd){.fd = s.conns[i].fd, .events = POLLIN};
+ size_t pane_slot = n;
+ for (size_t i = 0; i < npanes; i++)
+ pfds[n++] = (struct pollfd){.fd = fds[i], .events = POLLIN};
+
+ int timeout = -1;
+ if (pending_paint) {
+ int64_t due = next_frame - now_ms();
+ timeout = due <= 0 ? 0 : (int)due;
+ }
+ if (input_pending(s.in)) {
+ int64_t due = esc_due - now_ms();
+ int t = due <= 0 ? 0 : (int)due;
+ if (timeout < 0 || t < timeout) timeout = t;
+ }
+
+ int r = poll(pfds, (nfds_t)n, timeout);
+ if (r < 0 && errno != EINTR) break;
+
+ if (pfds[0].revents & POLLIN) {
+ int c = accept(lfd, NULL, NULL);
+ if (c >= 0) {
+ if (s.nconns == MAX_CONNS) {
+ close(c);
+ } else {
+ conn_t *nc = &s.conns[s.nconns++];
+ nc->fd = c;
+ nc->display = false;
+ msg_reader_init(&nc->reader);
+ }
+ }
+ }
+
+ for (size_t ci = 0; ci < s.nconns;) {
+ struct pollfd *pf = &pfds[conn_slot + ci];
+ if (pf->fd != s.conns[ci].fd || !(pf->revents & (POLLIN | POLLHUP))) {
+ ci++;
+ continue;
+ }
+ uint8_t buf[65536];
+ ssize_t got = read(s.conns[ci].fd, buf, sizeof buf);
+ if (got <= 0) {
+ conn_close(&s, ci); /* gone: the session keeps running regardless */
+ continue;
+ }
+ msg_reader_feed(&s.conns[ci].reader, buf, (size_t)got);
+ msg_t m;
+ bool closed = false;
+ while (!closed && msg_reader_next(&s.conns[ci].reader, &m)) {
+ switch (m.type) {
+ case MSG_HELLO:
+ case MSG_RESIZE:
+ if (m.type == MSG_HELLO && !s.conns[ci].display) {
+ /* now it is a client: the previous display is displaced */
+ for (size_t j = 0; j < s.nconns; j++)
+ if (j != ci && s.conns[j].display) {
+ conn_drop(&s, j, EXIT_REPLACED);
+ if (j < ci) ci--; /* the array compacted under us */
+ break;
+ }
+ s.conns[ci].display = true;
+ }
+ if (m.len >= 4 && s.conns[ci].display) {
+ uint16_t c = (uint16_t)(m.data[0] << 8 | m.data[1]);
+ uint16_t rr = (uint16_t)(m.data[2] << 8 | m.data[3]);
+ if (c && rr) {
+ screen_resize(&s.screen, c, rr);
+ app_resize(s.app, c, rr);
+ }
+ }
+ s.screen.force_full = true; /* a fresh client knows nothing */
+ pending_paint = true;
+ next_frame = now_ms();
+ break;
+ case MSG_INPUT:
+ if (!s.conns[ci].display) break; /* only the client types */
+ input_feed(s.in, m.data, m.len, srv_event, &s);
+ esc_due = now_ms() + ESC_MS;
+ pending_paint = true;
+ break;
+ case MSG_DETACH:
+ conn_drop(&s, ci, EXIT_DETACHED);
+ closed = true;
+ break;
+ case MSG_CMD: {
+ /* The same vocabulary the headless driver speaks (cmd.c), so a
+ * script written against one works against the other. */
+ bool q = false;
+ char *reply =
+ cmd_exec(s.app, &s.screen, s.in, (const char *)m.data, &q);
+ const char *body = reply ? reply : "{\"error\":\"unknown command\"}";
+ msg_send(s.conns[ci].fd, MSG_REPLY, body, strlen(body));
+ free(reply);
+ if (q) g_stop = 1;
+ s.screen.force_full = true; /* it may have changed the layout */
+ pending_paint = true;
+ next_frame = now_ms();
+ break;
+ }
+ default:
+ break;
+ }
+ }
+ if (!closed) ci++;
+ }
+
+ if (input_pending(s.in) && now_ms() >= esc_due)
+ input_timeout(s.in, srv_event, &s);
+
+ for (size_t i = 0; i < npanes; i++) {
+ if (pfds[pane_slot + i].revents & (POLLIN | POLLHUP)) {
+ app_pump_fd(s.app, pfds[pane_slot + i].fd);
+ if (!pending_paint) {
+ pending_paint = true;
+ next_frame = now_ms() + FRAME_MS;
+ }
+ }
+ }
+ app_reap(s.app);
+
+ if (app_detach_requested(s.app)) {
+ app_clear_detach(s.app);
+ drop_display(&s, EXIT_DETACHED);
+ }
+
+ if (pending_paint && now_ms() >= next_frame) {
+ app_compose(s.app, &s.screen);
+ push_frame(&s);
+ pending_paint = false;
+ next_frame = now_ms();
+ }
+ }
+
+ while (s.nconns) conn_drop(&s, 0, EXIT_SESSION_ENDED);
+ unlink(path);
+ close(lfd);
+ input_free(s.in);
+ screen_free(&s.screen);
+ app_free(s.app);
+ return 0;
+}
+
+/* Fork a server into the background and wait for its socket to answer. */
+int server_spawn(const char *name, const char *const argv[], uint16_t cols,
+ uint16_t rows) {
+ char path[512], logp[512];
+ if (session_socket_path(name, path, sizeof path) != 0) return -1;
+ session_log_path(name, logp, sizeof logp);
+
+ pid_t pid = fork();
+ if (pid < 0) return -1;
+ if (pid == 0) {
+ setsid();
+ if (fork() != 0) _exit(0); /* orphan the server so no one waits on it */
+ int null = open("/dev/null", O_RDWR);
+ int log = open(logp, O_WRONLY | O_CREAT | O_APPEND, 0600);
+ dup2(null, STDIN_FILENO);
+ dup2(log >= 0 ? log : null, STDOUT_FILENO);
+ dup2(log >= 0 ? log : null, STDERR_FILENO);
+ if (null > 2) close(null);
+ if (log > 2) close(log);
+ _exit(server_run(name, argv, cols, rows));
+ }
+ waitpid(pid, NULL, 0); /* the intermediate exits immediately */
+
+ /* Wait for the socket to accept, rather than sleeping and hoping. */
+ for (int i = 0; i < 400; i++) {
+ int fd = socket(AF_UNIX, SOCK_STREAM, 0);
+ struct sockaddr_un addr = {.sun_family = AF_UNIX};
+ snprintf(addr.sun_path, sizeof addr.sun_path, "%s", path);
+ if (connect(fd, (struct sockaddr *)&addr, sizeof addr) == 0) return fd;
+ close(fd);
+ struct timespec ts = {.tv_nsec = 5 * 1000 * 1000};
+ nanosleep(&ts, NULL);
+ }
+ return -1;
+}
+
+int server_connect(const char *name) {
+ char path[512];
+ if (session_socket_path(name, path, sizeof path) != 0) return -1;
+ int fd = socket(AF_UNIX, SOCK_STREAM, 0);
+ if (fd < 0) return -1;
+ struct sockaddr_un addr = {.sun_family = AF_UNIX};
+ snprintf(addr.sun_path, sizeof addr.sun_path, "%s", path);
+ if (connect(fd, (struct sockaddr *)&addr, sizeof addr) != 0) {
+ close(fd);
+ return -1;
+ }
+ return fd;
+}
diff --git a/src/server.h b/src/server.h
new file mode 100644
index 0000000..c81dd33
--- /dev/null
+++ b/src/server.h
@@ -0,0 +1,22 @@
+/* Server and client entry points. See proto.h for the wire. */
+#ifndef SL0PTTY_SERVER_H
+#define SL0PTTY_SERVER_H
+
+#include <stdbool.h>
+#include <stdint.h>
+
+#include "sl0ptty.h"
+
+/* Run the session in this process (the daemonised half). */
+int server_run(const char *name, const char *const argv[], uint16_t cols,
+ uint16_t rows);
+/* Fork a server, then wait for its socket to answer. Returns a connected fd. */
+int server_spawn(const char *name, const char *const argv[], uint16_t cols,
+ uint16_t rows);
+/* Connect to an existing session, or -1. */
+int server_connect(const char *name);
+
+int client_run(int fd);
+int client_control(int fd, const char *line);
+
+#endif /* SL0PTTY_SERVER_H */
diff --git a/src/sl0ptty.h b/src/sl0ptty.h
index fdba027..2a87f75 100644
--- a/src/sl0ptty.h
+++ b/src/sl0ptty.h
@@ -86,7 +86,9 @@ void screen_put_utf8(screen_t *s, uint16_t x, uint16_t y, const char *txt,
/* Write a UTF-8 string one cell per codepoint; returns cells written. */
uint16_t screen_text(screen_t *s, uint16_t x, uint16_t y, const char *txt,
color_t fg, color_t bg, uint16_t attrs);
-/* Diff cur against prev and write the minimal byte stream to fd. */
+/* Diff cur against prev into s->out (the minimal byte stream for a terminal). */
+void screen_render(screen_t *s);
+/* screen_render, then write it to fd. */
void screen_flush(screen_t *s, int fd);
/* Plain-text dump of the composited screen; caller frees. (headless tests) */
char *screen_dump(screen_t *s);