Skip to content

Commit 5d0c7ad

Browse files
authored
DRIVERS-2949 Test server for happy eyeballs (#544)
1 parent aeb2d4a commit 5d0c7ad

File tree

3 files changed

+258
-0
lines changed

3 files changed

+258
-0
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Happy Eyeballs test scripts
2+
3+
This folder contains a test server ([`server.py`](server.py)) for driver Happy Eyeballs TCP connection behavior. It also has has [`client.py`](client.py), a simple client for that server that can be useful for debugging but won't be needed in most cases.
4+
5+
**NOTE**: This server relies on network stack behavior present in Windows and MacOS but not Linux, so any tests using it must only be run on those two OSes.
6+
7+
## Command-line Usage
8+
9+
`python3 server.py [-c|--control PORT] [--wait] [--stop]`
10+
11+
Run with:
12+
* no arguments, or with just `-c PORT`, to start the server in the foreground. `PORT` defaults to 10036 if not specified.
13+
* `--wait` to wait for a server running in another process to be ready to start accepting control connections.
14+
* `--stop` to signal a server running in another process to gracefully shutdown.
15+
16+
## Integration with Evergreen
17+
18+
The server can be incorporated into an evergreen test run via these functions:
19+
```yaml
20+
functions:
21+
"start happy eyeballs server":
22+
- command: subprocess.exec
23+
params:
24+
working_dir: src
25+
background: true
26+
binary: ${PYTHON3}
27+
args:
28+
- ${DRIVERS_TOOLS}/.evergreen/happy_eyeballs/server.py
29+
- command: subprocess.exec
30+
params:
31+
working_dir: src
32+
binary: ${PYTHON3}
33+
args:
34+
- ${DRIVERS_TOOLS}/.evergreen/happy_eyeballs/server.py
35+
- --wait
36+
"stop happy eyeballs server":
37+
- command: subprocess.exec
38+
params:
39+
working_dir: src
40+
binary: ${PYTHON3}
41+
args:
42+
- ${DRIVERS_TOOLS}/.evergreen/happy_eyeballs/server.py
43+
- --stop
44+
```
45+
The `"stop happy eyeballs server"` function should be included in the `post` configuration or a `teardown_task` section to ensure that the server isn't left running after the test finishes.
46+
47+
## Test Usage
48+
49+
On opening a connection to the control port, the driver test should send a single byte: `0x04` to request a port pair with a slow IPv4 connection, or `0x06` to request one with a slow IPv6 connection. The server will respond with:
50+
1. `0x01` (success signal), followed by
51+
2. `uint16` (IPv4 port, big-endian), followed by
52+
3. `uint16` (IPv6 port, big-endian)
53+
54+
Any other response should be treated as an error. The connection will be closed after the ports are sent; to request another pair, open a new connection to the control port.
55+
56+
Test connections to the two ports should be initiated immediately; the TCP handshake completion will be delayed on the slow port by two seconds from the time of the port _being bound_, not the ACK received. Only one port is expected to successfully connect. Once connected, the port will write out a single byte for verification (`0x04`
57+
for IPv4, `0x06` for IPv6) and then immediately close both ports.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
### A test client for server.py
2+
#
3+
# This can be used to check that server.py is functioning properly. When run, it
4+
# will connect to the control port on that server, request a pair of ports, open a connection to
5+
# both ports in parallel, and assert that the byte read is the expected one for that port.
6+
7+
import argparse
8+
import asyncio
9+
import socket
10+
11+
parser = argparse.ArgumentParser(
12+
prog='client',
13+
description='client for testing the happy eyeballs test server',
14+
)
15+
parser.add_argument('-c', '--control', default=10036, type=int, metavar='PORT', help='control port')
16+
parser.add_argument('-d', '--delay', default=4, choices=[4,6], type=int, help="ip protocol to request server delay")
17+
args = parser.parse_args()
18+
19+
async def main():
20+
print('connecting to control')
21+
control_r, control_w = await asyncio.open_connection('localhost', args.control)
22+
control_w.write(args.delay.to_bytes(1, 'big'))
23+
await control_w.drain()
24+
data = await control_r.read(1)
25+
if data != b'\x01':
26+
raise Exception(f'Expected byte 1, got {data}')
27+
ipv4_port = int.from_bytes(await control_r.read(2), 'big')
28+
ipv6_port = int.from_bytes(await control_r.read(2), 'big')
29+
connect_tasks = [
30+
asyncio.create_task(connect('IPv4', ipv4_port, socket.AF_INET, b'\x04')),
31+
asyncio.create_task(connect('IPv6', ipv6_port, socket.AF_INET6, b'\x06')),
32+
]
33+
await asyncio.wait(connect_tasks)
34+
35+
async def connect(name: str, port: int, family: socket.AddressFamily, payload: bytes):
36+
print(f'{name}: connecting')
37+
try:
38+
reader, writer = await asyncio.open_connection('localhost', port, family=family)
39+
except Exception as e:
40+
print(f'{name}: failed ({e})')
41+
return
42+
print(f'{name}: connected')
43+
data = await reader.readexactly(1)
44+
if data != payload:
45+
raise Exception(f'Expected {payload}, got {data}')
46+
writer.close()
47+
await writer.wait_closed()
48+
print(f'{name}: done')
49+
50+
asyncio.run(main())
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# A test server for driver Happy Eyeballs behavior. See README.md for more information.
2+
3+
import argparse
4+
import asyncio
5+
import socket
6+
import sys
7+
import platform
8+
9+
if platform.system() not in ["Darwin", "Windows"]:
10+
print(f'Only macOS (Darwin) and Windows are supported, but got: {platform.system()}', file=sys.stderr)
11+
exit(1)
12+
parser = argparse.ArgumentParser(
13+
prog='server',
14+
description='Test server for happy eyeballs',
15+
)
16+
parser.add_argument('-c', '--control', default=10036, type=int, metavar='PORT', help='control port')
17+
parser.add_argument('--wait', action='store_true', help='wait for a running server to be ready')
18+
parser.add_argument('--stop', action='store_true', help='stop a running server')
19+
args = parser.parse_args()
20+
21+
PREFIX='happy eyeballs server'
22+
23+
async def control_server():
24+
shutdown = asyncio.Event()
25+
srv = await asyncio.start_server(lambda reader, writer: on_control_connected(reader, writer, shutdown), 'localhost', args.control)
26+
print(f'{PREFIX}: listening for control connections on {args.control}', file=sys.stderr)
27+
async with srv:
28+
await shutdown.wait()
29+
print(f'{PREFIX}: all done', file=sys.stderr)
30+
31+
async def on_control_connected(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, shutdown: asyncio.Event):
32+
# Read the control request byte
33+
data = await reader.readexactly(1)
34+
if data == b'\x04':
35+
print(f'{PREFIX}: ========================', file=sys.stderr)
36+
print(f'{PREFIX}: request for delayed IPv4', file=sys.stderr)
37+
slow = 'IPv4'
38+
elif data == b'\x06':
39+
print(f'{PREFIX}: ========================', file=sys.stderr)
40+
print(f'{PREFIX}: request for delayed IPv6', file=sys.stderr)
41+
slow = 'IPv6'
42+
elif data == b'\xF0':
43+
writer.write(b'\x01')
44+
await writer.drain()
45+
writer.close()
46+
await writer.wait_closed()
47+
return
48+
elif data == b'\xFF':
49+
print(f'{PREFIX}: shutting down', file=sys.stderr)
50+
writer.close()
51+
await writer.wait_closed()
52+
shutdown.set()
53+
return
54+
else:
55+
print(f'Unexpected control byte: {data}', file=sys.stderr)
56+
exit(1)
57+
58+
# Bind the test ports but do not yet start accepting connections
59+
connected = asyncio.Event()
60+
on_ipv4_connected = lambda reader, writer: on_test_connected('IPv4', writer, b'\x04', connected, slow)
61+
on_ipv6_connected = lambda reader, writer: on_test_connected('IPv6', writer, b'\x06', connected, slow)
62+
# port 0: pick random unused port
63+
srv4 = await asyncio.start_server(on_ipv4_connected, 'localhost', 0, family=socket.AF_INET, start_serving=False)
64+
srv6 = await asyncio.start_server(on_ipv6_connected, 'localhost', 0, family=socket.AF_INET6, start_serving=False)
65+
ipv4_port = srv4.sockets[0].getsockname()[1]
66+
ipv6_port = srv6.sockets[0].getsockname()[1]
67+
print(f'{PREFIX}: [slow {slow}] bound for IPv4 on {ipv4_port}', file=sys.stderr)
68+
print(f'{PREFIX}: [slow {slow}] bound for IPv6 on {ipv6_port}', file=sys.stderr)
69+
70+
# Reply to control request with success byte and test server ports
71+
writer.write(b'\x01')
72+
writer.write(ipv4_port.to_bytes(2, 'big'))
73+
writer.write(ipv6_port.to_bytes(2, 'big'))
74+
await writer.drain()
75+
writer.close()
76+
await writer.wait_closed()
77+
78+
# Start test servers listening in parallel
79+
# Hold a reference to the tasks so they aren't GC'd
80+
test_tasks = [
81+
asyncio.create_task(test_listen('IPv4', srv4, data == b'\x04', connected, slow)),
82+
asyncio.create_task(test_listen('IPv6', srv6, data == b'\x06', connected, slow)),
83+
]
84+
await asyncio.wait(test_tasks)
85+
86+
# Wait for the test servers to shut down
87+
srv4.close()
88+
srv6.close()
89+
close_tasks = [
90+
asyncio.create_task(srv4.wait_closed()),
91+
asyncio.create_task(srv6.wait_closed()),
92+
]
93+
await asyncio.wait(close_tasks)
94+
95+
print(f'{PREFIX}: [slow {slow}] connection complete, test ports closed', file=sys.stderr)
96+
print(f'{PREFIX}: ========================', file=sys.stderr)
97+
98+
async def test_listen(name: str, srv, delay: bool, connected: asyncio.Event, slow: str):
99+
# Both connections are delayed; the slow one is delayed by more than the fast one; this
100+
# ensures that the client is comparing timing and not simply choosing an immediate success
101+
# over a connection denied.
102+
if delay:
103+
print(f'{PREFIX}: [slow {slow}] delaying {name} connections', file=sys.stderr)
104+
await asyncio.sleep(2.0)
105+
else:
106+
await asyncio.sleep(1.0)
107+
async with srv:
108+
await srv.start_serving()
109+
print(f'{PREFIX}: [slow {slow}] accepting {name} connections', file=sys.stderr)
110+
# Terminate this test server when either test server has handled a request
111+
await connected.wait()
112+
113+
async def on_test_connected(name: str, writer: asyncio.StreamWriter, payload: bytes, connected: asyncio.Event, slow: str):
114+
print(f'{PREFIX}: [slow {slow}] connected on {name}', file=sys.stderr)
115+
writer.write(payload)
116+
await writer.drain()
117+
writer.close()
118+
await writer.wait_closed()
119+
connected.set()
120+
121+
async def stop_server():
122+
control_r, control_w = await asyncio.open_connection('localhost', args.control)
123+
control_w.write(b'\xFF')
124+
await control_w.drain()
125+
control_w.close()
126+
await control_w.wait_closed()
127+
128+
async def wait_for_server():
129+
while True:
130+
try:
131+
control_r, control_w = await asyncio.open_connection('localhost', args.control)
132+
except OSError as e:
133+
print(f'{PREFIX}: failed ({e}), will retry', file=sys.stderr)
134+
await asyncio.sleep(1)
135+
continue
136+
break
137+
control_w.write(b'\xF0')
138+
await control_w.drain()
139+
data = await control_r.read(1)
140+
if data != b'\x01':
141+
print(f'{PREFIX}: expected byte 1, got {data}', file=sys.stderr)
142+
exit(1)
143+
print(f'{PREFIX}: happy eyeballs server ready on port {args.control}', file=sys.stderr)
144+
145+
146+
if args.stop:
147+
asyncio.run(stop_server())
148+
elif args.wait:
149+
asyncio.run(wait_for_server())
150+
else:
151+
asyncio.run(control_server())

0 commit comments

Comments
 (0)