fix(perception): require joint clock readiness before source activation
This commit is contained in:
@@ -145,6 +145,7 @@ class ClockBounds:
|
||||
or type(self.offset_lower_ns) is not int
|
||||
or type(self.offset_upper_ns) is not int
|
||||
or self.offset_lower_ns > self.offset_upper_ns
|
||||
or not -(2**63) < self.offset_lower_ns <= self.offset_upper_ns < 2**63
|
||||
or type(self.rate_ppm) is not int
|
||||
or not 1 <= self.rate_ppm <= 1000
|
||||
or type(self.timestamp_error_ns) is not int
|
||||
@@ -187,6 +188,46 @@ class ClockBounds:
|
||||
"symmetric_network_assumed": False,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> ClockBounds:
|
||||
"""Closed, exact-integer wire contract, including signed clock offsets."""
|
||||
if not isinstance(value, dict):
|
||||
raise ClockMappingError("invalid clock bounds document")
|
||||
|
||||
def integer(key: str) -> int:
|
||||
raw = value.get(key)
|
||||
if not isinstance(raw, str) or not 1 <= len(raw) <= 20:
|
||||
raise ClockMappingError("clock nanoseconds must be canonical decimal strings")
|
||||
try:
|
||||
parsed = int(raw)
|
||||
except ValueError as exc:
|
||||
raise ClockMappingError("invalid clock nanoseconds") from exc
|
||||
if str(parsed) != raw:
|
||||
raise ClockMappingError("noncanonical clock nanoseconds")
|
||||
return parsed
|
||||
|
||||
try:
|
||||
result = cls(
|
||||
value["local_clock_id"],
|
||||
value["remote_clock_id"],
|
||||
integer("measured_at_ns"),
|
||||
integer("expires_at_ns"),
|
||||
integer("offset_lower_ns"),
|
||||
integer("offset_upper_ns"),
|
||||
value["relative_rate_budget_ppm"],
|
||||
integer("timestamp_error_budget_ns"),
|
||||
value["samples"],
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise ClockMappingError("incomplete clock bounds") from exc
|
||||
if (
|
||||
value != result.to_dict()
|
||||
or value.get("conditional_rate_error_envelope") is not True
|
||||
or value.get("symmetric_network_assumed") is not False
|
||||
):
|
||||
raise ClockMappingError("unsupported clock bounds document")
|
||||
return result
|
||||
|
||||
|
||||
class ClockWindow:
|
||||
def __init__(
|
||||
|
||||
@@ -21,9 +21,9 @@ import grpc # type: ignore[import-untyped]
|
||||
|
||||
from . import streaming_wire as wire
|
||||
from .realtime_contract import StreamStart
|
||||
from .streaming_clock import ClockProbe
|
||||
from .streaming_clock import ClockProbe, ClockReceipt
|
||||
from .streaming_grpc import OPTIONS, StreamAccess
|
||||
from .streaming_source_clock import SourceAnchor
|
||||
from .streaming_source_clock import SourceAnchor, SourceClockState
|
||||
|
||||
SERVICE = "missioncore.perception.v1.StreamControl"
|
||||
METHOD = f"/{SERVICE}/Poll"
|
||||
@@ -79,7 +79,8 @@ class StreamControlEndpoint:
|
||||
*,
|
||||
clock_id: str,
|
||||
clock_ns: Callable[[], int] = time.monotonic_ns,
|
||||
observe: Callable[[ClockProbe, int, SourceAnchor, bool], None] | None = None,
|
||||
observe: Callable[[ClockProbe, int, SourceAnchor | None, bool], SourceClockState]
|
||||
| None = None,
|
||||
) -> None:
|
||||
wire.identifier(clock_id)
|
||||
self.activation, self.pending, self.clock_id, self.clock_ns = (
|
||||
@@ -198,7 +199,11 @@ class StreamControlEndpoint:
|
||||
try:
|
||||
self._authenticate(context)
|
||||
value = _document(raw)
|
||||
if set(value) != {"probe", "anchor", "ended"} or self.observe is None:
|
||||
if (
|
||||
set(value) != {"schema_version", "probe", "anchor", "ended"}
|
||||
or value["schema_version"] != "missioncore.stream-clock-report/v2"
|
||||
or self.observe is None
|
||||
):
|
||||
raise ValueError("clock receipt not admitted")
|
||||
evidence = value["probe"]
|
||||
with self._lock:
|
||||
@@ -225,17 +230,18 @@ class StreamControlEndpoint:
|
||||
)
|
||||
),
|
||||
)
|
||||
anchor = SourceAnchor.from_dict(value["anchor"])
|
||||
self.observe(probe, received, anchor, value["ended"])
|
||||
anchor = None if value["anchor"] is None else SourceAnchor.from_dict(value["anchor"])
|
||||
state = self.observe(probe, received, anchor, value["ended"])
|
||||
except (ValueError, TypeError):
|
||||
self.rejected += 1
|
||||
await context.abort(grpc.StatusCode.INVALID_ARGUMENT, "clock receipt rejected")
|
||||
return b""
|
||||
return wire.canonical(
|
||||
{
|
||||
"schema_version": "missioncore.stream-clock-receipt/v1",
|
||||
"schema_version": "missioncore.stream-clock-receipt/v2",
|
||||
"activation_binding": wire.binding(self.activation),
|
||||
"nonce": probe.nonce,
|
||||
"state": state.to_dict(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -338,8 +344,8 @@ class StreamControlClient:
|
||||
await self.channel.close()
|
||||
|
||||
async def acknowledge(
|
||||
self, probe: ClockProbe, anchor: SourceAnchor, *, ended: bool = False
|
||||
) -> None:
|
||||
self, probe: ClockProbe, anchor: SourceAnchor | None, *, ended: bool = False
|
||||
) -> SourceClockState:
|
||||
if self.polling or probe.local_clock_id != self.clock_id or type(ended) is not bool:
|
||||
raise ValueError("one scoped control request at a time")
|
||||
self.polling = True
|
||||
@@ -365,17 +371,37 @@ class StreamControlClient:
|
||||
)
|
||||
response = _document(
|
||||
await self.report_call(
|
||||
wire.canonical({"probe": evidence, "anchor": anchor.to_dict(), "ended": ended}),
|
||||
wire.canonical(
|
||||
{
|
||||
"schema_version": "missioncore.stream-clock-report/v2",
|
||||
"probe": evidence,
|
||||
"anchor": None if anchor is None else anchor.to_dict(),
|
||||
"ended": ended,
|
||||
}
|
||||
),
|
||||
metadata=self.ticket.metadata(),
|
||||
timeout=0.5,
|
||||
wait_for_ready=False,
|
||||
)
|
||||
)
|
||||
state = SourceClockState.from_dict(response.get("state"))
|
||||
if response != {
|
||||
"schema_version": "missioncore.stream-clock-receipt/v1",
|
||||
"schema_version": "missioncore.stream-clock-receipt/v2",
|
||||
"activation_binding": wire.binding(self.ticket.activation),
|
||||
"nonce": probe.nonce,
|
||||
"state": state.to_dict(),
|
||||
}:
|
||||
raise ValueError("clock receipt response binding mismatch")
|
||||
if (
|
||||
(state.bounds.local_clock_id, state.bounds.remote_clock_id)
|
||||
!= (probe.remote_clock_id, probe.local_clock_id)
|
||||
or state.bounds.rate_ppm != 500
|
||||
or state.bounds.timestamp_error_ns != 50_000
|
||||
or state.ended != ended
|
||||
or (state.anchor is not None and state.anchor != anchor)
|
||||
):
|
||||
raise ValueError("clock receipt state binding mismatch")
|
||||
ClockReceipt(probe, state.bounds.measured_at_ns)
|
||||
return state
|
||||
finally:
|
||||
self.polling = False
|
||||
|
||||
@@ -6,7 +6,14 @@ import threading
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import streaming_wire as wire
|
||||
from .streaming_clock import ClockMappingError, ClockProbe, ClockReceipt, ClockWindow, _stamp
|
||||
from .streaming_clock import (
|
||||
ClockBounds,
|
||||
ClockMappingError,
|
||||
ClockProbe,
|
||||
ClockReceipt,
|
||||
ClockWindow,
|
||||
_stamp,
|
||||
)
|
||||
from .streaming_continuity import StreamSuspended
|
||||
|
||||
|
||||
@@ -32,6 +39,36 @@ class SourceAnchor:
|
||||
return cls(wire.uint64(value["source_zero_ns"]), wire.uint64(value["local_zero_ns"]))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceClockState:
|
||||
bounds: ClockBounds
|
||||
anchor: SourceAnchor | None
|
||||
ended: bool
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.bounds, ClockBounds) or type(self.ended) is not bool:
|
||||
raise ValueError("invalid source clock state")
|
||||
if self.anchor is not None and not isinstance(self.anchor, SourceAnchor):
|
||||
raise ValueError("invalid accepted source anchor")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"bounds": self.bounds.to_dict(),
|
||||
"anchor": None if self.anchor is None else self.anchor.to_dict(),
|
||||
"ended": self.ended,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> SourceClockState:
|
||||
if not isinstance(value, dict) or set(value) != {"bounds", "anchor", "ended"}:
|
||||
raise ValueError("invalid source clock state document")
|
||||
return cls(
|
||||
ClockBounds.from_dict(value["bounds"]),
|
||||
None if value["anchor"] is None else SourceAnchor.from_dict(value["anchor"]),
|
||||
value["ended"],
|
||||
)
|
||||
|
||||
|
||||
class SourceClockMonitor:
|
||||
def __init__(self, worker_clock_id: str, source_zero_ns: int) -> None:
|
||||
wire.identifier(worker_clock_id)
|
||||
@@ -44,30 +81,40 @@ class SourceClockMonitor:
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def observe(
|
||||
self, probe: ClockProbe, received_ns: int, anchor: SourceAnchor, ended: bool
|
||||
) -> None:
|
||||
self, probe: ClockProbe, received_ns: int, anchor: SourceAnchor | None, ended: bool
|
||||
) -> SourceClockState:
|
||||
"""Called only after the transport verifies the one-use issued challenge."""
|
||||
receipt = ClockReceipt(probe, received_ns)
|
||||
if probe.remote_clock_id != self.worker_clock_id or type(ended) is not bool:
|
||||
raise ValueError("foreign clock/status")
|
||||
with self._lock:
|
||||
if self.ended or anchor.source_zero_ns != self.source_zero:
|
||||
if self.ended or (anchor is not None and anchor.source_zero_ns != self.source_zero):
|
||||
raise ValueError("source activation ended or source anchor changed")
|
||||
if self.anchor is None:
|
||||
if not 0 <= anchor.local_zero_ns - probe.local_receive_ns <= 10_000_000_000:
|
||||
if (
|
||||
anchor is not None
|
||||
and not 0 <= anchor.local_zero_ns - probe.local_receive_ns <= 10_000_000_000
|
||||
):
|
||||
raise ValueError("initial source start must be bounded and not in the past")
|
||||
self.anchor = anchor
|
||||
elif anchor != self.anchor:
|
||||
raise ValueError("source timeline is immutable for the activation")
|
||||
if self.window is None:
|
||||
self.window = ClockWindow(
|
||||
self.worker_clock_id,
|
||||
probe.local_clock_id,
|
||||
rate_ppm=500,
|
||||
timestamp_error_ns=50_000,
|
||||
)
|
||||
elif anchor != self.anchor:
|
||||
raise ValueError("source timeline is immutable for the activation")
|
||||
assert self.window is not None
|
||||
self.window.add(receipt)
|
||||
bounds = self.window.add(receipt)
|
||||
if self.anchor is None and anchor is not None:
|
||||
try:
|
||||
bounds.require(received_ns)
|
||||
except ClockMappingError:
|
||||
pass # Explicitly unaccepted proposal; keep warming BOTH clock windows.
|
||||
else:
|
||||
self.anchor = anchor
|
||||
self.ended = ended
|
||||
return SourceClockState(bounds, self.anchor, ended)
|
||||
|
||||
def observed(self, now_ns: int) -> tuple[int, int]:
|
||||
"""Source time midpoint + integer half-width, freshly checked at use."""
|
||||
|
||||
Reference in New Issue
Block a user