fix(perception): require joint clock readiness before source activation
This commit is contained in:
@@ -161,68 +161,6 @@ def test_source_eof_ack_precedes_data_channel_retirement(adapter, has_stream):
|
||||
assert calls == (["ack", "end", "drain"] if has_stream else ["ack"])
|
||||
|
||||
|
||||
def test_source_anchor_latches_only_after_initial_clock_gate_and_never_on_recovery(
|
||||
adapter, monkeypatch
|
||||
):
|
||||
module = importlib.import_module("pilot_source_control")
|
||||
healthy, acknowledgements = [False], []
|
||||
|
||||
class Bounds:
|
||||
def require(self, now):
|
||||
if not healthy[0]:
|
||||
raise module.ClockMappingError("synthetic excessive uncertainty")
|
||||
|
||||
def to_dict(self):
|
||||
return {}
|
||||
|
||||
def uncertainty_ns(self, now):
|
||||
return 1_000_000 if healthy[0] else 6_000_000
|
||||
|
||||
class Client:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def poll(self):
|
||||
return object(), None
|
||||
|
||||
async def acknowledge(self, probe, anchor, *, ended):
|
||||
acknowledgements.append(anchor)
|
||||
|
||||
monkeypatch.setattr(module, "StreamControlClient", Client)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"ClockWindow",
|
||||
lambda *a, **kw: SimpleNamespace(add=lambda probe: Bounds(), current=lambda now: Bounds()),
|
||||
)
|
||||
control = module.SourceControl(
|
||||
"unused",
|
||||
b"",
|
||||
{
|
||||
"activation": identity().to_dict(),
|
||||
"token": "b" * 64,
|
||||
"remote_clock_id": "worker",
|
||||
"source_zero_ns": str(10**12),
|
||||
},
|
||||
)
|
||||
|
||||
async def check():
|
||||
await control.update()
|
||||
assert control.anchor is None and not control.ready() and not acknowledgements
|
||||
healthy[0] = True
|
||||
await control.update()
|
||||
anchor = await control.wait_anchor()
|
||||
assert anchor == control.anchor and control.ready()
|
||||
healthy[0] = False
|
||||
await control.update()
|
||||
assert not control.ready() and control.anchor == anchor
|
||||
healthy[0] = True
|
||||
await control.update()
|
||||
assert control.ready() and acknowledgements == [anchor] * 3
|
||||
assert [row["acknowledged"] for row in control.samples] == [False, True, True, True]
|
||||
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
def test_cross_host_bridge_clock_expiry_retains_owner_and_rotates_epoch(tmp_path, tls, adapter): # noqa: F811
|
||||
module, _ = adapter
|
||||
cert, key = tmp_path / "cert", tmp_path / "key"
|
||||
|
||||
@@ -18,6 +18,37 @@ from k1link.perception.streaming_queue import StreamMailbox
|
||||
from k1link.perception.streaming_source_clock import SourceAnchor, SourceClockMonitor
|
||||
|
||||
|
||||
def test_responder_warms_without_source_admission_and_rejects_wide_start():
|
||||
monitor = SourceClockMonitor("worker", 10**12)
|
||||
wide = sample(outbound=20_000_000, inbound=20_000_000)
|
||||
now = wide.local_receive_ns + 10**12 + 1_000_000
|
||||
anchor = SourceAnchor(10**12, wide.local_receive_ns + 1_000_000_000)
|
||||
response = monitor.observe(wide, now, anchor, False)
|
||||
assert response.anchor is monitor.anchor is None and response.bounds.samples == 1
|
||||
with pytest.raises(StreamSuspended):
|
||||
monitor.observed(now)
|
||||
narrow = sample(2)
|
||||
now = narrow.local_receive_ns + 10**12 + 1_000_000
|
||||
response = monitor.observe(narrow, now, None, False)
|
||||
assert response.anchor is None and response.bounds.samples == 2
|
||||
response.bounds.require(now)
|
||||
with pytest.raises(StreamSuspended):
|
||||
monitor.observed(now) # Valid clock is not permission to start without an anchor.
|
||||
third = sample(3)
|
||||
now = third.local_receive_ns + 10**12 + 1_000_000
|
||||
assert monitor.observe(third, now, anchor, False).anchor == anchor
|
||||
assert monitor.observed(now)[1] < 5_000_000
|
||||
|
||||
|
||||
def test_end_before_start_is_terminal_without_inventing_a_source_timeline():
|
||||
monitor = SourceClockMonitor("worker", 10**12)
|
||||
first = sample()
|
||||
state = monitor.observe(first, first.local_receive_ns + 10**12 + 1_000_000, None, True)
|
||||
assert state.ended and state.anchor is None
|
||||
with pytest.raises(StreamSuspended):
|
||||
monitor.observed(state.bounds.measured_at_ns)
|
||||
|
||||
|
||||
def sample(number=1, outbound=1_000_000, inbound=2_000_000, offset=10**12):
|
||||
t1 = 10**16 + number * 100_000_000
|
||||
return ClockProbe(
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Joint pre-start admission, using real interval windows with synthetic time."""
|
||||
|
||||
# ruff: noqa: F811 -- shared probe path fixture
|
||||
import asyncio
|
||||
import importlib
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from test_perception_network_graph_adapter import adapter # noqa: F401
|
||||
from test_perception_streaming_control_grpc import controlled
|
||||
from test_perception_streaming_grpc import identity, tls # noqa: F401
|
||||
|
||||
from k1link.perception.streaming_clock import ClockProbe
|
||||
from k1link.perception.streaming_source_clock import SourceClockMonitor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def joint(adapter, monkeypatch):
|
||||
module = importlib.import_module("pilot_source_control")
|
||||
clock = [10**16]
|
||||
monitor = SourceClockMonitor("worker", 10**12)
|
||||
state = SimpleNamespace(lost_ack=False, wide_peer=False, grant=True, proposals=[])
|
||||
|
||||
class Client:
|
||||
def __init__(self, *args, clock_id):
|
||||
self.clock_id = clock_id
|
||||
|
||||
async def poll(self):
|
||||
clock[0] += 100_000_000
|
||||
t1 = clock[0]
|
||||
probe = ClockProbe(
|
||||
self.clock_id,
|
||||
"worker",
|
||||
str(t1),
|
||||
t1,
|
||||
t1 + 10**12 + 1_000_000,
|
||||
t1 + 10**12 + 1_010_000,
|
||||
t1 + 3_010_000,
|
||||
)
|
||||
clock[0] = probe.local_receive_ns
|
||||
return probe, object() if monitor.anchor and state.grant else None
|
||||
|
||||
async def acknowledge(self, probe, anchor, *, ended):
|
||||
state.proposals.append(anchor)
|
||||
peer = monitor.observe(probe, clock[0] + 10**12 + 1_000_000, anchor, ended)
|
||||
clock[0] += 2_000_000
|
||||
if state.lost_ack:
|
||||
state.lost_ack = False
|
||||
raise TimeoutError("lost ACK after Worker accepted anchor")
|
||||
if state.wide_peer:
|
||||
peer = replace(
|
||||
peer,
|
||||
bounds=replace(
|
||||
peer.bounds,
|
||||
offset_lower_ns=-(10**12) - 6_000_000,
|
||||
offset_upper_ns=-(10**12) + 6_000_000,
|
||||
),
|
||||
)
|
||||
return peer
|
||||
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"time",
|
||||
SimpleNamespace(monotonic_ns=lambda: clock[0], monotonic=lambda: clock[0] / 1e9),
|
||||
)
|
||||
monkeypatch.setattr(module, "StreamControlClient", Client)
|
||||
control = module.SourceControl(
|
||||
"unused",
|
||||
b"",
|
||||
{
|
||||
"activation": identity().to_dict(),
|
||||
"token": "b" * 64,
|
||||
"remote_clock_id": "worker",
|
||||
"source_zero_ns": str(10**12),
|
||||
},
|
||||
)
|
||||
return control, monitor, state, clock
|
||||
|
||||
|
||||
def test_both_windows_warm_before_anchor_and_grant_and_never_reanchor(joint):
|
||||
control, monitor, state, clock = joint
|
||||
|
||||
async def check():
|
||||
await control.update()
|
||||
assert control.anchor is monitor.anchor is None
|
||||
assert len(monitor.window.samples) == 1 and state.proposals == [None]
|
||||
assert not control.ready()
|
||||
await control.update()
|
||||
anchor = control.anchor
|
||||
assert anchor is not None and anchor == monitor.anchor and control.access is None
|
||||
await control.update()
|
||||
assert await control.wait_anchor() == anchor and control.ready()
|
||||
state.wide_peer = True
|
||||
await control.update()
|
||||
assert not control.ready() and control.anchor == anchor
|
||||
state.wide_peer = False
|
||||
await control.update()
|
||||
assert control.ready() and control.anchor == anchor
|
||||
assert all(p == anchor for p in state.proposals[1:])
|
||||
assert all(row["acknowledged"] for row in control.samples)
|
||||
row = control.samples[-1]
|
||||
assert (
|
||||
int(row["t1_source_send_ns"])
|
||||
< int(row["t4_source_receive_ns"])
|
||||
< int(row["t6_source_ack_receive_ns"])
|
||||
)
|
||||
assert int(row["t3_worker_send_ns"]) < int(row["t5_worker_report_receive_ns"])
|
||||
clock[0] += 2_000_000_000
|
||||
assert not control.ready() and control.anchor == anchor
|
||||
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
def test_one_sided_readiness_never_proposes_start(joint):
|
||||
control, monitor, state, _ = joint
|
||||
|
||||
async def check():
|
||||
state.wide_peer = True
|
||||
for _ in range(4):
|
||||
await control.update()
|
||||
assert control.anchor is monitor.anchor is None
|
||||
assert state.proposals == [None] * 4
|
||||
state.wide_peer = False
|
||||
await control.update()
|
||||
assert control.anchor is None # Fresh peer evidence arrives in this ACK.
|
||||
await control.update()
|
||||
assert control.anchor == monitor.anchor and control.ready()
|
||||
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
def test_lost_acceptance_echo_keeps_exact_same_proposal(joint):
|
||||
control, monitor, state, _ = joint
|
||||
|
||||
async def check():
|
||||
await control.update()
|
||||
state.lost_ack = True
|
||||
with pytest.raises(TimeoutError):
|
||||
await control.update()
|
||||
proposal = control.proposal
|
||||
assert proposal is not None and control.anchor is None and monitor.anchor == proposal
|
||||
await control.update()
|
||||
assert await control.wait_anchor() == proposal
|
||||
assert state.proposals[-2:] == [proposal, proposal]
|
||||
assert not control.samples[-2]["acknowledged"]
|
||||
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
def test_missing_grant_cannot_silently_start_late_or_reanchor(joint):
|
||||
control, _, state, clock = joint
|
||||
|
||||
async def check():
|
||||
state.grant = False
|
||||
await control.update()
|
||||
await control.update()
|
||||
anchor = control.anchor
|
||||
waiting = asyncio.create_task(control.wait_anchor())
|
||||
await asyncio.sleep(0)
|
||||
assert not waiting.done()
|
||||
clock[0] = anchor.local_zero_ns
|
||||
with pytest.raises(TimeoutError, match="immutable start"):
|
||||
await waiting
|
||||
assert control.anchor == anchor and control.access is None
|
||||
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
def test_peer_ready_does_not_replace_local_gate_or_refresh_peer_expiry(joint, monkeypatch):
|
||||
control, _, _, clock = joint
|
||||
|
||||
async def check():
|
||||
await control.update()
|
||||
await control.update()
|
||||
assert control.ready()
|
||||
original = control.window.current
|
||||
|
||||
def wide(now):
|
||||
return replace(
|
||||
original(now),
|
||||
offset_lower_ns=10**12 - 6_000_000,
|
||||
offset_upper_ns=10**12 + 6_000_000,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(control.window, "current", wide)
|
||||
assert not control.ready()
|
||||
monkeypatch.setattr(control.window, "current", original)
|
||||
assert control.ready()
|
||||
old_peer = control.peer
|
||||
clock[0] += 2_000_000_000
|
||||
await control.update()
|
||||
assert control.ready()
|
||||
control.peer = old_peer
|
||||
assert not control.ready() # Fresh Mac probe cannot revive stale Worker evidence.
|
||||
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
def test_real_tls_joint_handshake_waits_for_worker_anchor_and_data_grant(tmp_path, tls, adapter):
|
||||
module = importlib.import_module("pilot_source_control")
|
||||
|
||||
async def check():
|
||||
async with controlled(tmp_path, tls) as (run, endpoint, server, client, pending, port):
|
||||
monitor = SourceClockMonitor("worker", 10**12)
|
||||
server.observe = monitor.observe
|
||||
server.pending = lambda: pending[0] if monitor.anchor is not None else None
|
||||
control = module.SourceControl(
|
||||
f"localhost:{port}",
|
||||
tls[0],
|
||||
{
|
||||
"activation": identity().to_dict(),
|
||||
"token": client.ticket.token,
|
||||
"remote_clock_id": "worker",
|
||||
"source_zero_ns": str(10**12),
|
||||
},
|
||||
)
|
||||
control.start()
|
||||
try:
|
||||
anchor = await control.wait_anchor()
|
||||
assert anchor == monitor.anchor
|
||||
assert control.access == pending[0] and control.ready()
|
||||
assert control.samples[0]["peer"]["anchor"] is None
|
||||
assert len(control.samples) >= 3
|
||||
assert endpoint.active is None and run.lease.start == identity()
|
||||
await control.finish()
|
||||
assert monitor.ended
|
||||
finally:
|
||||
await control.close()
|
||||
|
||||
asyncio.run(check())
|
||||
@@ -4,11 +4,62 @@ from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.perception.streaming_clock import ClockMappingError, ClockProbe, ClockWindow
|
||||
from k1link.perception.streaming_clock import (
|
||||
ClockBounds,
|
||||
ClockMappingError,
|
||||
ClockProbe,
|
||||
ClockWindow,
|
||||
)
|
||||
|
||||
BASE = 10**16 # Deliberately above IEEE754 exact-integer range.
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fault",
|
||||
[
|
||||
None,
|
||||
"float",
|
||||
"integer",
|
||||
"plus",
|
||||
"leading",
|
||||
"negative_zero",
|
||||
"extra",
|
||||
"missing",
|
||||
"flags",
|
||||
"wide",
|
||||
"schema",
|
||||
],
|
||||
)
|
||||
def test_bounds_wire_exact_closed_and_signed(fault):
|
||||
bounds = window().add(probe())
|
||||
document = bounds.to_dict()
|
||||
if fault is None:
|
||||
assert ClockBounds.from_dict(document) == bounds
|
||||
return
|
||||
if fault == "float":
|
||||
document["measured_at_ns"] = float(bounds.measured_at_ns)
|
||||
elif fault == "integer":
|
||||
document["measured_at_ns"] = bounds.measured_at_ns
|
||||
elif fault == "plus":
|
||||
document["offset_upper_ns"] = "+1"
|
||||
elif fault == "leading":
|
||||
document["measured_at_ns"] = "0" + document["measured_at_ns"]
|
||||
elif fault == "negative_zero":
|
||||
document["offset_upper_ns"] = "-0"
|
||||
elif fault == "extra":
|
||||
document["ready"] = True
|
||||
elif fault == "missing":
|
||||
del document["samples"]
|
||||
elif fault == "flags":
|
||||
document["conditional_rate_error_envelope"] = 1
|
||||
elif fault == "wide":
|
||||
document["offset_upper_ns"] = str(2**63)
|
||||
else:
|
||||
document["schema_version"] = "old"
|
||||
with pytest.raises(ClockMappingError):
|
||||
ClockBounds.from_dict(document)
|
||||
|
||||
|
||||
def probe(number=1, *, outbound=1_000_000, inbound=2_000_000, offset=-(10**12)):
|
||||
sent = BASE + number * 100_000_000
|
||||
return ClockProbe(
|
||||
|
||||
@@ -254,3 +254,39 @@ def test_wrong_clock_receipt_cannot_update_mapping(tmp_path, tls, fault):
|
||||
assert monitor.anchor is None and monitor.window is None
|
||||
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fault", ["v1", "nonce", "clock", "anchor", "time", "envelope", "end"])
|
||||
def test_receipt_v2_client_rejects_unbound_or_legacy_state(tmp_path, tls, fault):
|
||||
async def check():
|
||||
async with controlled(tmp_path, tls) as (_, _, control, client, _, _):
|
||||
control.observe = SourceClockMonitor("worker", 10**12).observe
|
||||
probe, _ = await client.poll()
|
||||
original = client.report_call
|
||||
|
||||
async def altered(raw, **kwargs):
|
||||
value = wire.parse_header(bytearray(await original(raw, **kwargs)))
|
||||
if fault == "v1":
|
||||
value["schema_version"] = "missioncore.stream-clock-receipt/v1"
|
||||
elif fault == "nonce":
|
||||
value["nonce"] = "foreign"
|
||||
elif fault == "clock":
|
||||
value["state"]["bounds"]["local_clock_id"] = "foreign"
|
||||
elif fault == "anchor":
|
||||
value["state"]["anchor"] = SourceAnchor(
|
||||
10**12, probe.local_receive_ns
|
||||
).to_dict()
|
||||
elif fault == "time":
|
||||
value["state"]["bounds"]["measured_at_ns"] = str(probe.remote_send_ns - 1)
|
||||
elif fault == "envelope":
|
||||
value["state"]["bounds"]["relative_rate_budget_ppm"] = 1
|
||||
else:
|
||||
value["state"]["ended"] = True
|
||||
return wire.canonical(value)
|
||||
|
||||
client.report_call = altered
|
||||
with pytest.raises(ValueError):
|
||||
await client.acknowledge(probe, None)
|
||||
assert not client.polling
|
||||
|
||||
asyncio.run(check())
|
||||
|
||||
Reference in New Issue
Block a user