feat(perception): preserve resident runtime across recoverable input gaps

This commit is contained in:
DCCONSTRUCTIONS
2026-09-02 15:27:06 +03:00
parent 06a66a3da8
commit c31c46caa5
14 changed files with 1041 additions and 33 deletions
@@ -0,0 +1,281 @@
"""CPU-only recovery proof: real H264 fixture, synthetic clock/pose/points.
Not a full graph run: a resident CPU sentinel stands in for loaded GPU models.
The real decoder process must survive two IPC reconnects and reinitialize its
codec. The source clock/tick counter continues through each outage, no backlog.
"""
import argparse
import hashlib
import json
import os
import select
import socket
import struct
import subprocess
import sys
import tempfile
import threading
import time
from datetime import UTC, datetime
from pathlib import Path
from k1link.compute.live_perception import LiveIngressEvent
from k1link.perception.realtime_contract import StreamStart
from k1link.perception.streaming_continuity import ResumeEvidence, StreamSuspended
from k1link.perception.streaming_decoder_client import StreamingDecoderClient
from k1link.perception.streaming_ingress import StreamingIngress
from k1link.perception.streaming_lifecycle import StreamingLifecycle
from k1link.perception.streaming_queue import StreamMailbox
from k1link.perception.streaming_sender import StreamingSender
from k1link.perception.streaming_sensors import CausalSensorWindow, normalized_sensor
from k1link.perception.worker_lease import WorkerLeaseError
def run(output, camera):
output.mkdir()
with (camera / "index.jsonl").open() as stream:
raw = stream.readline(65537)
assert len(raw) <= 65536
row = json.loads(raw)
frame_path = (camera / row["path"]).resolve(strict=True)
assert frame_path.is_relative_to(camera.resolve())
assert 0 < frame_path.stat().st_size <= 1024 * 1024
assert 0 < (camera / "init.mp4").stat().st_size <= 65536
fragment, init = frame_path.read_bytes(), (camera / "init.mp4").read_bytes()
assert len(init) <= 65536 and hashlib.sha256(fragment).hexdigest() == row["sha256"]
report = {
"created_utc": datetime.now(UTC).isoformat(),
"started_monotonic_ns": time.monotonic_ns(),
"scope": (
"real decoder and Linux processes; synthetic source timestamps/pose/points; "
"NO GPU models or full graph"
),
"source_full_prepass": False,
"source_index_rows_read": 1,
"fixture_fragment_sha256": hashlib.sha256(fragment).hexdigest(),
"gpu_devices": sorted(str(p) for p in Path("/dev").glob("nvidia*")),
"cycles": [],
}
assert not report["gpu_devices"] and os.environ.get("CUDA_VISIBLE_DEVICES") == ""
activation = StreamStart(
output.name,
"synthetic-recovery",
"synthetic-worker",
"activation-1",
1,
"a" * 64,
"b" * 64,
hashlib.sha256(b"resumable-input-real-decoder-probe-v1").hexdigest(),
"d" * 64,
"worker-mapped-fixture-clock",
"live",
)
with tempfile.TemporaryDirectory(prefix="continuity-lease-") as directory:
runtime = StreamingLifecycle(
activation,
Path(directory),
StreamMailbox(),
threading.Event(),
recover_input=True,
source_clock_ns=time.monotonic_ns,
)
finish = threading.Event()
ticks = [0]
def controller():
while not finish.wait(0.05):
ticks[0] += 1
try:
runtime.renew(activation)
except WorkerLeaseError:
return
thread = threading.Thread(target=controller, daemon=True)
thread.start()
decoder = window = receiver = sender = None
try:
sentinel = runtime.spawn(
lambda: subprocess.Popen(
[
sys.executable,
"-c",
"import time; print('ready',flush=True); time.sleep(30)",
],
stdout=subprocess.PIPE,
start_new_session=True,
)
)
assert (
select.select([sentinel.stdout], [], [], 2)[0]
and sentinel.stdout.readline() == b"ready\n"
)
child = runtime.spawn(
lambda: subprocess.Popen(
["python3", "-B", "/probe/pilot_fragment_decoder.py"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
start_new_session=True,
)
)
decoder = StreamingDecoderClient(runtime, child.stdout.fileno(), child.stdin.fileno())
window = CausalSensorWindow(runtime.mailbox)
runtime.ready()
original_pids = [sentinel.pid, child.pid]
report["resident_pids"] = original_pids
epoch = activation
for cycle in range(3):
received = threading.Event()
item = {"cycle": cycle, "input_start": epoch.to_dict()}
def consume(event, cycle=cycle, epoch=epoch, item=item, received=received):
if event.modality == "camera-init":
if cycle:
decoder.reset(epoch, event.payload)
window.reset()
else:
decoder.configure(event.payload)
return
if event.modality in ("lidar", "pose"):
window.append(
normalized_sensor(
event.modality,
event.source_sequence,
event.received_monotonic_ns,
event.payload,
)
)
return
ticket = runtime.mailbox.reserve_ingress(1440000)
target = None
try:
target = bytearray(1440000)
decoded = decoder.decode(event.payload, target)
binding = window.bind(event.received_monotonic_ns)
assert binding.available and decoded["frame_index"] == 0
if cycle:
evidence = ResumeEvidence(
event.received_monotonic_ns,
window.pose.time_ns,
binding.increments[0].time_ns,
binding.increments[-1].time_ns,
decoder.frames == 1,
)
# Synthetic temporal-state owner only; not JointGraph/TGS reset proof.
runtime.resume_input(
epoch, evidence, lambda: item.update(temporal_fixture_reset=True)
)
with runtime.work(epoch, "gpu"):
item["bgr_sha256"] = hashlib.sha256(target).hexdigest()
runtime.validate_result_binding(epoch.to_dict())
window.finish_camera(event.received_monotonic_ns)
item["completed_monotonic_ns"] = time.monotonic_ns()
received.set()
finally:
target = None
ticket.release()
left, right = socket.socketpair()
receiver = StreamingIngress(
right,
runtime,
"fixture-acquisition",
1,
consume,
lambda _: None,
input_epoch=epoch,
)
receiver.start()
sender = StreamingSender(
left,
epoch,
"fixture-acquisition",
1,
lambda epoch=epoch: runtime.check_input(epoch, synchronizing=True),
)
stamp = time.monotonic_ns()
payloads = [
("camera-init", "camera", init),
("pose", "pose", struct.pack("<7d", 0, 0, 0, 0, 0, 0, 1)),
("lidar", "points", struct.pack("<I3dB", 1, 1, 0, 0, 1)),
("camera-frame", "camera", fragment),
]
for sequence, (modality, source, payload) in enumerate(payloads, 1):
sender.send(
LiveIngressEvent(
sequence,
"fixture-acquisition",
1,
modality,
source,
0,
0,
stamp,
payload,
)
)
assert received.wait(2), receiver.error
assert sentinel.poll() is None and child.poll() is None
if cycle == 2:
sender.end()
assert receiver.join() and receiver.terminal == "end"
else:
sender.close() # No End: a temporary cable/route loss.
assert receiver.join() and receiver.terminal == "paused"
assert not runtime.stop_event.is_set() and not runtime.lease.released
before = ticks[0]
delay = 0.15 if cycle == 0 else 2.2
assert not runtime.stop_event.wait(delay)
item["outage_seconds"] = delay
item["source_ticks_during_outage"] = ticks[0] - before
assert item["source_ticks_during_outage"] > 0
assert sentinel.poll() is None and child.poll() is None
with_rejected = False
try:
runtime.validate_result_binding(epoch.to_dict())
except StreamSuspended:
with_rejected = True
assert with_rejected
epoch = runtime.begin_input(activation)
item["receiver"] = receiver.snapshot()
item["resident_pids_unchanged"] = original_pids == [sentinel.pid, child.pid]
report["cycles"].append(item)
assert len({item["bgr_sha256"] for item in report["cycles"]}) == 1
report["passed"] = True
finally:
finish.set()
thread.join(timeout=1)
if sender:
sender.close()
runtime.request_stop("completed" if report.get("passed") else "failed")
if receiver:
receiver.join(timeout=2)
runtime.stop_children()
if window:
window.close()
if decoder:
decoder.close()
report["released"] = runtime.close()
report["runtime"] = runtime.snapshot()
report["input_bytes"] = runtime.mailbox.bytes
report["peak_input_bytes"] = runtime.mailbox.peak_bytes
report["finished_monotonic_ns"] = time.monotonic_ns()
report["source_ticks_total"] = ticks[0]
report["full_graph_recovery_proved"] = False
(output / "report.json").write_text(json.dumps(report, indent=2) + "\n")
for process in runtime._children:
for stream in (process.stdin, process.stdout):
if stream:
stream.close()
assert report["released"] and not report["input_bytes"]
print(
json.dumps({"passed": True, "cycles": 3, "same_decoder_pid": True, "full_graph": False})
)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--output", required=True)
parser.add_argument("--camera", required=True)
args = parser.parse_args()
run(Path(args.output), Path(args.camera))
@@ -20,6 +20,9 @@ def main():
if header == {"op": "init"}:
decoder.configure(raw)
send(sys.stdout.buffer, {"initialized": True})
elif header == {"op": "reset"}:
decoder.reset(raw)
send(sys.stdout.buffer, {"reset": True})
elif header == {"op": "decode"}:
image = decoder.decode(raw)
send(
@@ -0,0 +1,92 @@
"""Recoverable input epochs; resident model ownership is a separate lifetime.
Used only by the explicitly enabled resumable adapter. The controller supplies
the source-clock mapping and the profile adapter supplies verified decoder and
sensor evidence. No socket, GPU, vehicle command or unbounded history lives here.
"""
from __future__ import annotations
from dataclasses import dataclass, replace
from hashlib import sha256
from .realtime_contract import StreamStart, _integer
class StreamSuspended(RuntimeError):
"""Discard obsolete work, keep the resident model processes and lease."""
@dataclass(frozen=True, slots=True)
class ResumeEvidence:
camera_ns: int
pose_ns: int
oldest_points_ns: int
newest_points_ns: int
decoded_keyframe: bool
def validate(self, *, cutoff_ns: int, source_now_ns: int) -> None:
for name in ("camera_ns", "pose_ns", "oldest_points_ns", "newest_points_ns"):
_integer(getattr(self, name), name)
_integer(source_now_ns, "source clock")
if (
self.decoded_keyframe is not True
or not cutoff_ns <= self.pose_ns <= self.camera_ns <= source_now_ns
or not cutoff_ns <= self.oldest_points_ns <= self.newest_points_ns <= self.camera_ns
or source_now_ns - self.camera_ns > 250_000_000
or self.camera_ns - self.pose_ns > 100_000_000
or self.camera_ns - self.newest_points_ns > 100_000_000
or self.camera_ns - self.oldest_points_ns > 250_000_000
or max(
abs(self.pose_ns - self.oldest_points_ns), abs(self.pose_ns - self.newest_points_ns)
)
> 100_000_000
):
raise StreamSuspended("fresh keyframe and causal camera/pose/points required")
class InputContinuity:
"""Access is serialized by StreamingLifecycle's lock."""
def __init__(self, activation: StreamStart) -> None:
self.activation = self.epoch = activation
self.phase = "active"
self.generation = self.pauses = 0
self.reason: str | None = None
self.cutoff_ns = 0
def check(self, epoch: StreamStart, *, synchronizing: bool = False) -> None:
if epoch != self.epoch:
raise StreamSuspended("obsolete input epoch")
if self.phase != "active" and not (synchronizing and self.phase == "synchronizing"):
raise StreamSuspended("input is waiting for resynchronization")
def pause(self, reason: str) -> None:
if reason not in ("input-disconnected", "input-timeout", "source-gap", "worker-telemetry"):
raise ValueError("unknown recoverable pause reason")
if self.phase != "waiting":
self.pauses += 1
self.phase, self.reason = "waiting", reason
def begin(self, source_now_ns: int) -> StreamStart:
_integer(source_now_ns, "source clock")
if self.phase != "waiting" or source_now_ns < self.cutoff_ns:
raise StreamSuspended("new input needs a pause and nonregressing source clock")
self.generation += 1
identity = sha256(f"{self.activation.epoch_id}:{self.generation}".encode()).hexdigest()
# Wire v2 already binds every packet to the complete StreamStart. Change
# the input epoch, NOT the resident lease generation/model activation.
self.epoch = replace(self.activation, epoch_id="input-" + identity)
self.cutoff_ns, self.phase = source_now_ns, "synchronizing"
return self.epoch
def snapshot(self) -> dict[str, object]:
return {
"phase": self.phase,
"reason": self.reason,
"input_generation": self.generation,
"pauses": self.pauses,
"input_start": self.epoch.to_dict(),
"minimum_source_ns": str(self.cutoff_ns),
"actuation_allowed": False,
}
@@ -105,6 +105,18 @@ class FragmentDecoder:
self.failed = True
raise
def reset(self, payload: bytes) -> None:
"""New input epoch only: release codec references, retain the child.
A genuine decoder failure is not revived by reset. The first new frame
still has to independently pass random-access/keyframe validation.
"""
if self.failed:
raise StreamingDecodeError("failed decoder cannot be resumed")
self._codec = self._init = self._timing = None
self._next_dts, self.frames = None, 0
self.configure(payload)
def close(self) -> None:
self.failed = True
self._codec = self._init = self._timing = None
@@ -6,6 +6,7 @@ import math
from typing import Any
from .graph_contracts import GraphState
from .realtime_contract import StreamStart
from .streaming_lifecycle import StreamingLifecycle
from .streaming_pipe_rpc import HEADER_SCRATCH, MAX_OUTPUT, BoundedPipeRpc, PipeRpcError
@@ -57,6 +58,19 @@ class StreamingDecoderClient:
self.frames += 1
return result
def reset(self, epoch: StreamStart, raw: bytes) -> None:
self.runtime.check_input(epoch, synchronizing=True)
if (
self.closed
or self.runtime.continuity is None
or self.runtime.continuity.phase != "synchronizing"
or not 0 < len(raw) <= 65536
):
raise PipeRpcError("decoder reset requires a new input epoch")
if self.rpc.exchange({"op": "reset"}, raw, bytearray()) != {"reset": True}:
raise PipeRpcError("decoder reset response changed")
self.frames = 0
def close(self) -> None:
# Caller joins its callback and stops the owned child before closing.
if not self.closed:
+49 -13
View File
@@ -1,7 +1,9 @@
"""One controller-supplied IPC socket feeding the common streaming lifecycle.
No listener, acquisition, archive reader, model RPC, network authentication or
automatic reconnect. Reconnect needs a new StreamStart/lease and decoder state.
No listener, acquisition, archive reader, model RPC or network authentication.
An explicitly resumable runtime preserves its resident lease on disconnect;
the controller supplies a new input epoch/socket and the adapter resets decoder
and temporal state. The historical non-resumable diagnostic remains supported.
The synchronous consumer is a trusted adapter: raw events are borrowed for that
call only. It must reserve decoder scratch before allocation and retain decoded
work only through the common mailbox. Slow/failed consumers fail this stream;
@@ -16,15 +18,22 @@ import threading
import time
from collections import Counter
from collections.abc import Callable
from contextlib import suppress
from hashlib import sha256
from typing import Any
from k1link.compute.live_perception import LiveIngressEvent
from . import streaming_wire as wire
from .realtime_contract import StreamStart
from .streaming_continuity import StreamSuspended
from .streaming_lifecycle import StreamingLifecycle
class InputInterrupted(wire.StreamWireError):
"""Transport unavailable, not malformed model data or an ownership loss."""
class StreamingIngress:
def __init__(
self,
@@ -37,6 +46,7 @@ class StreamingIngress:
*,
fragment_timeout: float = 0.25,
idle_timeout: float = 2.0,
input_epoch: StreamStart | None = None,
) -> None:
if not 0 < fragment_timeout <= 2 or not 0 < idle_timeout <= 30:
raise ValueError("invalid bounded ingress timeouts")
@@ -49,7 +59,8 @@ class StreamingIngress:
self.session_id, self.session_generation = session_id, session_generation
self.consume, self.notice = consume, notice
self.fragment_timeout, self.idle_timeout = fragment_timeout, idle_timeout
self.binding = wire.binding(runtime.start)
self.input_epoch = input_epoch or runtime.start
self.binding = wire.binding(self.input_epoch)
self.opened = False
self.error: str | None = None
self.terminal: str | None = None
@@ -60,10 +71,10 @@ class StreamingIngress:
self.thread = threading.Thread(
target=self._serve, name="perception-binary-ingress", daemon=True
)
runtime.track_thread(self.thread)
runtime.register_ingress(self.thread, self.input_epoch)
def start(self) -> None:
self.runtime.check_current(self.runtime.start)
self.runtime.check_input(self.input_epoch, synchronizing=True)
self.thread.start()
def join(self, timeout: float = 1.0) -> bool:
@@ -71,9 +82,9 @@ class StreamingIngress:
return not self.thread.is_alive()
def _check(self, deadline: float) -> None:
self.runtime.check_current(self.runtime.start)
self.runtime.check_input(self.input_epoch, synchronizing=True)
if time.monotonic() >= deadline:
raise wire.StreamWireError("ingress deadline exceeded")
raise InputInterrupted("ingress deadline exceeded")
def _read_into(self, target: memoryview, deadline: float) -> None:
offset = 0
@@ -87,8 +98,10 @@ class StreamingIngress:
read = self.connection.recv_into(target[offset:])
except BlockingIOError:
continue
except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError) as exc:
raise InputInterrupted("input connection lost") from exc
if read == 0:
raise wire.StreamWireError("unexpected EOF without explicit End")
raise InputInterrupted("unexpected EOF without explicit End")
self.counts["wire_bytes"] += read
offset += read
self._check(deadline)
@@ -109,8 +122,10 @@ class StreamingIngress:
def _bound(self, header: dict[str, Any]) -> None:
if header.get("binding") != self.binding:
if self.runtime.continuity is not None:
raise StreamSuspended("obsolete packet from another input epoch")
raise wire.StreamWireError("frame belongs to another StreamStart")
self.runtime.check_current(self.runtime.start)
self.runtime.check_input(self.input_epoch, synchronizing=True)
def _fragment(self, header: dict[str, Any]) -> tuple[dict[str, Any], int, int, int]:
if set(header) != {
@@ -157,6 +172,8 @@ class StreamingIngress:
if modality == "camera-frame" and not self.camera_initialized:
raise wire.StreamWireError("camera frame requires new codec initialization")
if modality == "camera-frame" and previous and event["source_sequence"] != previous[1] + 1:
if self.runtime.continuity is not None:
raise StreamSuspended("camera sequence gap requires resynchronization")
raise wire.StreamWireError("camera sequence gap requires new codec initialization")
self.counts["observations_started"] += 1
reservation = self.runtime.mailbox.reserve_ingress(2 * total)
@@ -191,6 +208,10 @@ class StreamingIngress:
if sha256(raw).hexdigest() != first["payload_sha256"]:
raise wire.StreamWireError("observation integrity mismatch")
self._check(deadline)
if event["modality"] != "camera-init":
self.runtime.check_input(
self.input_epoch, synchronizing=True, event_ns=event["received_monotonic_ns"]
)
admitted = LiveIngressEvent(**event, payload=bytes(raw))
raw = None # Immutable callback bytes remain fully reserved.
self.consume(admitted)
@@ -224,8 +245,10 @@ class StreamingIngress:
self.counts["declared_gap_observations"] += wire.uint64(header["count"])
if header["modality"].startswith("camera"):
self.camera_initialized = False
if self.runtime.continuity is not None:
raise StreamSuspended("declared source gap requires resynchronization")
self.notice(dict(header))
self.runtime.check_current(self.runtime.start)
self.runtime.check_input(self.input_epoch, synchronizing=True)
self.counts["gap_notices"] += 1
def _serve(self) -> None:
@@ -240,7 +263,7 @@ class StreamingIngress:
expected = wire.parse_header(
bytearray(
wire.open_packet(
self.runtime.start,
self.input_epoch,
self.session_id,
self.session_generation,
)[wire.PREFIX.size :]
@@ -269,8 +292,21 @@ class StreamingIngress:
self.error = str(exc)
self.terminal = "failed"
if self.opened:
self.runtime.mailbox.finish(self.error)
self.runtime.request_stop("failed")
if self.runtime.continuity is not None and isinstance(
exc, (InputInterrupted, StreamSuspended)
):
self.terminal = "paused"
# A late old socket cannot pause a replacement connection.
with suppress(RuntimeError): # Already replaced, stopping, or fenced.
self.runtime.pause_input(
self.input_epoch,
"input-disconnected"
if isinstance(exc, InputInterrupted)
else "source-gap",
)
else:
self.runtime.mailbox.finish(self.error)
self.runtime.request_stop("failed")
finally:
self.connection.close()
if reservation is not None:
+148 -15
View File
@@ -21,10 +21,15 @@ from typing import Any, Literal
from .graph_contracts import GraphState
from .realtime_contract import StreamStart
from .realtime_scene import _wire_integer
from .streaming_continuity import InputContinuity, ResumeEvidence, StreamSuspended
from .streaming_queue import IngressReservation, StreamBundle, StreamMailbox
from .worker_lease import WorkerLease, WorkerLeaseError
from .worker_operating_envelope import WorkerSnapshot
from .worker_readiness import WorkerReadinessError, WorkerReadinessMonitor
from .worker_readiness import (
WorkerReadinessError,
WorkerReadinessMonitor,
WorkerTelemetryUnavailable,
)
def _group_exists(pgid: int) -> bool:
@@ -48,16 +53,25 @@ class StreamingLifecycle:
ttl_seconds: float = 2.0,
clock_ns: Callable[[], int] = time.monotonic_ns,
readiness: WorkerReadinessMonitor | None = None,
recover_input: bool = False,
source_clock_ns: Callable[[], int] | None = None,
) -> None:
self.start, self.mailbox, self.stop_event = start, mailbox, stop
self._clock_ns, self._readiness = clock_ns, readiness
if recover_input and (
source_clock_ns is None or (readiness is not None and not readiness.recoverable)
):
raise ValueError("resumable input needs mapped source clock and recoverable telemetry")
self.continuity = InputContinuity(start) if recover_input else None
self._source_clock_ns = source_clock_ns
self._ingress_thread: threading.Thread | None = None
if readiness is not None:
readiness.check(start, now_monotonic_ns=clock_ns(), require_warmup=False)
self._lock = threading.RLock()
self._cleanup_lock = threading.Lock()
self._children: list[subprocess.Popen[bytes]] = []
self._threads: list[threading.Thread] = []
self._active = {"gpu": 0, "cpu": 0}
self._active = {"gpu": 0, "cpu": 0, "resync": 0}
self.state = GraphState.CREATED
self.reason: str | None = None
self.stop_requested_ns: int | None = None
@@ -70,7 +84,9 @@ class StreamingLifecycle:
)
self._watchdog.start()
def _check(self, start: StreamStart, *, starting: bool = False) -> None:
def _check(
self, start: StreamStart, *, starting: bool = False, allow_unavailable: bool = False
) -> None:
allowed = (GraphState.STARTING, GraphState.RUNNING) if starting else (GraphState.RUNNING,)
if self.state not in allowed or self.stop_event.is_set():
raise WorkerLeaseError("runtime is not accepting work")
@@ -81,9 +97,11 @@ class StreamingLifecycle:
if start == self.start:
self.request_stop("lease-lost")
raise
self._check_readiness(require_warmup=self.state == GraphState.RUNNING)
self._check_readiness(
require_warmup=self.state == GraphState.RUNNING, allow_unavailable=allow_unavailable
)
def _check_readiness(self, *, require_warmup: bool) -> None:
def _check_readiness(self, *, require_warmup: bool, allow_unavailable: bool = False) -> None:
if self._readiness is not None:
try:
self._readiness.check(
@@ -91,6 +109,14 @@ class StreamingLifecycle:
now_monotonic_ns=self._clock_ns(),
require_warmup=require_warmup,
)
except WorkerTelemetryUnavailable as exc:
if self.continuity is None:
self.request_stop("worker-not-ready")
raise
self.continuity.pause("worker-telemetry")
self.mailbox.pause()
if not allow_unavailable:
raise StreamSuspended(str(exc)) from exc
except WorkerReadinessError:
self.request_stop("worker-not-ready")
raise
@@ -102,7 +128,7 @@ class StreamingLifecycle:
heartbeat are independent requirements. No host operations under lock.
"""
with self._lock:
self._check(start, starting=True)
self._check(start, starting=True, allow_unavailable=True)
if self._readiness is None:
raise WorkerReadinessError("worker readiness monitoring is not configured")
try:
@@ -112,13 +138,19 @@ class StreamingLifecycle:
now_monotonic_ns=self._clock_ns(),
require_warmup=self.state == GraphState.RUNNING,
)
except WorkerTelemetryUnavailable:
if self.continuity is None:
self.request_stop("worker-not-ready")
raise
self.continuity.pause("worker-telemetry")
self.mailbox.pause()
except WorkerReadinessError:
self.request_stop("worker-not-ready")
raise
def renew(self, start: StreamStart) -> None:
with self._lock:
self._check(start, starting=True)
self._check(start, starting=True, allow_unavailable=True)
self.lease.renew(start)
def ready(self) -> None:
@@ -148,32 +180,127 @@ class StreamingLifecycle:
def track_thread(self, thread: threading.Thread) -> None:
with self._lock:
self._check(self.start, starting=True)
self._threads = [t for t in self._threads if t.ident is None or t.is_alive()]
if len(self._threads) >= 8 or thread in self._threads:
raise WorkerLeaseError("runtime thread registration outside bound")
self._threads.append(thread)
def admit(self, start: StreamStart, bundle: StreamBundle) -> bool:
def register_ingress(self, thread: threading.Thread, epoch: StreamStart) -> None:
with self._lock:
if self.continuity is not None:
self.check_input(epoch, synchronizing=True)
if self._ingress_thread is not None and (
self._ingress_thread.ident is None or self._ingress_thread.is_alive()
):
raise StreamSuspended("one input connection per resident profile")
self.track_thread(thread)
self._ingress_thread = thread
def check_input(
self, epoch: StreamStart, *, synchronizing: bool = False, event_ns: int | None = None
) -> None:
with self._lock:
if self.continuity is None:
self._check(epoch)
return
self._check(self.start)
self.continuity.check(epoch, synchronizing=synchronizing)
if event_ns is not None:
assert self._source_clock_ns is not None
now = self._source_clock_ns()
if event_ns < self.continuity.cutoff_ns or not 0 <= now - event_ns <= 250_000_000:
raise StreamSuspended("obsolete or future input, no backlog replay")
def pause_input(self, epoch: StreamStart, reason: str) -> None:
with self._lock:
if self.continuity is None:
raise ValueError("resumable input was not enabled")
# A disconnected old socket must not pause the replacement epoch.
if epoch != self.continuity.epoch:
raise StreamSuspended("obsolete input epoch")
self._check(self.start, allow_unavailable=True)
self.continuity.pause(reason)
self.mailbox.pause()
def begin_input(self, start: StreamStart) -> StreamStart:
with self._lock:
self._check(start)
if self.continuity is None or self._source_clock_ns is None:
raise ValueError("resumable input was not enabled")
if any(self._active.values()) or (
self._ingress_thread is not None and self._ingress_thread.is_alive()
):
raise StreamSuspended("old epoch callbacks or connection still active")
if self.continuity.phase != "waiting":
raise StreamSuspended("pause required before a new input epoch")
self.mailbox.begin_epoch()
if self._ingress_thread is not None:
self._threads = [t for t in self._threads if t is not self._ingress_thread]
self._ingress_thread = None
return self.continuity.begin(self._source_clock_ns())
def resume_input(
self, epoch: StreamStart, evidence: ResumeEvidence, reset_temporal: Callable[[], None]
) -> None:
"""Trusted adapter proves a new decoded keyframe + current sensor pair.
Reset only temporal/rolling outputs, outside the lock, after old compute
has drained. No model restart. A failed reset is an actual runtime fault.
"""
with self._lock:
self.check_input(epoch, synchronizing=True)
if (
self.continuity is None
or self.continuity.phase != "synchronizing"
or any(self._active.values())
):
raise StreamSuspended("resynchronization is not quiescent")
assert self._source_clock_ns is not None
evidence.validate(
cutoff_ns=self.continuity.cutoff_ns, source_now_ns=self._source_clock_ns()
)
self._active["resync"] = 1
try:
reset_temporal()
with self._lock:
self.check_input(epoch, synchronizing=True)
evidence.validate(
cutoff_ns=self.continuity.cutoff_ns, source_now_ns=self._source_clock_ns()
)
self.continuity.phase, self.continuity.reason = "active", None
except StreamSuspended:
raise
except BaseException:
self.request_stop("failed")
raise
finally:
with self._lock:
self._active["resync"] = 0
def admit(self, start: StreamStart, bundle: StreamBundle) -> bool:
with self._lock:
self.check_input(start)
return self.mailbox.put(bundle)
def admit_reserved(
self, start: StreamStart, bundle: StreamBundle, reservation: IngressReservation
) -> bool:
with self._lock:
self._check(start)
self.check_input(start)
return self.mailbox.put_reserved(bundle, reservation)
@contextmanager
def work(self, start: StreamStart, lane: Literal["gpu", "cpu"]) -> Iterator[None]:
with self._lock:
self._check(start)
self.check_input(start)
if lane not in self._active or self._active[lane]:
raise WorkerLeaseError("only one active call per compute lane")
self._active[lane] += 1
try:
yield
self.check_current(start)
self.check_input(start)
except StreamSuspended:
raise # The caller discards this epoch's result, not its models.
except BaseException:
self.request_stop("failed")
raise
@@ -182,14 +309,17 @@ class StreamingLifecycle:
self._active[lane] -= 1
def check_current(self, start: StreamStart, *, starting: bool = False) -> None:
"""Only child startup handshakes may opt into the warmup state."""
"""Resource/IPC guard: drain active RPC on pause; work/result gates fence it.
Only child startup handshakes may opt into the warmup state.
"""
with self._lock:
self._check(start, starting=starting)
self._check(start, starting=starting, allow_unavailable=self.continuity is not None)
def validate_result_binding(self, value: object) -> None:
"""Recheck at receipt/use; accepting a hash alone cannot renew authority."""
start = StreamStart.from_dict(value)
self.check_current(start)
self.check_input(start)
def request_stop(self, reason: str = "cancelled") -> None:
if reason not in (
@@ -217,7 +347,7 @@ class StreamingLifecycle:
try:
with self._lock:
if self.state in (GraphState.STARTING, GraphState.RUNNING):
self._check(self.start, starting=True)
self._check(self.start, starting=True, allow_unavailable=True)
if any(p.poll() is not None for p in self._children):
self.request_stop("child-exited")
stopping = self.state == GraphState.STOPPING
@@ -304,6 +434,9 @@ class StreamingLifecycle:
_wire_integer(self.retired_ns) if self.retired_ns is not None else None
),
"event_clock": "worker-process-monotonic",
"input_continuity": self.continuity.snapshot()
if self.continuity is not None
else {"enabled": False},
"worker_readiness": (
self._readiness.snapshot()
if self._readiness is not None
+19 -1
View File
@@ -14,7 +14,7 @@ from threading import Condition
from typing import Any
StreamBundle = Mapping[str, Any]
DISCARD_REASONS = frozenset(("cancelled", "gpu-failed", "processing-failed"))
DISCARD_REASONS = frozenset(("cancelled", "gpu-failed", "processing-failed", "input-gap"))
class IngressReservation:
@@ -210,3 +210,21 @@ class StreamMailbox:
while self.pending:
self._discard_pending(reason)
self.condition.notify_all()
def pause(self) -> None:
"""Drop queued work without EOF, freeing only queue-owned payloads."""
with self.condition:
while self.pending:
self._discard_pending("input-gap")
self.condition.notify_all()
def begin_epoch(self) -> None:
"""Only after all prior queued/active/completed bundles are released.
Long-lived bounded decoder/sensor scratch reservations may remain.
The lifecycle separately verifies that the old ingress thread is gone.
"""
with self.condition:
if self.done or self._owned or self.external_pending:
raise ValueError("old epoch still owns work or mailbox is closed")
self._last_sequence = -1
+8 -1
View File
@@ -13,6 +13,7 @@ import traceback
from collections.abc import Callable
from typing import Any
from .streaming_continuity import StreamSuspended
from .streaming_queue import StreamBundle, StreamMailbox
@@ -43,7 +44,13 @@ class SerialGpuStage:
break
if self.stop.is_set():
break
result = self.compute(active)
try:
result = self.compute(active)
except StreamSuspended:
self.mailbox.release(active, discard_reason="input-gap")
active = None
self.output_slot.release()
continue
with self.output_condition:
if self.stop.is_set():
break
@@ -246,3 +246,12 @@ class CausalSensorWindow:
self.pose = None
self.closed = True
self._reservation.release()
def reset(self) -> None:
"""Called by the single adapter after old-epoch callbacks have drained."""
if self.closed:
raise ValueError("closed sensor window cannot resume")
self.rolling.clear()
self.fresh.clear()
self.pose = self.previous_camera_time = None
self._last_time = -1
+31 -2
View File
@@ -8,6 +8,7 @@ clocks, or certify real-time operation. The lifecycle serializes all access.
from __future__ import annotations
from contextlib import suppress
from dataclasses import asdict
from typing import Literal
@@ -34,6 +35,10 @@ class WorkerReadinessError(WorkerLeaseError):
"""A current controller lost the prerequisites for further execution."""
class WorkerTelemetryUnavailable(WorkerReadinessError):
"""Readiness is unproved, but no confirmed ownership/runtime failure exists."""
class WorkerReadinessMonitor:
def __init__(
self,
@@ -44,12 +49,14 @@ class WorkerReadinessMonitor:
mode: ReadinessMode,
clock_domain_id: str,
now_monotonic_ns: int,
recoverable: bool = False,
) -> None:
if mode not in ("strict-envelope", "labelled-experiment"):
raise RealtimeContractError("unknown Worker readiness mode")
_identifier(clock_domain_id, "worker clock domain")
self.start, self.envelope = start, envelope
self.mode, self.clock_domain_id = mode, clock_domain_id
self.recoverable = recoverable
self._observed = initial
self._last_check_ns = now_monotonic_ns
self._failure: tuple[str, ...] = ()
@@ -97,7 +104,26 @@ class WorkerReadinessMonitor:
if failure not in _PERFORMANCE_FAILURES
and (require_warmup or failure != "warmup-not-complete")
)
if fatal:
if self.recoverable:
# Missing metrics are not a proof that another process owns GPU.
# Known identity/conflict/clock failures still fence immediately.
uncertain = {"worker-snapshot-expired", "warmup-not-complete"}
for field in ("image_sha256", "effective_config_sha256"):
if getattr(self._observed, field) is None:
uncertain.add(f"{field}-mismatch-or-unknown")
if self._observed.competing_gpu_clients is None:
uncertain.add("competing-gpu-clients-or-inventory-unknown")
if self._observed.gpu_owner_run_id in (
None,
start.run_id,
) and self._observed.lease_generation in (None, start.lease_generation):
uncertain.add("exclusive-worker-lease-unproved")
confirmed = tuple(reason for reason in fatal if reason not in uncertain)
if confirmed:
self._fail(confirmed)
if fatal or (require_warmup and self.mode == "strict-envelope" and failures):
raise WorkerTelemetryUnavailable("worker readiness unproved: " + ",".join(failures))
elif fatal:
self._fail(fatal)
if require_warmup and self.mode == "strict-envelope" and failures:
self._fail(failures)
@@ -112,7 +138,9 @@ class WorkerReadinessMonitor:
) -> None:
# Recheck the OLD observation before replacement: late telemetry cannot
# resurrect expired authority, even if the watchdog has not run yet.
self.check(start, now_monotonic_ns=now_monotonic_ns, require_warmup=require_warmup)
# Fresh facts may restore readiness, not the separate lease/input epoch.
with suppress(WorkerTelemetryUnavailable):
self.check(start, now_monotonic_ns=now_monotonic_ns, require_warmup=require_warmup)
if observed.observed_monotonic_ns <= self._observed.observed_monotonic_ns:
self._fail(("worker-snapshot-not-increasing",))
self._observed = observed
@@ -126,6 +154,7 @@ class WorkerReadinessMonitor:
return {
"enabled": True,
"mode": self.mode,
"recoverable": self.recoverable,
"envelope": asdict(self.envelope),
"worker_clock_domain_id": self.clock_domain_id,
"last_check_monotonic_ns": str(self._last_check_ns),
+6 -1
View File
@@ -229,7 +229,12 @@ def test_bridge_initialization_failure_releases_unstarted_resources(monkeypatch,
monkeypatch.setattr(bridge.socket, "socketpair", sockets)
# Only constructor ownership is under test, without a live lease/thread.
monkeypatch.setattr("k1link.perception.streaming_ingress.wire.binding", lambda start: {})
runtime = SimpleNamespace(mailbox=mailbox, start=None, track_thread=register)
runtime = SimpleNamespace(
mailbox=mailbox,
start=None,
track_thread=register,
register_ingress=lambda thread, epoch: register(thread),
)
source = SimpleNamespace(source_zero=0, wall_zero=0, run=lambda sock: None)
try:
with pytest.raises(ValueError, match="injected"):
@@ -0,0 +1,353 @@
"""Small synthetic pause/resume tests: never model/GPU load on the Mac."""
import socket
import threading
from dataclasses import replace
import pytest
from k1link.compute.live_perception import LiveIngressEvent
from k1link.perception import streaming_wire as wire
from k1link.perception.graph_contracts import GraphState
from k1link.perception.realtime_contract import StreamStart
from k1link.perception.streaming_continuity import ResumeEvidence, StreamSuspended
from k1link.perception.streaming_ingress import StreamingIngress
from k1link.perception.streaming_lifecycle import StreamingLifecycle
from k1link.perception.streaming_queue import StreamMailbox
from k1link.perception.streaming_scheduler import SerialGpuStage
from k1link.perception.worker_lease import WorkerLeaseError
from k1link.perception.worker_operating_envelope import WorkerOperatingEnvelope, WorkerSnapshot
from k1link.perception.worker_readiness import WorkerReadinessMonitor
def binding():
return StreamStart(
"run",
"source",
"worker",
"epoch",
1,
"a" * 64,
"b" * 64,
"c" * 64,
"d" * 64,
"source-clock",
"live",
)
def snapshot(now, **changes):
return replace(
WorkerSnapshot(
"worker",
"worker-clock",
now,
"GPU",
"driver",
"b" * 64,
"c" * 64,
8000,
8192,
2610,
10251,
"run",
1,
(),
True,
),
**changes,
)
@pytest.fixture
def harness(tmp_path):
runs = []
def create(*, telemetry=False):
clock = [1_000_000_000]
monitor = (
WorkerReadinessMonitor(
binding(),
WorkerOperatingEnvelope("envelope", "GPU", "driver", 8000, 8192, 2610, 10251),
snapshot(clock[0]),
mode="strict-envelope",
clock_domain_id="worker-clock",
now_monotonic_ns=clock[0],
recoverable=True,
)
if telemetry
else None
)
run = StreamingLifecycle(
binding(),
tmp_path / "lease",
StreamMailbox(),
threading.Event(),
clock_ns=lambda: clock[0],
recover_input=True,
source_clock_ns=lambda: clock[0],
readiness=monitor,
)
run.ready()
runs.append(run)
return run, clock
yield create
for run in runs:
assert run.close()
def proof(now):
return ResumeEvidence(now, now, now, now, True)
def test_pause_keeps_lease_mailbox_and_warm_models_then_new_epoch(harness):
run, clock = harness()
run.admit(run.start, {"sequence": 100, "payload_bytes": 8})
run.pause_input(run.start, "input-disconnected")
assert run.state == GraphState.RUNNING and not run.stop_event.is_set()
assert not run.mailbox.done and not run.mailbox.bytes and not run.lease.released
assert run.mailbox.drop_counts["input-gap"] == 1
for _ in range(4):
clock[0] += 900_000_000
run.renew(run.start)
with pytest.raises(StreamSuspended):
run.admit(run.start, {"sequence": 101, "payload_bytes": 8})
epoch = run.begin_input(run.start)
assert epoch.epoch_id != run.start.epoch_id and epoch.lease_generation == 1
reset = []
with pytest.raises(StreamSuspended):
run.admit(epoch, {"sequence": 0, "payload_bytes": 8})
run.resume_input(epoch, proof(clock[0]), lambda: reset.append("temporal-reset"))
assert reset == ["temporal-reset"] and run.continuity.phase == "active"
assert run.admit(epoch, {"sequence": 0, "payload_bytes": 8})
run.mailbox.release(run.mailbox.take())
with pytest.raises(StreamSuspended):
run.validate_result_binding(run.start.to_dict())
run.validate_result_binding(epoch.to_dict())
def test_active_old_callback_cannot_publish_or_be_freed_early(harness):
run, clock = harness()
packet = {"sequence": 0, "payload_bytes": 8}
run.admit(run.start, packet)
assert run.mailbox.take() is packet
with pytest.raises(StreamSuspended), run.work(run.start, "gpu"):
run.pause_input(run.start, "input-timeout")
run.check_current(run.start) # In-flight IPC can drain without poisoning the child.
with pytest.raises(StreamSuspended):
run.begin_input(run.start)
with pytest.raises(ValueError, match="still owns"):
run.begin_input(run.start)
run.mailbox.release(packet, discard_reason="input-gap")
epoch = run.begin_input(run.start)
run.resume_input(epoch, proof(clock[0]), lambda: None)
with pytest.raises(StreamSuspended):
run.pause_input(run.start, "input-timeout")
assert run.continuity.phase == "active"
@pytest.mark.parametrize(
"field,value",
[
("decoded_keyframe", False),
("pose_ns", 0),
("newest_points_ns", 0),
("oldest_points_ns", 0),
("camera_ns", 2_000_000_000),
],
)
def test_resume_requires_fresh_synchronized_evidence(harness, field, value):
run, clock = harness()
run.pause_input(run.start, "source-gap")
epoch = run.begin_input(run.start)
called = []
with pytest.raises(StreamSuspended):
run.resume_input(
epoch, replace(proof(clock[0]), **{field: value}), lambda: called.append(True)
)
assert not called and run.continuity.phase == "synchronizing" and not run.stop_event.is_set()
def test_pause_during_reset_never_reactivates_input(harness):
run, clock = harness()
run.pause_input(run.start, "source-gap")
epoch = run.begin_input(run.start)
with pytest.raises(StreamSuspended):
run.resume_input(
epoch, proof(clock[0]), lambda: run.pause_input(epoch, "input-disconnected")
)
assert run.continuity.phase == "waiting"
def test_reset_error_is_fatal_not_hidden_as_a_network_pause(harness):
run, clock = harness()
run.pause_input(run.start, "source-gap")
epoch = run.begin_input(run.start)
def broken():
raise ValueError("reset failed")
with pytest.raises(ValueError, match="reset failed"):
run.resume_input(epoch, proof(clock[0]), broken)
assert run.stop_event.is_set()
@pytest.mark.parametrize(
"changes",
[
{"competing_gpu_clients": None},
{"gpu_owner_run_id": None},
{"memory_clock_mhz": 405},
{"image_sha256": None},
],
)
def test_telemetry_unknown_or_low_clocks_pause_and_fresh_facts_require_resync(harness, changes):
run, clock = harness(telemetry=True)
clock[0] += 100_000_000
run.observe_worker(run.start, snapshot(clock[0], **changes))
assert run.continuity.phase == "waiting" and not run.stop_event.is_set()
run.renew(run.start)
with pytest.raises(StreamSuspended):
run.begin_input(run.start)
clock[0] += 100_000_000
run.observe_worker(run.start, snapshot(clock[0]))
assert run.continuity.phase == "waiting"
epoch = run.begin_input(run.start)
run.resume_input(epoch, proof(clock[0]), lambda: None)
assert run.continuity.phase == "active" and run.lease.renewals == 1
def test_expired_inventory_can_recover_but_expired_local_lease_cannot(harness):
run, clock = harness(telemetry=True)
for _ in range(4):
clock[0] += 900_000_000
run.renew(run.start)
assert run.continuity.phase == "waiting" and not run.stop_event.is_set()
run.observe_worker(run.start, snapshot(clock[0]))
epoch = run.begin_input(run.start)
run.resume_input(epoch, proof(clock[0]), lambda: None)
clock[0] += 2_000_000_000
with pytest.raises(WorkerLeaseError):
run.observe_worker(run.start, snapshot(clock[0]))
assert run.reason == "lease-lost"
@pytest.mark.parametrize(
"changes",
[
{"competing_gpu_clients": ("other-model",)},
{"gpu_owner_run_id": "other"},
{"lease_generation": 2},
{"image_sha256": "f" * 64},
],
)
def test_confirmed_owner_or_identity_conflict_still_stops(harness, changes):
run, clock = harness(telemetry=True)
clock[0] += 100_000_000
with pytest.raises(WorkerLeaseError):
run.observe_worker(run.start, snapshot(clock[0], **changes))
assert run.stop_event.is_set() and not run.lease.released
def test_serial_gpu_lane_survives_a_discarded_epoch(harness):
run, _ = harness()
seen = threading.Event()
def compute(packet):
if packet["sequence"] == 0:
seen.set()
raise StreamSuspended("discard this frame")
return packet["sequence"]
stage = SerialGpuStage(run.mailbox, compute, run.stop_event)
try:
run.admit(run.start, {"sequence": 0, "payload_bytes": 8})
assert seen.wait(1)
run.admit(run.start, {"sequence": 1, "payload_bytes": 8})
packet, result = stage.take()
assert result == 1 and stage.error is None
run.mailbox.release(packet)
finally:
assert stage.close()
@pytest.mark.parametrize("fault", ["eof", "idle", "partial", "gap"])
def test_connection_loss_keeps_runtime_and_reconnects_without_thread_growth(harness, fault):
run, clock = harness()
epoch = run.start
for index in range(10):
left, right = socket.socketpair()
seen = []
recv = StreamingIngress(
right,
run,
"capture",
1,
seen.append,
lambda _: None,
input_epoch=epoch,
idle_timeout=0.05,
fragment_timeout=0.05,
)
recv.start()
try:
left.sendall(wire.open_packet(epoch, "capture", 1))
if fault == "eof":
left.shutdown(socket.SHUT_WR)
if fault == "partial":
event = LiveIngressEvent(
1, "capture", 1, "lidar", "points", index, 0, clock[0], b"points"
)
header, _ = next(wire.event_packets(epoch, event))
left.sendall(header + b"p")
left.shutdown(socket.SHUT_WR)
if fault == "gap":
left.sendall(
wire.gap_packet(epoch, modality="camera-frame", reason="source-gap", count=1)
)
assert recv.join(1)
assert recv.terminal == "paused" and not seen and not run.stop_event.is_set()
assert (
run.continuity.phase == "waiting"
and run.mailbox.bytes == 0
and not run.mailbox.done
)
clock[0] += 1_000_000
epoch = run.begin_input(run.start)
run.resume_input(epoch, proof(clock[0]), lambda: None)
finally:
left.close()
assert len(run._threads) <= 1 and run.continuity.generation == 10
def test_old_transport_binding_and_backlog_never_reach_new_consumer(harness):
run, clock = harness()
run.pause_input(run.start, "input-timeout")
clock[0] += 100_000_000
epoch = run.begin_input(run.start)
with pytest.raises(StreamSuspended):
run.check_input(epoch, synchronizing=True, event_ns=clock[0] - 1)
with pytest.raises(StreamSuspended):
run.check_input(run.start, synchronizing=True)
assert not run.stop_event.is_set()
def test_late_old_packet_on_new_connection_does_not_kill_models(harness):
run, clock = harness()
run.pause_input(run.start, "input-disconnected")
epoch = run.begin_input(run.start)
run.resume_input(epoch, proof(clock[0]), lambda: None)
left, right = socket.socketpair()
seen = []
receiver = StreamingIngress(
right, run, "capture", 1, seen.append, lambda _: None, input_epoch=epoch
)
receiver.start()
try:
left.sendall(wire.open_packet(epoch, "capture", 1))
left.sendall(wire.terminal_packet(run.start))
assert receiver.join() and receiver.terminal == "paused"
assert not seen and not run.stop_event.is_set() and not run.mailbox.done
finally:
left.close()
@@ -162,6 +162,22 @@ def fake_av(monkeypatch):
return codec, av, opens
def test_epoch_reset_discards_predictive_state_but_keeps_decoder_object(fake_av):
decoder = decoder_module.FragmentDecoder()
init, frame = fixture()
decoder.configure(init)
decoder.decode(frame)
decoder.reset(init)
assert decoder.frames == 0 and decoder._next_dts is None
decoder.decode(fixture(dts=500)[1])
assert decoder.frames == 1 and decoder._next_dts == 600
decoder.reset(init)
with pytest.raises(decoder_module.StreamingDecodeError, match="random-access"):
decoder.decode(fixture(dts=600, sync=False)[1])
with pytest.raises(decoder_module.StreamingDecodeError, match="failed decoder"):
decoder.reset(init)
def test_persistent_codec_emits_current_frame_without_demux_or_flush(fake_av):
codec, _, opens = fake_av
init, fragment = fixture()