Files
StarPilot/jetlink_repo/jetlink/openpilot/joining.py
T

787 lines
35 KiB
Python

"""
Copyright (c) 2026-, Zeph Leggett.
This file is part of jetlink and is licensed under the MIT License.
See the LICENSE file in the root directory for more details.
A ModelState that starts as the small model and upgrades to the Jetson.
modeld's large-model load is a one-shot with BIG_MODEL_TIMEOUT and a silent,
one-way fallback. That suits a chestnut, powered from the comma. A Jetson is
on the ignition rail: cranking browns it out, so its boot starts about when
the comma goes onroad and takes 45 to 100 s, long after modeld gave up.
So modeld is handed the small model it already loaded, and the Jetson is
swapped in underneath once the link, the engine and the warp are all there.
modeld re-reads `model` every frame, and `modelV2.big` keeps its meaning.
Until the swap the comma runs its own model and nothing else; the link idles
on a keepalive ping. The large model is built on the swap frame and starts
from a reset history, as at every modeld start. The second after the swap is
its proof: the fork holds engagement off for it, and a large model that holds
a frame in it (model_state.PROVING_FRAMES) hands back before anyone can
engage. While it drives, a reply that is not back in time is not waited for:
the previous frame's output is published again (a held frame,
model_state.HOLD_FRAME) and the camera frame is not dropped. Too many holds
hand the drive back to the small model, as a lost link does.
No frames go to the host while the small model drives. Sending them costs
the comma ~9 ms of every frame for as long as the window stays shut (a whole
drive with MADS on), which starves the driver monitoring model under
selfdrived's frequency floor (commIssue).
Two rules the swap keeps:
- tinygrad work happens on modeld's thread. The joining thread does link IO
only; building the JetlinkModelState unpickles a TinyJit, and doing that
next to the small model running frames on the same device is not safe.
- never swap while the plan is steering. The two models disagree by ~195 m of
planned path, and the swap costs a frame or two. The adapter says before
every frame whether anything is in control (in_control), from openpilot's
own messages; standstill alone is not enough, longitudinal control may still
hold the brake.
"""
from __future__ import annotations
import os
import threading
import time
from jetlink import protocol as P
from jetlink.comma import gadget
from jetlink.openpilot.model_state import JetlinkModelState
from jetlink.transport.priority import background_thread
# after a join fails or the large model dies mid-drive. Long enough not to
# thrash a booting Jetson, short enough to catch one that finished a moment later
REJOIN_DELAY = 5.0
# doubled per consecutive failure, reset by a join that lasted STABLE_SECONDS.
# A link that dies on its first frame every time must not cost a swap, a
# demote and an alert every few seconds
REJOIN_DELAY_MAX = 60.0
STABLE_SECONDS = 60.0
# after a join that held: a link that ran for minutes and then went is a USB
# drop, and the host re-enumerates a rebound gadget in under a second
REJOIN_DELAY_QUICK = 1.0
# failures in a streak retried that quickly before the doubling starts. A
# demote is mostly one late frame, a tail of the link or the host and not a
# dead one, and a full backoff costs 5 to 60 s on the small model. A link
# that fails on its heels this many times is backed off as usual
QUICK_RETRIES = 3
# how often a link that is ready but has nowhere to land gets checked, and
# how long the check may take; both off the frame loop
KEEPALIVE_PERIOD = 10.0
PING_TIMEOUT = 2.0
# how often a backoff looks at the gadget. A host that configures it again
# after it went away is a replug, which the backoff is not for: a replugged
# Jetson is back in under half a second
REPLUG_POLL = 0.25
# link drops in one drive before the status names the cable. One is chance;
# repeated drops are almost always the USB-C port losing its host, and nothing
# else the driver can see says so
DROPS_TO_BLAME_CABLE = 2
# A host slower than the camera but never that slow (an iPhone that has warmed
# up: 55 ms a frame drops one camera frame in ten) still has modeld skipping
# frames, and selfdrived soft-disables on modeldLagging once modeld's filtered
# share of them (frameDropPerc) passes 1 %: three drops close together. modeld
# writes that share onto the model before every run(), and the large model
# hands back past DROP_LIMIT, short of the line: one dropped frame is forgiven,
# a second within about 6.5 s is not. modeld forgives the frame of a handover
# too, so the drops that decided it never reach selfdrived
DROP_LIMIT = 0.0075
# the small model drives the first frames of every modeld start, even with the
# large model ready: its first run in a process costs ~1.3 s, which the first
# fallback frame would pay (25 dropped frames, commIssue) if the large model
# drove from frame one. Nothing is in control at a modeld start
SMALL_WARMUP_FRAMES = 3
# the panel's line while a ready link waits for the window
READY = 're-engage to switch'
# why a demote happened, as the log says it
LOST = 'lost jetlink'
BEHIND = 'jetlink fell behind'
# a NaN or an inf in an output: a host that answers, wrongly. A lag and not a
# drop, the link is kept, and the next swap starts the host from a reset
BAD_OUTPUT = 'jetlink output not finite'
# what modeld_v2 builds the frame's inputs from: the small bundle's desire
# name and optional slots, read off the small model whichever drives
SMALL_READS = ('desire_key', 'numpy_inputs')
# why a built model was let go before it drove
NOT_UP = 'could not bring up the large model'
# and as the leave says it to the server (protocol.Msg.LEAVE)
LEAVING = {LOST: P.LEAVE_LOST, BEHIND: P.LEAVE_BEHIND}
# the demotes that count as lag, which says nothing about the cable
LAGS = (BEHIND, BAD_OUTPUT)
# and as the panel's one line says it while the join tries again, with the
# `why` of its progress code (status.PROGRESS_CODES 'reconnecting')
RECONNECTING = {LOST: ('reconnecting', 'lost'), BEHIND: ('fell behind, reconnecting', 'behind'),
NOT_UP: ('load failed, reconnecting', 'load_failed'),
BAD_OUTPUT: ('bad output, reconnecting', 'bad_output')}
# how long a model name the catalog had not resolved stays the sha's
NAME_RETRY = 30.0
class JoiningModelState:
"""Duck-types openpilot's modeld ModelState, with a second one inside.
`progress` is where the join says what it is waiting on (status.Progress),
`log` is cloudlog on a comma, and `in_control` the adapter's, asked before
every frame; without one, the in_control last written stands. `name` is
the catalog's name for a model's sha256, for the runtime record.
"""
def __init__(self, small, connect, build, prepare=None, reset_small=None, *, progress, log, in_control=None,
name=None):
self._small = small
self._active = small
self._connect = connect
self._build = build
self._reset_small = reset_small
self._progress = progress
self._log = log
# whatever the swap would otherwise do on the frame loop, done now on
# modeld's main thread before the frame loop exists; at the swap it costs
# a 1.5 s frame
if prepare is not None:
try:
t0 = time.monotonic()
prepare()
log.warning("jetlink: prepared the large model ahead of the swap in %.2f s", time.monotonic() - t0)
except Exception:
log.exception("jetlink: could not prepare the large model ahead of the swap")
raise
# handed over by the joining thread, consumed by the swap frame, which
# builds the large model state from it. Only ever assigned under the lock
self._joined: tuple[object, object] | None = None
# a link is ready and waiting for the window. Kept true while a keepalive
# ping has _joined out for a moment
self._available = False
# the large model state, from the swap until it hands back: always _active.
# Only the frame thread assigns it
self._big = None
# what the frame thread let go of and why (LOST, BEHIND, BAD_OUTPUT, or a
# build that failed), for the join thread to say goodbye to and close or keep.
# Assigned under the lock
self._retired: tuple[object, str] | None = None
self._lock = threading.Lock()
self._rejoin = threading.Event()
self._rejoin.set()
# earliest the join loop may try again. Without a backoff a fault that
# recurs on the first frame after every swap is a connect, build and demote
# every 700 ms for the drive, each costing modeld a 100 ms frame
self._rejoin_at = 0.0
self._failures = 0
self._joined_at = 0.0
# links lost and lag demotes after a swap this drive. Only a lost link
# says anything about the cable
self._drops = 0
self._lags = 0
# changes of the model that drives, and decisions to change it: modeld
# compares it across run() (handovers)
self._handovers = 0
# set on a frame the large model fell behind on; the next frame demotes
self._lagging = False
# modeld's share of dropped camera frames, for the frame about to run
self._frame_drop_ratio = 0.0
# set by the large model's first frame: the join thread clears the
# progress, which is params IO and not the frame thread's
self._announce = False
# frames the small model has run since start
self._small_frames = 0
# whether the host had let go of the gadget when the last link was lost,
# and whether this failure streak has already skipped a backoff for a replug
self._host_left = False
self._replugged = False
# in control until the adapter says otherwise, so a swap can never happen
# on no information
self._in_control = True
self._ask_in_control = in_control
self._stop = threading.Event()
# whether the large model has produced a frame; a first inference that
# fails never announces readiness. Travels in modelV2.big and
# modelDataV2SP, not a param: a chestnut's load is over once, this never is
self._loading = True
# the runtime record's: when the large model's last answer (not a held
# frame) came, and a wake for its writer on a hand-back and the first
# answer. The frame thread assigns them and nothing else; the writer
# thread reads the rest itself. The path is taken now, so a test's stays
# its own
self._output_at = 0.0
self._record = threading.Event()
self._record_path = gadget.MODELD
self._name = name
self._thread = threading.Thread(target=self._join_loop, daemon=True)
self._thread.start()
self._writer = threading.Thread(target=self._write_loop, daemon=True)
self._writer.start()
# -- what modeld reads ------------------------------------------------------
# A read not defined here follows the model that is driving (__getattr__):
# vision_input_names, and modeld_v2's constants, smoothing and action
# function, which sunnypilot keeps on the ModelState so a custom small bundle
# can carry its own. Swaps and demotes happen inside run(), so what is read
# after it belongs to the model whose output it is. What the loop writes
# lands on both, but for what only this reads. The frame's inputs are built
# for the small bundle (SMALL_READS, off the small model whichever drives);
# the large model reads the desire under any name and the loop always
# supplies what it needs, so a swap can land on any frame
@property
def big_model_available(self) -> bool:
"""Connected and waiting to switch: what big_model_state calls ready, and
what the driver is offered."""
return not self._stop.is_set() and self._available and self._active is self._small
@property
def chestnut(self) -> bool:
# modelV2.big. False while proxying, as the small model would report
return getattr(self._active, 'chestnut', False)
@property
def handovers(self) -> int:
"""Moves on every swap and demote, and on the frame that decides a lag
demote. modeld resets its dropped-frame filter when this changes across
run(), so the stall of a handover is not lag, as for a chestnut's fallback.
A count and not modelV2.big: a swap whose first frame fails and demotes in
the same run() leaves `chestnut` as it was."""
return self._handovers
@property
def big_model_state(self) -> str:
"""modelDataV2SP.acceleratorState, one of its enum names (STATES)."""
if self._stop.is_set():
return 'unavailable'
if self._active is not self._small:
return 'running'
if self.big_model_available:
# nothing left to wait for but a window. The icon says so rather than
# pulsing "loading" for the rest of a drive with no stop in it
return 'ready'
return 'retrying' if self._failures else 'joining'
@property
def client(self):
# read by the status publisher on every send, so the telemetry follows the link
return getattr(self._active, 'client', None)
@property
def lat_delay(self):
return self._active.lat_delay
@lat_delay.setter
def lat_delay(self, value):
# modeld writes this every frame. Set on both, so a model that joins
# mid-drive does not start on a stale delay
self._small.lat_delay = value
if self._active is not self._small:
self._active.lat_delay = value
@property
def PLANPLUS_CONTROL(self):
return self._active.PLANPLUS_CONTROL
@PLANPLUS_CONTROL.setter
def PLANPLUS_CONTROL(self, value):
self._small.PLANPLUS_CONTROL = value
if self._active is not self._small:
self._active.PLANPLUS_CONTROL = value
@property
def frame_drop_ratio(self) -> float:
return self._frame_drop_ratio
@frame_drop_ratio.setter
def frame_drop_ratio(self, value):
# modeld's frameDropPerc / 100, written before every run(). Read here, by
# the lag rule; no small model reads it
self._frame_drop_ratio = value
@property
def in_control(self) -> bool:
return self._in_control
@in_control.setter
def in_control(self, value):
# openpilot or MADS in control, by openpilot's own messages, asked before
# every run(): the swap waits for False. Not a thread polling selfdrived:
# that wakes ~300 times a second and takes 6 to 11 % of a core inside modeld
self._in_control = value
def __getattr__(self, name):
# Only for names this class does not define, so a read that a comma or
# sunnypilot sync adds is not an AttributeError on the frame thread (modeld
# re-raises it and is dead for the drive). Reads only; a write lands on
# this object alone, so each one modeld makes has its own setter above
if name.startswith('_'):
# also what keeps __init__ from recursing before _active exists
raise AttributeError(name)
# a small model without one of SMALL_READS has none, whichever drives
return getattr(self._small if name in SMALL_READS else self._active, name)
# -- the frame path ---------------------------------------------------------
def run(self, bufs, transforms, inputs, after_enqueue=None):
if self._ask_in_control is not None:
self.in_control = self._ask_in_control()
if self._lagging:
# the last frame was the large model's last, published as it came
self._lagging = False
self._demote(BEHIND)
elif self._active is not self._small and self._frame_drop_ratio > DROP_LIMIT:
# modeld is behind the large model; this frame is the small model's
self._log.warning("jetlink: modeld dropped %.2f %% of camera frames behind the large model, " +
"the small model drives from this one", self._frame_drop_ratio * 100)
self._demote(BEHIND)
self._maybe_swap()
if self._active is self._small:
self._small_frames += 1
return self._small.run(bufs, transforms, inputs, after_enqueue)
return self._run_big(bufs, transforms, inputs, after_enqueue)
def _run_big(self, bufs, transforms, inputs, after_enqueue):
big = self._big
started = time.monotonic()
try:
result = big.run(bufs, transforms, inputs, after_enqueue)
except Exception as e:
failed = time.monotonic()
self._log.exception("jetlink: large model failed mid-drive, back to the small model")
self._demote(BAD_OUTPUT if isinstance(e, P.NotFinite) else LOST)
demoted = time.monotonic()
# re-run the frame rather than propagate: modeld's fallback is permanent,
# this one is retryable. after_enqueue is dropped, the large model may
# already have called it
result = self._small.run(bufs, transforms, inputs, None)
# all of it is one modeld frame, 42 to 107 ms measured with the small
# model warm. The dropped camera frames are forgiven
# (modeld sees the model change), but the log should say which part it was
done = time.monotonic()
self._log.warning("jetlink: fallback frame %.0f ms: link %.0f, demote %.0f, small model %.0f",
(done - started) * 1e3, (failed - started) * 1e3,
(demoted - failed) * 1e3, (done - demoted) * 1e3)
return result
if not getattr(big, 'held', False):
self._output_at = time.monotonic()
if self._loading:
# a connected engine can still fail its first inference; only announce
# readiness after a frame the caller can publish
self._loading = False
self._announce = True
self._rejoin.set()
self._record.set()
self._log.warning("jetlink: large model joined mid-drive, modelV2.big is now true")
if big.behind:
# this frame's output is published as it came; its stall is forgiven now
self._lagging = True
self._handed_over()
self._log.warning("jetlink: large model %s, the small model drives from the next", big.behind)
return result
def _maybe_swap(self) -> None:
"""Build the large model state from a join that has landed and swap it in,
on this thread, which is where everything tinygrad touches must happen.
Its first frame resets the host's history. A build that fails is backed
off like a demote, or one that fails the same way every time is a connect
and a build per second for the drive."""
if self._joined is None or self._in_control or self._small_frames < SMALL_WARMUP_FRAMES:
return
with self._lock:
joined, self._joined = self._joined, None
if joined is not None:
self._available = False
if joined is None:
return
client, spec = joined
try:
t0 = time.monotonic()
big = self._build(client, spec)
big.lat_delay = getattr(self._small, 'lat_delay', big.lat_delay)
self._log.warning("jetlink: built the large model state in %.0f ms", (time.monotonic() - t0) * 1000)
except Exception:
self._log.exception("jetlink: could not bring up the large model, staying small")
with self._lock:
self._retired = (client, NOT_UP)
self._back_off()
return
self._big = big
self._joined_at = time.monotonic()
# no wake: the record says the swap with the first answer, which wakes it
self._handovers += 1
self._active = big
def _demote(self, why: str) -> None:
"""Back to the small model, from a reset history. On the frame thread, so
nothing here waits: the join thread reads the port, reports and closes
the link, moments later. Counted as a lost link or a hand-back for lag,
and backed off."""
big, self._big = self._big, None
if why in LAGS:
self._lags += 1
else:
self._drops += 1
with self._lock:
self._retired = (big, why)
self._back_off()
self._active = self._small
self._handed_over()
self._loading = True
if self._reset_small is not None:
self._reset_small()
def _handed_over(self) -> None:
self._handovers += 1
self._record.set()
def _close_retired(self, retired, why: str) -> None:
"""Say goodbye over the retired large model's link, then close it if it
is dead. A live one stays, on `Link`, for the next attempt, which is a
hello and a model request over it and not a reconnect: a model that fell
behind, or failed on the comma's side, leaves one. `retired` is the model
state, or the bare client of a build that failed."""
self._leave(retired, LEAVING.get(why, why))
if not getattr(getattr(retired, 'client', retired), 'dead', True):
return
try:
retired.close()
except Exception:
self._log.exception('jetlink: closing the retired link')
def _leave(self, model, reason: str) -> None:
"""Tell the server why the large model `model` stops using its link, with
what the comma measured over it (model_state.Trips). Off the frame thread."""
self._say_leaving(getattr(model, 'client', None), reason, getattr(model, 'trips', None))
def _say_leaving(self, client, reason: str, trips=None) -> None:
"""JetlinkClient.leave, which says nothing over a dead link."""
leave = getattr(client, 'leave', None)
if leave is None:
return
measured = trips.summary() if trips is not None else {}
try:
leave(reason, drops=self._drops, lags=self._lags, **measured)
except Exception:
self._log.exception("jetlink: could not say why the link is left")
def _back_off(self) -> None:
"""Push the next attempt out, further each time one fails on its heels.
Each failed cycle is a swap frame, a demote frame and the alerts that go
with them. The first QUICK_RETRIES failures of a streak are retried in
REJOIN_DELAY_QUICK; the ones after double from REJOIN_DELAY. A join that
held for STABLE_SECONDS starts a new streak.
"""
held = time.monotonic() - self._joined_at if self._joined_at else 0.0
stable = bool(self._joined_at) and held > STABLE_SECONDS
self._failures = 1 if stable else self._failures + 1
if stable:
self._replugged = False # a new streak may skip a backoff for a replug again
self._joined_at = 0.0
if self._failures <= QUICK_RETRIES:
delay = REJOIN_DELAY_QUICK
else:
delay = min(REJOIN_DELAY * 2 ** (self._failures - QUICK_RETRIES - 1), REJOIN_DELAY_MAX)
self._rejoin_at = time.monotonic() + delay
self._rejoin.set()
self._log.warning("jetlink: next attempt in %.0f s (failure %d, link held %.0f s, drop %d, lag %d this drive)",
delay, self._failures, held, self._drops, self._lags)
# -- background -------------------------------------------------------------
def _report(self, stage: str, msg: str, code: str | None, args: dict | None = None) -> None:
"""Tell the UI what the join is waiting on.
Offroad the record carries a provisioning run's progress; without this, a
Jetson that is not plugged in looks like one about to load. No fraction to
give, and the panel does not invent one.
"""
# the count is the diagnosis. A link that runs for minutes and then goes,
# again and again, is the cable, and the cable is the one thing the
# driver can do something about
self._progress.report(stage, 0.0, msg, drops=self._drops if self._drops >= DROPS_TO_BLAME_CABLE else 0,
code=code, args=args)
def _note_link_loss(self, why: str) -> None:
"""Off the frame thread: what the comma's USB-C port sees now, next to the
failure (gadget.cc_orientation). A host still on the cable means the data
link alone went; the kernel logs the same edge as a Type-C disconnect."""
cc = gadget.cc_orientation()
if cc is None:
port = "port state unknown"
else:
port = f"port sees a host (cc {cc})" if cc else "port sees no host (cc 0)"
# read now, before the teardown: a host back by the time the backoff starts
# has still been replugged
self._host_left = not gadget.host_attached()
self._log.warning("jetlink: %s, %s; drop %d, lag %d this drive", why, port, self._drops, self._lags)
msg, code = RECONNECTING[why]
self._report('connect', msg, 'reconnecting', {'why': code})
def _join_loop(self) -> None:
"""Open the link and get the engine ready. No tinygrad in here."""
# off modeld's realtime core first. A thread started after
# config_realtime_process(7, 54) inherits SCHED_FIFO and the single-core
# affinity, and an equal-priority thread that wakes takes the core until
# it blocks. Measured with these threads left as created: exec p95
# 90.8 ms, max 159.9, 5% frame drops, enough for modeldLagging
background_thread()
while not self._stop.is_set():
# no timeout: once joined there is nothing to poll for, the frames find
# a dead link, and close() sets this. An idle wake per second is not
# free on modeld's core
self._rejoin.wait()
if self._announce:
# the large model's first frame is out. Before the retired one below,
# whose reconnecting line is newer; woken for this alone, wait again.
# A demote or close sets its own flag before the event, so clearing
# the event and then looking misses neither
self._announce = False
self._progress.clear()
self._rejoin.clear()
if self._retired is None and not self._stop.is_set():
continue
with self._lock:
retired, self._retired = self._retired, None
if retired is not None:
model, why = retired
if not self._stop.is_set():
# before the teardown below, which can block: the port is read about
# when it let go. Not after close(), which has cleared the progress
self._note_link_loss(why)
# unbind and reader joins can block; only this thread does teardown,
# and it finishes before opening another link
self._close_retired(model, why)
if self._stop.is_set():
return
self._rejoin.clear()
if self._wait_out_back_off():
return
self._report('connect', 'waiting for jetlink', 'waiting_for_host')
try:
# the connect can take minutes when the picked model still has to be
# built, so it is handed the flag close() sets rather than polled
client, spec = self._connect(self._stop.is_set)
except Exception as e:
# expected while the Jetson boots, so no stack trace. A failure that
# knows when it is worth asking again says so (link.ModelMissing), and
# what the panel should show meanwhile
delay = getattr(e, 'retry_after', REJOIN_DELAY)
if waiting := getattr(e, 'waiting', None):
self._report('connect', waiting, getattr(e, 'code', None))
self._log.warning("jetlink: not joined yet (%s), retrying in %.0fs", e, delay)
if self._stop.wait(delay):
return
self._rejoin.set()
continue
with self._lock:
if self._stop.is_set():
client.close()
return
self._available = True
self._joined = (client, spec)
self._log.warning("jetlink: link ready, waiting for a window to swap")
self._report('connect', READY, 'ready_waiting_window')
self._keep_alive()
def _wait_out_back_off(self) -> bool:
"""Until the next attempt is due, or a host configures the gadget again
after it went away. Once per failure streak: a host that flaps, a marginal
cable, would otherwise cycle the swap and its alerts with no backoff at
all. True once closed."""
host_left, self._host_left = self._host_left or not gadget.host_attached(), False
while (left := self._rejoin_at - time.monotonic()) > 0:
if self._stop.wait(min(left, REPLUG_POLL)):
return True
attached = gadget.host_attached()
if attached and host_left and not self._replugged:
self._replugged = True
self._log.warning("jetlink: the host configured the gadget again, retrying now")
return False
host_left = host_left or not attached
return self._stop.is_set()
def _keep_alive(self) -> None:
"""Ping a link that is waiting for a swap window.
On a drive with no stop and no disengage that is the whole drive, and a
host that reboots in there would otherwise be found at the swap: a build
on a dead link, a demote and the backoff, all on modeld's thread.
The client is taken out of _joined for the ping and put back after, so the
frame loop sees a whole one or none, and never waits on the lock.
"""
while not self._stop.is_set():
if self._rejoin.wait(KEEPALIVE_PERIOD):
return
with self._lock:
joined, self._joined = self._joined, None
if joined is None:
return # swapped in on a frame, or closed under us
try:
joined[0].ping(timeout=PING_TIMEOUT)
except Exception as e:
self._available = False
self._log.warning("jetlink: the link died before it could be used (%s), reopening", e)
self._note_link_loss(LOST)
try:
joined[0].close()
except Exception:
self._log.exception('jetlink: closing the dead link')
self._back_off()
return
with self._lock:
stopped = self._stop.is_set()
if not stopped:
self._joined = joined
if stopped:
# close() ran during the ping and found nothing to close
self._say_leaving(joined[0], P.LEAVE_STOPPED)
joined[0].close()
return
def _write_loop(self) -> None:
"""The runtime record (gadget.MODELD), once a second, on a hand-back and on
the first answer after a swap: tmpfs IO, the link record and a catalog
read for the name, so never on the frame thread, and off modeld's realtime
core first (_join_loop). The last one, after close(), is stopping.
status.runtime reads pid, at, stopping, state, active and last_output_at;
handovers, held, sha256, name, checkpoint, transport, drops and lags have
no reader in jetlink yet, they are for forks (StarPilot's sha and
checkpoint, a drive's counts)."""
background_thread()
pid, names, kinds = os.getpid(), {}, {}
failing = False
while True:
# before the record is built: a wake from here on is for the next one
self._record.clear()
stopping = self._stop.is_set()
try:
gadget.write_record(self._record_path, self._runtime(pid, names, kinds, stopping))
failing = False
except Exception:
if not failing: # once, not every second
self._log.exception("jetlink: could not write the runtime record")
failing = True
if stopping:
return
self._record.wait(gadget.RECORD_PERIOD)
def _runtime(self, pid: int, names: dict, kinds: dict, stopping: bool) -> dict:
big = self._big
state = self.big_model_state
swapped_at = self._joined_at # after the state: a swap sets it before _active
if state == 'running' and self._output_at < swapped_at:
# swapped in and not answered yet: the swap is still landing, not a
# model that stopped answering
state = 'joining'
spec = getattr(big, 'spec', None)
sha = getattr(spec, 'sha256', None)
if big is not None and swapped_at not in kinds:
# once per swap: the gadget the link runs on does not change under it
kinds.clear()
kinds[swapped_at] = gadget.link_kind()
return {'pid': pid, 'at': time.monotonic(), 'stopping': stopping, 'state': state,
'handovers': self._handovers, 'active': big is not None and self._active is big,
'last_output_at': self._output_at, 'held': bool(getattr(big, 'held', False)),
'sha256': sha, 'name': self._name_of(sha, names) if sha else None,
'checkpoint': getattr(spec, 'checkpoint', None),
'transport': kinds.get(swapped_at) if big is not None else None, 'drops': self._drops,
'lags': self._lags}
def _name_of(self, sha: str, names: dict) -> str:
"""The catalog's name, kept once it is one. The sha's stand-in (a row not
resolved yet, or a lookup that failed) is asked again after NAME_RETRY."""
name, retry_at = names.get(sha, (None, 0.0))
if time.monotonic() >= retry_at:
try:
name = self._name(sha) if self._name is not None else None
except Exception:
name = None
real = bool(name) and name != sha[:16]
name = name if real else sha[:16]
names[sha] = (name, float('inf') if real else time.monotonic() + NAME_RETRY)
return name
def close(self) -> None:
first = not self._stop.is_set() # the leave is said once; cleanups close twice
self._stop.set()
self._available = False
self._rejoin.set()
# after the stop: the writer's next record is its last, and says stopping
self._record.set()
self._writer.join(1.0)
self._progress.clear()
with self._lock:
joined, self._joined = self._joined, None
if joined is not None:
if first:
self._say_leaving(joined[0], P.LEAVE_STOPPED)
joined[0].close()
big, self._big = self._big, None
close = getattr(big, 'close', None)
if close is not None:
if first:
self._leave(big, P.LEAVE_STOPPED)
close()
def join(parts, cam_w: int, cam_h: int, small) -> JoiningModelState:
"""The model modeld runs: the small model now, the Jetson once it is there.
Returns straight away; the join runs in the background.
The warp is loaded and warmed here, before the frame loop exists, rather
than at the swap on a driving frame. Sized from the recorded spec, which is
what the link will hand back; another geometry is rejected.
"""
from jetlink.openpilot import link as links
op = parts.op
face = op.model_face()
ready: dict = {}
link = links.Link(parts.log)
def prepare():
from jetlink.openpilot.interface import FrameWarp, conformance
from jetlink.openpilot.warp import Warp, prepare_reset
# the gadget first, so the Jetson enumerates while the warp loads. Left to
# the join thread the bind lands ~3 s later, behind the small model's
# first frame, and can stall a frame by hundreds of ms
links.present_early(link, background_thread)
cached = parts.spec.load()
if cached is not None:
img_h, img_w = cached.model_hw
geometry = (img_w * 2, img_h * 2)
else:
geometry = parts.warps.geometry()[2:]
try:
# the fork's own reset of its small model, else jetlink's from its shape
ready['reset_small'] = getattr(op, 'small_reset', lambda small: None)(small) or prepare_reset(small)
# the fork's own warp, else openpilot's driving warp pickle
warp = getattr(op, 'model_warp', lambda *args: None)(small, cam_w, cam_h, *geometry)
if warp is None:
warp = Warp(parts.warps.load(cam_w, cam_h, *geometry), face.frame_size(cam_w, cam_h))
elif problems := conformance(warp, FrameWarp):
raise RuntimeError(f"the adapter's model_warp is not a warp: {', '.join(problems)}")
except Exception:
link.close()
raise
ready.update(warp=warp, geometry=geometry)
def build(client, spec):
img_h, img_w = spec.model_hw
warp = ready.get('warp') if ready.get('geometry') == (img_w * 2, img_h * 2) else None
if warp is None:
raise RuntimeError('no prepared warp for the server model geometry')
return JetlinkModelState(client, spec, warp, face=face, log=parts.log, event=op.event)
def connect(should_stop=None):
return links.open_link(parts, link, should_stop)
return JoiningModelState(small, connect, build, prepare, reset_small=lambda: ready['reset_small'](),
progress=parts.progress, log=parts.log, in_control=op.in_control,
name=parts.models.name_for)