OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid / blender-addon / tests / test_net_isolation.py

118 lines · python ref:
"""A dead fixture must not delay a live one.

Run: python3 blender-addon/tests/test_net_isolation.py

net.py imports nothing from Blender, so it is exercised directly. A local TCP
server stands in for a live fixture; a .local name that does not exist stands
in for a powered-off one. Resolving such a name blocks getaddrinfo for ~5 s
(nss-mdns timeout), which is exactly what a real rig with unplugged units does.
"""

import importlib.util
import os
import socket
import sys
import threading
import time

HERE = os.path.dirname(os.path.abspath(__file__))
NET_PY = os.path.join(HERE, "..", "cthuloid_lights", "net.py")
sys.path.insert(0, os.path.dirname(NET_PY))   # net.py imports protocol

spec = importlib.util.spec_from_file_location("cthuloid_net", NET_PY)
net = importlib.util.module_from_spec(spec)
spec.loader.exec_module(net)

DEAD_HOST = "cthuloid-test-nonexistent.local"
BUDGET_S = 0.5      # a live fixture must see each update within this


class FakeFixture:
    """Accepts connections, greets like the firmware, records each line's arrival."""

    def __init__(self):
        self.srv = socket.socket()
        self.srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.srv.bind(("127.0.0.1", 0))
        self.srv.listen(4)
        self.port = self.srv.getsockname()[1]
        self.lines = []          # (monotonic arrival, line)
        self.lock = threading.Lock()
        threading.Thread(target=self._accept, daemon=True).start()

    def _accept(self):
        while True:
            try:
                c, _ = self.srv.accept()
            except OSError:
                return
            c.sendall(b"fake ready\r\n")
            threading.Thread(target=self._read, args=(c,), daemon=True).start()

    def _read(self, c):
        buf = b""
        while True:
            try:
                d = c.recv(4096)
            except OSError:
                return
            if not d:
                return
            buf += d
            while b"\n" in buf:
                line, buf = buf.split(b"\n", 1)
                with self.lock:
                    self.lines.append((time.monotonic(), line.decode()))

    def wait_for(self, text, timeout):
        end = time.monotonic() + timeout
        while time.monotonic() < end:
            with self.lock:
                for t, line in self.lines:
                    if line == text:
                        return t
            time.sleep(0.01)
        return None


def main():
    fx = FakeFixture()
    s = net.Sender()
    s.min_interval = 0.0
    s.start()
    failures = []
    try:
        # Bring the live fixture up first so its connect is not what we time.
        s.send("127.0.0.1", fx.port, "pwm 0\n")
        if fx.wait_for("pwm 0", 3.0) is None:
            print("FAIL: live fixture never received its first frame")
            return 1

        # Now a dead fixture joins, as an unplugged unit in the scene does.
        s.send(DEAD_HOST, 1337, "pwm 1\n")
        time.sleep(0.3)     # let the worker start resolving it

        for i in range(2, 7):
            payload = "pwm %d" % i
            t0 = time.monotonic()
            s.send("127.0.0.1", fx.port, payload + "\n")
            t1 = fx.wait_for(payload, 15.0)
            lat = None if t1 is None else t1 - t0
            print("update %d -> live fixture: %s" % (
                i, "never" if lat is None else "%.3f s" % lat))
            if lat is None or lat > BUDGET_S:
                failures.append(i)
            time.sleep(0.5)
    finally:
        s.stop()

    if failures:
        print("FAIL: %d/5 updates exceeded %.1f s" % (len(failures), BUDGET_S))
        return 1
    print("PASS: every update reached the live fixture within %.1f s" % BUDGET_S)
    return 0


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