|
5 | 5 | create_fake_device_token) |
6 | 6 | import simple_websocket |
7 | 7 | import pytest |
8 | | -from rdfm.ws import receive_message, send_message |
| 8 | +from rdfm.ws import receive_message, send_message, decode_json, RDFM_WS_DUPLICATE_CONNECTION |
9 | 9 | from rdfm_mgmt_communication import CapabilityReport, DeviceAttachToManager |
| 10 | +import subprocess |
| 11 | +import signal |
| 12 | +from api.v1.middleware import WS_PING_INTERVAL |
10 | 13 |
|
11 | 14 |
|
12 | 15 | FAKE_DEVICE_MAC = "00:00:00:00:00:00" |
@@ -72,6 +75,20 @@ def spawn_shell_on_mock_device(): |
72 | 75 | pytest.fail(f"connecting to the shell WebSocket failed: {e}") |
73 | 76 |
|
74 | 77 |
|
| 78 | +def connect_interruptable_mock_device(): |
| 79 | + """ Spawn a mock device that can be paused using SIGSTOP |
| 80 | +
|
| 81 | + To simulate a connection dropout we need a device mock that can be paused, |
| 82 | + without triggering the usual WebSocket disconnection mechanism. Calling |
| 83 | + client.close() on the above mocks would just gracefully shutdown the connection. |
| 84 | + """ |
| 85 | + process = subprocess.Popen(["python", "tests/scripts/device-websocket-loop.py"]) |
| 86 | + # Hacky sleep to ensure the process starts |
| 87 | + time.sleep(5.0) |
| 88 | + |
| 89 | + return process |
| 90 | + |
| 91 | + |
75 | 92 | def test_ws_connect_device(process, connect_mock_device): |
76 | 93 | """ This tests if the device WebSocket connection works properly |
77 | 94 |
|
@@ -161,3 +178,46 @@ def test_ws_shell_spawn_on_nonexistent_device(process): |
161 | 178 | pytest.fail("shell connection did not close, despite the device not existing") |
162 | 179 |
|
163 | 180 | assert "not connected" in client.close_message, "close message should indicate the device does not exist" |
| 181 | + |
| 182 | + |
| 183 | +def test_ws_stale_connections_are_disconnected(process: subprocess.Popen): |
| 184 | + """ This tests if devices that have lost connection to the server get properly |
| 185 | + disconnected from the server. |
| 186 | +
|
| 187 | + This is important, as only one connection is allowed per device, and stale |
| 188 | + connections would prevent a device from accessing the device WS again. |
| 189 | + """ |
| 190 | + mock = connect_interruptable_mock_device() |
| 191 | + assert mock.poll() is None, "the mock device connected successfully" |
| 192 | + |
| 193 | + # Send SIGSTOP to pause the mock |
| 194 | + mock.send_signal(signal.SIGSTOP) |
| 195 | + time.sleep(5.0) |
| 196 | + |
| 197 | + # Sleep until the ping interval expires, at which point the server should |
| 198 | + # disconnect the device. The timeout occurs after two intervals (one during |
| 199 | + # which the Ping is sent, and after the other the connection is closed as |
| 200 | + # no Pong was received). |
| 201 | + time.sleep(WS_PING_INTERVAL * 2) |
| 202 | + |
| 203 | + # Resume the mock |
| 204 | + mock.send_signal(signal.SIGCONT) |
| 205 | + time.sleep(5.0) |
| 206 | + |
| 207 | + # The mock should now be disconnected from the server |
| 208 | + assert mock.poll() is not None, "the mock device has closed after timing out" |
| 209 | + |
| 210 | + # Now reconnect to the server |
| 211 | + # Don't use the mock, as we need to check the closure status |
| 212 | + # If a stale connection is present, the connection will immediately |
| 213 | + # close with `4002: duplicate connections not allowed` |
| 214 | + try: |
| 215 | + client = simple_websocket.Client.connect(DEVICES_WS, headers={ |
| 216 | + "Authorization": f"Bearer token={create_fake_device_token()}", |
| 217 | + }) |
| 218 | + _ = client.receive() |
| 219 | + client.close() |
| 220 | + except simple_websocket.ConnectionClosed as e: |
| 221 | + if e.reason == RDFM_WS_DUPLICATE_CONNECTION: |
| 222 | + pytest.fail("REGRESSION: Device connection was not properly cleaned up") |
| 223 | + raise |
0 commit comments