Files
libchat/bin/chat-cli/spawn-peers.py
T

164 lines
5.9 KiB
Python
Raw Normal View History

#!/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()