mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-08-25 23:21:10 +00:00
164 lines
5.9 KiB
Python
164 lines
5.9 KiB
Python
#!/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()
|