summaryrefslogtreecommitdiff
path: root/graph/test_transport.py
blob: f1834403cfdc2088725900f37dde21902ca79933 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
"""Check HTTP assets and a delayed, fragmented WebSocket upgrade.

Build the chapter first, then run: python graph/test_transport.py chapter25
Uses only the Python standard library; does not launch a browser.
"""

from pathlib import Path
import socket
import subprocess
import sys
import time
import urllib.request
import json
import struct


def packet(data, op=1, fin=True):
    n = len(data)
    head = bytes([op | (128 if fin else 0)])
    head += bytes([128 | n]) if n < 126 else (b'\xfe' + struct.pack('>H', n) if n <= 65535
                                           else b'\xff' + struct.pack('>Q', n))
    mask = b'abcd'
    return head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data))


def frame(stream):
    head = stream.read(2)
    assert len(head) == 2, 'Incomplete server frame'
    n = head[1]
    if n == 126: n = struct.unpack('>H', stream.read(2))[0]
    elif n == 127: n = struct.unpack('>Q', stream.read(8))[0]
    data = stream.read(n)
    assert len(data) == n
    return head[0] & 15, data


def compile_frames(stream):
    frames = []
    while True:
        op, data = frame(stream)
        assert op == 1
        if data == b'#': return frames
        frames.append(json.loads(data))


def check(chapter):
    root = Path(__file__).resolve().parent.parent
    number = int(chapter.removeprefix("chapter"))
    assert 1 <= number <= 25
    chapter_dir = root / chapter
    package = "com.seaofnodes.simple.print."
    log = root / "build" / "graph-transport" / (chapter + ".log")
    log.parent.mkdir(parents=True, exist_ok=True)
    with log.open("w") as output:
        process = subprocess.Popen(
            ["java", "-ea", "-Dsimple.graph.open=false", "-cp",
             "build/classes/main", package + "SimpleGraphObserver"],
            cwd=chapter_dir, stdout=output, stderr=subprocess.STDOUT)
        try:
            deadline = time.monotonic() + 10
            url = None
            while time.monotonic() < deadline and process.poll() is None:
                lines = log.read_text().splitlines()
                addresses = [line.removeprefix("Graph viewer: ") for line in lines
                             if line.startswith("Graph viewer: ")]
                if addresses:
                    url = addresses[0]
                    break
                time.sleep(.05)
            assert url and url.startswith("http://127.0.0.1:"), log.read_text()
            # The browser must be able to load all assets before connecting.
            for asset in ("index.html", "viewer.js", "layout.js", "render.js",
                          "vendor/elk-api.js", "vendor/elk-worker.min.js", "vendor/d3.v7.min.js"):
                with urllib.request.urlopen(url.rsplit("/", 1)[0] + "/" + asset,
                                            timeout=3) as response:
                    assert response.status == 200
                    assert response.read() == (root / "graph/web" / asset).read_bytes()
            with socket.create_connection(("127.0.0.1", 12345), timeout=3) as client:
                # Reproduces the old ready() race: accept precedes any headers.
                time.sleep(.3)
                client.sendall(b"GET / HTTP/1.1\r\nHost: localhost:12345\r\n")
                time.sleep(.1)
                client.sendall(b"Upgrade: websocket\r\nConnection: Upgrade\r\n"
                               b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
                               b"Sec-WebSocket-Version: 13\r\n")
                # Even with the key present, the header block is not complete.
                client.settimeout(.2)
                try:
                    early = client.recv(1)
                except socket.timeout:
                    pass
                else:
                    raise AssertionError("Responded before headers ended: " + repr(early))
                client.settimeout(3)
                # A frame in the same write as the upgrade must not get lost in a reader buffer.
                client.sendall(b"\r\n" + packet(b'hello', op=9))
                with client.makefile("rb") as stream:
                    assert stream.readline() == b"HTTP/1.1 101 Switching Protocols\r\n"
                    headers = []
                    while True:
                        line = stream.readline()
                        assert line, "Connection closed during upgrade"
                        if line == b"\r\n":
                            break
                        headers.append(line)
                    assert b"Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n" in headers
                    assert stream.read(3) == b"\x81\x01!", "Missing source request"
                    assert frame(stream) == (10, b'hello')
                    # Larger than both the old 1 KB buffer and the 16-bit length field.
                    data = packet(b' ' * 70000 + b'return 3;')
                    client.sendall(data[:3])
                    time.sleep(.05)
                    for start in range(3, len(data), 509):
                        client.sendall(data[start:start + 509])
                    frames = compile_frames(stream)
                    assert frames and all('snap' in f for f in frames)
                    # Split a UTF-8 code point across fragments, with an interleaved PING.
                    client.sendall(packet(b'return 3; \xc3', fin=False) + packet(b'ping', op=9) + packet(b'\xa9', op=0))
                    assert frame(stream) == (10, b'ping')
                    errors = compile_frames(stream)
                    assert any('error' in f for f in errors)
                    assert '\u00e9' in log.read_text()
                    client.sendall(packet(b'return 4;'))
                    frames = compile_frames(stream)
                    assert frames and all('snap' in f for f in frames)
                    # Masked 'null' asks the compiler to exit normally.
                    mask = b"abcd"
                    payload = bytes(byte ^ mask[i % 4] for i, byte in enumerate(b"null"))
                    client.sendall(b"\x81\x84" + mask + payload)
                    assert stream.read(2) == b"\x88\x00", "Invalid close frame"
            assert process.wait(timeout=5) == 0, log.read_text()
            # The HTTP server must not outlive the compiler session.
            try:
                urllib.request.urlopen(url, timeout=.5)
            except OSError:
                pass
            else:
                raise AssertionError("HTTP server survived viewer shutdown")
        finally:
            if process.poll() is None:
                process.terminate()
                process.wait(timeout=5)
    print(chapter + ": HTTP assets, fragmented upgrade, greeting, and shutdown passed" +
          "; large frames, UTF-8 fragments, ping, and compile-error recovery passed")


if __name__ == "__main__":
    check(sys.argv[1] if len(sys.argv) > 1 else "chapter25")