feat(perception): gate cross-host graph with acknowledged source clocks
This commit is contained in:
@@ -126,7 +126,11 @@ class BinaryGraphInput:
|
||||
length = len(e.value[0])
|
||||
rolling_times[offset : offset + length] = e.time_ns
|
||||
offset += length
|
||||
due = self.wall_zero + stamp - self.source_zero
|
||||
due = (
|
||||
self.source.due(stamp, time.monotonic_ns())[0]
|
||||
if hasattr(self.source, "observed")
|
||||
else self.wall_zero + stamp - self.source_zero
|
||||
)
|
||||
bundle = {
|
||||
"input_start": self.epoch,
|
||||
"sequence": event.source_sequence,
|
||||
@@ -158,6 +162,8 @@ class BinaryGraphInput:
|
||||
"enqueued_ns": time.monotonic_ns(),
|
||||
"payload_bytes": size,
|
||||
}
|
||||
if hasattr(self.source, "observed"):
|
||||
bundle["clock_observer"] = self.source.observed
|
||||
self.bgr_hashes.append(hashlib.sha256(image).hexdigest())
|
||||
bundle["enqueued_ns"] = time.monotonic_ns()
|
||||
transferred = self.runtime.admit_reserved(self.epoch, bundle, reservation)
|
||||
|
||||
@@ -89,15 +89,25 @@ def build_freshness(scene, bundle, ddr_layer, epoch_id):
|
||||
return SceneFreshness(epoch_id, CLOCK_DOMAIN, sequence, stamp, tuple(layers.values()))
|
||||
|
||||
|
||||
def source_observation(bundle, now_ns):
|
||||
if "clock_observer" in bundle:
|
||||
stamp, uncertainty_ns = bundle["clock_observer"](now_ns)
|
||||
return stamp, uncertainty_ns / 1e6
|
||||
# Legacy same-kernel path, or source/consumer on the originating Mac clock.
|
||||
return bundle["time_ns"] + now_ns - bundle["due_ns"], 0
|
||||
|
||||
|
||||
def assess(freshness, *, bundle, now_ns):
|
||||
# Both sides of this pilot use the SAME monotonic clock. Map it back to
|
||||
# original source arrival time; zero mapping uncertainty is local-only and
|
||||
# is not a claim of hardware camera/LiDAR synchronization or network quality.
|
||||
observed, uncertainty = source_observation(bundle, now_ns)
|
||||
return assess_observation(freshness, observed, uncertainty)
|
||||
|
||||
|
||||
def assess_observation(freshness, observed, uncertainty):
|
||||
return freshness.assess(
|
||||
epoch_id=freshness.epoch_id,
|
||||
clock_domain_id=CLOCK_DOMAIN,
|
||||
observed_source_time_ns=bundle["time_ns"] + now_ns - bundle["due_ns"],
|
||||
clock_uncertainty_ms=0,
|
||||
observed_source_time_ns=observed,
|
||||
clock_uncertainty_ms=uncertainty,
|
||||
maximum_clock_uncertainty_ms=5,
|
||||
maximum_layer_age_ms=250,
|
||||
)
|
||||
@@ -141,19 +151,23 @@ def prepare_publication(scene, bundle, ddr_layer, *, epoch_id, now_ns, mode="who
|
||||
if mode not in ("whole-scene", "per-cell"):
|
||||
raise ValueError("unknown costmap freshness mode")
|
||||
scene["costmap_freshness_mode"] = mode
|
||||
observed, uncertainty = source_observation(bundle, now_ns)
|
||||
if mode == "per-cell":
|
||||
apply_cell_expiry(
|
||||
scene,
|
||||
source_time_ns=bundle["time_ns"],
|
||||
observed_source_time_ns=bundle["time_ns"] + now_ns - bundle["due_ns"],
|
||||
observed_source_time_ns=observed,
|
||||
uncertainty_ms=uncertainty,
|
||||
)
|
||||
freshness = build_freshness(scene, bundle, ddr_layer, epoch_id)
|
||||
checked = assess(freshness, bundle=bundle, now_ns=now_ns)
|
||||
# One immutable clock observation per boundary, including cell and policy
|
||||
# reassessment. A concurrent refresh must not reinterpret this same instant.
|
||||
checked = assess_observation(freshness, observed, uncertainty)
|
||||
if not checked.fresh_complete:
|
||||
suppress_policy(scene)
|
||||
# The envelope hashes the actual guarded policy, not the discarded one.
|
||||
freshness = build_freshness(scene, bundle, ddr_layer, epoch_id)
|
||||
checked = assess(freshness, bundle=bundle, now_ns=now_ns)
|
||||
checked = assess_observation(freshness, observed, uncertainty)
|
||||
scene["freshness"] = freshness.to_dict()
|
||||
scene["freshness_at_publication"] = checked.to_dict()
|
||||
scene["stale_at_publication"] = any(item.state == "stale" for item in checked.layers)
|
||||
@@ -202,9 +216,14 @@ def assess_receipt(scene, freshness, *, bundle, now_ns):
|
||||
their original support time. Layer identity/source timestamps do not change;
|
||||
updated costmap/policy hashes belong to the derived view, not the wire bytes.
|
||||
"""
|
||||
observed = bundle["time_ns"] + now_ns - bundle["due_ns"]
|
||||
observed, uncertainty = source_observation(bundle, now_ns)
|
||||
previous = scene.get("freshness_at_receipt") or scene["freshness_at_publication"]
|
||||
if observed < _read_wire_integer(previous["checked_at_source_time_ns"]):
|
||||
# Independent clocks can have overlapping intervals even though receipt is
|
||||
# later. Reject a provably reversed interval, not a shifted midpoint alone.
|
||||
if observed + int(uncertainty * 1e6) < (
|
||||
_read_wire_integer(previous["checked_at_source_time_ns"])
|
||||
- int(previous["clock_uncertainty_ms"] * 1e6)
|
||||
):
|
||||
raise ValueError("consumer clock moved backwards")
|
||||
view = dict(scene)
|
||||
if scene.get("costmap_freshness_mode") == "per-cell":
|
||||
@@ -212,6 +231,7 @@ def assess_receipt(scene, freshness, *, bundle, now_ns):
|
||||
view,
|
||||
source_time_ns=freshness.source_time_ns,
|
||||
observed_source_time_ns=observed,
|
||||
uncertainty_ms=uncertainty,
|
||||
)
|
||||
layers = {item.layer: item for item in freshness.layers[:4]}
|
||||
for name in ("costmap", "policy"):
|
||||
@@ -230,7 +250,7 @@ def assess_receipt(scene, freshness, *, bundle, now_ns):
|
||||
oldest_required_input_time_ns=oldest,
|
||||
)
|
||||
freshness = replace(freshness, layers=tuple(layers.values()))
|
||||
checked = assess(freshness, bundle=bundle, now_ns=now_ns)
|
||||
checked = assess_observation(freshness, observed, uncertainty)
|
||||
view = receipt_view(view, checked)
|
||||
# Global suppression also changes the policy payload. A derived view must
|
||||
# remain internally verifiable, including on a second consumer boundary.
|
||||
@@ -238,7 +258,7 @@ def assess_receipt(scene, freshness, *, bundle, now_ns):
|
||||
if policy.payload_sha256 is not None:
|
||||
policy = replace(policy, payload_sha256=payload_digest(view, "policy"))
|
||||
freshness = replace(freshness, layers=(*freshness.layers[:-1], policy))
|
||||
checked = assess(freshness, bundle=bundle, now_ns=now_ns)
|
||||
checked = assess_observation(freshness, observed, uncertainty)
|
||||
view["freshness"] = freshness.to_dict()
|
||||
view["freshness_at_receipt"] = checked.to_dict()
|
||||
return view, checked
|
||||
|
||||
@@ -5,6 +5,7 @@ accepted from the network. Grant rotation stays in this trusted local adapter.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
@@ -15,7 +16,9 @@ from pilot_binary_bridge import BinaryGraphInput
|
||||
from pilot_network_control import kernel_clock, read_control, write_control
|
||||
|
||||
from k1link.perception.streaming_continuity import StreamSuspended
|
||||
from k1link.perception.streaming_control_grpc import ControlTicket, StreamControlEndpoint
|
||||
from k1link.perception.streaming_grpc import GrpcStreamEndpoint
|
||||
from k1link.perception.streaming_source_clock import SourceClockMonitor
|
||||
|
||||
|
||||
class NetworkGraphBridge(BinaryGraphInput):
|
||||
@@ -33,16 +36,40 @@ class NetworkGraphBridge(BinaryGraphInput):
|
||||
address,
|
||||
reset_temporal,
|
||||
start_delay=2,
|
||||
cross_host=False,
|
||||
):
|
||||
self.clock = kernel_clock()
|
||||
source = SimpleNamespace(
|
||||
source_zero=source_zero, wall_zero=time.monotonic_ns() + int(start_delay * 1e9)
|
||||
self.cross_host = cross_host
|
||||
self.clock_id = "worker-clock-" + secrets.token_hex(12)
|
||||
self.clock = (
|
||||
{"scope": "cross-host-conditional", "clock_id": self.clock_id}
|
||||
if cross_host
|
||||
else kernel_clock()
|
||||
)
|
||||
source = (
|
||||
SourceClockMonitor(self.clock_id, source_zero)
|
||||
if cross_host
|
||||
else SimpleNamespace(
|
||||
source_zero=source_zero, wall_zero=time.monotonic_ns() + int(start_delay * 1e9)
|
||||
)
|
||||
)
|
||||
super().__init__(runtime, decoder, source, report, reset_temporal=reset_temporal)
|
||||
self.control = Path(control)
|
||||
self.source_status = Path(source_status)
|
||||
self.certificate, self.private_key, self.address = certificate, private_key, address
|
||||
self.endpoint = GrpcStreamEndpoint(runtime, self.consume, self.notice)
|
||||
self.access = None
|
||||
self.ticket = ControlTicket(runtime.start, secrets.token_hex(32)) if cross_host else None
|
||||
self.control_endpoint = (
|
||||
StreamControlEndpoint(
|
||||
self.ticket,
|
||||
self._pending,
|
||||
clock_id=self.clock_id,
|
||||
observe=source.observe,
|
||||
)
|
||||
if cross_host
|
||||
else None
|
||||
)
|
||||
self.clock_states = []
|
||||
self.stopping, self.ready = threading.Event(), threading.Event()
|
||||
self.failure = None
|
||||
self.published = []
|
||||
@@ -64,6 +91,9 @@ class NetworkGraphBridge(BinaryGraphInput):
|
||||
|
||||
def _grant(self):
|
||||
access = self.endpoint.issue(self.epoch, "recorded-acquisition", 1)
|
||||
if self.cross_host:
|
||||
self.access = access
|
||||
return
|
||||
write_control(
|
||||
self.control / "grant.json",
|
||||
{
|
||||
@@ -76,6 +106,13 @@ class NetworkGraphBridge(BinaryGraphInput):
|
||||
},
|
||||
)
|
||||
|
||||
def _pending(self):
|
||||
try:
|
||||
self.source.observed(time.monotonic_ns())
|
||||
except StreamSuspended:
|
||||
return None
|
||||
return self.access if self.endpoint.grant is not None else None
|
||||
|
||||
def _run(self):
|
||||
try:
|
||||
asyncio.run(self._serve())
|
||||
@@ -90,21 +127,55 @@ class NetworkGraphBridge(BinaryGraphInput):
|
||||
self.address,
|
||||
certificate=Path(self.certificate).read_bytes(),
|
||||
private_key=Path(self.private_key).read_bytes(),
|
||||
control_handlers=(self.control_endpoint.handler(),) if self.cross_host else (),
|
||||
)
|
||||
try:
|
||||
self._grant()
|
||||
if self.cross_host:
|
||||
self.runtime.pause_input(self.epoch, "source-clock")
|
||||
write_control(
|
||||
self.control / "bootstrap.json",
|
||||
{
|
||||
"activation": self.runtime.start.to_dict(),
|
||||
"token": self.ticket.token,
|
||||
"remote_clock_id": self.clock_id,
|
||||
"source_zero_ns": str(self.source_zero),
|
||||
},
|
||||
)
|
||||
else:
|
||||
self._grant()
|
||||
self.ready.set()
|
||||
while not self.stopping.is_set() and not self.runtime.stop_event.is_set():
|
||||
if self.cross_host:
|
||||
try:
|
||||
self.source.observed(time.monotonic_ns())
|
||||
clock_ready = True
|
||||
if not self.report["wall_zero_ns"]:
|
||||
self.report["wall_zero_ns"] = self.source.due(
|
||||
self.source_zero, time.monotonic_ns()
|
||||
)[0]
|
||||
except StreamSuspended:
|
||||
clock_ready = False
|
||||
self.runtime.pause_input(self.epoch, "source-clock")
|
||||
if not self.clock_states or self.clock_states[-1]["ready"] != clock_ready:
|
||||
self.clock_states.append(
|
||||
{"at_ns": time.monotonic_ns(), "ready": clock_ready}
|
||||
)
|
||||
if len(self.clock_states) > 128:
|
||||
raise ValueError("bounded clock transition count exceeded")
|
||||
if self.endpoint.active is None:
|
||||
ended = self.source_status / "source-end.json"
|
||||
if ended.exists():
|
||||
if self.cross_host and self.source.ended:
|
||||
self.runtime.mailbox.finish()
|
||||
elif not self.cross_host and ended.exists():
|
||||
terminal = read_control(ended)
|
||||
if terminal.get("run_id") != self.runtime.start.run_id:
|
||||
raise ValueError("external source completion binding mismatch")
|
||||
if terminal.get("error"):
|
||||
raise ValueError("external source failed; inspect its bounded report")
|
||||
self.runtime.mailbox.finish()
|
||||
elif self.runtime.continuity.phase == "waiting":
|
||||
elif self.runtime.continuity.phase == "waiting" and (
|
||||
not self.cross_host or clock_ready
|
||||
):
|
||||
try:
|
||||
epoch = self.runtime.begin_input(self.runtime.start)
|
||||
except StreamSuspended:
|
||||
@@ -118,6 +189,8 @@ class NetworkGraphBridge(BinaryGraphInput):
|
||||
await asyncio.sleep(0.01)
|
||||
finally:
|
||||
await server.stop(0)
|
||||
if self.cross_host:
|
||||
(self.control / "bootstrap.json").unlink(missing_ok=True)
|
||||
deadline = time.monotonic() + 2
|
||||
while self.endpoint.active is not None and time.monotonic() < deadline:
|
||||
await asyncio.sleep(0.01)
|
||||
@@ -157,6 +230,7 @@ class NetworkGraphBridge(BinaryGraphInput):
|
||||
accepted_camera_sequences=self.accepted,
|
||||
published=self.published,
|
||||
adapter_failure=self.failure,
|
||||
clock_states=self.clock_states,
|
||||
)
|
||||
if not self.thread.is_alive() and self.endpoint.active is None:
|
||||
self.window.close()
|
||||
|
||||
@@ -18,8 +18,9 @@ from pathlib import Path
|
||||
import grpc
|
||||
from pilot_binary_source import event_bytes, input_gaps, read_member
|
||||
from pilot_freshness import assess_receipt, validate_receipt
|
||||
from pilot_network_control import read_grant, write_control
|
||||
from pilot_network_control import read_control, read_grant, write_control
|
||||
from pilot_source import SensorArchive, camera_events, merged_events
|
||||
from pilot_source_control import SourceControl
|
||||
|
||||
from k1link.compute.live_perception import LiveIngressEvent
|
||||
from k1link.perception.streaming_grpc import GrpcStreamClient
|
||||
@@ -32,6 +33,16 @@ def connection_delay(wall_zero_ns, now_ns):
|
||||
return max(0, (wall_zero_ns - now_ns - 100_000_000) / 1e9)
|
||||
|
||||
|
||||
async def finish_source(client, reader, clock_control):
|
||||
# EOF on Exchange can finish the graph and retire its control endpoint.
|
||||
# Confirm source completion first while that data stream is still alive.
|
||||
if clock_control:
|
||||
await clock_control.finish()
|
||||
if client is not None:
|
||||
await client.end()
|
||||
await asyncio.wait_for(reader, timeout=5)
|
||||
|
||||
|
||||
async def run(args):
|
||||
report = {
|
||||
"started_utc": datetime.now(UTC).isoformat(),
|
||||
@@ -43,7 +54,9 @@ async def run(args):
|
||||
"models": 0,
|
||||
"source_clock_speed": 1.0,
|
||||
"full_source_prepass": False,
|
||||
"clock_scope": "two-containers-same-unshifted-Linux-kernel",
|
||||
"clock_scope": "cross-host-conditional"
|
||||
if args.cross_host
|
||||
else "two-containers-same-unshifted-Linux-kernel",
|
||||
"error": None,
|
||||
"actuation_allowed": False,
|
||||
"commands_enabled": False,
|
||||
@@ -59,6 +72,7 @@ async def run(args):
|
||||
gaps, next_gap, outage_until = input_gaps(args.input_gap), 0, 0
|
||||
previous_epoch = None
|
||||
clock = None
|
||||
clock_control = None
|
||||
|
||||
async def disconnect():
|
||||
nonlocal client, reader
|
||||
@@ -71,11 +85,25 @@ async def run(args):
|
||||
|
||||
try:
|
||||
deadline = time.monotonic() + 120
|
||||
while not grant_path.exists():
|
||||
startup_path = control / "bootstrap.json" if args.cross_host else grant_path
|
||||
while not startup_path.exists():
|
||||
if time.monotonic() > deadline:
|
||||
raise TimeoutError("trusted graph grant was not issued after warmup")
|
||||
await asyncio.sleep(0.02)
|
||||
clock, _ = read_grant(grant_path)
|
||||
if args.cross_host:
|
||||
bootstrap = read_control(startup_path)
|
||||
clock_control = SourceControl(
|
||||
args.target, Path(args.certificate).read_bytes(), bootstrap
|
||||
)
|
||||
clock_control.start()
|
||||
anchor = await clock_control.wait_anchor()
|
||||
clock = {
|
||||
"source_zero_ns": anchor.source_zero_ns,
|
||||
"wall_zero_ns": anchor.local_zero_ns,
|
||||
"clock": {"scope": "source-and-consumer-same-Mac-clock"},
|
||||
}
|
||||
else:
|
||||
clock, _ = read_grant(grant_path)
|
||||
first = next(camera_events(args.camera_index, 1)).time_ns
|
||||
if clock["source_zero_ns"] != first - 500_000_000:
|
||||
raise ValueError("recording prefix does not match admitted source clock")
|
||||
@@ -104,6 +132,20 @@ async def run(args):
|
||||
"due_ns": clock["wall_zero_ns"] + source_stamp - clock["source_zero_ns"],
|
||||
}
|
||||
freshness = validate_receipt(scene, bundle, epoch_id=epoch.epoch_id)
|
||||
if clock_control and not clock_control.ready():
|
||||
rejected = report.setdefault("clock_rejected_results", [])
|
||||
if len(rejected) >= args.frames:
|
||||
raise ValueError("bounded rejected receipt ledger exceeded")
|
||||
rejected.append(
|
||||
{
|
||||
"sequence": sequence,
|
||||
"epoch_id": epoch.epoch_id,
|
||||
"payload_sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"reason": "source-clock-unavailable",
|
||||
}
|
||||
)
|
||||
# No fresh scene/policy authority while clock admission is lost.
|
||||
continue
|
||||
checked_at = time.monotonic_ns()
|
||||
view, checked = assess_receipt(
|
||||
scene, freshness, bundle=bundle, now_ns=checked_at
|
||||
@@ -140,7 +182,18 @@ async def run(args):
|
||||
|
||||
async def connect():
|
||||
nonlocal client, reader, access, previous_epoch
|
||||
value, candidate = read_grant(grant_path)
|
||||
if clock_control:
|
||||
if not clock_control.ready() or clock_control.access is None:
|
||||
return False
|
||||
candidate = clock_control.access
|
||||
value = {
|
||||
**clock,
|
||||
"cutoff_ns": clock["source_zero_ns"]
|
||||
+ time.monotonic_ns()
|
||||
- clock["wall_zero_ns"],
|
||||
}
|
||||
else:
|
||||
value, candidate = read_grant(grant_path)
|
||||
if candidate.epoch.epoch_id == previous_epoch:
|
||||
return False
|
||||
if any(
|
||||
@@ -185,6 +238,8 @@ async def run(args):
|
||||
if event.channel == "camera":
|
||||
released.append(event.sequence)
|
||||
try:
|
||||
if clock_control and not clock_control.ready():
|
||||
await disconnect()
|
||||
if reader is not None and reader.done():
|
||||
reader.result() # Invalid payload is fatal; transport interruption is not.
|
||||
raise ConnectionError("result stream ended before source EOF")
|
||||
@@ -240,20 +295,23 @@ async def run(args):
|
||||
break
|
||||
source_end = True
|
||||
report["end_sent_monotonic_ns"] = time.monotonic_ns()
|
||||
if client is not None:
|
||||
await client.end()
|
||||
await asyncio.wait_for(reader, timeout=5)
|
||||
write_control(
|
||||
status / "source-end.json", {"run_id": access.epoch.run_id, "error": False}
|
||||
)
|
||||
await finish_source(client, reader, clock_control)
|
||||
if not clock_control:
|
||||
write_control(
|
||||
status / "source-end.json", {"run_id": access.epoch.run_id, "error": False}
|
||||
)
|
||||
except Exception:
|
||||
report["error"] = traceback.format_exc()
|
||||
if access is not None:
|
||||
if access is not None and not args.cross_host:
|
||||
write_control(
|
||||
status / "source-end.json", {"run_id": access.epoch.run_id, "error": True}
|
||||
)
|
||||
finally:
|
||||
await disconnect()
|
||||
if clock_control:
|
||||
await clock_control.close()
|
||||
report["clock_samples"] = clock_control.samples
|
||||
report["clock_errors"] = clock_control.errors
|
||||
from run_joint_pilot import distribution
|
||||
|
||||
report.update(
|
||||
@@ -291,6 +349,7 @@ async def run(args):
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--target", required=True)
|
||||
parser.add_argument("--cross-host", action="store_true")
|
||||
parser.add_argument("--control", required=True)
|
||||
parser.add_argument("--source-status", required=True)
|
||||
parser.add_argument("--certificate", required=True)
|
||||
|
||||
@@ -166,6 +166,8 @@ class PilotController:
|
||||
def source_now(self):
|
||||
if self.source_clock is None:
|
||||
raise WorkerLeaseError("source clock mapping not configured")
|
||||
if hasattr(self.source_clock, "source_now"):
|
||||
return self.source_clock.source_now(time.monotonic_ns())
|
||||
return self.source_clock.source_zero + time.monotonic_ns() - self.source_clock.wall_zero
|
||||
|
||||
def _renew(self):
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Bounded application-side clock loop; no lease or model control."""
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
import time
|
||||
|
||||
import grpc
|
||||
|
||||
from k1link.perception.realtime_contract import StreamStart
|
||||
from k1link.perception.streaming_clock import ClockMappingError, ClockWindow
|
||||
from k1link.perception.streaming_control_grpc import ControlTicket, StreamControlClient
|
||||
from k1link.perception.streaming_source_clock import SourceAnchor
|
||||
|
||||
|
||||
class SourceControl:
|
||||
def __init__(self, target, roots, bootstrap):
|
||||
ticket = ControlTicket(StreamStart.from_dict(bootstrap["activation"]), bootstrap["token"])
|
||||
clock_id = "source-clock-" + secrets.token_hex(12)
|
||||
self.client = StreamControlClient(target, roots, ticket, clock_id=clock_id)
|
||||
self.window = ClockWindow(
|
||||
clock_id, bootstrap["remote_clock_id"], rate_ppm=500, timestamp_error_ns=50_000
|
||||
)
|
||||
self.source_zero_ns = int(bootstrap["source_zero_ns"])
|
||||
self.anchor = None # Latch once after the initial clock handshake, never on resume.
|
||||
self.access = None
|
||||
self.confirmed = False
|
||||
self.failure = None
|
||||
self.stopping = self.ending = False
|
||||
self.samples, self.errors = [], []
|
||||
self.task = None
|
||||
|
||||
async def update(self):
|
||||
probe, access = await self.client.poll()
|
||||
bounds = self.window.add(probe)
|
||||
now = time.monotonic_ns()
|
||||
if self.anchor is None:
|
||||
try:
|
||||
self.window.current(now).require(now)
|
||||
except ClockMappingError:
|
||||
pass # Pre-start clock warmup, not a running source pause or retimestamp.
|
||||
else:
|
||||
self.anchor = SourceAnchor(self.source_zero_ns, now + 2_000_000_000)
|
||||
if self.anchor is not None:
|
||||
await self.client.acknowledge(probe, self.anchor, ended=self.ending)
|
||||
self.confirmed = True
|
||||
self.access = access
|
||||
now = time.monotonic_ns()
|
||||
self.samples.append(
|
||||
{
|
||||
"at_ns": now,
|
||||
"bounds": bounds.to_dict(),
|
||||
"uncertainty_ns": bounds.uncertainty_ns(now),
|
||||
"ready": self.ready(),
|
||||
"acknowledged": self.anchor is not None,
|
||||
}
|
||||
)
|
||||
if len(self.samples) > 2048:
|
||||
raise ValueError("bounded clock diagnostic count exceeded")
|
||||
|
||||
def ready(self):
|
||||
if self.failure:
|
||||
raise RuntimeError("source clock task failed") from self.failure
|
||||
if not self.confirmed:
|
||||
return False
|
||||
try:
|
||||
now = time.monotonic_ns()
|
||||
self.window.current(now).require(now)
|
||||
except ClockMappingError:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def loop(self):
|
||||
while not self.stopping:
|
||||
try:
|
||||
await self.update()
|
||||
if self.ending:
|
||||
return
|
||||
except (grpc.RpcError, TimeoutError, OSError) as exc:
|
||||
self.confirmed = False
|
||||
self.errors.append({"at_ns": time.monotonic_ns(), "type": type(exc).__name__})
|
||||
if len(self.errors) > 128:
|
||||
self.failure = RuntimeError("bounded clock transport error count exceeded")
|
||||
return
|
||||
except Exception as exc:
|
||||
self.failure = exc
|
||||
return
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
def start(self):
|
||||
self.task = asyncio.create_task(self.loop())
|
||||
|
||||
async def wait_anchor(self):
|
||||
deadline = time.monotonic() + 10
|
||||
while self.anchor is None or not self.confirmed:
|
||||
if self.failure:
|
||||
raise RuntimeError("initial source clock handshake failed") from self.failure
|
||||
if time.monotonic() > deadline:
|
||||
raise TimeoutError("bounded initial source clock handshake")
|
||||
await asyncio.sleep(0.01)
|
||||
return self.anchor
|
||||
|
||||
async def finish(self):
|
||||
self.ending = True
|
||||
await asyncio.wait_for(asyncio.shield(self.task), timeout=2)
|
||||
if self.failure:
|
||||
raise RuntimeError("source clock final acknowledgement failed") from self.failure
|
||||
|
||||
async def close(self):
|
||||
self.stopping = True
|
||||
if self.task:
|
||||
self.task.cancel()
|
||||
await asyncio.gather(self.task, return_exceptions=True)
|
||||
await self.client.close()
|
||||
@@ -276,6 +276,17 @@ def run(args):
|
||||
"costmap_freshness_mode": args.costmap_freshness,
|
||||
"ddrnet_min_source_interval_ms": args.ddrnet_min_source_interval_ms,
|
||||
"input_transport": args.input_transport,
|
||||
"network_clock_mode": "acknowledged-monotonic/v1"
|
||||
if getattr(args, "network_cross_host", False)
|
||||
else "local-only",
|
||||
"network_clock_envelope": {
|
||||
"relative_rate_ppm": 500,
|
||||
"timestamp_error_ns": 50000,
|
||||
"maximum_age_ns": 2000000000,
|
||||
"maximum_uncertainty_ns": 5000000,
|
||||
}
|
||||
if getattr(args, "network_cross_host", False)
|
||||
else None,
|
||||
"recover_input": getattr(args, "recover_input", False),
|
||||
"input_gap_plan": getattr(args, "input_gap", []),
|
||||
}
|
||||
@@ -549,6 +560,7 @@ def run(args):
|
||||
private_key=args.network_private_key,
|
||||
address=args.network_address,
|
||||
reset_temporal=reset_temporal,
|
||||
cross_host=args.network_cross_host,
|
||||
)
|
||||
controller.source_clock = binary_bridge.source
|
||||
report["scope"] = "full-graph-network-candidate; receiver evidence is separate"
|
||||
@@ -710,7 +722,10 @@ def run(args):
|
||||
"scene_sha256": hashlib.sha256(encoded).hexdigest(),
|
||||
}
|
||||
results.append(result)
|
||||
except StreamSuspended:
|
||||
except StreamSuspended as exc:
|
||||
report.setdefault("suspended_results", []).append(
|
||||
{"sequence": bundle["sequence"], "reason": str(exc)}
|
||||
)
|
||||
mailbox.release(bundle, discard_reason="input-gap")
|
||||
cpu_bundle = bundle = computed = mask = proposals = scene = received = view = (
|
||||
None
|
||||
@@ -947,6 +962,7 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--network-certificate")
|
||||
parser.add_argument("--network-private-key")
|
||||
parser.add_argument("--network-address", default="[::]:50061")
|
||||
parser.add_argument("--network-cross-host", action="store_true")
|
||||
parser.add_argument(
|
||||
"--ddrnet-layout", choices=("reference", "channels-last"), default="reference"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user