OverviewFilesCommitsIssuesDocsReleasesPeers

hyperswarm-peer / daemon / test_daemon_e2e.py

187 lines · python ref:
#!/usr/bin/env python3
"""End to end: a Python client on one machine reaches a Python client on another.

blender-bridge/docs/P2P_BRIDGE_SPEC.md §4. This is the whole path:

    python  --UDS-->  session-daemon  ==swarm==>  session-daemon  --UDS-->  python

which is exactly what a Blender addon will do. Nothing above the daemon is
native, and nothing in Blender is: the addon is a plain socket client, the way
multi-user 0.8.2 ended up after extension.blender.org pushed it off binary wheels.

⛔ WHAT THIS PROVES. That a frame authored in Python arrives, byte-identical, in
another Python process that shares only a topic. It proves NOTHING about grab
arbitration — that is blender-bridge/session/grab.py, gated there, and the daemon
never parses a frame.

Run:
    node ../integration/layer2_testnet.js &     # writes /tmp/layer_bootstrap.txt
    ../test/build_tests.sh && ./build.sh
    ./test_daemon_e2e.py
"""

import os
import socket
import struct
import subprocess
import sys
import tempfile
import time

HERE = os.path.dirname(os.path.abspath(__file__))
DAEMON = os.path.join(HERE, 'session-daemon')

passed = 0
failed = 0


def ck(name, cond, detail=""):
    global passed, failed
    if cond:
        passed += 1
        print("  PASS: %-52s %s" % (name, detail))
    else:
        failed += 1
        print("  FAIL: %-52s %s" % (name, detail))


def send_frame(sock, payload):
    """4-byte LE length then the payload — the boundary a byte stream lacks."""
    sock.sendall(struct.pack('<I', len(payload)) + payload)


def read_frame(sock, timeout=15.0):
    """Read exactly one frame, tolerating coalescing and short reads."""
    sock.settimeout(timeout)
    buf = b''
    while len(buf) < 4:
        chunk = sock.recv(4 - len(buf))
        if not chunk:
            return None
        buf += chunk
    n = struct.unpack('<I', buf)[0]
    out = b''
    while len(out) < n:
        chunk = sock.recv(n - len(out))
        if not chunk:
            return None
        out += chunk
    return out


def main():
    if not os.path.exists(DAEMON):
        print("SKIP: no session-daemon binary. Run ./build.sh first.")
        return 0

    bootstrap = None
    if os.path.exists('/tmp/layer_bootstrap.txt'):
        with open('/tmp/layer_bootstrap.txt') as f:
            bootstrap = f.read().strip().split(',')[0]
    if not bootstrap:
        print("SKIP: no testnet. Start it first:")
        print("  node ../integration/layer2_testnet.js &")
        return 0

    print("=" * 78)
    print("Daemon end-to-end  (bootstrap %s)" % bootstrap)
    print("=" * 78)

    tmp = tempfile.mkdtemp(prefix='session-e2e-')
    topic = 'blender-bridge-e2e/%d' % os.getpid()
    sock_a = os.path.join(tmp, 'a.sock')
    sock_b = os.path.join(tmp, 'b.sock')

    procs = []
    for path in (sock_a, sock_b):
        procs.append(subprocess.Popen(
            [DAEMON, '--topic', topic, '--bootstrap', bootstrap, '--socket', path],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL))

    try:
        # The socket file appears as soon as the daemon binds, before the swarm
        # is up. Connecting early is fine and is what an addon will do.
        deadline = time.time() + 15
        while time.time() < deadline:
            if os.path.exists(sock_a) and os.path.exists(sock_b):
                break
            time.sleep(0.1)
        ck("both daemons bound their sockets",
           os.path.exists(sock_a) and os.path.exists(sock_b), tmp)

        ca = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        cb = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        ca.connect(sock_a)
        cb.connect(sock_b)
        ck("two Python clients connected", True, "one per daemon")

        # The peers still have to find each other on the topic. Retry rather than
        # sleep a guessed interval: the first frames are simply lost until the
        # channel exists, which is honest behaviour for an ephemeral channel.
        payload = bytes.fromhex('00aabbccddee')
        got = None
        deadline = time.time() + 45
        while time.time() < deadline and got is None:
            send_frame(ca, payload)
            try:
                got = read_frame(cb, timeout=2.0)
            except socket.timeout:
                got = None
        ck("a frame authored in Python crossed the swarm", got is not None,
           "to the other Python process")
        ck("it arrived byte-identical", got == payload,
           got.hex() if got else "nothing arrived")

        # Reverse. An untested direction is how bug #15 blocked every incoming
        # connection to a C++ server while the client path passed.
        back = bytes.fromhex('01112233')
        send_frame(cb, back)
        got2 = None
        try:
            got2 = read_frame(ca, timeout=10.0)
        except socket.timeout:
            pass
        ck("the reverse direction carries a frame too", got2 == back,
           got2.hex() if got2 else "nothing came back")

        # A second client on the SAME daemon must see it, or two Blenders on one
        # machine would be invisible to each other.
        ca2 = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        ca2.connect(sock_a)
        time.sleep(0.5)
        local = bytes.fromhex('0299')
        send_frame(cb, local)
        seen = None
        try:
            seen = read_frame(ca2, timeout=10.0)
        except socket.timeout:
            pass
        ck("a second client on one daemon also receives", seen == local,
           seen.hex() if seen else "nothing")

        # Framing: two frames in one write must not merge into one.
        f1, f2 = b'\x03aaa', b'\x04bb'
        ca.sendall(struct.pack('<I', len(f1)) + f1 +
                   struct.pack('<I', len(f2)) + f2)
        r1 = read_frame(cb, timeout=10.0)
        r2 = read_frame(cb, timeout=10.0)
        ck("two frames in one write stay two frames", r1 == f1 and r2 == f2,
           "%s then %s" % (r1.hex() if r1 else None, r2.hex() if r2 else None))

    finally:
        for p in procs:
            p.terminate()
        for p in procs:
            try:
                p.wait(timeout=5)
            except subprocess.TimeoutExpired:
                p.kill()

    print()
    print("%d/%d passed" % (passed, passed + failed))
    return 0 if failed == 0 else 1


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