@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,460 @@
|
|
|
1
|
+
"""The wire. Two routes, and a hard rule about what may go out of them.
|
|
2
|
+
|
|
3
|
+
THE CONTRACT IS FROZEN AND IT LIVES ON THE OTHER SIDE.
|
|
4
|
+
`trillboards-edge-sdk/packages/edge-sdk/src/LocalDetectorClient.ts` is the only
|
|
5
|
+
consumer, it shipped first, and its test file enumerates fifteen payloads it
|
|
6
|
+
REFUSES. This module's job is to never produce one of them. So the last thing
|
|
7
|
+
`/detect` does before answering 200 is re-check every detection against the
|
|
8
|
+
client's own rules (`_assert_wire_safe`), and a failure there becomes a 500
|
|
9
|
+
rather than a malformed 200.
|
|
10
|
+
|
|
11
|
+
That inversion is the whole design. The client rejects a bad envelope instead of
|
|
12
|
+
defaulting it to `[]` because a cascade reading "detector broken" as "venue
|
|
13
|
+
quiet" suppresses every alert while every health surface stays green. The
|
|
14
|
+
service side of that bargain is to refuse loudly rather than emit something
|
|
15
|
+
plausible — the failure mode catalogued over and over in
|
|
16
|
+
`docs/runbooks/cctv-connector-runtime-edge-cases.md`, where `recordAnalysis`
|
|
17
|
+
never reads `response.ok` and a 401 counts as a successful upload.
|
|
18
|
+
|
|
19
|
+
STDLIB ONLY, DELIBERATELY. No onnxruntime, no numpy, no Pillow is imported from
|
|
20
|
+
here — the detector arrives as an injected object. That is what lets the wire
|
|
21
|
+
contract be tested without the 128 MiB artifact or a 50 MB runtime, which is the
|
|
22
|
+
half of this service that a regression would actually silence.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import base64
|
|
28
|
+
import binascii
|
|
29
|
+
import json
|
|
30
|
+
import math
|
|
31
|
+
import sys as _sys
|
|
32
|
+
import threading
|
|
33
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
34
|
+
|
|
35
|
+
from .errors import DetectorNotReady
|
|
36
|
+
from .labels import KNOWN_CLASS_NAMES, resolve_requested_classes
|
|
37
|
+
|
|
38
|
+
#: Loopback only. litert-lm owns 9379; this is a SECOND service, not a route on
|
|
39
|
+
#: that one — see `LocalDetectorClient.ts` for why (its own uv tool, its own
|
|
40
|
+
#: venv, its own OS supervisor), and `DEFAULT_DETECTOR_PORT` there for the
|
|
41
|
+
#: matching constant.
|
|
42
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
43
|
+
DEFAULT_PORT = 9380
|
|
44
|
+
|
|
45
|
+
#: A frame arrives base64'd, so ~1.34x its bytes. 32 MiB covers a 4K JPEG many
|
|
46
|
+
#: times over and still bounds what a runaway caller can make this process
|
|
47
|
+
#: allocate on a box whose whole RAM budget is accounted for in
|
|
48
|
+
#: `docs/runbooks/cctv-local-inference-hardware-floor.md`.
|
|
49
|
+
MAX_BODY_BYTES = 32 * 1024 * 1024
|
|
50
|
+
|
|
51
|
+
#: A chunk-size line is a few hex digits plus optional extensions. Bounding it
|
|
52
|
+
#: keeps a caller from making `readline` grow without limit on a body whose
|
|
53
|
+
#: total length is never declared.
|
|
54
|
+
MAX_CHUNK_HEADER_BYTES = 1024
|
|
55
|
+
|
|
56
|
+
#: This contract sends no trailers at all, so any is already unexpected.
|
|
57
|
+
MAX_TRAILER_LINES = 32
|
|
58
|
+
|
|
59
|
+
DEFAULT_MIN_CONFIDENCE = 0.25
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class WireError(Exception):
|
|
63
|
+
"""A caller mistake. Carries the status code it becomes."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, status: int, message: str):
|
|
66
|
+
super().__init__(message)
|
|
67
|
+
self.status = status
|
|
68
|
+
self.message = message
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_detect_request(raw: bytes) -> tuple[bytes, float, "set[str] | None"]:
|
|
72
|
+
"""Body bytes -> (jpeg, min_confidence, wanted classes or None for all).
|
|
73
|
+
|
|
74
|
+
Every refusal here is a 400: the caller sent something this service cannot
|
|
75
|
+
act on, and saying so beats guessing. Guessing is how a profile naming a
|
|
76
|
+
class the model cannot emit returns an empty frame forever while looking
|
|
77
|
+
healthy — `yolov8_nano`, offered to ~97 of 114 profiles and loadable
|
|
78
|
+
nowhere, `vehicle_count` at ~0% fleet-wide for six months.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
82
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as err:
|
|
83
|
+
raise WireError(400, f"body is not JSON: {err}") from None
|
|
84
|
+
if not isinstance(payload, dict):
|
|
85
|
+
raise WireError(400, "body must be a JSON object")
|
|
86
|
+
|
|
87
|
+
b64 = payload.get("image_b64")
|
|
88
|
+
if not isinstance(b64, str) or not b64:
|
|
89
|
+
raise WireError(400, "`image_b64` must be a non-empty base64 string")
|
|
90
|
+
try:
|
|
91
|
+
jpeg = base64.b64decode(b64, validate=True)
|
|
92
|
+
except (binascii.Error, ValueError) as err:
|
|
93
|
+
raise WireError(400, f"`image_b64` is not valid base64: {err}") from None
|
|
94
|
+
if not jpeg:
|
|
95
|
+
raise WireError(400, "`image_b64` decoded to zero bytes")
|
|
96
|
+
|
|
97
|
+
raw_conf = payload.get("min_confidence", DEFAULT_MIN_CONFIDENCE)
|
|
98
|
+
if isinstance(raw_conf, bool) or not isinstance(raw_conf, (int, float)):
|
|
99
|
+
raise WireError(400, "`min_confidence` must be a number in [0,1]")
|
|
100
|
+
min_confidence = float(raw_conf)
|
|
101
|
+
if not math.isfinite(min_confidence) or not 0.0 <= min_confidence <= 1.0:
|
|
102
|
+
raise WireError(400, f"`min_confidence` must be in [0,1], got {raw_conf!r}")
|
|
103
|
+
|
|
104
|
+
raw_classes = payload.get("classes")
|
|
105
|
+
if raw_classes is None:
|
|
106
|
+
# ABSENT MEANS EVERY CLASS — `LocalDetectorClient` omits the key
|
|
107
|
+
# entirely rather than sending `[]`, and its test pins that. An empty
|
|
108
|
+
# list is therefore never the caller's way of saying "all", and
|
|
109
|
+
# collapsing the two would make an accidentally-empty profile silently
|
|
110
|
+
# return everything.
|
|
111
|
+
return jpeg, min_confidence, None
|
|
112
|
+
if not isinstance(raw_classes, list) or not all(isinstance(c, str) for c in raw_classes):
|
|
113
|
+
raise WireError(400, "`classes` must be an array of strings")
|
|
114
|
+
if not raw_classes:
|
|
115
|
+
raise WireError(400, "`classes` was an empty array; omit the key to mean every class")
|
|
116
|
+
|
|
117
|
+
wanted, unknown = resolve_requested_classes(raw_classes)
|
|
118
|
+
if not wanted:
|
|
119
|
+
raise WireError(
|
|
120
|
+
400,
|
|
121
|
+
f"none of the requested classes exist in this model's vocabulary: {unknown}. "
|
|
122
|
+
f"Known classes: {sorted(KNOWN_CLASS_NAMES)}",
|
|
123
|
+
)
|
|
124
|
+
return jpeg, min_confidence, wanted
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _assert_wire_safe(detections: list) -> None:
|
|
128
|
+
"""The client's fifteen refusals, checked on THIS side before answering 200.
|
|
129
|
+
|
|
130
|
+
Reproduced from `LocalDetectorClient.detect` rather than trusted: the client
|
|
131
|
+
rejects the WHOLE payload on any one of these, so a single bad row costs the
|
|
132
|
+
frame. Better to find it here, where the reason is still in hand, and answer
|
|
133
|
+
500 — a status that cannot be mistaken for a quiet venue.
|
|
134
|
+
"""
|
|
135
|
+
for i, d in enumerate(detections):
|
|
136
|
+
label = d.get("label")
|
|
137
|
+
if not isinstance(label, str) or not label:
|
|
138
|
+
raise ValueError(f"detection {i} has no label")
|
|
139
|
+
conf = d.get("confidence")
|
|
140
|
+
if not isinstance(conf, (int, float)) or isinstance(conf, bool) or not math.isfinite(conf):
|
|
141
|
+
raise ValueError(f"detection {i} confidence is not a finite number: {conf!r}")
|
|
142
|
+
if not 0.0 <= conf <= 1.0:
|
|
143
|
+
raise ValueError(f"detection {i} confidence {conf} is outside [0,1]")
|
|
144
|
+
box = d.get("box")
|
|
145
|
+
if not isinstance(box, dict):
|
|
146
|
+
raise ValueError(f"detection {i} has no box")
|
|
147
|
+
for key in ("x", "y", "width", "height"):
|
|
148
|
+
v = box.get(key)
|
|
149
|
+
if not isinstance(v, (int, float)) or isinstance(v, bool) or not math.isfinite(v):
|
|
150
|
+
raise ValueError(f"detection {i} box.{key} is not a finite number: {v!r}")
|
|
151
|
+
if box["width"] <= 0 or box["height"] <= 0:
|
|
152
|
+
raise ValueError(
|
|
153
|
+
f"detection {i} box has no extent ({box['width']}x{box['height']})"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_detect_response(batch, model_name: str) -> dict:
|
|
158
|
+
body = {
|
|
159
|
+
"detections": [
|
|
160
|
+
{
|
|
161
|
+
"label": d.label,
|
|
162
|
+
"confidence": d.confidence,
|
|
163
|
+
"box": {"x": d.x, "y": d.y, "width": d.width, "height": d.height},
|
|
164
|
+
}
|
|
165
|
+
for d in batch.detections
|
|
166
|
+
],
|
|
167
|
+
# The client keeps this only when it is a finite number > 0, else null.
|
|
168
|
+
# It is the ORT forward pass alone, so it stays comparable with the
|
|
169
|
+
# ~63 ms in `docs/runbooks/cctv-detector-tier.md` — a decode cost folded
|
|
170
|
+
# in here would quietly invalidate that comparison later.
|
|
171
|
+
"inference_ms": batch.inference_ms,
|
|
172
|
+
"model": model_name,
|
|
173
|
+
}
|
|
174
|
+
# A non-finite timing would serialise as a bare `NaN`, which is INVALID JSON
|
|
175
|
+
# (`json.dumps` emits it by default; `JSON.parse` refuses it). The client
|
|
176
|
+
# would then report "returned a body that is not JSON" — a transport-shaped
|
|
177
|
+
# message for a producer-side arithmetic fault, pointing at the wrong side
|
|
178
|
+
# of the wire. A perf_counter delta cannot be non-finite, which is exactly
|
|
179
|
+
# why this must raise rather than be tolerated if it ever is.
|
|
180
|
+
if not isinstance(batch.inference_ms, (int, float)) or not math.isfinite(batch.inference_ms):
|
|
181
|
+
raise ValueError(f"inference_ms is not a finite number: {batch.inference_ms!r}")
|
|
182
|
+
_assert_wire_safe(body["detections"])
|
|
183
|
+
return body
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class DetectorHandler(BaseHTTPRequestHandler):
|
|
187
|
+
"""Routes. The server instance carries `loader` and `log_line`."""
|
|
188
|
+
|
|
189
|
+
protocol_version = "HTTP/1.1"
|
|
190
|
+
server_version = "trillboards-detector"
|
|
191
|
+
sys_version = ""
|
|
192
|
+
|
|
193
|
+
#: Bound on how long one request may take to ARRIVE.
|
|
194
|
+
#:
|
|
195
|
+
#: `handle_one_request` catches TimeoutError, closes the connection and
|
|
196
|
+
#: moves on, so this converts "a caller opened a socket, sent half a chunked
|
|
197
|
+
#: body and stopped" from a worker thread pinned forever into a closed
|
|
198
|
+
#: connection. `docs/runbooks/cctv-connector-runtime-edge-cases.md` records
|
|
199
|
+
#: the same shape on the other side of the wire — a response aborted after
|
|
200
|
+
#: its headers never settles and kills that feed until restart — and the
|
|
201
|
+
#: lesson is the same: an unbounded read is a leak with a long fuse.
|
|
202
|
+
#:
|
|
203
|
+
#: Safe to set only BECAUSE every response closes its connection (see
|
|
204
|
+
#: `_json`). On a keep-alive connection this same timeout would fire during
|
|
205
|
+
#: the idle gap between analyses — 7 to 300 s at the analyze cadence — and
|
|
206
|
+
#: hand the client a socket the server had already closed.
|
|
207
|
+
timeout = 30
|
|
208
|
+
|
|
209
|
+
# ── routing ──────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
def do_GET(self) -> None: # noqa: N802 (stdlib naming)
|
|
212
|
+
if self._path() == "/health":
|
|
213
|
+
self._health()
|
|
214
|
+
else:
|
|
215
|
+
self._json(404, {"error": "not found"})
|
|
216
|
+
|
|
217
|
+
def do_POST(self) -> None: # noqa: N802
|
|
218
|
+
if self._path() == "/detect":
|
|
219
|
+
self._detect()
|
|
220
|
+
elif self._path() == "/health":
|
|
221
|
+
self._json(405, {"error": "/health is a GET"})
|
|
222
|
+
else:
|
|
223
|
+
self._json(404, {"error": "not found"})
|
|
224
|
+
|
|
225
|
+
def _path(self) -> str:
|
|
226
|
+
return self.path.split("?", 1)[0].rstrip("/") or "/"
|
|
227
|
+
|
|
228
|
+
# ── /health ──────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
def _health(self) -> None:
|
|
231
|
+
"""Cheap, and it NEVER runs inference.
|
|
232
|
+
|
|
233
|
+
It reports the outcome of the load that already happened, which is the
|
|
234
|
+
only honest answer available for free. The alternative — answering 200
|
|
235
|
+
because a socket is open — is exactly `GET /v1/models`, which passed a
|
|
236
|
+
Windows fleet that could not analyze a single frame because it never
|
|
237
|
+
builds an engine. `docs/runbooks/cctv-local-inference-hardware-floor.md`
|
|
238
|
+
has the incident.
|
|
239
|
+
"""
|
|
240
|
+
loader = self.server.loader # type: ignore[attr-defined]
|
|
241
|
+
info = loader.info()
|
|
242
|
+
ready = info.get("state") == "ready"
|
|
243
|
+
info["status"] = "ok" if ready else info.get("state", "unavailable")
|
|
244
|
+
self._json(200 if ready else 503, info)
|
|
245
|
+
|
|
246
|
+
# ── /detect ──────────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
def _detect(self) -> None:
|
|
249
|
+
loader = self.server.loader # type: ignore[attr-defined]
|
|
250
|
+
try:
|
|
251
|
+
raw = self._read_body()
|
|
252
|
+
jpeg, min_confidence, wanted = parse_detect_request(raw)
|
|
253
|
+
except WireError as err:
|
|
254
|
+
self._json(err.status, {"error": err.message})
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
batch = loader.detect(jpeg, min_confidence, wanted)
|
|
259
|
+
except DetectorNotReady as err:
|
|
260
|
+
# 503, never a 200 with an empty array. The client raises
|
|
261
|
+
# DetectorUnavailableError on this and falls through to the VLM.
|
|
262
|
+
self._json(503, {"error": str(err), "status": loader.state})
|
|
263
|
+
return
|
|
264
|
+
except Exception as err: # inference itself failed
|
|
265
|
+
self._log(f"[detector] inference failed: {type(err).__name__}: {err}")
|
|
266
|
+
self._json(500, {"error": f"inference failed: {type(err).__name__}"})
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
body = build_detect_response(batch, loader.model_name)
|
|
271
|
+
except ValueError as err:
|
|
272
|
+
self._log(f"[detector] REFUSING to emit a malformed payload: {err}")
|
|
273
|
+
self._json(500, {"error": f"refused to emit a malformed payload: {err}"})
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
self._json(200, body)
|
|
277
|
+
|
|
278
|
+
# ── plumbing ─────────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
def _read_body(self) -> bytes:
|
|
281
|
+
"""Read the request body — CHUNKED OR CONTENT-LENGTH.
|
|
282
|
+
|
|
283
|
+
CHUNKED IS THE PRODUCTION PATH, NOT AN EDGE CASE, AND THIS COST A REAL
|
|
284
|
+
BUG. `LocalDetectorClient.defaultDetectorPoster` sets exactly two
|
|
285
|
+
headers, Content-Type and Accept, and then `req.write(body); req.end()`.
|
|
286
|
+
Node has no declared length at that point, so it frames the request with
|
|
287
|
+
`Transfer-Encoding: chunked` — every single call from the shipped
|
|
288
|
+
client. `http.server` does not decode chunked bodies at all: it exposes
|
|
289
|
+
the raw socket and a Content-Length of zero.
|
|
290
|
+
|
|
291
|
+
So the first version of this method answered **400 "empty request body"
|
|
292
|
+
to every real request**, and then — because the undrained chunk bytes
|
|
293
|
+
were still in the socket — the next keep-alive request on that
|
|
294
|
+
connection was parsed as garbage and Node reported
|
|
295
|
+
`Parse Error: Expected HTTP/`. A stubbed transport cannot reach any of
|
|
296
|
+
that; only running the real client against the real service can, which
|
|
297
|
+
is what `localDetectorService.integration.test.ts` is for.
|
|
298
|
+
|
|
299
|
+
The client is FROZEN and it is not wrong here — chunked is legal
|
|
300
|
+
HTTP/1.1 and it shipped first. The service accommodates it.
|
|
301
|
+
|
|
302
|
+
On any refusal the connection is closed rather than kept alive: bytes we
|
|
303
|
+
did not drain would be read as the NEXT request line, turning one
|
|
304
|
+
rejected call into a corrupted connection.
|
|
305
|
+
"""
|
|
306
|
+
encoding = (self.headers.get("Transfer-Encoding") or "").lower()
|
|
307
|
+
try:
|
|
308
|
+
if "chunked" in encoding:
|
|
309
|
+
return self._read_chunked_body()
|
|
310
|
+
return self._read_sized_body()
|
|
311
|
+
except WireError:
|
|
312
|
+
self.close_connection = True
|
|
313
|
+
raise
|
|
314
|
+
|
|
315
|
+
def _read_sized_body(self) -> bytes:
|
|
316
|
+
try:
|
|
317
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
318
|
+
except ValueError:
|
|
319
|
+
raise WireError(400, "Content-Length is not an integer") from None
|
|
320
|
+
if length <= 0:
|
|
321
|
+
raise WireError(400, "empty request body")
|
|
322
|
+
if length > MAX_BODY_BYTES:
|
|
323
|
+
raise WireError(413, f"body of {length} bytes exceeds {MAX_BODY_BYTES}")
|
|
324
|
+
raw = self.rfile.read(length)
|
|
325
|
+
if len(raw) != length:
|
|
326
|
+
raise WireError(400, "request body was shorter than its Content-Length")
|
|
327
|
+
return raw
|
|
328
|
+
|
|
329
|
+
def _read_chunked_body(self) -> bytes:
|
|
330
|
+
"""Decode `<hex-size>[;ext]\r\n<data>\r\n` … `0\r\n<trailers>\r\n`.
|
|
331
|
+
|
|
332
|
+
A chunked body declares no total length, so MAX_BODY_BYTES is the ONLY
|
|
333
|
+
bound on what a caller can make this process allocate — it is checked as
|
|
334
|
+
the chunks accumulate, not after.
|
|
335
|
+
"""
|
|
336
|
+
parts: list[bytes] = []
|
|
337
|
+
total = 0
|
|
338
|
+
while True:
|
|
339
|
+
line = self.rfile.readline(MAX_CHUNK_HEADER_BYTES + 1)
|
|
340
|
+
if not line:
|
|
341
|
+
raise WireError(400, "chunked body ended before its terminating chunk")
|
|
342
|
+
if len(line) > MAX_CHUNK_HEADER_BYTES:
|
|
343
|
+
raise WireError(400, "chunk size line is implausibly long")
|
|
344
|
+
# A chunk extension (`;name=value`) is legal and carries nothing we use.
|
|
345
|
+
size_token = line.split(b";", 1)[0].strip()
|
|
346
|
+
try:
|
|
347
|
+
size = int(size_token, 16)
|
|
348
|
+
except ValueError:
|
|
349
|
+
raise WireError(400, f"chunk size {size_token!r} is not hexadecimal") from None
|
|
350
|
+
if size < 0:
|
|
351
|
+
raise WireError(400, "negative chunk size")
|
|
352
|
+
if size == 0:
|
|
353
|
+
break
|
|
354
|
+
total += size
|
|
355
|
+
if total > MAX_BODY_BYTES:
|
|
356
|
+
raise WireError(413, f"chunked body exceeds {MAX_BODY_BYTES} bytes")
|
|
357
|
+
chunk = self.rfile.read(size)
|
|
358
|
+
if len(chunk) != size:
|
|
359
|
+
raise WireError(400, "chunk was shorter than its declared size")
|
|
360
|
+
parts.append(chunk)
|
|
361
|
+
if self.rfile.read(2) != b"\r\n":
|
|
362
|
+
raise WireError(400, "chunk was not terminated by CRLF")
|
|
363
|
+
|
|
364
|
+
# Trailers, then the final blank line. Read them so the connection stays
|
|
365
|
+
# usable; nothing in this contract sends any — which is why the count is
|
|
366
|
+
# BOUNDED. An unbounded loop here is the one place a caller could keep a
|
|
367
|
+
# worker busy without ever exceeding MAX_BODY_BYTES, since trailer lines
|
|
368
|
+
# are not body bytes.
|
|
369
|
+
for _ in range(MAX_TRAILER_LINES):
|
|
370
|
+
line = self.rfile.readline(MAX_CHUNK_HEADER_BYTES + 1)
|
|
371
|
+
if not line or line in (b"\r\n", b"\n"):
|
|
372
|
+
break
|
|
373
|
+
else:
|
|
374
|
+
raise WireError(400, f"more than {MAX_TRAILER_LINES} trailer lines after the final chunk")
|
|
375
|
+
|
|
376
|
+
if total == 0:
|
|
377
|
+
raise WireError(400, "empty request body")
|
|
378
|
+
return b"".join(parts)
|
|
379
|
+
|
|
380
|
+
def _json(self, status: int, payload: dict) -> None:
|
|
381
|
+
"""One response, one connection. NO KEEP-ALIVE, deliberately.
|
|
382
|
+
|
|
383
|
+
The frozen client posts through Node's global agent, which pools free
|
|
384
|
+
sockets and does NOT retry one the server closed underneath it — the
|
|
385
|
+
classic keep-alive race, and here it would surface as a spurious
|
|
386
|
+
`DetectorUnavailableError` that reads as "detector down" and pushes a
|
|
387
|
+
frame to the VLM for no reason. Closing every connection removes the
|
|
388
|
+
race outright, and it costs nothing at the cadence this service runs at:
|
|
389
|
+
the detector gates the ANALYZE, which is a few frames a minute box-wide,
|
|
390
|
+
not the 500 ms LOOK.
|
|
391
|
+
|
|
392
|
+
It is also what makes the read timeout above safe to have at all.
|
|
393
|
+
"""
|
|
394
|
+
# `allow_nan=False` is the backstop, not the primary check. Python's
|
|
395
|
+
# default emits bare `NaN`/`Infinity`, which no JSON parser accepts, so
|
|
396
|
+
# a single non-finite float anywhere in a payload would reach the client
|
|
397
|
+
# as "not JSON" — a message that blames the transport for a value we
|
|
398
|
+
# produced. Everything reaching here is already checked; this makes an
|
|
399
|
+
# unchecked future field fail loudly on OUR side instead.
|
|
400
|
+
try:
|
|
401
|
+
blob = json.dumps(payload, allow_nan=False).encode("utf-8")
|
|
402
|
+
except ValueError as err:
|
|
403
|
+
self._log(f"[detector] REFUSING to emit unserialisable JSON: {err}")
|
|
404
|
+
status = 500
|
|
405
|
+
blob = json.dumps({"error": "response contained a value that is not valid JSON"}).encode("utf-8")
|
|
406
|
+
self.close_connection = True
|
|
407
|
+
self.send_response(status)
|
|
408
|
+
self.send_header("Content-Type", "application/json")
|
|
409
|
+
self.send_header("Content-Length", str(len(blob)))
|
|
410
|
+
self.send_header("Connection", "close")
|
|
411
|
+
self.end_headers()
|
|
412
|
+
self.wfile.write(blob)
|
|
413
|
+
if status >= 400:
|
|
414
|
+
self._log(f"[detector] {self.command} {self.path} -> {status}")
|
|
415
|
+
|
|
416
|
+
def _log(self, message: str) -> None:
|
|
417
|
+
logger = getattr(self.server, "log_line", None)
|
|
418
|
+
if logger:
|
|
419
|
+
logger(message)
|
|
420
|
+
|
|
421
|
+
def log_message(self, fmt: str, *args) -> None:
|
|
422
|
+
"""Silence the per-request access log.
|
|
423
|
+
|
|
424
|
+
Not cosmetic. macOS has NO log rotation for this connector at all
|
|
425
|
+
(`docs/runbooks/cctv-connector-runtime-edge-cases.md`), and a 32-camera
|
|
426
|
+
install analyses continuously — an access line per frame is an unbounded
|
|
427
|
+
file on a box nobody visits. Non-2xx still logs, through `_json`.
|
|
428
|
+
"""
|
|
429
|
+
return
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class DetectorServer(ThreadingHTTPServer):
|
|
433
|
+
daemon_threads = True
|
|
434
|
+
|
|
435
|
+
# SO_REUSEADDR EVERYWHERE EXCEPT WINDOWS, and the exception is the point.
|
|
436
|
+
#
|
|
437
|
+
# On POSIX it only lets a restart reclaim a port still in TIME_WAIT, which a
|
|
438
|
+
# supervised always-on service needs — otherwise a 1s blip becomes a 30s
|
|
439
|
+
# outage against launchd's ThrottleInterval. On Windows the same flag lets a
|
|
440
|
+
# SECOND process bind a port the first is actively listening on, and both
|
|
441
|
+
# then accept. `http.server` turns it on unconditionally, so this turns it
|
|
442
|
+
# back off there.
|
|
443
|
+
#
|
|
444
|
+
# `register-litertlm-task.ps1` documents exactly what that costs: a serve
|
|
445
|
+
# left alive keeps ANSWERING, so an upgrade installs the new binary and
|
|
446
|
+
# leaves the OLD one serving while every health probe passes. A hijacked
|
|
447
|
+
# bind is that incident with no way to even notice.
|
|
448
|
+
allow_reuse_address = _sys.platform != "win32"
|
|
449
|
+
|
|
450
|
+
def __init__(self, address, loader, log_line=print):
|
|
451
|
+
super().__init__(address, DetectorHandler)
|
|
452
|
+
self.loader = loader
|
|
453
|
+
self.log_line = log_line
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def serve(loader, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, log_line=print) -> DetectorServer:
|
|
457
|
+
"""Start the server on a background thread and return it."""
|
|
458
|
+
server = DetectorServer((host, port), loader, log_line)
|
|
459
|
+
threading.Thread(target=server.serve_forever, name="detector-http", daemon=True).start()
|
|
460
|
+
return server
|