@trillboards/edge-sdk 0.2.81 → 0.2.82
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 +108 -21
- package/deploy/detector/bench/verify_against_reference.py +6 -1
- package/deploy/detector/pyproject.toml +1 -1
- package/deploy/detector/tests/test_loader.py +188 -0
- package/deploy/detector/tests/test_modelpath.py +48 -3
- package/deploy/detector/tests/test_registry.py +880 -0
- package/deploy/detector/tests/test_server.py +163 -7
- package/deploy/detector/tests/test_session.py +29 -10
- package/deploy/detector/trillboards_detector/__init__.py +1 -1
- package/deploy/detector/trillboards_detector/cli.py +49 -2
- package/deploy/detector/trillboards_detector/detections.py +98 -0
- package/deploy/detector/trillboards_detector/errors.py +38 -0
- package/deploy/detector/trillboards_detector/labels.py +239 -24
- package/deploy/detector/trillboards_detector/loader.py +186 -148
- package/deploy/detector/trillboards_detector/modelpath.py +32 -0
- package/deploy/detector/trillboards_detector/registry.py +836 -0
- package/deploy/detector/trillboards_detector/server.py +59 -40
- package/deploy/detector/trillboards_detector/session.py +84 -55
- package/dist/EdgeAgent.js +1 -1
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Model lifecycle:
|
|
1
|
+
"""Model lifecycle: build the catalogue, watch the disk, and say which is up.
|
|
2
2
|
|
|
3
3
|
WHY LOADING IS A BACKGROUND STATE MACHINE AND NOT A STARTUP STEP
|
|
4
4
|
---------------------------------------------------------------
|
|
@@ -25,21 +25,38 @@ Three constraints, none optional:
|
|
|
25
25
|
synthetic frame before declaring ready, and `/health` reports that outcome
|
|
26
26
|
rather than re-deriving anything — which is also how `/health` stays cheap
|
|
27
27
|
and inference-free.
|
|
28
|
+
|
|
29
|
+
WHAT THIS FILE IS AFTER THE MODEL LIBRARY
|
|
30
|
+
-----------------------------------------
|
|
31
|
+
It used to own the session, singular — its own docstring said so, and
|
|
32
|
+
`docs/runbooks/sensing-model-library.md` §3 names that as the thing standing
|
|
33
|
+
between here and a library of specialists. The residency policy (name -> session
|
|
34
|
+
map, class index, lazy load, LRU eviction under a RAM budget, merging two
|
|
35
|
+
models' answers) now lives in `registry.py`, which imports nothing heavier than
|
|
36
|
+
the standard library and is therefore testable on every CI runner. What is left
|
|
37
|
+
here is the half that genuinely needs onnxruntime and Pillow: building a real
|
|
38
|
+
session, running a real probe frame through it, and the poll loop that watches
|
|
39
|
+
the disk.
|
|
28
40
|
"""
|
|
29
41
|
|
|
30
42
|
from __future__ import annotations
|
|
31
43
|
|
|
32
44
|
import io
|
|
33
45
|
import threading
|
|
34
|
-
import time
|
|
35
46
|
from pathlib import Path
|
|
36
47
|
|
|
37
|
-
from .
|
|
38
|
-
from .
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
48
|
+
from .detections import DetectionBatch
|
|
49
|
+
from .errors import DetectorNotReady, UnknownClasses
|
|
50
|
+
from .labels import BUILTIN_VOCABULARIES, ClassIndex, ModelVocabulary
|
|
51
|
+
from .modelpath import resolve_model_path
|
|
52
|
+
from .registry import (
|
|
53
|
+
STATE_LOADING,
|
|
54
|
+
STATE_READY,
|
|
55
|
+
STATE_UNAVAILABLE,
|
|
56
|
+
ModelEntry,
|
|
57
|
+
ModelRegistry,
|
|
58
|
+
)
|
|
59
|
+
from .session import OnnxDetector
|
|
43
60
|
|
|
44
61
|
DEFAULT_POLL_INTERVAL_S = 30.0
|
|
45
62
|
|
|
@@ -58,8 +75,49 @@ def _warmup_jpeg() -> bytes:
|
|
|
58
75
|
return buf.getvalue()
|
|
59
76
|
|
|
60
77
|
|
|
78
|
+
def build_catalogue(
|
|
79
|
+
default_model_name: str,
|
|
80
|
+
default_model_path: "Path | None" = None,
|
|
81
|
+
vocabularies: "tuple[ModelVocabulary, ...]" = BUILTIN_VOCABULARIES,
|
|
82
|
+
env=None,
|
|
83
|
+
) -> "list[ModelEntry]":
|
|
84
|
+
"""Every model this build can label, bound to where it would live on THIS box.
|
|
85
|
+
|
|
86
|
+
One entry per committed vocabulary, each at `resolve_model_path(<name>)` —
|
|
87
|
+
the per-model-name directory layout that `modelpath.py` was already written
|
|
88
|
+
for ("the multi-model shape was anticipated at the path layer"). Nothing
|
|
89
|
+
here checks whether the file exists: presence is the watcher's business and
|
|
90
|
+
it changes under us, so deciding it once at startup would freeze a fact that
|
|
91
|
+
an OTA is about to move.
|
|
92
|
+
|
|
93
|
+
`default_model_name` names the PINNED entry — the general cascade gate, the
|
|
94
|
+
one `/health` reports and the one that is never evicted. It must be a
|
|
95
|
+
committed vocabulary: a name with no class list is a model nothing can
|
|
96
|
+
label, which is `yolov8_nano` exactly.
|
|
97
|
+
"""
|
|
98
|
+
by_name = {v.model_name: v for v in vocabularies}
|
|
99
|
+
if default_model_name not in by_name:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"no committed vocabulary for {default_model_name!r}; known: {sorted(by_name)}. "
|
|
102
|
+
"A model with no class list is a model nothing can label — see labels.py."
|
|
103
|
+
)
|
|
104
|
+
entries = []
|
|
105
|
+
for vocabulary in vocabularies:
|
|
106
|
+
pinned = vocabulary.model_name == default_model_name
|
|
107
|
+
if pinned and default_model_path is not None:
|
|
108
|
+
path = default_model_path
|
|
109
|
+
else:
|
|
110
|
+
path = resolve_model_path(vocabulary.model_name, env)
|
|
111
|
+
entries.append(ModelEntry(vocabulary, path, pinned=pinned))
|
|
112
|
+
# Pinned first, so catalogue order — which is what breaks a class-ownership
|
|
113
|
+
# tie — puts the general detector ahead of every specialist. `person` must
|
|
114
|
+
# keep coming from the model the 98.8/98.8 was measured on.
|
|
115
|
+
entries.sort(key=lambda e: (not e.pinned,))
|
|
116
|
+
return entries
|
|
117
|
+
|
|
118
|
+
|
|
61
119
|
class DetectorLoader:
|
|
62
|
-
"""Owns the
|
|
120
|
+
"""Owns the model CATALOGUE, the poll loop, and the words describing both."""
|
|
63
121
|
|
|
64
122
|
def __init__(
|
|
65
123
|
self,
|
|
@@ -67,30 +125,45 @@ class DetectorLoader:
|
|
|
67
125
|
model_name: str,
|
|
68
126
|
intra_op_threads: "int | None" = None,
|
|
69
127
|
poll_interval_s: float = DEFAULT_POLL_INTERVAL_S,
|
|
128
|
+
ram_budget_bytes: "int | None" = None,
|
|
129
|
+
vocabularies: "tuple[ModelVocabulary, ...]" = BUILTIN_VOCABULARIES,
|
|
70
130
|
log=print,
|
|
71
131
|
):
|
|
132
|
+
# `ram_budget_bytes=None` is NOT a default policy — it is the absence of
|
|
133
|
+
# one, and the registry refuses every portfolio decision in that state
|
|
134
|
+
# rather than inventing a number. See `registry.py`'s header: the
|
|
135
|
+
# allocation authority is server-side and a ceiling chosen here would be
|
|
136
|
+
# a second one that cannot see it.
|
|
72
137
|
self.model_path = model_path
|
|
73
138
|
self.model_name = model_name
|
|
74
139
|
self._intra_op_threads = intra_op_threads
|
|
75
140
|
self._poll_interval_s = poll_interval_s
|
|
76
141
|
self._log = log
|
|
77
142
|
|
|
78
|
-
self.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
self._degenerate_dropped = 0
|
|
87
|
-
self._unnamed_class_dropped = 0
|
|
88
|
-
self._detect_count = 0
|
|
89
|
-
self._detect_failures = 0
|
|
143
|
+
self.registry = ModelRegistry(
|
|
144
|
+
entries=build_catalogue(model_name, model_path, vocabularies),
|
|
145
|
+
session_factory=self._build_session,
|
|
146
|
+
probe=self._probe,
|
|
147
|
+
ram_budget_bytes=ram_budget_bytes,
|
|
148
|
+
log=log,
|
|
149
|
+
)
|
|
90
150
|
|
|
91
151
|
self._stop = threading.Event()
|
|
92
152
|
self._thread = threading.Thread(target=self._run, name="detector-loader", daemon=True)
|
|
93
153
|
|
|
154
|
+
# ── the two halves that need a real runtime ──────────────────────────────
|
|
155
|
+
|
|
156
|
+
def _build_session(self, entry: ModelEntry) -> OnnxDetector:
|
|
157
|
+
return OnnxDetector(entry.path, entry.vocabulary, self._intra_op_threads)
|
|
158
|
+
|
|
159
|
+
def _probe(self, session: OnnxDetector) -> None:
|
|
160
|
+
"""PROVE IT. A session that constructs is not a session that infers.
|
|
161
|
+
|
|
162
|
+
`0.99` keeps the probe's own post-processing trivial on a grey frame;
|
|
163
|
+
what is being established is that a forward pass completes at all.
|
|
164
|
+
"""
|
|
165
|
+
session.detect(_warmup_jpeg(), 0.99, None)
|
|
166
|
+
|
|
94
167
|
def start(self) -> None:
|
|
95
168
|
self._thread.start()
|
|
96
169
|
|
|
@@ -102,156 +175,121 @@ class DetectorLoader:
|
|
|
102
175
|
|
|
103
176
|
# ── what the server reads ────────────────────────────────────────────────
|
|
104
177
|
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _top_level_state(pinned_state: str) -> str:
|
|
180
|
+
if pinned_state == STATE_READY:
|
|
181
|
+
return STATE_READY
|
|
182
|
+
if pinned_state == STATE_LOADING:
|
|
183
|
+
return STATE_LOADING
|
|
184
|
+
return STATE_UNAVAILABLE
|
|
185
|
+
|
|
105
186
|
@property
|
|
106
187
|
def state(self) -> str:
|
|
107
|
-
|
|
108
|
-
|
|
188
|
+
"""The PINNED model's state, in the three words that shipped first.
|
|
189
|
+
|
|
190
|
+
`/health` has always answered 200 iff a load completed and a probe
|
|
191
|
+
inference succeeded, and `LocalDetectorClient.isHealthy()` turns
|
|
192
|
+
anything else into "detector down, send the frame to the VLM". So the
|
|
193
|
+
top-level word keeps describing the general cascade gate: a box whose
|
|
194
|
+
gate is missing cannot do the job it exists for, whatever specialists
|
|
195
|
+
happen to be resident. Per-model states — including `idle` and `absent`,
|
|
196
|
+
which have no top-level equivalent — are in `models` on the same
|
|
197
|
+
payload.
|
|
198
|
+
"""
|
|
199
|
+
return self._top_level_state(self.registry.snapshot()["pinned_state"])
|
|
109
200
|
|
|
110
201
|
@property
|
|
111
202
|
def reason(self) -> "str | None":
|
|
112
|
-
|
|
113
|
-
|
|
203
|
+
return self.registry.snapshot()["pinned"].get("reason")
|
|
204
|
+
|
|
205
|
+
def class_index(self) -> ClassIndex:
|
|
206
|
+
"""The union vocabulary of every model whose artifact is on this box."""
|
|
207
|
+
return self.registry.class_index()
|
|
208
|
+
|
|
209
|
+
def resolve_classes(self, requested: "list[str]") -> "set[str]":
|
|
210
|
+
"""Canonicalise, or raise `UnknownClasses` — which the server makes a 400."""
|
|
211
|
+
return self.registry.resolve_classes(requested)
|
|
114
212
|
|
|
115
213
|
def info(self) -> dict:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
214
|
+
"""The `/health` payload. Reads state that already exists; loads nothing.
|
|
215
|
+
|
|
216
|
+
THE TOP-LEVEL KEYS STILL DESCRIBE THE PINNED MODEL, unchanged, because
|
|
217
|
+
every existing reader — the three installers' post-install probes, the
|
|
218
|
+
integration test, an operator with `curl` — was written against them.
|
|
219
|
+
`models`, `resident_models` and the two RAM figures are additive.
|
|
220
|
+
|
|
221
|
+
ONE SNAPSHOT, not one per field. Reading `self.state` here would take a
|
|
222
|
+
SECOND snapshot, and the watcher thread can move between them — so
|
|
223
|
+
`/health` could answer `state: "ready"` next to a `models` map showing
|
|
224
|
+
the pinned model still loading. Neither half would be wrong and the
|
|
225
|
+
pair would describe no moment, which is precisely the shape
|
|
226
|
+
`postgres-tls-uri-override.md` records for two scans a minute apart. A
|
|
227
|
+
payload whose entire job is honesty about a load that happened must be
|
|
228
|
+
one read.
|
|
229
|
+
"""
|
|
230
|
+
snap = self.registry.snapshot()
|
|
231
|
+
pinned = snap["pinned"]
|
|
232
|
+
payload = {
|
|
233
|
+
"state": self._top_level_state(snap["pinned_state"]),
|
|
234
|
+
"model": snap["pinned_model"],
|
|
235
|
+
"model_path": pinned["path"],
|
|
236
|
+
"detect_count": snap["detect_count"],
|
|
237
|
+
"detect_failures": snap["detect_failures"],
|
|
238
|
+
# Published because a drop nobody can see is the failure this
|
|
239
|
+
# whole stack keeps repeating. Both are normally 0. The per-model
|
|
240
|
+
# split is in `models`; these are the box-wide totals the existing
|
|
241
|
+
# readers already parse.
|
|
242
|
+
"degenerate_boxes_dropped": snap["degenerate_boxes_dropped"],
|
|
243
|
+
"unnamed_class_dropped": snap["unnamed_class_dropped"],
|
|
244
|
+
# WHICH MODELS ARE RESIDENT — `sensing-model-library.md` §3. Derived
|
|
245
|
+
# from the same state the loads wrote, so this stays a report and
|
|
246
|
+
# never becomes a probe.
|
|
247
|
+
"models": snap["models"],
|
|
248
|
+
"resident_models": snap["resident_models"],
|
|
249
|
+
# THE CEILING AS INJECTED, and whether one was injected at ALL —
|
|
250
|
+
# `ram_budget_bytes: null` alone is ambiguous with a reader that
|
|
251
|
+
# drops nulls, and the difference between "nobody has set a ceiling"
|
|
252
|
+
# and "the ceiling is X" is the difference between a policy hole and
|
|
253
|
+
# a policy. The service picks no number in either case.
|
|
254
|
+
"ram_budget_bytes": snap["ram_budget_bytes"],
|
|
255
|
+
"ram_ceiling_injected": snap["ram_ceiling_injected"],
|
|
256
|
+
"ram_resident_bytes": snap["ram_resident_bytes"],
|
|
257
|
+
# Non-zero means OVERSUBSCRIBED against what this box was allocated.
|
|
258
|
+
# Eviction is a safety valve, not the allocator, and a service
|
|
259
|
+
# quietly thrashing under a ceiling looks exactly like one working
|
|
260
|
+
# fine — so the count has to leave the process.
|
|
261
|
+
"evictions": snap["evictions"],
|
|
262
|
+
"known_class_count": snap["known_class_count"],
|
|
263
|
+
}
|
|
264
|
+
reason = pinned.get("reason")
|
|
265
|
+
if reason:
|
|
266
|
+
payload["reason"] = reason
|
|
267
|
+
for key in ("warmup_inference_ms", "loaded_at", "input", "classes", "providers"):
|
|
268
|
+
if key in pinned:
|
|
269
|
+
payload[key] = pinned[key]
|
|
270
|
+
return payload
|
|
140
271
|
|
|
141
272
|
def detect(self, jpeg: bytes, min_confidence: float, wanted: "set[str] | None") -> DetectionBatch:
|
|
142
|
-
|
|
143
|
-
detector, reason = self._detector, self._reason
|
|
144
|
-
if detector is None:
|
|
145
|
-
raise DetectorNotReady(reason or "model is not loaded")
|
|
146
|
-
try:
|
|
147
|
-
batch = detector.detect(jpeg, min_confidence, wanted)
|
|
148
|
-
except Exception:
|
|
149
|
-
with self._lock:
|
|
150
|
-
self._detect_failures += 1
|
|
151
|
-
raise
|
|
152
|
-
with self._lock:
|
|
153
|
-
self._detect_count += 1
|
|
154
|
-
self._degenerate_dropped += batch.degenerate_dropped
|
|
155
|
-
self._unnamed_class_dropped += batch.unnamed_class_dropped
|
|
156
|
-
if batch.degenerate_dropped or batch.unnamed_class_dropped:
|
|
157
|
-
self._log(
|
|
158
|
-
f"[detector] dropped {batch.degenerate_dropped} zero-extent box(es) and "
|
|
159
|
-
f"{batch.unnamed_class_dropped} unnamed-class detection(s) from one frame"
|
|
160
|
-
)
|
|
161
|
-
return batch
|
|
273
|
+
return self.registry.detect(jpeg, min_confidence, wanted)
|
|
162
274
|
|
|
163
275
|
# ── the loop ─────────────────────────────────────────────────────────────
|
|
164
276
|
|
|
165
277
|
def _run(self) -> None:
|
|
166
278
|
while not self._stop.is_set():
|
|
167
279
|
try:
|
|
168
|
-
self.
|
|
280
|
+
self.registry.refresh()
|
|
169
281
|
except Exception as err: # never let the watcher die
|
|
170
|
-
self.
|
|
282
|
+
self._log(f"[detector] loader crashed: {type(err).__name__}: {err}")
|
|
171
283
|
self._stop.wait(self._poll_interval_s)
|
|
172
284
|
|
|
173
|
-
def _tick(self) -> None:
|
|
174
|
-
stamp = self._file_stamp()
|
|
175
|
-
if stamp is None:
|
|
176
|
-
if self._state != STATE_UNAVAILABLE or self._detector is not None:
|
|
177
|
-
self._log(f"[detector] model artifact not present at {self.model_path}")
|
|
178
|
-
self._drop(
|
|
179
|
-
f"model artifact not found at {self.model_path}; it arrives over the air "
|
|
180
|
-
"and this service will pick it up within "
|
|
181
|
-
f"{int(self._poll_interval_s)}s of it landing"
|
|
182
|
-
)
|
|
183
|
-
return
|
|
184
|
-
|
|
185
|
-
with self._lock:
|
|
186
|
-
unchanged = self._detector is not None and self._stamp == stamp
|
|
187
|
-
if unchanged:
|
|
188
|
-
return
|
|
189
|
-
|
|
190
|
-
if self._detector is not None:
|
|
191
|
-
self._log(f"[detector] artifact changed on disk — reloading {self.model_path}")
|
|
192
|
-
self._load(stamp)
|
|
193
|
-
|
|
194
|
-
def _load(self, stamp: "tuple[int, int]") -> None:
|
|
195
|
-
self._set(STATE_LOADING, None)
|
|
196
|
-
try:
|
|
197
|
-
detector = OnnxDetector(self.model_path, self.model_name, self._intra_op_threads)
|
|
198
|
-
except ModelContractError as err:
|
|
199
|
-
self._drop(f"artifact loaded but does not match the detector contract: {err}")
|
|
200
|
-
return
|
|
201
|
-
except Exception as err:
|
|
202
|
-
self._drop(f"could not load {self.model_path}: {err}")
|
|
203
|
-
return
|
|
204
|
-
|
|
205
|
-
# PROVE IT. A session that constructs is not a session that infers.
|
|
206
|
-
try:
|
|
207
|
-
started = time.perf_counter()
|
|
208
|
-
detector.detect(_warmup_jpeg(), 0.99, None)
|
|
209
|
-
warmup_ms = (time.perf_counter() - started) * 1000.0
|
|
210
|
-
except Exception as err:
|
|
211
|
-
self._drop(f"artifact loaded but a probe inference failed: {err}")
|
|
212
|
-
return
|
|
213
|
-
|
|
214
|
-
with self._lock:
|
|
215
|
-
self._detector = detector
|
|
216
|
-
self._stamp = stamp
|
|
217
|
-
self._state = STATE_READY
|
|
218
|
-
self._reason = None
|
|
219
|
-
self._warmup_ms = warmup_ms
|
|
220
|
-
self._loaded_at = time.time()
|
|
221
|
-
self._log(
|
|
222
|
-
f"[detector] ready — {self.model_name} at {self.model_path} "
|
|
223
|
-
f"({detector.spec.width}x{detector.spec.height}, {detector.spec.num_classes} classes, "
|
|
224
|
-
f"probe {warmup_ms:.0f} ms, providers {detector.spec.providers})"
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
def _file_stamp(self) -> "tuple[int, int] | None":
|
|
228
|
-
try:
|
|
229
|
-
st = self.model_path.stat()
|
|
230
|
-
except OSError:
|
|
231
|
-
return None
|
|
232
|
-
if st.st_size == 0:
|
|
233
|
-
return None
|
|
234
|
-
return (st.st_size, st.st_mtime_ns)
|
|
235
|
-
|
|
236
|
-
def _drop(self, reason: str) -> None:
|
|
237
|
-
with self._lock:
|
|
238
|
-
self._detector = None
|
|
239
|
-
self._stamp = None
|
|
240
|
-
self._warmup_ms = None
|
|
241
|
-
self._loaded_at = None
|
|
242
|
-
self._state = STATE_UNAVAILABLE
|
|
243
|
-
self._reason = reason
|
|
244
|
-
|
|
245
|
-
def _set(self, state: str, reason: "str | None") -> None:
|
|
246
|
-
with self._lock:
|
|
247
|
-
self._state = state
|
|
248
|
-
self._reason = reason
|
|
249
|
-
|
|
250
285
|
|
|
251
286
|
__all__ = [
|
|
287
|
+
"DEFAULT_POLL_INTERVAL_S",
|
|
252
288
|
"DetectorLoader",
|
|
253
289
|
"DetectorNotReady",
|
|
254
290
|
"STATE_LOADING",
|
|
255
291
|
"STATE_READY",
|
|
256
292
|
"STATE_UNAVAILABLE",
|
|
293
|
+
"UnknownClasses",
|
|
294
|
+
"build_catalogue",
|
|
257
295
|
]
|
|
@@ -72,3 +72,35 @@ def resolve_model_path(
|
|
|
72
72
|
if override:
|
|
73
73
|
return Path(override)
|
|
74
74
|
return resolve_data_dir(e) / "models" / model_name / MODEL_FILE_NAME
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_ram_budget_mb(
|
|
78
|
+
env: "os._Environ[str] | dict[str, str] | None" = None,
|
|
79
|
+
) -> "int | None":
|
|
80
|
+
"""`TRILLBOARDS_DETECTOR_RAM_BUDGET_MB`, or None to take the default.
|
|
81
|
+
|
|
82
|
+
An env override as well as a CLI flag because the three supervisor
|
|
83
|
+
definitions are the awkward place to add an argument — a launchd plist, a
|
|
84
|
+
systemd unit and a Windows Scheduled Task each hold their own hard-coded
|
|
85
|
+
`ProgramArguments`, and all three are rewritten by installers. An
|
|
86
|
+
environment variable reaches the process through all three without touching
|
|
87
|
+
any of them.
|
|
88
|
+
|
|
89
|
+
A malformed value RAISES rather than falling back to the default. Silently
|
|
90
|
+
ignoring `RAM_BUDGET_MB=1.5gb` would leave an operator convinced they had
|
|
91
|
+
raised a limit that never moved — an operation reporting success while doing
|
|
92
|
+
nothing, which is the failure shape this whole tree keeps catching.
|
|
93
|
+
"""
|
|
94
|
+
e = os.environ if env is None else env
|
|
95
|
+
raw = (e.get("TRILLBOARDS_DETECTOR_RAM_BUDGET_MB") or "").strip()
|
|
96
|
+
if not raw:
|
|
97
|
+
return None
|
|
98
|
+
try:
|
|
99
|
+
value = int(raw)
|
|
100
|
+
except ValueError:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"TRILLBOARDS_DETECTOR_RAM_BUDGET_MB must be an integer number of MiB, got {raw!r}"
|
|
103
|
+
) from None
|
|
104
|
+
if value <= 0:
|
|
105
|
+
raise ValueError(f"TRILLBOARDS_DETECTOR_RAM_BUDGET_MB must be positive, got {value}")
|
|
106
|
+
return value
|