// Differential tests for MplexServer: the same raw-socket scenario (control protocol, frames, key // handling, UI flows) is run against the C++ server and against tai.py's Python server in two // throwaway TAI_HOMEs, and the normalized transcripts must be byte for byte equal. Real sockets, // real bash panes, no mocks. Modeled on tests/plexer.py and tests/mplex.py. // // g++ -std=c++23 -Wall -Wextra -Isrc/mplex tests/mplex_server_test.cpp src/mplex/*.cpp -o build/mplex_server_test // build/mplex_server_test [--tai ../tai.py] [--slow] #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "mplex_json.h" #include "mplex_server.h" using namespace detz::mplex; namespace { int g_checks = 0; int g_failed = 0; void check(bool ok, const std::string& what) { ++g_checks; if (!ok) { ++g_failed; std::cerr << "FAIL: " << what << "\n"; } } void sleep_ms(int ms) { ::usleep(static_cast(ms) * 1000); } std::string replace_all(std::string text, const std::string& from, const std::string& to) { if (from.empty()) return text; size_t pos = 0; while ((pos = text.find(from, pos)) != std::string::npos) { text.replace(pos, from.size(), to); pos += to.size(); } return text; } struct Frame { char kind; std::string payload; }; // One unix socket connection. class Conn { public: Conn() = default; Conn(const Conn&) = delete; Conn(Conn&& o) noexcept : fd(o.fd), buf(std::move(o.buf)) { o.fd = -1; } ~Conn() { close(); } int fd = -1; std::string buf; bool open(const std::string& path) { fd = ::socket(AF_UNIX, SOCK_STREAM, 0); sockaddr_un addr{}; addr.sun_family = AF_UNIX; std::strncpy(addr.sun_path, path.c_str(), sizeof addr.sun_path - 1); if (::connect(fd, reinterpret_cast(&addr), sizeof addr) != 0) { ::close(fd); fd = -1; return false; } return true; } void close() { if (fd >= 0) ::close(fd); fd = -1; } void send_all(const std::string& data) { size_t off = 0; while (off < data.size()) { ssize_t n = ::send(fd, data.data() + off, data.size() - off, MSG_NOSIGNAL); if (n <= 0) return; off += static_cast(n); } } // Read what arrives within timeout_ms into buf; returns false when the peer closed. bool fill(int timeout_ms) { pollfd p{fd, POLLIN, 0}; if (::poll(&p, 1, timeout_ms) <= 0) return true; char tmp[65536]; ssize_t n = ::recv(fd, tmp, sizeof tmp, 0); if (n <= 0) return false; buf.append(tmp, static_cast(n)); return true; } // JSON line reply (control protocol): until newline or close. std::string read_line(int timeout_ms = 5000) { int waited = 0; while (buf.find('\n') == std::string::npos && waited < timeout_ms) { if (!fill(50)) break; waited += 50; } size_t nl = buf.find('\n'); std::string line = nl == std::string::npos ? buf : buf.substr(0, nl); buf.clear(); return line; } // Frames: kind byte, u32 big-endian length, payload. std::optional frame(int timeout_ms) { int waited = 0; for (;;) { if (buf.size() >= 5) { uint32_t len = (static_cast(buf[1]) << 24) | (static_cast(buf[2]) << 16) | (static_cast(buf[3]) << 8) | static_cast(buf[4]); if (buf.size() >= 5 + len) { Frame f{buf[0], buf.substr(5, len)}; buf.erase(0, 5 + len); return f; } } if (waited >= timeout_ms) return std::nullopt; if (!fill(50)) { waited = timeout_ms; continue; } waited += 50; } } bool closed_by_peer(int timeout_ms) { int waited = 0; while (waited < timeout_ms) { pollfd p{fd, POLLIN, 0}; if (::poll(&p, 1, 50) > 0) { char tmp[4096]; ssize_t n = ::recv(fd, tmp, sizeof tmp, 0); if (n == 0) return true; if (n < 0) return true; } waited += 50; } return false; } }; std::string make_frame(char kind, const std::string& payload) { std::string out(1, kind); uint32_t n = static_cast(payload.size()); out += static_cast(n >> 24); out += static_cast(n >> 16); out += static_cast(n >> 8); out += static_cast(n); return out + payload; } // Everything one scenario needs about one server. struct Env { std::string label; std::string home; std::string sock; std::string transcript; void note(const std::string& key, std::string value) { value = replace_all(value, home, ""); // stamp: 8 digits T 6 digits for (size_t i = 0; i + 15 <= value.size(); ++i) { bool ok = value[i + 8] == 'T'; for (size_t k = 0; ok && k < 8; ++k) ok = std::isdigit(static_cast(value[i + k])) != 0; for (size_t k = 9; ok && k < 15; ++k) ok = std::isdigit(static_cast(value[i + k])) != 0; if (ok) value.replace(i, 15, ""); } // "pid": N for (const char* tag : {"\"pid\": ", "pid\":"}) { size_t pos = 0; std::string t = tag; while ((pos = value.find(t, pos)) != std::string::npos) { size_t s = pos + t.size(); size_t e = s; while (e < value.size() && std::isdigit(static_cast(value[e]))) ++e; if (e > s) value.replace(s, e - s, ""); pos = s + 1; } } std::string clean; for (char c : value) { if (c == '\x1b') clean += ""; else if (c == '\r') clean += ""; else clean += c; } transcript += key + " => " + clean + "\n"; } std::string control(const std::string& line) { Conn c; if (!c.open(sock)) return ""; c.send_all(line); return c.read_line(); } std::string ctl(const std::string& key, const std::string& line) { std::string reply = control(line + "\n"); note(key, reply); return reply; } }; std::string hello_json(const std::vector>& commands, const std::string& pane = "") { Json info = Json::object(); info.set("rows", Json::integer(24)); info.set("cols", Json::integer(80)); info.set("term", Json::string("xterm-256color")); info.set("cwd", Json::string("/tmp")); info.set("pid", Json::integer(4242)); info.set("tty", Json::string("/dev/pts/99")); Json cmds = Json::array(); for (const auto& words : commands) { Json w = Json::array(); for (const auto& word : words) w.push(Json::string(word)); cmds.push(w); } info.set("commands", cmds); Json env = Json::object(); env.set("SHELL", Json::string("/bin/bash")); env.set("TERM", Json::string("xterm-256color")); env.set("PS1", Json::string("$ ")); env.set("PATH", Json::string(std::getenv("PATH") ? std::getenv("PATH") : "/usr/bin:/bin")); env.set("HOME", Json::string("/tmp")); info.set("env", env); info.set("pane", Json::string(pane)); return info.dumps(); } // Run one hello conversation; returns ":" lines for the frames until close/X/D/E. std::string run_command(Env& e, const std::vector>& commands, int wait_ms = 3000) { Conn c; if (!c.open(e.sock)) return ""; c.send_all(std::string("\x01") + make_frame('H', hello_json(commands))); std::string out; int waited = 0; while (waited < wait_ms) { auto f = c.frame(100); waited += 100; if (!f) continue; out += std::string(1, f->kind) + ":" + f->payload + "|"; if (f->kind == 'X' || f->kind == 'D' || f->kind == 'E') break; } return out; } // An attached raw client. pump() drains frames; the screen text of the last O frames is kept. struct Attached { Conn conn; std::string last_kind; std::string seen; // concatenated O payloads bool attach(Env& e, const std::vector>& commands) { if (!conn.open(e.sock)) return false; conn.send_all(std::string("\x01") + make_frame('H', hello_json(commands))); for (int i = 0; i < 60; ++i) { auto f = conn.frame(100); if (!f) continue; last_kind += f->kind; if (f->kind == 'A') return true; if (f->kind == 'E' || f->kind == 'X' || f->kind == 'D') return false; } return false; } void pump(int ms) { int waited = 0; while (waited < ms) { auto f = conn.frame(50); waited += 50; if (f) { last_kind += f->kind; if (f->kind == 'O') seen += f->payload; } } } void keys(const std::string& data) { conn.send_all(make_frame('I', data)); } void resize(int rows, int cols) { conn.send_all(make_frame('R', "{\"rows\": " + std::to_string(rows) + ", \"cols\": " + std::to_string(cols) + "}")); } }; // ---- the scenario --------------------------------------------------------------------------------- void scenario(Env& e, bool slow) { // 1. frames: a detached base session (bash without rc files so both servers see the same shell). e.note("new-session -d", run_command(e, {{"new-session", "-d", "-s", "base", "bash", "--norc", "--noprofile"}})); sleep_ms(700); e.note("has-session", run_command(e, {{"has-session", "-t", "base"}})); e.note("has-session missing", run_command(e, {{"has-session", "-t", "nosuch"}})); e.note("unknown command", run_command(e, {{"zzz"}})); e.note("ambiguous command", run_command(e, {{"list-"}})); e.note("unsupported command", run_command(e, {{"display-menu"}})); e.note("bad flag", run_command(e, {{"list-sessions", "-Q"}})); e.note("list-sessions -F", run_command(e, {{"list-sessions", "-F", "#{session_name}:#{session_windows}:#{session_attached}"}})); e.note("list-windows -F", run_command(e, {{"list-windows", "-F", "#{window_index}:#{window_name}:#{window_flags}:#{window_panes}"}})); e.note("list-panes -F", run_command(e, {{"list-panes", "-F", "#{pane_index}:#{pane_id}:#{pane_width}x#{pane_height}:#{pane_active}"}})); e.note("two commands", run_command(e, {{"list-sessions", "-F", "a#{session_name}"}, {"list-sessions", "-F", "b#{session_name}"}})); e.note("empty commands", run_command(e, {})); { Conn c; c.open(e.sock); c.send_all(std::string("\x01") + make_frame('H', "{not json")); auto f = c.frame(3000); e.note("bad hello json", f ? std::string(1, f->kind) + ":" + f->payload : ""); } { Conn c; c.open(e.sock); c.send_all(std::string("\x01") + make_frame('H', "[1]")); auto f = c.frame(3000); e.note("hello not object", f ? std::string(1, f->kind) + ":" + f->payload : ""); } { Conn c; c.open(e.sock); c.send_all(std::string("\x01") + make_frame('H', "{\"rows\": \"x\"}")); auto f = c.frame(3000); e.note("hello bad rows", f ? std::string(1, f->kind) + ":" + f->payload : ""); } { Conn c; // a frame length over MPLEX_FRAME_MAX drops the client c.open(e.sock); std::string bad = "\x01H"; bad += std::string("\x7f\xff\xff\xff", 4); c.send_all(bad); e.note("oversized frame closes", c.closed_by_peer(2000) ? "closed" : "open"); } // 2. control protocol e.ctl("status", "{\"cmd\": \"status\"}"); e.ctl("send", "{\"cmd\": \"send\", \"text\": \"echo marker_one\", \"enter\": true}"); sleep_ms(500); { std::string reply = e.control("{\"cmd\": \"read\", \"lines\": 50}\n"); Json parsed = Json::loads(reply); const Json* data = parsed.get("data"); bool has = data && data->is_string() && data->as_string().find("marker_one") != std::string::npos; e.note("read has marker", has ? "yes" : "no: " + reply); } e.ctl("send nonascii", "{\"cmd\": \"send\", \"text\": \"echo \\u00e9\\u4e2d\", \"enter\": true}"); sleep_ms(300); e.ctl("send pane 0", "{\"cmd\": \"send\", \"text\": \"\", \"pane\": 0}"); e.ctl("send pane %0", "{\"cmd\": \"send\", \"text\": \"\", \"pane\": \"%0\"}"); e.ctl("send pane 999", "{\"cmd\": \"send\", \"pane\": \"999\"}"); e.ctl("send pane x", "{\"cmd\": \"send\", \"pane\": \"x\"}"); e.ctl("send pane list", "{\"cmd\": \"send\", \"pane\": [1]}"); e.ctl("send pane empty", "{\"cmd\": \"send\", \"text\": \"\", \"pane\": \"\"}"); e.ctl("read lines abc", "{\"cmd\": \"read\", \"lines\": \"abc\"}"); e.ctl("read lines list", "{\"cmd\": \"read\", \"lines\": [1]}"); for (const char* variant : {"null", "NaN", "2.9", "true", "\"3\"", "-4", "0"}) { std::string reply = e.control(std::string("{\"cmd\": \"read\", \"lines\": ") + variant + "}\n"); // pane output carries per-run uuids: only the envelope is compared e.note(std::string("read lines ") + variant, reply.substr(0, 20) + (reply.find("\"error\"") != std::string::npos ? reply : "")); } e.ctl("not an object", "[1, 2, 3]"); e.ctl("string request", "\"hello\""); e.ctl("number request", "7"); e.ctl("null request", "null"); e.ctl("empty object", "{}"); e.ctl("unknown cmd", "{\"cmd\": \"nope\"}"); e.ctl("cmd null", "{\"cmd\": null}"); e.ctl("cmd number", "{\"cmd\": 5}"); e.ctl("cmd list", "{\"cmd\": [1]}"); e.ctl("not json", "not json at all"); e.ctl("json trailing", "{\"cmd\": \"status\"} x"); e.ctl("empty line", ""); e.ctl("result no id", "{\"cmd\": \"result\"}"); e.ctl("result bad id", "{\"cmd\": \"result\", \"id\": \"zz\"}"); e.ctl("result id list", "{\"cmd\": \"result\", \"id\": [1]}"); e.ctl("result unknown", "{\"cmd\": \"result\", \"id\": 99}"); e.ctl("result unknown str", "{\"cmd\": \"result\", \"id\": \"99\"}"); e.ctl("cancel no id", "{\"cmd\": \"cancel\"}"); e.ctl("cancel unknown", "{\"cmd\": \"cancel\", \"id\": 99}"); e.ctl("show no screen", "{\"cmd\": \"show\"}"); e.ctl("show screen list", "{\"cmd\": \"show\", \"screen\": []}"); e.ctl("show bad kind", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"x\"}}"); e.ctl("show kind number", "{\"cmd\": \"show\", \"screen\": {\"kind\": 1}}"); e.ctl("show bad title", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"message\", \"title\": 1}}"); e.ctl("show long title", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"message\", \"title\": \"" + std::string(201, 'a') + "\"}}"); e.ctl("show long text", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"message\", \"text\": \"" + std::string(2001, 'a') + "\"}}"); e.ctl("show choice no options", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"choice\"}}"); e.ctl("show choice empty options", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"choice\", \"options\": []}}"); { std::string many = "["; for (int i = 0; i < 13; ++i) many += std::string(i ? "," : "") + "\"o" + std::to_string(i) + "\""; many += "]"; e.ctl("show choice 13 options", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"choice\", \"options\": " + many + "}}"); } e.ctl("show choice blank label", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"choice\", \"options\": [\" \"]}}"); e.ctl("show choice number label", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"choice\", \"options\": [1]}}"); e.ctl("show choice bad default", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"choice\", \"options\": [\"a\"], \"default\": \"x\"}}"); e.ctl("show choice default range", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"choice\", \"options\": [\"a\"], \"default\": 5}}"); e.ctl("show choice default null", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"choice\", \"options\": [\"a\"], \"default\": null}}"); e.ctl("show bad initial", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"text\", \"initial\": 5}}"); e.ctl("show long initial", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"text\", \"initial\": \"" + std::string(2001, 'a') + "\"}}"); e.ctl("show bad timeout", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"message\"}, \"timeout\": \"x\"}"); e.ctl("show timeout list", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"message\"}, \"timeout\": []}"); e.ctl("show no client", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"message\", \"text\": \"hi\"}}"); e.ctl("show pane 999", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"message\"}, \"pane\": \"999\"}"); { // a request larger than one recv and larger than PLEXER_RECV_MAX without a newline Conn c; c.open(e.sock); c.send_all("{\"cmd\": \"send\", \"text\": \"" + std::string(1100000, 'a')); e.note("recv max without newline", c.read_line()); } { // a large well formed request (> 65536 bytes) lands as one request std::string big = "{\"cmd\": \"send\", \"text\": \"echo BIG_START_" + std::string(90000, 'x') + "_BIG_END\", \"enter\": true}"; e.ctl("send large", big); sleep_ms(800); std::string reply = e.control("{\"cmd\": \"read\", \"lines\": 2000}\n"); Json parsed = Json::loads(reply); const Json* data = parsed.get("data"); bool ok = data && data->as_string().find("BIG_START_") != std::string::npos && data->as_string().find("_BIG_END") != std::string::npos; e.note("large payload intact", ok ? "yes" : "no"); } { Conn junk; // binary garbage, then the server must still answer junk.open(e.sock); junk.send_all(std::string("\xff\xff\xff not json at all")); junk.close(); e.ctl("status after junk", "{\"cmd\": \"status\"}"); } // 3. attached client: UI flows driven by real keystrokes Attached a; bool attached = a.attach(e, {{"attach-session", "-t", "base"}}); e.note("attach", attached ? "A" : "failed " + a.last_kind); if (attached) { a.pump(300); e.note("list-clients", run_command(e, {{"list-clients", "-F", "#{client_width}x#{client_height} #{client_session} #{client_key_table}"}})); e.note("session_attached", run_command(e, {{"list-sessions", "-F", "#{session_name}:#{session_attached}"}})); // choice: Down, Enter -> index 1 std::string reply = e.ctl("show choice", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"choice\", \"title\": \"T\", \"text\": \"Pick one\", \"options\": [\"alpha\", \"beta\"]}, \"timeout\": 10}"); e.ctl("poll choice", "{\"cmd\": \"result\", \"id\": 0}"); e.ctl("show while busy", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"message\", \"text\": \"two\"}}"); a.pump(200); a.keys("\x1b[B"); a.pump(200); a.keys("\r"); a.pump(200); e.ctl("result choice", "{\"cmd\": \"result\", \"id\": 0}"); e.ctl("result collected once", "{\"cmd\": \"result\", \"id\": 0}"); // text: type then Enter e.ctl("show text", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"text\", \"title\": \"T\", \"text\": \"Name?\", \"initial\": \"ab\"}, \"timeout\": 10}"); a.pump(200); a.keys("c\x01X\r"); a.pump(200); e.ctl("result text", "{\"cmd\": \"result\", \"id\": 1}"); // message: any key e.ctl("show message", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"message\", \"text\": \"hi\"}, \"timeout\": 10}"); a.pump(200); a.keys("x"); a.pump(200); e.ctl("result message", "{\"cmd\": \"result\", \"id\": 2}"); // cancel by Escape (escape-time) e.ctl("show cancel", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"message\", \"text\": \"hi\"}, \"timeout\": 10}"); a.pump(200); a.keys("\x1b"); a.pump(300); e.ctl("result cancelled", "{\"cmd\": \"result\", \"id\": 3}"); // cancel via control e.ctl("show stuck", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"message\", \"text\": \"stuck\"}}"); e.ctl("cancel stuck", "{\"cmd\": \"cancel\", \"id\": 4}"); e.ctl("result after cancel", "{\"cmd\": \"result\", \"id\": 4}"); // digit picks a choice, default selected e.ctl("show digit", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"choice\", \"options\": [\"a\", \"b\", \"c\"], \"default\": 2}, \"timeout\": 10}"); a.pump(200); a.keys("2"); a.pump(200); e.ctl("result digit", "{\"cmd\": \"result\", \"id\": 5}"); // text prompt cancelled e.ctl("show text cancel", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"text\", \"text\": \"x\"}, \"timeout\": 10}"); a.pump(200); a.keys("\x03"); a.pump(200); e.ctl("result text cancel", "{\"cmd\": \"result\", \"id\": 6}"); // timeout of a flow e.ctl("show short", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"message\", \"text\": \"t\"}, \"timeout\": 1}"); sleep_ms(1500); a.pump(100); e.ctl("result timeout", "{\"cmd\": \"result\", \"id\": 7}"); // timeout floor / ceiling are clamped, not rejected e.ctl("show timeout clamp", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"message\", \"text\": \"t\"}, \"timeout\": -5}"); e.ctl("cancel clamp", "{\"cmd\": \"cancel\", \"id\": 8}"); // 4. key handling through raw input frames a.keys("\x02" "c"); // C-b c: new window a.pump(500); e.note("after C-b c", run_command(e, {{"list-windows", "-F", "#{window_index}:#{window_flags}:#{window_panes}"}})); a.keys("\x02%"); // split horizontally a.pump(500); e.note("after C-b %", run_command(e, {{"list-panes", "-F", "#{pane_index}:#{pane_width}x#{pane_height}:#{pane_active}"}})); a.keys("\x02\""); // split vertically a.pump(500); e.note("after C-b quote", run_command(e, {{"list-panes", "-F", "#{pane_index}:#{pane_width}x#{pane_height}:#{pane_active}"}})); a.keys("\x02" "o"); // next pane a.pump(200); e.note("after C-b o", run_command(e, {{"list-panes", "-F", "#{pane_index}:#{pane_active}"}})); // repeat table: C-b Up Up (repeatable) then a plain key leaves the table a.keys("\x02\x1b[A\x1b[A"); a.pump(300); e.note("after repeat", run_command(e, {{"list-panes", "-F", "#{pane_index}:#{pane_active}"}})); // prompt: C-b : rename-window a.keys("\x02:"); a.pump(200); a.keys("rename-window zed\r"); a.pump(400); e.note("after prompt rename", run_command(e, {{"list-windows", "-F", "#{window_index}:#{window_name}"}})); // prefix then unbound key is dropped a.keys("\x02" "\x01"); a.pump(200); // zoom, window switching a.keys("\x02z"); a.pump(300); e.note("after zoom", run_command(e, {{"list-windows", "-F", "#{window_index}:#{window_flags}:#{window_zoomed_flag}"}})); a.keys("\x02p"); a.pump(300); e.note("after C-b p", run_command(e, {{"list-windows", "-F", "#{window_index}:#{window_flags}"}})); a.keys("\x02" "l"); a.pump(300); e.note("after C-b l", run_command(e, {{"list-windows", "-F", "#{window_index}:#{window_flags}"}})); // typing reaches the shell a.keys("echo typed_ok\r"); a.pump(500); { std::string reply = e.control("{\"cmd\": \"read\", \"lines\": 100}\n"); e.note("typed reaches pane", reply.find("typed_ok") != std::string::npos ? "yes" : "no"); } // resize frame: bad ones ignored, good ones applied a.conn.send_all(make_frame('R', "not json")); a.conn.send_all(make_frame('R', "{\"rows\": 30}")); a.resize(30, 100); a.pump(300); e.note("after resize", run_command(e, {{"list-clients", "-F", "#{client_width}x#{client_height}"}})); e.note("window size", run_command(e, {{"list-windows", "-F", "#{window_width}x#{window_height}"}})); // detach a.keys("\x02" "d"); bool got_d = false; for (int i = 0; i < 30 && !got_d; ++i) { auto f = a.conn.frame(100); if (f && f->kind == 'D') { got_d = true; e.note("detach frame", std::string("D:") + f->payload); } } if (!got_d) e.note("detach frame", "missing"); e.note("closed after detach", a.conn.closed_by_peer(2000) ? "closed" : "open"); } // 5. attached via new-session: the session count and a second client sharing a session { Attached b; bool ok = b.attach(e, {{"new-session", "-s", "second", "bash", "--norc", "--noprofile"}}); e.note("attach new-session", ok ? "A" : "failed"); b.pump(500); e.note("sessions now", run_command(e, {{"list-sessions", "-F", "#{session_name}:#{session_attached}"}})); e.note("attach to attached -d", run_command(e, {{"list-clients", "-F", "#{client_session}"}})); // no client for a pane whose session is not attached e.ctl("show other session pane", "{\"cmd\": \"show\", \"screen\": {\"kind\": \"message\", \"text\": \"hi\"}, \"pane\": \"0\"}"); // killing the session a client is attached to ends the client with X [exited] e.note("kill-session", run_command(e, {{"kill-session", "-t", "second"}})); bool got_x = false; for (int i = 0; i < 30 && !got_x; ++i) { auto f = b.conn.frame(100); if (f && (f->kind == 'X' || f->kind == 'D')) { got_x = true; e.note("kill-session client frame", std::string(1, f->kind) + ":" + f->payload); } } if (!got_x) e.note("kill-session client frame", "missing"); } // 6. flow cap: one attached client per session, 17 shows { std::vector> clients; std::vector panes; for (int i = 0; i < 17; ++i) { auto c = std::make_unique(); bool ok = c->attach(e, {{"new-session", "-s", "f" + std::to_string(i), "bash", "--norc", "--noprofile"}}); if (!ok) { e.note("flow client " + std::to_string(i), "attach failed"); break; } clients.push_back(std::move(c)); } sleep_ms(500); std::string listing = run_command(e, {{"list-panes", "-a", "-F", "#{pane_id}:#{session_name}"}}); e.note("flow panes", listing); std::string last; for (int i = 0; i < 17 && i < static_cast(clients.size()); ++i) { // pane ids are assigned in order: base session pane %0, then second, f0.. each own pane std::string session = "f" + std::to_string(i); size_t pos = listing.find(":" + session + "\n"); size_t pos2 = listing.find(":" + session + "|"); (void)pos; (void)pos2; // find "%N:fI" in listing size_t at = listing.find(":" + session); std::string id; if (at != std::string::npos) { size_t s = listing.rfind('%', at); size_t e2 = at; // listing is "%N:name\n%M:name" joined with newline inside M payload id = listing.substr(s + 1, e2 - s - 1); } last = e.ctl("flow show " + std::to_string(i), "{\"cmd\": \"show\", \"screen\": {\"kind\": \"message\", \"text\": \"x\"}, \"timeout\": 30, \"pane\": \"" + id + "\"}"); } check(last.find("too many pending flows (16)") != std::string::npos, "17th flow refused with the cap text: " + last); // clients detach: results report detached for (auto& c : clients) c->conn.close(); sleep_ms(500); e.ctl("flow result detached", "{\"cmd\": \"result\", \"id\": 9}"); e.ctl("flow result detached 2", "{\"cmd\": \"result\", \"id\": 10}"); } // 7. lock behaviour: a second server for the same home gives up { pid_t pid = ::fork(); if (pid == 0) { // the grandchild exits quickly: serve() returns 1 when the lock is held MplexServer second(e.home); int code = second.serve(); ::_exit(code); } int raw = 0; ::waitpid(pid, &raw, 0); e.note("second server exit code", std::to_string(WIFEXITED(raw) ? WEXITSTATUS(raw) : -1)); e.ctl("still serving", "{\"cmd\": \"status\"}"); } if (slow) { // session cap std::string last; for (int i = 0; i < 70; ++i) { last = run_command(e, {{"new-session", "-d", "-s", "cap" + std::to_string(i), "sleep", "300"}}, 2000); if (last.find("E:") != std::string::npos) break; } e.note("session cap", last); } // 8. kill-server ends everything and removes the socket e.note("kill-server", run_command(e, {{"kill-server"}})); sleep_ms(1500); e.note("socket gone", std::filesystem::exists(e.sock) ? "present" : "removed"); } std::string make_home(const std::string& tag) { std::string pattern = "/tmp/mplex-server-test-" + tag + "-XXXXXX"; std::vector buf(pattern.begin(), pattern.end()); buf.push_back('\0'); char* dir = ::mkdtemp(buf.data()); if (dir == nullptr) { std::perror("mkdtemp"); std::exit(2); } return dir; } bool wait_socket(const std::string& sock) { for (int i = 0; i < 100; ++i) { if (std::filesystem::exists(sock)) { Conn c; if (c.open(sock)) return true; } sleep_ms(100); } return false; } } // namespace int main(int argc, char** argv) { std::string tai = "../tai.py"; bool slow = false; for (int i = 1; i < argc; ++i) { if (std::string(argv[i]) == "--tai" && i + 1 < argc) tai = argv[++i]; else if (std::string(argv[i]) == "--slow") slow = true; } ::setenv("SHELL", "/bin/bash", 1); ::setenv("TERM", "xterm-256color", 1); ::unsetenv("TAI_PLEXER_ACTIVE"); ::unsetenv("TAI_PLEXER_SOCKET"); ::unsetenv("TAI_PLEXER_PANE"); ::unsetenv("TMUX"); ::unsetenv("TAI_HOME"); // ---- C++ server Env cpp; cpp.label = "cpp"; cpp.home = make_home("cpp"); cpp.sock = cpp.home + "/plexer/" + MPLEX_SOCKET_NAME; check(MplexServer::ensure_running(cpp.home), "C++ server started by ensure_running"); check(wait_socket(cpp.sock), "C++ server socket answers"); { struct stat st {}; ::stat((cpp.home + "/plexer").c_str(), &st); check((st.st_mode & 0777) == 0700, "plexer dir is 0700"); ::stat(cpp.sock.c_str(), &st); check((st.st_mode & 0777) == 0600, "socket is 0600"); } scenario(cpp, slow); // ---- Python server (the reference) Env py; py.label = "python"; py.home = make_home("py"); py.sock = py.home + "/plexer/server.sock"; { pid_t pid = ::fork(); if (pid == 0) { ::setenv("TAI_HOME", py.home.c_str(), 1); int devnull = ::open("/dev/null", O_RDWR); ::dup2(devnull, 0); ::dup2(devnull, 1); ::dup2(devnull, 2); ::execlp("python3", "python3", tai.c_str(), "--mplex", "start-server", static_cast(nullptr)); ::_exit(127); } int raw = 0; ::waitpid(pid, &raw, 0); } if (!wait_socket(py.sock)) { std::cerr << "python server did not start (tai.py at " << tai << ")\n"; return 2; } scenario(py, slow); // ---- compare { std::ofstream("build/mplex_server_cpp.txt") << cpp.transcript; std::ofstream("build/mplex_server_py.txt") << py.transcript; std::istringstream a(cpp.transcript), b(py.transcript); std::string la, lb; int line = 0; int diffs = 0; while (true) { bool ga = static_cast(std::getline(a, la)); bool gb = static_cast(std::getline(b, lb)); if (!ga && !gb) break; ++line; ++g_checks; if (la != lb) { ++g_failed; ++diffs; std::cerr << "DIFF line " << line << "\n cpp: " << (ga ? la : "") << "\n py : " << (gb ? lb : "") << "\n"; } } std::cerr << "transcript lines compared: " << line << ", differing: " << diffs << "\n"; } if (slow) { // a server that never had a session exits after MPLEX_IDLE_EXIT seconds Env idle; idle.home = make_home("idle"); idle.sock = idle.home + "/plexer/" + MPLEX_SOCKET_NAME; check(MplexServer::ensure_running(idle.home), "idle server starts"); check(wait_socket(idle.sock), "idle server answers"); sleep_ms(static_cast(MPLEX_IDLE_EXIT * 1000) + 3000); check(!std::filesystem::exists(idle.sock), "idle server exited and removed its socket"); std::filesystem::remove_all(idle.home); } std::cerr << "checks: " << g_checks << ", failed: " << g_failed << "\n"; std::filesystem::remove_all(cpp.home); std::filesystem::remove_all(py.home); return g_failed == 0 ? 0 : 1; }