From 182e1f8b3178eb18ffd5ec82aee8aeba5d9fafa1 Mon Sep 17 00:00:00 2001 From: osmaczko <33099791+osmaczko@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:01:04 +0200 Subject: [PATCH] test: spawn a farm of chat-cli peers and print their addresses Growing a group to a realistic size by hand needs that many clients running at once, and chat-cli cannot simply be backgrounded: it has no headless mode, crossterm opens /dev/tty when stdin is not a terminal, and an instance without one exits at launch. The account address is only reachable through the TUI as well, since nothing logs it and the data dir keeps chats alone. So each peer gets its own pty, is asked for its address once its delivery node reports in, and is held open until the script is interrupted. --- bin/chat-cli/spawn-peers.py | 163 ++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100755 bin/chat-cli/spawn-peers.py diff --git a/bin/chat-cli/spawn-peers.py b/bin/chat-cli/spawn-peers.py new file mode 100755 index 0000000..0f5162f --- /dev/null +++ b/bin/chat-cli/spawn-peers.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Spawn N chat-cli peers on the logos.test network and print their addresses. + +chat-cli has no headless mode: it always launches a ratatui TUI, and crossterm +opens /dev/tty when stdin is not a terminal, so a background instance without a +pty dies immediately. Each peer therefore gets its own pty, whose output this +drains and scrapes. Once a peer's delivery node is up, `/account` is typed into +it and the 64-hex account address is read back off the rendered frame. + +The peers stay alive until this exits (Ctrl-C), which is the point: paste the +printed addresses into logos-chat-ui's Add member field by hand. + +Usage: spawn-peers.py N [--bin PATH] [--data DIR] [--preset logos.test] +""" +import argparse, os, pty, re, select, signal, subprocess, sys, time + +ADDR = re.compile(rb"[0-9a-f]{64}") +# The banner chat-cli prints once its embedded delivery node has joined; typing +# into the TUI before it appears goes nowhere. +READY = b"Node connected." +# Give the TUI this long to take over the screen after the banner before typing, +# then re-ask on this period until an address comes back: a keystroke sent while +# the frame is still being set up lands nowhere and there is no reply to detect. +SETTLE_S = 1.5 +REASK_S = 15.0 + + +class Peer: + def __init__(self, idx, bin_path, data_dir, preset): + self.idx = idx + self.name = f"peer{idx:02d}" + self.buf = bytearray() + self.address = None + self.ready_at = None + self.last_ask = 0.0 + d = os.path.join(data_dir, self.name) + os.makedirs(d, exist_ok=True) + self.master, slave = pty.openpty() + # A real window size: ratatui draws nothing into an 0x0 terminal, and the + # address is read off what it draws. + import fcntl, struct, termios + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 50, 200, 0, 0)) + self.proc = subprocess.Popen( + [bin_path, "--name", self.name, "--transport", "logos-delivery", + "--preset", preset, "--data", d, "--log-file", os.path.join(d, "run.log")], + stdin=slave, stdout=slave, stderr=slave, start_new_session=True) + os.close(slave) + + def pump(self): + """Drain whatever the pty has and pick the address out of the frame.""" + try: + chunk = os.read(self.master, 65536) + except OSError: + return + if not chunk: + return + self.buf += chunk + if self.address is None: + if self.ready_at is None and READY in self.buf: + self.ready_at = time.time() + m = ADDR.search(self.buf) + if m: + self.address = m.group(0).decode() + # Keep the tail only: a long-running TUI redraws forever. + if len(self.buf) > 262144: + del self.buf[:131072] + + def ask(self, now): + """Type `/account` once the node is up, and again until it answers.""" + if self.address is not None or self.ready_at is None: + return + if now - self.ready_at < SETTLE_S or now - self.last_ask < REASK_S: + return + self.last_ask = now + try: + os.write(self.master, b"/account\r") + except OSError: + pass + + def alive(self): + return self.proc.poll() is None + + def stop(self): + try: + os.killpg(os.getpgid(self.proc.pid), signal.SIGTERM) + except (ProcessLookupError, PermissionError): + pass + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("n", type=int) + ap.add_argument("--bin", default="target/debug/chat-cli") + ap.add_argument("--data", default="/tmp/chat-peers") + ap.add_argument("--preset", default="logos.test") + ap.add_argument("--stagger", type=float, default=2.0, + help="seconds between launches; the nodes all bootstrap at once otherwise") + ap.add_argument("--timeout", type=float, default=300.0) + args = ap.parse_args() + + # An orphaned peer keeps redrawing into a dead pty and costs roughly ten + # times its idle CPU, so a SIGTERM has to reach the teardown rather than + # kill this process where it stands. + signal.signal(signal.SIGTERM, _raise_interrupt) + + peers = [] + try: + print(f"launching {args.n} peers on {args.preset} ...", file=sys.stderr) + for i in range(1, args.n + 1): + peers.append(Peer(i, args.bin, args.data, args.preset)) + deadline = time.time() + args.stagger + while time.time() < deadline: + drain(peers, 0.2) + print(f" {i}/{args.n} launched", end="\r", file=sys.stderr) + + deadline = time.time() + args.timeout + while time.time() < deadline: + drain(peers, 0.5) + got = sum(1 for p in peers if p.address) + dead = [p.name for p in peers if not p.alive()] + print(f" addresses {got}/{len(peers)}" + + (f" DIED: {','.join(dead)}" if dead else "") + " ", + end="\r", file=sys.stderr) + if got == len(peers): + break + print(file=sys.stderr) + + for p in peers: + print(f"{p.name}\t{p.address or 'NO ADDRESS'}") + sys.stdout.flush() + + missing = [p.name for p in peers if not p.address] + if missing: + print(f"no address from: {' '.join(missing)}", file=sys.stderr) + print("\npeers are running; Ctrl-C to stop them all", file=sys.stderr) + while True: + drain(peers, 1.0) + except KeyboardInterrupt: + pass + finally: + for p in peers: + p.stop() + + +def _raise_interrupt(_signum, _frame): + raise KeyboardInterrupt + + +def drain(peers, timeout): + fds = [p.master for p in peers if p.alive()] + if not fds: + time.sleep(timeout) + return + ready, _, _ = select.select(fds, [], [], timeout) + now = time.time() + for p in peers: + if p.master in ready: + p.pump() + p.ask(now) + + +if __name__ == "__main__": + main()