feat(perception): gate cross-host graph with acknowledged source clocks
This commit is contained in:
@@ -69,6 +69,60 @@ class ClockProbe:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClockReceipt:
|
||||
"""The responder's view AFTER an authenticated echo of its issued probe.
|
||||
|
||||
Responder receive=t2 and send=t3 bracket only processing, NOT network RTT.
|
||||
t1-t2 is a lower bound and t4-t3 an upper bound for initiator-minus-responder.
|
||||
Never construct an ordinary reversed ClockProbe: that would invert causality.
|
||||
"""
|
||||
|
||||
probe: ClockProbe
|
||||
acknowledged_ns: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_stamp(self.acknowledged_ns)
|
||||
if (
|
||||
not self.probe.remote_send_ns
|
||||
<= self.acknowledged_ns
|
||||
<= (self.probe.remote_receive_ns + 500_000_000)
|
||||
):
|
||||
raise ClockMappingError("clock acknowledgement outside bounded round trip")
|
||||
|
||||
@property
|
||||
def local_clock_id(self) -> str:
|
||||
return self.probe.remote_clock_id
|
||||
|
||||
@property
|
||||
def remote_clock_id(self) -> str:
|
||||
return self.probe.local_clock_id
|
||||
|
||||
@property
|
||||
def nonce(self) -> str:
|
||||
return self.probe.nonce
|
||||
|
||||
@property
|
||||
def local_send_ns(self) -> int:
|
||||
return self.probe.remote_receive_ns
|
||||
|
||||
@property
|
||||
def local_receive_ns(self) -> int:
|
||||
return self.acknowledged_ns
|
||||
|
||||
def bounds_at(
|
||||
self, local_ns: int, *, rate_ppm: int, timestamp_error_ns: int
|
||||
) -> tuple[int, int]:
|
||||
_stamp(local_ns)
|
||||
if local_ns < self.acknowledged_ns:
|
||||
raise ClockMappingError("acknowledgement is from the future")
|
||||
widen = _drift(local_ns - self.probe.remote_receive_ns, rate_ppm) + 2 * timestamp_error_ns
|
||||
return (
|
||||
self.probe.local_send_ns - self.probe.remote_receive_ns - widen,
|
||||
self.probe.local_receive_ns - self.probe.remote_send_ns + widen,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClockBounds:
|
||||
local_clock_id: str
|
||||
@@ -161,11 +215,11 @@ class ClockWindow:
|
||||
timestamp_error_ns,
|
||||
maximum_age_ns,
|
||||
)
|
||||
self.samples: deque[ClockProbe] = deque(maxlen=16)
|
||||
self.samples: deque[ClockProbe | ClockReceipt] = deque(maxlen=16)
|
||||
self.failed = False
|
||||
self.last_receive_ns = -1
|
||||
|
||||
def add(self, sample: ClockProbe) -> ClockBounds:
|
||||
def add(self, sample: ClockProbe | ClockReceipt) -> ClockBounds:
|
||||
if self.failed:
|
||||
raise ClockMappingError("clock session quarantined; explicit new session required")
|
||||
if (
|
||||
|
||||
@@ -62,7 +62,13 @@ class InputContinuity:
|
||||
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"):
|
||||
if reason not in (
|
||||
"input-disconnected",
|
||||
"input-timeout",
|
||||
"source-gap",
|
||||
"worker-telemetry",
|
||||
"source-clock",
|
||||
):
|
||||
raise ValueError("unknown recoverable pause reason")
|
||||
if self.phase != "waiting":
|
||||
self.pauses += 1
|
||||
|
||||
@@ -23,10 +23,12 @@ from . import streaming_wire as wire
|
||||
from .realtime_contract import StreamStart
|
||||
from .streaming_clock import ClockProbe
|
||||
from .streaming_grpc import OPTIONS, StreamAccess
|
||||
from .streaming_source_clock import SourceAnchor
|
||||
|
||||
SERVICE = "missioncore.perception.v1.StreamControl"
|
||||
METHOD = f"/{SERVICE}/Poll"
|
||||
MAX_CONTROL = 8192
|
||||
REPORT_METHOD = f"/{SERVICE}/ReportClock"
|
||||
|
||||
|
||||
def _document(raw: bytes) -> dict[str, Any]:
|
||||
@@ -77,6 +79,7 @@ class StreamControlEndpoint:
|
||||
*,
|
||||
clock_id: str,
|
||||
clock_ns: Callable[[], int] = time.monotonic_ns,
|
||||
observe: Callable[[ClockProbe, int, SourceAnchor, bool], None] | None = None,
|
||||
) -> None:
|
||||
wire.identifier(clock_id)
|
||||
self.activation, self.pending, self.clock_id, self.clock_ns = (
|
||||
@@ -89,10 +92,16 @@ class StreamControlEndpoint:
|
||||
self._lock = threading.Lock()
|
||||
self._last_poll_ns = -1
|
||||
self.accepted = self.rejected = 0
|
||||
self.observe = observe
|
||||
self._challenge: dict[str, Any] | None = None
|
||||
|
||||
def handler(self) -> Any:
|
||||
return grpc.method_handlers_generic_handler(
|
||||
SERVICE, {"Poll": grpc.unary_unary_rpc_method_handler(self.poll)}
|
||||
SERVICE,
|
||||
{
|
||||
"Poll": grpc.unary_unary_rpc_method_handler(self.poll),
|
||||
"ReportClock": grpc.unary_unary_rpc_method_handler(self.report_clock),
|
||||
},
|
||||
)
|
||||
|
||||
def _authenticate(self, context: Any) -> None:
|
||||
@@ -163,6 +172,19 @@ class StreamControlEndpoint:
|
||||
encoded = wire.canonical(response)
|
||||
if len(encoded) > MAX_CONTROL:
|
||||
raise ValueError("trusted control snapshot exceeds bound")
|
||||
if self.observe is not None:
|
||||
with self._lock:
|
||||
self._challenge = {
|
||||
key: response[key]
|
||||
for key in (
|
||||
"nonce",
|
||||
"local_clock_id",
|
||||
"local_send_ns",
|
||||
"remote_clock_id",
|
||||
"remote_receive_ns",
|
||||
"remote_send_ns",
|
||||
)
|
||||
}
|
||||
except (ValueError, RuntimeError):
|
||||
await context.abort(
|
||||
grpc.StatusCode.UNAVAILABLE, "local controller has no current offer"
|
||||
@@ -171,6 +193,52 @@ class StreamControlEndpoint:
|
||||
self.accepted += 1
|
||||
return encoded
|
||||
|
||||
async def report_clock(self, raw: bytes, context: Any) -> bytes:
|
||||
received = self.clock_ns()
|
||||
try:
|
||||
self._authenticate(context)
|
||||
value = _document(raw)
|
||||
if set(value) != {"probe", "anchor", "ended"} or self.observe is None:
|
||||
raise ValueError("clock receipt not admitted")
|
||||
evidence = value["probe"]
|
||||
with self._lock:
|
||||
challenge = self._challenge
|
||||
if (
|
||||
challenge is None
|
||||
or not isinstance(evidence, dict)
|
||||
or set(evidence) != {*challenge, "local_receive_ns"}
|
||||
or any(evidence[k] != v for k, v in challenge.items())
|
||||
):
|
||||
raise ValueError("clock receipt is not the current issued challenge")
|
||||
self._challenge = None # Single-use, including malformed/late receipts.
|
||||
probe = ClockProbe(
|
||||
evidence["local_clock_id"],
|
||||
evidence["remote_clock_id"],
|
||||
evidence["nonce"],
|
||||
*(
|
||||
wire.uint64(evidence[k])
|
||||
for k in (
|
||||
"local_send_ns",
|
||||
"remote_receive_ns",
|
||||
"remote_send_ns",
|
||||
"local_receive_ns",
|
||||
)
|
||||
),
|
||||
)
|
||||
anchor = SourceAnchor.from_dict(value["anchor"])
|
||||
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",
|
||||
"activation_binding": wire.binding(self.activation),
|
||||
"nonce": probe.nonce,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class StreamControlClient:
|
||||
def __init__(
|
||||
@@ -196,6 +264,7 @@ class StreamControlClient:
|
||||
),
|
||||
)
|
||||
self.call = self.channel.unary_unary(METHOD)
|
||||
self.report_call = self.channel.unary_unary(REPORT_METHOD)
|
||||
self.polling = False
|
||||
|
||||
async def poll(self) -> tuple[ClockProbe, StreamAccess | None]:
|
||||
@@ -267,3 +336,46 @@ class StreamControlClient:
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.channel.close()
|
||||
|
||||
async def acknowledge(
|
||||
self, probe: ClockProbe, anchor: SourceAnchor, *, ended: bool = False
|
||||
) -> None:
|
||||
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
|
||||
try:
|
||||
evidence = {
|
||||
name: getattr(probe, name)
|
||||
for name in (
|
||||
"nonce",
|
||||
"local_clock_id",
|
||||
"remote_clock_id",
|
||||
)
|
||||
}
|
||||
evidence.update(
|
||||
{
|
||||
name: str(getattr(probe, name))
|
||||
for name in (
|
||||
"local_send_ns",
|
||||
"remote_receive_ns",
|
||||
"remote_send_ns",
|
||||
"local_receive_ns",
|
||||
)
|
||||
}
|
||||
)
|
||||
response = _document(
|
||||
await self.report_call(
|
||||
wire.canonical({"probe": evidence, "anchor": anchor.to_dict(), "ended": ended}),
|
||||
metadata=self.ticket.metadata(),
|
||||
timeout=0.5,
|
||||
wait_for_ready=False,
|
||||
)
|
||||
)
|
||||
if response != {
|
||||
"schema_version": "missioncore.stream-clock-receipt/v1",
|
||||
"activation_binding": wire.binding(self.ticket.activation),
|
||||
"nonce": probe.nonce,
|
||||
}:
|
||||
raise ValueError("clock receipt response binding mismatch")
|
||||
finally:
|
||||
self.polling = False
|
||||
|
||||
@@ -291,22 +291,23 @@ class StreamingIngress:
|
||||
except Exception as exc:
|
||||
self.error = str(exc)
|
||||
self.terminal = "failed"
|
||||
if self.opened:
|
||||
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")
|
||||
if self.runtime.continuity is not None and isinstance(
|
||||
exc, (InputInterrupted, StreamSuspended)
|
||||
):
|
||||
# Admission already consumed the single-use grant. A transport
|
||||
# loss before the application OPEN is still WAIT, not an active
|
||||
# owner stranded forever. A late old socket remains fenced.
|
||||
self.terminal = "paused"
|
||||
with suppress(RuntimeError): # Already replaced, stopping, or fenced.
|
||||
self.runtime.pause_input(
|
||||
self.input_epoch,
|
||||
"input-disconnected"
|
||||
if isinstance(exc, InputInterrupted)
|
||||
else "source-gap",
|
||||
)
|
||||
elif self.opened:
|
||||
self.runtime.mailbox.finish(self.error)
|
||||
self.runtime.request_stop("failed")
|
||||
finally:
|
||||
self.connection.close()
|
||||
if reservation is not None:
|
||||
|
||||
@@ -223,11 +223,17 @@ class StreamingLifecycle:
|
||||
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
|
||||
assert self._source_clock_ns is not None
|
||||
try:
|
||||
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")
|
||||
except StreamSuspended:
|
||||
self.continuity.pause("source-clock")
|
||||
self.mailbox.pause()
|
||||
raise
|
||||
if event_ns is not None and (
|
||||
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:
|
||||
@@ -253,11 +259,12 @@ class StreamingLifecycle:
|
||||
raise StreamSuspended("old epoch callbacks or connection still active")
|
||||
if self.continuity.phase != "waiting":
|
||||
raise StreamSuspended("pause required before a new input epoch")
|
||||
source_now = self._source_clock_ns() # Check BEFORE mutating the paused mailbox.
|
||||
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())
|
||||
return self.continuity.begin(source_now)
|
||||
|
||||
def resume_input(
|
||||
self, epoch: StreamStart, evidence: ResumeEvidence, reset_temporal: Callable[[], None]
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Responder-owned source timeline and conditional clock readiness, no GPU authority."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import streaming_wire as wire
|
||||
from .streaming_clock import ClockMappingError, ClockProbe, ClockReceipt, ClockWindow, _stamp
|
||||
from .streaming_continuity import StreamSuspended
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceAnchor:
|
||||
source_zero_ns: int
|
||||
local_zero_ns: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_stamp(self.source_zero_ns)
|
||||
_stamp(self.local_zero_ns)
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
return {
|
||||
"source_zero_ns": str(self.source_zero_ns),
|
||||
"local_zero_ns": str(self.local_zero_ns),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> SourceAnchor:
|
||||
if not isinstance(value, dict) or set(value) != {"source_zero_ns", "local_zero_ns"}:
|
||||
raise ValueError("invalid immutable source anchor")
|
||||
return cls(wire.uint64(value["source_zero_ns"]), wire.uint64(value["local_zero_ns"]))
|
||||
|
||||
|
||||
class SourceClockMonitor:
|
||||
def __init__(self, worker_clock_id: str, source_zero_ns: int) -> None:
|
||||
wire.identifier(worker_clock_id)
|
||||
_stamp(source_zero_ns)
|
||||
self.worker_clock_id, self.source_zero = worker_clock_id, source_zero_ns
|
||||
self.wall_zero = 0 # Not a comparable host clock; use due()/observed().
|
||||
self.anchor: SourceAnchor | None = None
|
||||
self.window: ClockWindow | None = None
|
||||
self.ended = False
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def observe(
|
||||
self, probe: ClockProbe, received_ns: int, anchor: SourceAnchor, ended: bool
|
||||
) -> None:
|
||||
"""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:
|
||||
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:
|
||||
raise ValueError("initial source start must be bounded and not in the past")
|
||||
self.anchor = anchor
|
||||
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)
|
||||
self.ended = ended
|
||||
|
||||
def observed(self, now_ns: int) -> tuple[int, int]:
|
||||
"""Source time midpoint + integer half-width, freshly checked at use."""
|
||||
with self._lock:
|
||||
try:
|
||||
if self.window is None or self.anchor is None:
|
||||
raise ClockMappingError("source clock not observed")
|
||||
bounds = self.window.current(now_ns)
|
||||
bounds.require(now_ns)
|
||||
lower, upper = bounds.offset_at(now_ns)
|
||||
stamp = self.source_zero + now_ns + (lower + upper) // 2 - self.anchor.local_zero_ns
|
||||
_stamp(stamp)
|
||||
return stamp, (upper - lower + 1) // 2
|
||||
except ClockMappingError as exc:
|
||||
raise StreamSuspended("source clock unavailable") from exc
|
||||
|
||||
def source_now(self, now_ns: int) -> int:
|
||||
stamp, uncertainty = self.observed(now_ns)
|
||||
# Admission/cutoff/resume use the upper age bound, never a younger estimate.
|
||||
return stamp + uncertainty
|
||||
|
||||
def due(self, stamp: int, now_ns: int) -> tuple[int, int]:
|
||||
observed, uncertainty = self.observed(now_ns)
|
||||
return now_ns + stamp - observed, uncertainty
|
||||
Reference in New Issue
Block a user