@trillboards/edge-sdk 0.2.73 → 0.2.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/deploy/detector/README.md +133 -0
- package/deploy/detector/bench/verify_against_reference.py +120 -0
- package/deploy/detector/pyproject.toml +49 -0
- package/deploy/detector/tests/__init__.py +0 -0
- package/deploy/detector/tests/test_modelpath.py +108 -0
- package/deploy/detector/tests/test_server.py +577 -0
- package/deploy/detector/tests/test_session.py +242 -0
- package/deploy/detector/trillboards_detector/__init__.py +8 -0
- package/deploy/detector/trillboards_detector/__main__.py +4 -0
- package/deploy/detector/trillboards_detector/cli.py +141 -0
- package/deploy/detector/trillboards_detector/errors.py +16 -0
- package/deploy/detector/trillboards_detector/labels.py +107 -0
- package/deploy/detector/trillboards_detector/loader.py +257 -0
- package/deploy/detector/trillboards_detector/modelpath.py +74 -0
- package/deploy/detector/trillboards_detector/server.py +460 -0
- package/deploy/detector/trillboards_detector/session.py +354 -0
- package/deploy/macos/build-macos-pkg.sh +48 -0
- package/deploy/macos/com.trillboards.detector.plist +61 -0
- package/deploy/macos/scripts/ensure-detector.sh +85 -0
- package/deploy/macos/scripts/postinstall +28 -0
- package/deploy/macos/scripts/setup-detector.sh +185 -0
- package/deploy/systemd/install.sh +35 -0
- package/deploy/systemd/setup-detector.sh +130 -0
- package/deploy/systemd/trillboards-detector.service +54 -0
- package/deploy/windows/build-windows.ps1 +38 -0
- package/deploy/windows/installer.nsi +24 -1
- package/deploy/windows/register-detector-task.ps1 +161 -0
- package/deploy/windows/register-task.ps1 +27 -0
- package/deploy/windows/setup-detector.ps1 +176 -0
- package/deploy/windows/stop-existing.ps1 +1 -0
- package/deploy/windows/trillboards-edge.cmd +25 -0
- package/dist/AudienceAnalyzeClient.d.ts +6 -0
- package/dist/AudienceAnalyzeClient.d.ts.map +1 -1
- package/dist/AudienceAnalyzeClient.js +14 -0
- package/dist/AudienceAnalyzeClient.js.map +1 -1
- package/dist/EdgeAgent.js +1 -1
- package/dist/LocalDetectorClient.d.ts +92 -0
- package/dist/LocalDetectorClient.d.ts.map +1 -0
- package/dist/LocalDetectorClient.js +241 -0
- package/dist/LocalDetectorClient.js.map +1 -0
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/dist/localSensing.d.ts.map +1 -1
- package/dist/localSensing.js +6 -0
- package/dist/localSensing.js.map +1 -1
- package/dist/zoneContainment.d.ts +148 -0
- package/dist/zoneContainment.d.ts.map +1 -0
- package/dist/zoneContainment.js +172 -0
- package/dist/zoneContainment.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
"""The wire contract, against a REAL server on a real socket, with NO model.
|
|
2
|
+
|
|
3
|
+
WHAT THIS SUITE IS FOR. `LocalDetectorClient.test.ts` enumerates fifteen
|
|
4
|
+
payloads the client refuses. Every one of them, arriving from us, costs a whole
|
|
5
|
+
frame — and worse, an emitted-but-refused payload is indistinguishable
|
|
6
|
+
downstream from a service that is simply down. So the assertions here are mostly
|
|
7
|
+
about what this service REFUSES TO EMIT and what status code it uses to say so,
|
|
8
|
+
in the same spirit as the client's own suite.
|
|
9
|
+
|
|
10
|
+
The detector is a stub. That is not a shortcut — it is the point. The 128 MiB
|
|
11
|
+
artifact is OTA'd and will be absent on any machine that has not fetched it,
|
|
12
|
+
including every CI runner, and a wire contract that can only be tested with a
|
|
13
|
+
model present is a wire contract nothing tests.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import json
|
|
20
|
+
import socket
|
|
21
|
+
import unittest
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.request
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
from trillboards_detector.errors import DetectorNotReady
|
|
27
|
+
from trillboards_detector.server import DetectorServer, MAX_BODY_BYTES
|
|
28
|
+
|
|
29
|
+
JPEG = bytes([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class FakeDetection:
|
|
34
|
+
label: str
|
|
35
|
+
confidence: float
|
|
36
|
+
x: float
|
|
37
|
+
y: float
|
|
38
|
+
width: float
|
|
39
|
+
height: float
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FakeBatch:
|
|
43
|
+
def __init__(self, detections, inference_ms=63.4):
|
|
44
|
+
self.detections = detections
|
|
45
|
+
self.inference_ms = inference_ms
|
|
46
|
+
self.degenerate_dropped = 0
|
|
47
|
+
self.unnamed_class_dropped = 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FakeLoader:
|
|
51
|
+
"""Everything `server.py` is allowed to know about the model."""
|
|
52
|
+
|
|
53
|
+
model_name = "rfdetr_medium"
|
|
54
|
+
|
|
55
|
+
def __init__(self, batch=None, state="ready", raises=None):
|
|
56
|
+
self._batch = batch if batch is not None else FakeBatch([])
|
|
57
|
+
self.state = state
|
|
58
|
+
self._raises = raises
|
|
59
|
+
self.calls: list[tuple] = []
|
|
60
|
+
|
|
61
|
+
def info(self):
|
|
62
|
+
return {"state": self.state, "model": self.model_name, "model_path": "/stub"}
|
|
63
|
+
|
|
64
|
+
def detect(self, jpeg, min_confidence, wanted):
|
|
65
|
+
self.calls.append((jpeg, min_confidence, wanted))
|
|
66
|
+
if self._raises is not None:
|
|
67
|
+
raise self._raises
|
|
68
|
+
return self._batch
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ServerCase(unittest.TestCase):
|
|
72
|
+
def serve(self, loader) -> str:
|
|
73
|
+
import threading
|
|
74
|
+
|
|
75
|
+
server = DetectorServer(("127.0.0.1", 0), loader, log_line=lambda _m: None)
|
|
76
|
+
# 20 ms poll: `shutdown()` blocks until the loop notices, and the
|
|
77
|
+
# 500 ms default turns a 22-test suite into 11 s of pure waiting.
|
|
78
|
+
threading.Thread(target=lambda: server.serve_forever(poll_interval=0.02), daemon=True).start()
|
|
79
|
+
self.addCleanup(server.shutdown)
|
|
80
|
+
self.addCleanup(server.server_close)
|
|
81
|
+
host, port = server.server_address[:2]
|
|
82
|
+
return f"http://{host}:{port}"
|
|
83
|
+
|
|
84
|
+
def request(self, url, method="GET", body=None, raw_body=None):
|
|
85
|
+
data = raw_body if raw_body is not None else (json.dumps(body).encode() if body is not None else None)
|
|
86
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
87
|
+
if data is not None:
|
|
88
|
+
req.add_header("Content-Type", "application/json")
|
|
89
|
+
try:
|
|
90
|
+
with urllib.request.urlopen(req, timeout=10) as res:
|
|
91
|
+
return res.status, json.loads(res.read().decode())
|
|
92
|
+
except urllib.error.HTTPError as err:
|
|
93
|
+
with err:
|
|
94
|
+
payload = err.read().decode()
|
|
95
|
+
try:
|
|
96
|
+
return err.code, json.loads(payload)
|
|
97
|
+
except json.JSONDecodeError:
|
|
98
|
+
return err.code, {"raw": payload}
|
|
99
|
+
|
|
100
|
+
def detect(self, url, **payload):
|
|
101
|
+
body = {"image_b64": base64.b64encode(JPEG).decode(), "min_confidence": 0.25}
|
|
102
|
+
body.update(payload)
|
|
103
|
+
return self.request(f"{url}/detect", method="POST", body=body)
|
|
104
|
+
|
|
105
|
+
def chunked(self, url, payload, chunk_size=64, terminator=b"0\r\n\r\n", half_close=False):
|
|
106
|
+
"""POST with Transfer-Encoding: chunked, on a raw socket.
|
|
107
|
+
|
|
108
|
+
urllib always sets Content-Length, so it cannot reach this path at all —
|
|
109
|
+
which is exactly how the defect below shipped and was only caught by
|
|
110
|
+
running the real Node client.
|
|
111
|
+
"""
|
|
112
|
+
host = url.rsplit(":", 1)[0].replace("http://", "")
|
|
113
|
+
port = int(url.rsplit(":", 1)[1])
|
|
114
|
+
raw = json.dumps(payload).encode()
|
|
115
|
+
head = (
|
|
116
|
+
f"POST /detect HTTP/1.1\r\nHost: {host}:{port}\r\n"
|
|
117
|
+
"Content-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n"
|
|
118
|
+
).encode()
|
|
119
|
+
frames = [head]
|
|
120
|
+
for i in range(0, len(raw), chunk_size):
|
|
121
|
+
piece = raw[i:i + chunk_size]
|
|
122
|
+
frames.append(f"{len(piece):x}\r\n".encode() + piece + b"\r\n")
|
|
123
|
+
frames.append(terminator)
|
|
124
|
+
s = socket.create_connection((host, port), timeout=15)
|
|
125
|
+
try:
|
|
126
|
+
s.sendall(b"".join(frames))
|
|
127
|
+
if half_close:
|
|
128
|
+
s.shutdown(socket.SHUT_WR)
|
|
129
|
+
data = b""
|
|
130
|
+
s.settimeout(10)
|
|
131
|
+
while b"\r\n\r\n" not in data:
|
|
132
|
+
c = s.recv(65536)
|
|
133
|
+
if not c:
|
|
134
|
+
break
|
|
135
|
+
data += c
|
|
136
|
+
head_bytes, _, rest = data.partition(b"\r\n\r\n")
|
|
137
|
+
status = int(head_bytes.split(b"\r\n")[0].split()[1])
|
|
138
|
+
length = 0
|
|
139
|
+
for line in head_bytes.split(b"\r\n")[1:]:
|
|
140
|
+
if line.lower().startswith(b"content-length:"):
|
|
141
|
+
length = int(line.split(b":", 1)[1])
|
|
142
|
+
while len(rest) < length:
|
|
143
|
+
c = s.recv(65536)
|
|
144
|
+
if not c:
|
|
145
|
+
break
|
|
146
|
+
rest += c
|
|
147
|
+
return status, json.loads(rest.decode()) if rest else {}
|
|
148
|
+
finally:
|
|
149
|
+
s.close()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class TestHealth(ServerCase):
|
|
153
|
+
def test_ready_is_200_and_never_runs_inference(self):
|
|
154
|
+
loader = FakeLoader()
|
|
155
|
+
url = self.serve(loader)
|
|
156
|
+
status, body = self.request(f"{url}/health")
|
|
157
|
+
self.assertEqual(status, 200)
|
|
158
|
+
self.assertEqual(body["status"], "ok")
|
|
159
|
+
# THE ASSERTION THAT MATTERS. `GET /v1/models` answers 200 without ever
|
|
160
|
+
# building an engine, which is how a Windows fleet that could not
|
|
161
|
+
# analyze a single frame passed both its install health check and its
|
|
162
|
+
# CPU-fallback re-check. Health here must be cheap in the same way and
|
|
163
|
+
# honest in the way that one was not.
|
|
164
|
+
self.assertEqual(loader.calls, [])
|
|
165
|
+
|
|
166
|
+
def test_unavailable_is_503_so_the_client_reads_it_as_down(self):
|
|
167
|
+
url = self.serve(FakeLoader(state="unavailable"))
|
|
168
|
+
status, body = self.request(f"{url}/health")
|
|
169
|
+
self.assertEqual(status, 503)
|
|
170
|
+
self.assertEqual(body["status"], "unavailable")
|
|
171
|
+
|
|
172
|
+
def test_loading_is_also_not_healthy(self):
|
|
173
|
+
url = self.serve(FakeLoader(state="loading"))
|
|
174
|
+
status, _ = self.request(f"{url}/health")
|
|
175
|
+
self.assertEqual(status, 503)
|
|
176
|
+
|
|
177
|
+
def test_health_is_a_get(self):
|
|
178
|
+
url = self.serve(FakeLoader())
|
|
179
|
+
status, _ = self.request(f"{url}/health", method="POST", body={})
|
|
180
|
+
self.assertEqual(status, 405)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class TestHealthCannotLoadTheModel(unittest.TestCase):
|
|
184
|
+
"""The strongest form of "/health does not load the model": it CANNOT.
|
|
185
|
+
|
|
186
|
+
Asserting that a health probe did not happen to run inference tests one
|
|
187
|
+
code path. This tests the design that makes it impossible — `server.py`
|
|
188
|
+
reaches the model only through an injected object, so importing the whole
|
|
189
|
+
wire layer pulls in no ONNX runtime, no numpy and no Pillow, and therefore
|
|
190
|
+
has no way to build a session however it is called.
|
|
191
|
+
|
|
192
|
+
That is also what lets every case here except the numpy-dependent
|
|
193
|
+
post-processing ones (45 of 60 on 2026-08-24) run on a bare interpreter with
|
|
194
|
+
the artifact absent — which is every CI runner, since the
|
|
195
|
+
~128 MiB ONNX is OTA'd and not committed. A wire contract that could only be
|
|
196
|
+
tested with the model present is a wire contract nothing tests.
|
|
197
|
+
|
|
198
|
+
IT CATCHES THE REGRESSION IN BOTH ENVIRONMENTS, BY DIFFERENT MECHANISMS, and
|
|
199
|
+
that is why the return-code assertion is not redundant with the leak
|
|
200
|
+
assertion:
|
|
201
|
+
|
|
202
|
+
bare interpreter (CI) an added `import numpy` makes the subprocess exit
|
|
203
|
+
non-zero — there is no numpy to import.
|
|
204
|
+
full venv (a dev box) the import succeeds, and the leak list is what
|
|
205
|
+
catches it.
|
|
206
|
+
|
|
207
|
+
Verified against a positive control on 2026-08-24: on an interpreter with
|
|
208
|
+
the runtime installed, this same probe against `trillboards_detector.loader`
|
|
209
|
+
reports `['onnxruntime', 'numpy', 'PIL', 'trillboards_detector.session']`.
|
|
210
|
+
A check that has never been shown to fail is not a check.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
def test_importing_the_wire_layer_pulls_in_no_inference_runtime(self):
|
|
214
|
+
import subprocess
|
|
215
|
+
import sys
|
|
216
|
+
|
|
217
|
+
probe = (
|
|
218
|
+
"import sys; import trillboards_detector.server as s; "
|
|
219
|
+
"leaked = [m for m in ('onnxruntime','numpy','PIL','trillboards_detector.session',"
|
|
220
|
+
"'trillboards_detector.loader') if m in sys.modules]; "
|
|
221
|
+
"print(','.join(leaked))"
|
|
222
|
+
)
|
|
223
|
+
out = subprocess.run(
|
|
224
|
+
[sys.executable, "-c", probe], capture_output=True, text=True, timeout=60,
|
|
225
|
+
)
|
|
226
|
+
self.assertEqual(out.returncode, 0, out.stderr)
|
|
227
|
+
self.assertEqual(
|
|
228
|
+
out.stdout.strip(),
|
|
229
|
+
"",
|
|
230
|
+
"server.py imported an inference dependency — the wire layer must reach the "
|
|
231
|
+
"model only through the injected loader, or /health gains a way to load one "
|
|
232
|
+
"and the stdlib-only suite stops running on a bare interpreter",
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class TestDetectHappyPath(ServerCase):
|
|
237
|
+
def test_shape_is_exactly_what_the_client_parses(self):
|
|
238
|
+
batch = FakeBatch([FakeDetection("person", 0.91, 10.0, 20.0, 30.0, 40.0)])
|
|
239
|
+
url = self.serve(FakeLoader(batch))
|
|
240
|
+
status, body = self.detect(url)
|
|
241
|
+
self.assertEqual(status, 200)
|
|
242
|
+
self.assertEqual(
|
|
243
|
+
body,
|
|
244
|
+
{
|
|
245
|
+
"detections": [
|
|
246
|
+
{
|
|
247
|
+
"label": "person",
|
|
248
|
+
"confidence": 0.91,
|
|
249
|
+
"box": {"x": 10.0, "y": 20.0, "width": 30.0, "height": 40.0},
|
|
250
|
+
}
|
|
251
|
+
],
|
|
252
|
+
"inference_ms": 63.4,
|
|
253
|
+
"model": "rfdetr_medium",
|
|
254
|
+
},
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def test_an_empty_frame_is_200_with_an_empty_array(self):
|
|
258
|
+
"""An empty array is a RESULT. It must never become a 503, and a 503
|
|
259
|
+
must never become an empty array — the two directions of the same
|
|
260
|
+
collapse the client exists to prevent."""
|
|
261
|
+
url = self.serve(FakeLoader(FakeBatch([])))
|
|
262
|
+
status, body = self.detect(url)
|
|
263
|
+
self.assertEqual(status, 200)
|
|
264
|
+
self.assertEqual(body["detections"], [])
|
|
265
|
+
self.assertEqual(body["model"], "rfdetr_medium")
|
|
266
|
+
|
|
267
|
+
def test_boundary_confidences_0_and_1_survive(self):
|
|
268
|
+
batch = FakeBatch(
|
|
269
|
+
[FakeDetection("person", 0.0, 1, 2, 3, 4), FakeDetection("person", 1.0, 1, 2, 3, 4)]
|
|
270
|
+
)
|
|
271
|
+
url = self.serve(FakeLoader(batch))
|
|
272
|
+
_, body = self.detect(url)
|
|
273
|
+
self.assertEqual([d["confidence"] for d in body["detections"]], [0.0, 1.0])
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class TestClassFilter(ServerCase):
|
|
277
|
+
def test_classes_reach_the_detector_canonicalised(self):
|
|
278
|
+
loader = FakeLoader()
|
|
279
|
+
url = self.serve(loader)
|
|
280
|
+
self.detect(url, classes=["Person", "cell_phone", "dining-table"])
|
|
281
|
+
_, _, wanted = loader.calls[0]
|
|
282
|
+
self.assertEqual(wanted, {"person", "cell phone", "dining table"})
|
|
283
|
+
|
|
284
|
+
def test_absent_classes_means_every_class(self):
|
|
285
|
+
loader = FakeLoader()
|
|
286
|
+
url = self.serve(loader)
|
|
287
|
+
body = {"image_b64": base64.b64encode(JPEG).decode(), "min_confidence": 0.25}
|
|
288
|
+
self.request(f"{url}/detect", method="POST", body=body)
|
|
289
|
+
self.assertIsNone(loader.calls[0][2])
|
|
290
|
+
|
|
291
|
+
def test_an_empty_classes_array_is_a_400_not_a_synonym_for_all(self):
|
|
292
|
+
url = self.serve(FakeLoader())
|
|
293
|
+
status, _ = self.detect(url, classes=[])
|
|
294
|
+
self.assertEqual(status, 400)
|
|
295
|
+
|
|
296
|
+
def test_all_unknown_classes_is_a_loud_400(self):
|
|
297
|
+
"""A profile naming classes the model cannot emit must not return an
|
|
298
|
+
empty frame forever while every surface looks healthy. That is
|
|
299
|
+
`yolov8_nano`: offered to ~97 of 114 profiles, loadable nowhere,
|
|
300
|
+
`vehicle_count` at ~0% fleet-wide for six months."""
|
|
301
|
+
url = self.serve(FakeLoader())
|
|
302
|
+
status, body = self.detect(url, classes=["shoplifter", "queue"])
|
|
303
|
+
self.assertEqual(status, 400)
|
|
304
|
+
self.assertIn("shoplifter", body["error"])
|
|
305
|
+
|
|
306
|
+
def test_a_partly_unknown_list_proceeds_on_the_known_half(self):
|
|
307
|
+
loader = FakeLoader()
|
|
308
|
+
url = self.serve(loader)
|
|
309
|
+
status, _ = self.detect(url, classes=["person", "shoplifter"])
|
|
310
|
+
self.assertEqual(status, 200)
|
|
311
|
+
self.assertEqual(loader.calls[0][2], {"person"})
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class TestRequestRefusals(ServerCase):
|
|
315
|
+
def test_min_confidence_bounds(self):
|
|
316
|
+
url = self.serve(FakeLoader())
|
|
317
|
+
for bad in (-0.1, 1.4, "0.9", None, True):
|
|
318
|
+
with self.subTest(bad=bad):
|
|
319
|
+
status, _ = self.detect(url, min_confidence=bad)
|
|
320
|
+
self.assertEqual(status, 400)
|
|
321
|
+
|
|
322
|
+
def test_min_confidence_is_optional_and_defaults(self):
|
|
323
|
+
loader = FakeLoader()
|
|
324
|
+
url = self.serve(loader)
|
|
325
|
+
body = {"image_b64": base64.b64encode(JPEG).decode()}
|
|
326
|
+
status, _ = self.request(f"{url}/detect", method="POST", body=body)
|
|
327
|
+
self.assertEqual(status, 200)
|
|
328
|
+
self.assertEqual(loader.calls[0][1], 0.25)
|
|
329
|
+
|
|
330
|
+
def test_image_refusals(self):
|
|
331
|
+
url = self.serve(FakeLoader())
|
|
332
|
+
for name, payload in [
|
|
333
|
+
("missing", {}),
|
|
334
|
+
("empty string", {"image_b64": ""}),
|
|
335
|
+
("not base64", {"image_b64": "!!!not base64!!!"}),
|
|
336
|
+
("not a string", {"image_b64": 42}),
|
|
337
|
+
("decodes to nothing", {"image_b64": ""}),
|
|
338
|
+
]:
|
|
339
|
+
with self.subTest(name=name):
|
|
340
|
+
body = {"min_confidence": 0.25}
|
|
341
|
+
body.update(payload)
|
|
342
|
+
status, _ = self.request(f"{url}/detect", method="POST", body=body)
|
|
343
|
+
self.assertEqual(status, 400)
|
|
344
|
+
|
|
345
|
+
def test_a_body_that_is_not_json(self):
|
|
346
|
+
url = self.serve(FakeLoader())
|
|
347
|
+
status, _ = self.request(f"{url}/detect", method="POST", raw_body=b"<html>Sign in to WiFi</html>")
|
|
348
|
+
self.assertEqual(status, 400)
|
|
349
|
+
|
|
350
|
+
def test_a_json_array_at_the_top_level(self):
|
|
351
|
+
url = self.serve(FakeLoader())
|
|
352
|
+
status, _ = self.request(f"{url}/detect", method="POST", body=[])
|
|
353
|
+
self.assertEqual(status, 400)
|
|
354
|
+
|
|
355
|
+
def test_an_oversized_body_is_413_not_an_allocation(self):
|
|
356
|
+
url = self.serve(FakeLoader())
|
|
357
|
+
req = urllib.request.Request(f"{url}/detect", data=b"{}", method="POST")
|
|
358
|
+
req.add_header("Content-Type", "application/json")
|
|
359
|
+
req.add_header("Content-Length", str(MAX_BODY_BYTES + 1))
|
|
360
|
+
try:
|
|
361
|
+
with urllib.request.urlopen(req, timeout=10) as res:
|
|
362
|
+
self.fail(f"expected 413, got {res.status}")
|
|
363
|
+
except urllib.error.HTTPError as err:
|
|
364
|
+
self.assertEqual(err.code, 413)
|
|
365
|
+
|
|
366
|
+
def test_unknown_routes_404(self):
|
|
367
|
+
url = self.serve(FakeLoader())
|
|
368
|
+
self.assertEqual(self.request(f"{url}/v1/models")[0], 404)
|
|
369
|
+
self.assertEqual(self.request(f"{url}/detect")[0], 404) # GET on the POST route
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class TestChunkedRequests(ServerCase):
|
|
373
|
+
"""`LocalDetectorClient` sends EVERY request chunked, and this is why.
|
|
374
|
+
|
|
375
|
+
`defaultDetectorPoster` sets exactly two headers — Content-Type and Accept —
|
|
376
|
+
then calls `req.write(body); req.end()`. With no declared length Node frames
|
|
377
|
+
the request with `Transfer-Encoding: chunked`, so chunked is the ONLY shape
|
|
378
|
+
production traffic arrives in. `http.server` does not decode it: it reports
|
|
379
|
+
Content-Length 0 and leaves the bytes on the socket.
|
|
380
|
+
|
|
381
|
+
Before this suite existed the service answered 400 "empty request body" to
|
|
382
|
+
every real call, and the undrained chunk bytes were then parsed as the next
|
|
383
|
+
request line, so Node reported `Parse Error: Expected HTTP/`. urllib always
|
|
384
|
+
sets Content-Length, so no test written with it could reach the defect —
|
|
385
|
+
only the real client could, which is what the integration test is for and
|
|
386
|
+
what these raw-socket cases keep from regressing.
|
|
387
|
+
"""
|
|
388
|
+
|
|
389
|
+
def test_a_chunked_body_is_decoded_like_any_other(self):
|
|
390
|
+
batch = FakeBatch([FakeDetection("person", 0.91, 10.0, 20.0, 30.0, 40.0)])
|
|
391
|
+
loader = FakeLoader(batch)
|
|
392
|
+
url = self.serve(loader)
|
|
393
|
+
status, body = self.chunked(
|
|
394
|
+
url, {"image_b64": base64.b64encode(JPEG).decode(), "min_confidence": 0.25}
|
|
395
|
+
)
|
|
396
|
+
self.assertEqual(status, 200)
|
|
397
|
+
self.assertEqual(body["detections"][0]["label"], "person")
|
|
398
|
+
self.assertEqual(loader.calls[0][0], JPEG)
|
|
399
|
+
|
|
400
|
+
def test_many_small_chunks_reassemble_exactly(self):
|
|
401
|
+
loader = FakeLoader()
|
|
402
|
+
url = self.serve(loader)
|
|
403
|
+
big = bytes(range(256)) * 40 # 10 KiB, split across ~640 chunks
|
|
404
|
+
status, _ = self.chunked(
|
|
405
|
+
url, {"image_b64": base64.b64encode(big).decode(), "min_confidence": 0.25}, chunk_size=16
|
|
406
|
+
)
|
|
407
|
+
self.assertEqual(status, 200)
|
|
408
|
+
self.assertEqual(loader.calls[0][0], big)
|
|
409
|
+
|
|
410
|
+
def test_a_chunked_body_that_decodes_to_nothing_is_a_400(self):
|
|
411
|
+
url = self.serve(FakeLoader())
|
|
412
|
+
status, _ = self.chunked(url, {"image_b64": "", "min_confidence": 0.25})
|
|
413
|
+
self.assertEqual(status, 400)
|
|
414
|
+
|
|
415
|
+
def test_a_client_that_dies_mid_body_gets_a_400_not_an_empty_frame(self):
|
|
416
|
+
"""No terminating zero-chunk, and the caller half-closes.
|
|
417
|
+
|
|
418
|
+
The read returns b"" and the service refuses. What it must NOT do is
|
|
419
|
+
treat a truncated body as an empty one and answer 200 with `[]` — the
|
|
420
|
+
collapse the whole contract exists to prevent.
|
|
421
|
+
"""
|
|
422
|
+
url = self.serve(FakeLoader())
|
|
423
|
+
status, _ = self.chunked(
|
|
424
|
+
url,
|
|
425
|
+
{"image_b64": base64.b64encode(JPEG).decode(), "min_confidence": 0.25},
|
|
426
|
+
terminator=b"",
|
|
427
|
+
half_close=True,
|
|
428
|
+
)
|
|
429
|
+
self.assertEqual(status, 400)
|
|
430
|
+
|
|
431
|
+
def test_a_caller_that_stalls_mid_body_does_not_pin_a_worker_forever(self):
|
|
432
|
+
"""Half a chunked body, socket left OPEN, caller says nothing more.
|
|
433
|
+
|
|
434
|
+
With no read timeout the handler blocks in `readline` for the life of
|
|
435
|
+
the process. `handle_one_request` catches TimeoutError and closes, so
|
|
436
|
+
the assertion is that the connection ENDS — the same unbounded-read leak
|
|
437
|
+
the runtime-edge-cases runbook records on the response side.
|
|
438
|
+
"""
|
|
439
|
+
from unittest.mock import patch
|
|
440
|
+
|
|
441
|
+
from trillboards_detector.server import DetectorHandler
|
|
442
|
+
|
|
443
|
+
url = self.serve(FakeLoader())
|
|
444
|
+
host, port = url.replace("http://", "").rsplit(":", 1)
|
|
445
|
+
with patch.object(DetectorHandler, "timeout", 1):
|
|
446
|
+
s = socket.create_connection((host, int(port)), timeout=15)
|
|
447
|
+
try:
|
|
448
|
+
s.sendall(
|
|
449
|
+
f"POST /detect HTTP/1.1\r\nHost: {host}\r\n"
|
|
450
|
+
"Content-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n"
|
|
451
|
+
"10\r\n".encode()
|
|
452
|
+
) # promises 16 bytes, sends none
|
|
453
|
+
s.settimeout(10)
|
|
454
|
+
self.assertEqual(s.recv(65536), b"", "the server kept the stalled connection open")
|
|
455
|
+
finally:
|
|
456
|
+
s.close()
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
class TestDownIsNotEmpty(ServerCase):
|
|
460
|
+
def test_not_ready_is_503_never_an_empty_result(self):
|
|
461
|
+
url = self.serve(FakeLoader(state="unavailable", raises=DetectorNotReady("no artifact")))
|
|
462
|
+
status, body = self.detect(url)
|
|
463
|
+
self.assertEqual(status, 503)
|
|
464
|
+
self.assertNotIn("detections", body)
|
|
465
|
+
|
|
466
|
+
def test_a_failed_inference_is_500_never_an_empty_result(self):
|
|
467
|
+
url = self.serve(FakeLoader(raises=RuntimeError("ORT exploded")))
|
|
468
|
+
status, body = self.detect(url)
|
|
469
|
+
self.assertEqual(status, 500)
|
|
470
|
+
self.assertNotIn("detections", body)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
class TestNeverEmitWhatTheClientRefuses(ServerCase):
|
|
474
|
+
"""If a detection cannot go on the wire, answer 500 — not a plausible 200.
|
|
475
|
+
|
|
476
|
+
Each row below is a payload `LocalDetectorClient` rejects outright. Reaching
|
|
477
|
+
the client at all would cost the whole frame AND look, from the client's
|
|
478
|
+
side, exactly like the service being down. So the service refuses first, in
|
|
479
|
+
a status code that cannot be read as a quiet venue.
|
|
480
|
+
"""
|
|
481
|
+
|
|
482
|
+
def test_refuses_rather_than_emitting(self):
|
|
483
|
+
cases = {
|
|
484
|
+
"empty label": FakeDetection("", 0.9, 1, 1, 5, 5),
|
|
485
|
+
"zero width": FakeDetection("person", 0.9, 1, 1, 0, 5),
|
|
486
|
+
"zero height": FakeDetection("person", 0.9, 1, 1, 5, 0),
|
|
487
|
+
"negative extent": FakeDetection("person", 0.9, 1, 1, -3, 5),
|
|
488
|
+
"confidence above 1": FakeDetection("person", 1.4, 1, 1, 5, 5),
|
|
489
|
+
"confidence below 0": FakeDetection("person", -0.1, 1, 1, 5, 5),
|
|
490
|
+
"NaN confidence": FakeDetection("person", float("nan"), 1, 1, 5, 5),
|
|
491
|
+
"inf confidence": FakeDetection("person", float("inf"), 1, 1, 5, 5),
|
|
492
|
+
"NaN box side": FakeDetection("person", 0.9, float("nan"), 1, 5, 5),
|
|
493
|
+
"inf box side": FakeDetection("person", 0.9, 1, 1, float("inf"), 5),
|
|
494
|
+
}
|
|
495
|
+
for name, bad in cases.items():
|
|
496
|
+
with self.subTest(name=name):
|
|
497
|
+
# A good detection sits alongside the bad one, exactly as in the
|
|
498
|
+
# client's own suite: the whole payload goes, not just the row.
|
|
499
|
+
batch = FakeBatch([FakeDetection("person", 0.7, 1, 1, 2, 2), bad])
|
|
500
|
+
url = self.serve(FakeLoader(batch))
|
|
501
|
+
status, body = self.detect(url)
|
|
502
|
+
self.assertEqual(status, 500, f"{name} was emitted instead of refused")
|
|
503
|
+
self.assertNotIn("detections", body)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
class TestNeverEmitInvalidJson(ServerCase):
|
|
507
|
+
"""`json.dumps` emits a bare `NaN` by default and no JSON parser accepts it.
|
|
508
|
+
|
|
509
|
+
The client would report "detector returned a body that is not JSON" — a
|
|
510
|
+
TRANSPORT-shaped message for an arithmetic fault on the producer side, which
|
|
511
|
+
sends the next reader to the wrong half of the wire. Detections are already
|
|
512
|
+
covered by `_assert_wire_safe`; these two rows cover the rest of the
|
|
513
|
+
envelope, which nothing else checks.
|
|
514
|
+
"""
|
|
515
|
+
|
|
516
|
+
def test_a_non_finite_inference_ms_is_refused_not_serialised(self):
|
|
517
|
+
for bad in (float("nan"), float("inf"), float("-inf")):
|
|
518
|
+
with self.subTest(bad=bad):
|
|
519
|
+
batch = FakeBatch([FakeDetection("person", 0.9, 1, 1, 5, 5)], inference_ms=bad)
|
|
520
|
+
url = self.serve(FakeLoader(batch))
|
|
521
|
+
status, body = self.detect(url)
|
|
522
|
+
self.assertEqual(status, 500)
|
|
523
|
+
self.assertNotIn("detections", body)
|
|
524
|
+
|
|
525
|
+
def test_every_response_body_actually_parses_as_json(self):
|
|
526
|
+
"""The assertion is `json.loads`, which — unlike Python's dumps — refuses
|
|
527
|
+
NaN, so it stands in for the client's own `JSON.parse`."""
|
|
528
|
+
batch = FakeBatch([FakeDetection("person", 0.91, 10.0, 20.0, 30.0, 40.0)])
|
|
529
|
+
url = self.serve(FakeLoader(batch))
|
|
530
|
+
for status, body in (
|
|
531
|
+
self.detect(url),
|
|
532
|
+
self.detect(url, min_confidence=2), # 400
|
|
533
|
+
self.request(f"{url}/health"), # 200
|
|
534
|
+
self.request(f"{url}/nope"), # 404
|
|
535
|
+
):
|
|
536
|
+
json.loads(json.dumps(body), parse_constant=_refuse_constant)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _refuse_constant(token):
|
|
540
|
+
raise AssertionError(f"response carried the non-JSON constant {token!r}")
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
class TestChunkedTrailerBound(ServerCase):
|
|
544
|
+
def test_a_flood_of_trailer_lines_is_refused(self):
|
|
545
|
+
"""Trailer lines are not body bytes, so MAX_BODY_BYTES does not bound
|
|
546
|
+
them. Without its own cap this is the one place a caller could keep a
|
|
547
|
+
worker busy indefinitely."""
|
|
548
|
+
url = self.serve(FakeLoader())
|
|
549
|
+
host, port = url.replace("http://", "").rsplit(":", 1)
|
|
550
|
+
payload = json.dumps(
|
|
551
|
+
{"image_b64": base64.b64encode(JPEG).decode(), "min_confidence": 0.25}
|
|
552
|
+
).encode()
|
|
553
|
+
frames = (
|
|
554
|
+
f"POST /detect HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\n"
|
|
555
|
+
"Transfer-Encoding: chunked\r\n\r\n"
|
|
556
|
+
).encode()
|
|
557
|
+
frames += f"{len(payload):x}\r\n".encode() + payload + b"\r\n0\r\n"
|
|
558
|
+
frames += b"X-Pad: 1\r\n" * 200 # far past the cap, never a blank line
|
|
559
|
+
s = socket.create_connection((host, int(port)), timeout=15)
|
|
560
|
+
try:
|
|
561
|
+
s.sendall(frames)
|
|
562
|
+
s.shutdown(socket.SHUT_WR)
|
|
563
|
+
data = b""
|
|
564
|
+
s.settimeout(10)
|
|
565
|
+
while b"\r\n\r\n" not in data:
|
|
566
|
+
c = s.recv(65536)
|
|
567
|
+
if not c:
|
|
568
|
+
break
|
|
569
|
+
data += c
|
|
570
|
+
self.assertTrue(data, "the server never answered the trailer flood")
|
|
571
|
+
self.assertEqual(int(data.split(b"\r\n")[0].split()[1]), 400)
|
|
572
|
+
finally:
|
|
573
|
+
s.close()
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
if __name__ == "__main__":
|
|
577
|
+
unittest.main()
|