OverviewFilesCommitsIssuesDocsReleasesPeers

hyperswarm-peer / daemon / test_daemon_longlived.py

177 lines · python ref:
#!/usr/bin/env python3
"""A daemon that has already served many peers must still answer the next one.

    python3 -B test_daemon_longlived.py

⛔ THE E2E FOR hyper-games #72's WALL. test_handshake_pool.cpp proves the
mechanism in ONE PROCESS — HyperDht kept 16 handshake slots, only a decrypt
FAILURE ever freed one, so the seventeenth handshake a node took part in was
refused with a bare `return`. A unit test is not evidence that the daemon works.
This drives real peers at a real long-lived daemon over the PUBLIC DHT, past
that limit.

⛔ THE CLIENT MUST SPEAK THE DAEMON'S PROTOCOL. The first version of this file
used tools/swarm-probe, which opens `hyperswarm-peer/probe/v1` while the daemon
opens `blender-bridge-session/v1`. They mesh on the topic and never pair a
channel, so no hello could EVER cross and every probe failed for a reason that
had nothing to do with the daemon. Peers here are real session-daemons.

⛔ AND IT REFUSES TO RENDER A VERDICT WITHOUT A BASELINE. That first version
reported "the responder has gone silent, which is the wall's exact symptom"
from a run in which NOT ONE peer had ever connected — a conclusion its data
could not distinguish from "nothing worked". If the early peers do not connect,
this exits INCONCLUSIVE, because the late ones then say nothing at all.

⚠ REQUIRES INTERNET and takes several minutes: every peer is a real DHT
lookup and holepunch.
"""

import os
import re
import subprocess
import sys
import tempfile
import time

HERE = os.path.dirname(os.path.realpath(__file__))
DAEMON = os.path.join(HERE, "session-daemon")

# ⛔ THE JS CONTROL. Our own pass rate means nothing on its own: the public DHT
# has a failure rate of its own, and without measuring it we cannot tell an
# ordinary flaky lookup from a defect of ours. `--js` runs the IDENTICAL driver
# and the IDENTICAL accounting against hyperswarm 4.17.1 instead of
# session-daemon, so the two numbers are comparable by construction.
USE_JS = "--js" in sys.argv
JS_PEER = os.path.join(HERE, "js_peer.js")
NODE_MODULES = os.path.normpath(os.path.join(HERE, "..", "test", "node_modules"))

PEERS = 20          # must exceed the 16-slot pool
BASELINE = 5        # of the first 8, at least this many must connect
CONNECT_TIMEOUT = 45.0
PEERS_RE = re.compile(rb"peers=(\d+)")

EXIT_PASS, EXIT_FAIL, EXIT_INCONCLUSIVE = 0, 1, 2


def spawn(topic, tmp, tag):
    logpath = os.path.join(tmp, "%s.log" % tag)
    log = open(logpath, "wb")
    if USE_JS:
        env = dict(os.environ, NODE_PATH=NODE_MODULES)
        cmd = ["node", JS_PEER, "--topic", topic]
        p = subprocess.Popen(cmd, stdout=log, stderr=subprocess.STDOUT, env=env)
    else:
        sock = os.path.join(tmp, "%s.sock" % tag)
        cmd = [DAEMON, "--topic", topic, "--socket", sock]
        p = subprocess.Popen(cmd, stdout=log, stderr=subprocess.STDOUT)
    return p, log, logpath


def wait_for(logpath, needle, timeout, proc):
    end = time.time() + timeout
    while time.time() < end:
        if proc.poll() is not None:
            return False
        try:
            with open(logpath, "rb") as fh:
                if needle(fh.read()):
                    return True
        except OSError:
            pass
        time.sleep(0.4)
    return False


def saw_a_peer(blob):
    return any(int(n) >= 1 for n in PEERS_RE.findall(blob))


def stop(proc, log):
    proc.terminate()
    try:
        proc.wait(timeout=5)
    except subprocess.TimeoutExpired:
        proc.kill()
    log.close()


def main():
    target = JS_PEER if USE_JS else DAEMON
    if not os.path.isfile(target):
        print("missing %s%s" % (target, "" if USE_JS else " — run daemon/build.sh"))
        return EXIT_INCONCLUSIVE

    topic = "pool-drain-%d" % os.getpid()
    tmp = tempfile.mkdtemp(prefix="longlived-")

    print("=" * 78)
    print("a long-lived %s on the PUBLIC DHT, %d sequential peers"
          % ("hyperswarm 4.17.1 (JS CONTROL)" if USE_JS else "session-daemon", PEERS))
    print("=" * 78)
    print("topic: %s" % topic)
    print("logs:  %s" % tmp)

    host, hostlog, hostlogpath = spawn(topic, tmp, "host")
    try:
        if not wait_for(hostlogpath, lambda b: b"dht=up" in b, 60, host):
            print("\nINCONCLUSIVE: the host daemon never reached dht=up — this "
                  "machine may have no route to the DHT.")
            return EXIT_INCONCLUSIVE
        print("  host is on the public DHT\n")

        results = []
        for i in range(1, PEERS + 1):
            p, log, logpath = spawn(topic, tmp, "peer%02d" % i)
            ok = wait_for(logpath, saw_a_peer, CONNECT_TIMEOUT, p)
            stop(p, log)
            results.append(ok)
            print("  peer %2d/%d  %s" % (i, PEERS, "connected" if ok else "silent"))
            sys.stdout.flush()

        with open(hostlogpath, "rb") as fh:
            hostblob = fh.read()
        pool_full = b"handshake pool FULL" in hostblob

        early = results[:8]
        late = results[16:]
        print("")
        print("  peers 1-8   connected : %d/%d  (baseline)" % (sum(early), len(early)))
        print("  peers 17+   connected : %d/%d  <- past the old 16 limit" % (sum(late), len(late)))
        print("  host logged pool exhaustion: %s" % ("YES" if pool_full else "no"))

        # ⛔ BASELINE FIRST. Without it, "the late ones failed" is indistinguishable
        # from "nothing ever connected", and reporting the wall from that is how
        # the previous version of this file lied.
        if sum(early) < BASELINE:
            print("\nINCONCLUSIVE: only %d of the first %d peers connected, so the "
                  "later results carry no information. Fix connectivity or "
                  "discovery before reading anything into this run."
                  % (sum(early), len(early)))
            return EXIT_INCONCLUSIVE

        if USE_JS:
            # The control measures the network, it does not gate our code.
            print("\nCONTROL: this is hyperswarm's own rate on the public DHT "
                  "today. Compare it with the session-daemon run before reading "
                  "anything into ours.")
            return EXIT_PASS

        if pool_full:
            print("\nFAIL: the host exhausted its handshake pool — #72's wall, "
                  "unfixed.")
            return EXIT_FAIL
        if not late or sum(late) == 0:
            print("\nFAIL: the baseline connected but nothing past the 16th did. "
                  "The responder goes silent once it has served enough peers.")
            return EXIT_FAIL

        print("\nPASS: the host still answers past the old 16-handshake limit "
              "(%d/%d late peers connected)." % (sum(late), len(late)))
        return EXIT_PASS
    finally:
        stop(host, hostlog)


if __name__ == "__main__":
    sys.exit(main())