@trillboards/edge-sdk 0.2.74 → 0.2.78

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 (53) 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 +591 -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/EdgeAgent.js +1 -1
  33. package/dist/LocalDetectorClient.d.ts +92 -0
  34. package/dist/LocalDetectorClient.d.ts.map +1 -0
  35. package/dist/LocalDetectorClient.js +241 -0
  36. package/dist/LocalDetectorClient.js.map +1 -0
  37. package/dist/SensingProfileClient.d.ts +25 -22
  38. package/dist/SensingProfileClient.d.ts.map +1 -1
  39. package/dist/SensingProfileClient.js.map +1 -1
  40. package/dist/diagnose.d.ts +1 -1
  41. package/dist/diagnose.js +1 -1
  42. package/dist/index.d.ts +4 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +17 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/localInferenceSchema.d.ts +11 -2
  47. package/dist/localInferenceSchema.d.ts.map +1 -1
  48. package/dist/localInferenceSchema.js.map +1 -1
  49. package/dist/zoneContainment.d.ts +148 -0
  50. package/dist/zoneContainment.d.ts.map +1 -0
  51. package/dist/zoneContainment.js +172 -0
  52. package/dist/zoneContainment.js.map +1 -0
  53. package/package.json +2 -2
@@ -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