@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.
Files changed (54) hide show
  1. package/deploy/detector/README.md +133 -0
  2. package/deploy/detector/bench/verify_against_reference.py +120 -0
  3. package/deploy/detector/pyproject.toml +49 -0
  4. package/deploy/detector/tests/__init__.py +0 -0
  5. package/deploy/detector/tests/test_modelpath.py +108 -0
  6. package/deploy/detector/tests/test_server.py +577 -0
  7. package/deploy/detector/tests/test_session.py +242 -0
  8. package/deploy/detector/trillboards_detector/__init__.py +8 -0
  9. package/deploy/detector/trillboards_detector/__main__.py +4 -0
  10. package/deploy/detector/trillboards_detector/cli.py +141 -0
  11. package/deploy/detector/trillboards_detector/errors.py +16 -0
  12. package/deploy/detector/trillboards_detector/labels.py +107 -0
  13. package/deploy/detector/trillboards_detector/loader.py +257 -0
  14. package/deploy/detector/trillboards_detector/modelpath.py +74 -0
  15. package/deploy/detector/trillboards_detector/server.py +460 -0
  16. package/deploy/detector/trillboards_detector/session.py +354 -0
  17. package/deploy/macos/build-macos-pkg.sh +48 -0
  18. package/deploy/macos/com.trillboards.detector.plist +61 -0
  19. package/deploy/macos/scripts/ensure-detector.sh +85 -0
  20. package/deploy/macos/scripts/postinstall +28 -0
  21. package/deploy/macos/scripts/setup-detector.sh +185 -0
  22. package/deploy/systemd/install.sh +35 -0
  23. package/deploy/systemd/setup-detector.sh +130 -0
  24. package/deploy/systemd/trillboards-detector.service +54 -0
  25. package/deploy/windows/build-windows.ps1 +38 -0
  26. package/deploy/windows/installer.nsi +24 -1
  27. package/deploy/windows/register-detector-task.ps1 +161 -0
  28. package/deploy/windows/register-task.ps1 +27 -0
  29. package/deploy/windows/setup-detector.ps1 +176 -0
  30. package/deploy/windows/stop-existing.ps1 +1 -0
  31. package/deploy/windows/trillboards-edge.cmd +25 -0
  32. package/dist/AudienceAnalyzeClient.d.ts +6 -0
  33. package/dist/AudienceAnalyzeClient.d.ts.map +1 -1
  34. package/dist/AudienceAnalyzeClient.js +14 -0
  35. package/dist/AudienceAnalyzeClient.js.map +1 -1
  36. package/dist/EdgeAgent.js +1 -1
  37. package/dist/LocalDetectorClient.d.ts +92 -0
  38. package/dist/LocalDetectorClient.d.ts.map +1 -0
  39. package/dist/LocalDetectorClient.js +241 -0
  40. package/dist/LocalDetectorClient.js.map +1 -0
  41. package/dist/diagnose.d.ts +1 -1
  42. package/dist/diagnose.js +1 -1
  43. package/dist/index.d.ts +4 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +17 -1
  46. package/dist/index.js.map +1 -1
  47. package/dist/localSensing.d.ts.map +1 -1
  48. package/dist/localSensing.js +6 -0
  49. package/dist/localSensing.js.map +1 -1
  50. package/dist/zoneContainment.d.ts +148 -0
  51. package/dist/zoneContainment.d.ts.map +1 -0
  52. package/dist/zoneContainment.js +172 -0
  53. package/dist/zoneContainment.js.map +1 -0
  54. 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()