feat(perception): add bounded binary stream ingress
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
"""CPU-only, bounded IPC evidence on Worker; NOT a model/network qualification.
|
||||
|
||||
Recorded mode transfers original fMP4 init/media plus existing normalized
|
||||
point/pose increments at original 1x release times. Receiver is never told the
|
||||
source length. The consumer only hashes raw input, it does not decode/infer.
|
||||
Truncated mode is explicitly synthetic and exercises multi-fragment failure.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import resource
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from pilot_source import SensorArchive, camera_events, merged_events
|
||||
|
||||
from k1link.compute.live_perception import LiveIngressEvent
|
||||
from k1link.perception import streaming_wire as wire
|
||||
from k1link.perception.realtime_contract import StreamStart
|
||||
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
|
||||
|
||||
|
||||
def record(event):
|
||||
return {
|
||||
**wire.event_metadata(event),
|
||||
"payload_bytes": len(event.payload),
|
||||
"payload_sha256": hashlib.sha256(event.payload).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def emit_json(stream, value):
|
||||
stream.write(json.dumps(value, sort_keys=True) + "\n")
|
||||
stream.flush()
|
||||
|
||||
|
||||
def bounded_file(path, maximum):
|
||||
with path.open("rb") as stream:
|
||||
raw = stream.read(maximum + 1)
|
||||
if not 0 < len(raw) <= maximum:
|
||||
raise ValueError("source member exceeds bound")
|
||||
return raw
|
||||
|
||||
|
||||
def recorded_events(root):
|
||||
camera_root = Path("/camera")
|
||||
camera_index = camera_root / "index.jsonl"
|
||||
first = next(camera_events(camera_index, 1)).time_ns
|
||||
zero = first - 500_000_000
|
||||
wall = time.monotonic_ns() + 50_000_000
|
||||
seq = 1
|
||||
yield (
|
||||
LiveIngressEvent(
|
||||
seq,
|
||||
"recorded-acquisition",
|
||||
1,
|
||||
"camera-init",
|
||||
"sensor.camera.right",
|
||||
0,
|
||||
0,
|
||||
zero,
|
||||
bounded_file(camera_root / "init.mp4", wire.MAX_FRAGMENT),
|
||||
),
|
||||
wall,
|
||||
)
|
||||
archive = SensorArchive(Path("/sensor-source.npz"))
|
||||
try:
|
||||
for observation in merged_events(archive, camera_index, 32):
|
||||
if observation.time_ns < zero:
|
||||
continue
|
||||
due = wall + observation.time_ns - zero
|
||||
threading.Event().wait(max(0, (due - time.monotonic_ns()) / 1e9))
|
||||
seq += 1
|
||||
utc = 0 # The normalized sensor archive has no UTC evidence; do not invent it.
|
||||
if observation.channel == "camera":
|
||||
row = observation.value
|
||||
path = (camera_root / row["path"]).resolve()
|
||||
if not path.is_relative_to(camera_root):
|
||||
raise ValueError("camera member escaped source root")
|
||||
raw = bounded_file(path, wire.MAX_FRAGMENT)
|
||||
if len(raw) != row["length"] or hashlib.sha256(raw).hexdigest() != row["sha256"]:
|
||||
raise ValueError("camera chunk integrity failed")
|
||||
modality, source_id, utc = (
|
||||
"camera-frame",
|
||||
"sensor.camera.right",
|
||||
row["host_epoch_ns"],
|
||||
)
|
||||
elif observation.channel == "points":
|
||||
xyz, intensity = observation.value
|
||||
raw = struct.pack("<I", len(xyz)) + xyz.tobytes() + intensity.tobytes()
|
||||
modality, source_id = "lidar", "normalized-map-point-increments"
|
||||
else:
|
||||
position, quaternion = observation.value
|
||||
raw = position.tobytes() + quaternion.tobytes()
|
||||
modality, source_id = "pose", "normalized-map-from-lidar-pose"
|
||||
yield (
|
||||
LiveIngressEvent(
|
||||
seq,
|
||||
"recorded-acquisition",
|
||||
1,
|
||||
modality,
|
||||
source_id,
|
||||
observation.sequence,
|
||||
utc,
|
||||
observation.time_ns,
|
||||
raw,
|
||||
),
|
||||
due,
|
||||
)
|
||||
if observation.channel == "camera" and observation.sequence == 31:
|
||||
break
|
||||
finally:
|
||||
(root / "source-reads.json").write_text(json.dumps(archive.counters(), indent=2))
|
||||
archive.close()
|
||||
|
||||
|
||||
def producer(args):
|
||||
root = Path(args.output)
|
||||
identity = StreamStart.from_dict(json.loads((root / "start.json").read_text()))
|
||||
connection = socket.socket(fileno=args.fd)
|
||||
if connection.recv(1) != b"R":
|
||||
raise ValueError("runtime did not become ready")
|
||||
with (root / "sent.jsonl").open("w") as journal:
|
||||
if args.case == "recorded":
|
||||
sender = StreamingSender(connection, identity, "recorded-acquisition", 1, lambda: None)
|
||||
for event, due in recorded_events(root):
|
||||
sent = time.monotonic_ns()
|
||||
sender.send(event)
|
||||
emit_json(
|
||||
journal,
|
||||
{
|
||||
**record(event),
|
||||
"due_monotonic_ns": str(due),
|
||||
"send_start_monotonic_ns": str(sent),
|
||||
"send_end_monotonic_ns": str(time.monotonic_ns()),
|
||||
},
|
||||
)
|
||||
end_ns = time.monotonic_ns()
|
||||
sender.end()
|
||||
else:
|
||||
connection.sendall(wire.open_packet(identity, "recorded-acquisition", 1))
|
||||
event = LiveIngressEvent(
|
||||
1,
|
||||
"recorded-acquisition",
|
||||
1,
|
||||
"lidar",
|
||||
"synthetic",
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
b"x" * (wire.MAX_FRAGMENT + 17),
|
||||
)
|
||||
header, payload = next(wire.event_packets(identity, event))
|
||||
connection.sendall(header)
|
||||
connection.sendall(payload)
|
||||
emit_json(journal, record(event))
|
||||
end_ns = time.monotonic_ns()
|
||||
connection.close() # Deliberate EOF between fragments; no End.
|
||||
(root / "source-end.json").write_text(json.dumps({"end_monotonic_ns": str(end_ns)}))
|
||||
# The supervised source child remains alive until owned-group cleanup.
|
||||
# Its normal completion is not mistaken for an unexpected model death.
|
||||
threading.Event().wait(30)
|
||||
|
||||
|
||||
def run_case(base, case, generation):
|
||||
root = base / case
|
||||
root.mkdir()
|
||||
specification = {
|
||||
"case": case,
|
||||
"transport": wire.SCHEMA,
|
||||
"consumer": "raw-digest-only",
|
||||
"camera": "original-fmp4-init-and-media",
|
||||
"points": "u32le-count+xyz-f64le+intensity-u8",
|
||||
"pose": "position-f64le3+map-from-lidar-quaternion-f64le4",
|
||||
"fragment_timeout_ms": 250,
|
||||
"idle_timeout_ms": 2000,
|
||||
}
|
||||
identity = StreamStart(
|
||||
run_id=f"binary-ingress-{case}",
|
||||
source_id="RAVNOVES00-prefix" if case == "recorded" else "synthetic",
|
||||
worker_id="worker-006",
|
||||
epoch_id=f"ipc-{generation}",
|
||||
lease_generation=generation,
|
||||
profile_sha256=hashlib.sha256(
|
||||
bounded_file(base / "candidate-profile.json", 65536)
|
||||
).hexdigest(),
|
||||
image_sha256="664824aa25de1db178f177d67a81b01541a938b479812b9383ddbf03f6b59dbe",
|
||||
effective_config_sha256=hashlib.sha256(wire.canonical(specification)).hexdigest(),
|
||||
# Opaque passthrough doesn't resolve or use calibration. Explicit synthetic
|
||||
# identity is NOT eligible for a real perception scene.
|
||||
calibration_sha256=hashlib.sha256(b"opaque-transport-no-calibration-use").hexdigest(),
|
||||
clock_domain_id="original-host-arrival-clock",
|
||||
input_mode="recorded-source-paced",
|
||||
)
|
||||
(root / "start.json").write_text(json.dumps(identity.to_dict(), indent=2))
|
||||
run = StreamingLifecycle(
|
||||
identity, Path("/tmp/worker-lease"), StreamMailbox(), threading.Event()
|
||||
)
|
||||
heartbeat_stop = threading.Event()
|
||||
|
||||
def renew():
|
||||
while not heartbeat_stop.wait(0.25):
|
||||
run.renew(identity)
|
||||
|
||||
heartbeat = threading.Thread(target=renew, daemon=True)
|
||||
heartbeat.start()
|
||||
left, right = socket.socketpair()
|
||||
receiver = None
|
||||
started = time.monotonic_ns()
|
||||
with (root / "producer.log").open("wb") as log, (root / "received.jsonl").open("w") as journal:
|
||||
try:
|
||||
run.spawn(
|
||||
lambda: subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-B",
|
||||
__file__,
|
||||
"--producer",
|
||||
"--case",
|
||||
case,
|
||||
"--fd",
|
||||
str(left.fileno()),
|
||||
"--output",
|
||||
str(root),
|
||||
],
|
||||
pass_fds=(left.fileno(),),
|
||||
start_new_session=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=log,
|
||||
stderr=log,
|
||||
)
|
||||
)
|
||||
left.close()
|
||||
|
||||
def consume(event):
|
||||
emit_json(
|
||||
journal,
|
||||
{
|
||||
**record(event),
|
||||
"received_monotonic_ns": str(time.monotonic_ns()),
|
||||
"original_received_monotonic_ns": str(event.received_monotonic_ns),
|
||||
},
|
||||
)
|
||||
|
||||
receiver = StreamingIngress(
|
||||
right,
|
||||
run,
|
||||
"recorded-acquisition",
|
||||
1,
|
||||
consume,
|
||||
lambda notice: emit_json(journal, {"notice": notice}),
|
||||
)
|
||||
run.ready()
|
||||
right.sendall(b"R")
|
||||
receiver.start()
|
||||
if not receiver.join(timeout=15):
|
||||
raise TimeoutError("bounded stream probe did not finish")
|
||||
finally:
|
||||
run.request_stop("completed" if receiver and receiver.terminal == "end" else "failed")
|
||||
run.stop_children()
|
||||
left.close()
|
||||
right.close()
|
||||
if receiver:
|
||||
receiver.join()
|
||||
heartbeat_stop.set()
|
||||
heartbeat.join(timeout=1)
|
||||
released = run.close()
|
||||
result = {
|
||||
"created_utc": datetime.now(UTC).isoformat(),
|
||||
"started_monotonic_ns": str(started),
|
||||
"finished_monotonic_ns": str(time.monotonic_ns()),
|
||||
"specification": specification,
|
||||
"receiver": receiver.snapshot(),
|
||||
"lifecycle": run.snapshot(),
|
||||
"released": released,
|
||||
"peak_input_bytes": run.mailbox.peak_bytes,
|
||||
"residual_input_bytes": run.mailbox.bytes,
|
||||
"maximum_rss_kib": resource.getrusage(resource.RUSAGE_SELF).ru_maxrss,
|
||||
"child_maximum_rss_kib": resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss,
|
||||
"cgroup_memory_peak": Path("/sys/fs/cgroup/memory.peak").read_text().strip(),
|
||||
"model_runs": 0,
|
||||
"network_qualified": False,
|
||||
"actuation_allowed": False,
|
||||
}
|
||||
(root / "report.json").write_text(json.dumps(result, indent=2))
|
||||
assert released and run.mailbox.bytes == 0
|
||||
assert receiver.terminal == ("end" if case == "recorded" else "failed")
|
||||
assert receiver.snapshot()["incomplete_observations"] == (0 if case == "recorded" else 1)
|
||||
print(json.dumps(result), flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--producer", action="store_true")
|
||||
parser.add_argument("--fd", type=int)
|
||||
parser.add_argument("--case", choices=("recorded", "truncated"))
|
||||
parser.add_argument("--output", default="/out")
|
||||
args = parser.parse_args()
|
||||
if args.producer:
|
||||
producer(args)
|
||||
else:
|
||||
for generation, case in enumerate(("recorded", "truncated"), 1):
|
||||
run_case(Path(args.output), case, generation)
|
||||
@@ -0,0 +1,293 @@
|
||||
"""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.
|
||||
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;
|
||||
they do not create another buffering queue.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import select
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
|
||||
from k1link.compute.live_perception import LiveIngressEvent
|
||||
|
||||
from . import streaming_wire as wire
|
||||
from .streaming_lifecycle import StreamingLifecycle
|
||||
|
||||
|
||||
class StreamingIngress:
|
||||
def __init__(
|
||||
self,
|
||||
connection: socket.socket,
|
||||
runtime: StreamingLifecycle,
|
||||
session_id: str,
|
||||
session_generation: int,
|
||||
consume: Callable[[LiveIngressEvent], None],
|
||||
notice: Callable[[dict[str, Any]], None],
|
||||
*,
|
||||
fragment_timeout: float = 0.25,
|
||||
idle_timeout: float = 2.0,
|
||||
) -> None:
|
||||
if not 0 < fragment_timeout <= 2 or not 0 < idle_timeout <= 30:
|
||||
raise ValueError("invalid bounded ingress timeouts")
|
||||
wire.identifier(session_id)
|
||||
wire.decimal(session_generation)
|
||||
if session_generation < 1:
|
||||
raise ValueError("invalid acquisition generation")
|
||||
self.connection, self.runtime = connection, runtime
|
||||
connection.setblocking(False)
|
||||
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.opened = False
|
||||
self.error: str | None = None
|
||||
self.terminal: str | None = None
|
||||
self.counts: Counter[str] = Counter()
|
||||
self.last_ingress_sequence = 0
|
||||
self.channel_sequences: dict[str, tuple[str, int, int]] = {}
|
||||
self.camera_initialized = False
|
||||
self.thread = threading.Thread(
|
||||
target=self._serve, name="perception-binary-ingress", daemon=True
|
||||
)
|
||||
runtime.track_thread(self.thread)
|
||||
|
||||
def start(self) -> None:
|
||||
self.runtime.check_current(self.runtime.start)
|
||||
self.thread.start()
|
||||
|
||||
def join(self, timeout: float = 1.0) -> bool:
|
||||
self.thread.join(timeout=timeout)
|
||||
return not self.thread.is_alive()
|
||||
|
||||
def _check(self, deadline: float) -> None:
|
||||
self.runtime.check_current(self.runtime.start)
|
||||
if time.monotonic() >= deadline:
|
||||
raise wire.StreamWireError("ingress deadline exceeded")
|
||||
|
||||
def _read_into(self, target: memoryview, deadline: float) -> None:
|
||||
offset = 0
|
||||
while offset < len(target):
|
||||
self._check(deadline)
|
||||
if not select.select(
|
||||
[self.connection], [], [], min(0.05, max(0, deadline - time.monotonic()))
|
||||
)[0]:
|
||||
continue
|
||||
try:
|
||||
read = self.connection.recv_into(target[offset:])
|
||||
except BlockingIOError:
|
||||
continue
|
||||
if read == 0:
|
||||
raise wire.StreamWireError("unexpected EOF without explicit End")
|
||||
self.counts["wire_bytes"] += read
|
||||
offset += read
|
||||
self._check(deadline)
|
||||
|
||||
def _header(self, deadline: float) -> tuple[int, dict[str, Any]]:
|
||||
prefix = bytearray(wire.PREFIX.size)
|
||||
self._read_into(memoryview(prefix), deadline)
|
||||
magic, kind, size = wire.PREFIX.unpack(prefix)
|
||||
if (
|
||||
magic != wire.MAGIC
|
||||
or kind not in (wire.OPEN, wire.FRAGMENT, wire.END, wire.CANCEL, wire.GAP)
|
||||
or not 0 < size <= wire.MAX_HEADER
|
||||
):
|
||||
raise wire.StreamWireError("invalid frame prefix or header size")
|
||||
raw = bytearray(size)
|
||||
self._read_into(memoryview(raw), deadline)
|
||||
return kind, wire.parse_header(raw)
|
||||
|
||||
def _bound(self, header: dict[str, Any]) -> None:
|
||||
if header.get("binding") != self.binding:
|
||||
raise wire.StreamWireError("frame belongs to another StreamStart")
|
||||
self.runtime.check_current(self.runtime.start)
|
||||
|
||||
def _fragment(self, header: dict[str, Any]) -> tuple[dict[str, Any], int, int, int]:
|
||||
if set(header) != {
|
||||
"binding",
|
||||
"event",
|
||||
"total_bytes",
|
||||
"offset",
|
||||
"fragment_bytes",
|
||||
"payload_sha256",
|
||||
"fragment_sha256",
|
||||
}:
|
||||
raise wire.StreamWireError("fragment fields changed")
|
||||
self._bound(header)
|
||||
event = wire.validate_event_metadata(header["event"])
|
||||
if (
|
||||
event["session_id"] != self.session_id
|
||||
or event["session_generation"] != self.session_generation
|
||||
):
|
||||
raise wire.StreamWireError("event acquisition binding changed")
|
||||
total, offset, size = (header[x] for x in ("total_bytes", "offset", "fragment_bytes"))
|
||||
if any(type(x) is not int for x in (total, offset, size)) or not (
|
||||
0 < total <= wire.PAYLOAD_LIMITS[event["modality"]]
|
||||
and 0 <= offset < total
|
||||
and 0 < size <= wire.MAX_FRAGMENT
|
||||
and size == min(wire.MAX_FRAGMENT, total - offset)
|
||||
):
|
||||
raise wire.StreamWireError("invalid fragment length or offset")
|
||||
wire.digest(header["payload_sha256"])
|
||||
wire.digest(header["fragment_sha256"])
|
||||
return event, total, offset, size
|
||||
|
||||
def _observation(self, first: dict[str, Any], deadline: float) -> None:
|
||||
event, total, offset, size = self._fragment(first)
|
||||
modality = event["modality"]
|
||||
if offset != 0 or event["ingress_sequence"] <= self.last_ingress_sequence:
|
||||
raise wire.StreamWireError("duplicate, out-of-order or incomplete observation")
|
||||
previous = self.channel_sequences.get(modality)
|
||||
if previous and (
|
||||
event["source_id"] != previous[0]
|
||||
or event["source_sequence"] <= previous[1]
|
||||
or event["received_monotonic_ns"] < previous[2]
|
||||
):
|
||||
raise wire.StreamWireError("channel identity, sequence or source clock regressed")
|
||||
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:
|
||||
raise wire.StreamWireError("camera sequence gap requires new codec initialization")
|
||||
self.counts["observations_started"] += 1
|
||||
reservation = self.runtime.mailbox.reserve_ingress(2 * total)
|
||||
raw = None
|
||||
admitted = None
|
||||
try:
|
||||
raw = bytearray(total)
|
||||
header = first
|
||||
while True:
|
||||
chunk = memoryview(raw)[offset : offset + size]
|
||||
try:
|
||||
self._read_into(chunk, deadline)
|
||||
if sha256(chunk).hexdigest() != header["fragment_sha256"]:
|
||||
raise wire.StreamWireError("fragment integrity mismatch")
|
||||
finally:
|
||||
chunk.release()
|
||||
self.counts["fragments"] += 1
|
||||
offset += size
|
||||
if offset == total:
|
||||
break
|
||||
kind, header = self._header(deadline)
|
||||
if kind != wire.FRAGMENT:
|
||||
raise wire.StreamWireError("incomplete observation before control event")
|
||||
next_event, next_total, next_offset, size = self._fragment(header)
|
||||
if (
|
||||
next_event != event
|
||||
or next_total != total
|
||||
or next_offset != offset
|
||||
or header["payload_sha256"] != first["payload_sha256"]
|
||||
):
|
||||
raise wire.StreamWireError("interleaved or discontinuous fragments")
|
||||
if sha256(raw).hexdigest() != first["payload_sha256"]:
|
||||
raise wire.StreamWireError("observation integrity mismatch")
|
||||
self._check(deadline)
|
||||
admitted = LiveIngressEvent(**event, payload=bytes(raw))
|
||||
raw = None # Immutable callback bytes remain fully reserved.
|
||||
self.consume(admitted)
|
||||
self._check(deadline)
|
||||
self.counts["ingress_sequence_gaps"] += (
|
||||
event["ingress_sequence"] - self.last_ingress_sequence - 1
|
||||
)
|
||||
self.last_ingress_sequence = event["ingress_sequence"]
|
||||
self.channel_sequences[modality] = (
|
||||
event["source_id"],
|
||||
event["source_sequence"],
|
||||
event["received_monotonic_ns"],
|
||||
)
|
||||
if modality == "camera-init":
|
||||
self.camera_initialized = True
|
||||
self.channel_sequences.pop("camera-frame", None)
|
||||
self.counts["observations_completed"] += 1
|
||||
finally:
|
||||
admitted = raw = None
|
||||
reservation.release()
|
||||
|
||||
def _gap(self, header: dict[str, Any]) -> None:
|
||||
if set(header) != {"binding", "modality", "reason", "count"}:
|
||||
raise wire.StreamWireError("gap fields changed")
|
||||
if header["modality"] not in wire.PAYLOAD_LIMITS or header["reason"] not in (
|
||||
"unavailable",
|
||||
"source-gap",
|
||||
"overload",
|
||||
):
|
||||
raise wire.StreamWireError("invalid gap notice")
|
||||
self.counts["declared_gap_observations"] += wire.uint64(header["count"])
|
||||
if header["modality"].startswith("camera"):
|
||||
self.camera_initialized = False
|
||||
self.notice(dict(header))
|
||||
self.runtime.check_current(self.runtime.start)
|
||||
self.counts["gap_notices"] += 1
|
||||
|
||||
def _serve(self) -> None:
|
||||
reservation = None
|
||||
try:
|
||||
# Covers raw header + JSON decoding copies; Python object overhead
|
||||
# belongs to the separately measured RSS envelope, not payload bytes.
|
||||
reservation = self.runtime.mailbox.reserve_ingress(
|
||||
3 * wire.MAX_HEADER + wire.PREFIX.size
|
||||
)
|
||||
kind, header = self._header(time.monotonic() + self.idle_timeout)
|
||||
expected = wire.parse_header(
|
||||
bytearray(
|
||||
wire.open_packet(
|
||||
self.runtime.start,
|
||||
self.session_id,
|
||||
self.session_generation,
|
||||
)[wire.PREFIX.size :]
|
||||
)
|
||||
)
|
||||
if kind != wire.OPEN or header != expected:
|
||||
raise wire.StreamWireError("StreamStart or acquisition handshake mismatch")
|
||||
self.opened = True
|
||||
while True:
|
||||
kind, header = self._header(time.monotonic() + self.idle_timeout)
|
||||
self._bound(header)
|
||||
if kind == wire.FRAGMENT:
|
||||
self._observation(header, time.monotonic() + self.fragment_timeout)
|
||||
elif kind == wire.GAP:
|
||||
self._gap(header)
|
||||
elif kind in (wire.END, wire.CANCEL) and set(header) == {"binding"}:
|
||||
self.terminal = "cancelled" if kind == wire.CANCEL else "end"
|
||||
if kind == wire.CANCEL:
|
||||
self.runtime.request_stop("cancelled")
|
||||
else:
|
||||
self.runtime.mailbox.finish()
|
||||
return
|
||||
else:
|
||||
raise wire.StreamWireError("unexpected stream control event")
|
||||
except Exception as exc:
|
||||
self.error = str(exc)
|
||||
self.terminal = "failed"
|
||||
if self.opened:
|
||||
self.runtime.mailbox.finish(self.error)
|
||||
self.runtime.request_stop("failed")
|
||||
finally:
|
||||
self.connection.close()
|
||||
if reservation is not None:
|
||||
reservation.release()
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": wire.SCHEMA,
|
||||
"opened": self.opened,
|
||||
"terminal": self.terminal,
|
||||
"error": self.error,
|
||||
"counts": dict(self.counts),
|
||||
"incomplete_observations": self.counts["observations_started"]
|
||||
- self.counts["observations_completed"],
|
||||
"source_duration_known": False,
|
||||
"requires_eof_before_delivery": False,
|
||||
"transport": "controller-supplied-ipc-socket",
|
||||
"network_qualified": False,
|
||||
"actuation_allowed": False,
|
||||
}
|
||||
@@ -17,6 +17,21 @@ StreamBundle = Mapping[str, Any]
|
||||
DISCARD_REASONS = frozenset(("cancelled", "gpu-failed", "processing-failed"))
|
||||
|
||||
|
||||
class IngressReservation:
|
||||
"""Raw/reassembly bytes retained outside the decoded work queue.
|
||||
|
||||
Trusted adapters reserve BEFORE allocation and release AFTER borrowed raw
|
||||
buffers/callbacks are gone. Closing ingress never frees a live reservation.
|
||||
This is byte ownership, not another pending camera slot.
|
||||
"""
|
||||
|
||||
def __init__(self, mailbox: StreamMailbox, size: int) -> None:
|
||||
self._mailbox, self.size = mailbox, size
|
||||
|
||||
def release(self) -> None:
|
||||
self._mailbox._release_ingress(self)
|
||||
|
||||
|
||||
class StreamMailbox:
|
||||
def __init__(self, capacity: int = 2, byte_limit: int = 16 * 1024 * 1024) -> None:
|
||||
if type(capacity) is not int or not 1 <= capacity <= 2:
|
||||
@@ -31,6 +46,7 @@ class StreamMailbox:
|
||||
self._recent_drops: deque[dict[str, Any]] = deque(maxlen=256)
|
||||
self._owned: dict[int, tuple[StreamBundle, int]] = {}
|
||||
self._active: set[int] = set()
|
||||
self._ingress: dict[IngressReservation, int] = {}
|
||||
self._last_sequence = -1
|
||||
self.done = False
|
||||
self.error: str | None = None
|
||||
@@ -49,7 +65,32 @@ class StreamMailbox:
|
||||
@property
|
||||
def quiescent(self) -> bool:
|
||||
with self.condition:
|
||||
return self.done and not self._owned and not self.external_pending
|
||||
return self.done and not self._owned and not self.external_pending and not self._ingress
|
||||
|
||||
def reserve_ingress(self, size: int) -> IngressReservation:
|
||||
"""Charge raw buffers to the SAME budget as queued and active inputs.
|
||||
|
||||
Fail closed on exhaustion; do not wait and silently stretch source time.
|
||||
Decoder scratch must also be reserved before allocation; retained decoded
|
||||
work uses the existing bundle ownership. No adapter may hide a buffer.
|
||||
"""
|
||||
if type(size) is not int or size <= 0:
|
||||
raise ValueError("invalid ingress reservation")
|
||||
with self.condition:
|
||||
if self.done or self.bytes + size > self.byte_limit or len(self._ingress) >= 8:
|
||||
raise ValueError("ingress byte budget exhausted or closed")
|
||||
reservation = IngressReservation(self, size)
|
||||
self._ingress[reservation] = size
|
||||
self.bytes += size
|
||||
self.peak_bytes = max(self.peak_bytes, self.bytes)
|
||||
return reservation
|
||||
|
||||
def _release_ingress(self, reservation: IngressReservation) -> None:
|
||||
with self.condition:
|
||||
if reservation not in self._ingress:
|
||||
raise ValueError("ingress release ownership mismatch")
|
||||
self.bytes -= self._ingress.pop(reservation)
|
||||
self.condition.notify_all()
|
||||
|
||||
def _drop(self, bundle: StreamBundle, reason: str) -> None:
|
||||
self.drop_counts[reason] += 1
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Synchronous, deadline-bounded IPC writer for existing raw ingress events.
|
||||
|
||||
The caller owns pacing and already-committed source bytes. No background queue,
|
||||
recording inventory or retry is created; timeout is an explicit failed source
|
||||
outcome. A new connection requires a new controller-approved StreamStart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import select
|
||||
import socket
|
||||
import time
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
from k1link.compute.live_perception import LiveIngressEvent
|
||||
|
||||
from . import streaming_wire as wire
|
||||
from .realtime_contract import StreamStart
|
||||
|
||||
|
||||
class StreamingSender:
|
||||
def __init__(
|
||||
self,
|
||||
connection: socket.socket,
|
||||
start: StreamStart,
|
||||
session_id: str,
|
||||
session_generation: int,
|
||||
check_current: Callable[[], None],
|
||||
*,
|
||||
timeout: float = 0.25,
|
||||
) -> None:
|
||||
if not 0 < timeout <= 2:
|
||||
raise ValueError("invalid bounded send timeout")
|
||||
self.connection, self.identity, self.check_current = connection, start, check_current
|
||||
self.session_id, self.session_generation = session_id, session_generation
|
||||
self.timeout, self.closed = timeout, False
|
||||
connection.setblocking(False)
|
||||
try:
|
||||
self._send((wire.open_packet(start, session_id, session_generation),))
|
||||
except Exception:
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def _send(self, pieces: Iterable[bytes | memoryview]) -> None:
|
||||
if self.closed:
|
||||
raise wire.StreamWireError("sender is closed")
|
||||
deadline = time.monotonic() + self.timeout
|
||||
try:
|
||||
for piece in pieces:
|
||||
view = memoryview(piece)
|
||||
offset = 0
|
||||
while offset < len(view):
|
||||
self.check_current()
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise wire.StreamWireError("send deadline exceeded")
|
||||
if not select.select([], [self.connection], [], min(0.05, remaining))[1]:
|
||||
continue
|
||||
try:
|
||||
count = self.connection.send(view[offset:])
|
||||
except BlockingIOError:
|
||||
continue
|
||||
if count == 0:
|
||||
raise wire.StreamWireError("source connection closed")
|
||||
offset += count
|
||||
self.check_current()
|
||||
if time.monotonic() >= deadline:
|
||||
raise wire.StreamWireError("send deadline exceeded")
|
||||
except Exception:
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def send(self, event: LiveIngressEvent) -> None:
|
||||
if (
|
||||
event.session_id != self.session_id
|
||||
or event.session_generation != self.session_generation
|
||||
):
|
||||
self.close()
|
||||
raise wire.StreamWireError("source acquisition binding changed")
|
||||
self._send(piece for pair in wire.event_packets(self.identity, event) for piece in pair)
|
||||
|
||||
def gap(self, *, modality: str, reason: str, count: int) -> None:
|
||||
self._send((wire.gap_packet(self.identity, modality=modality, reason=reason, count=count),))
|
||||
|
||||
def end(self, *, cancel: bool = False) -> None:
|
||||
self._send((wire.terminal_packet(self.identity, cancel=cancel),))
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
self.connection.close()
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Bounded v2 mapping of existing raw-first LiveIngressEvent semantics.
|
||||
|
||||
This IPC candidate carries binary payloads, not base64/PNG/archive uploads.
|
||||
It is not the selected/authenticated network transport. A trusted controller
|
||||
binds one acquisition session to StreamStart; hashes provide integrity, NOT
|
||||
authentication. No peer-supplied paths, commands, models or calibration loading.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
from collections.abc import Iterator
|
||||
from hashlib import sha256
|
||||
from typing import Any, Final
|
||||
|
||||
from k1link.compute.live_perception import LiveIngressEvent
|
||||
|
||||
from .realtime_contract import StreamStart
|
||||
|
||||
SCHEMA: Final = "missioncore.live-perception-wire/v2"
|
||||
MAGIC: Final = b"MCI2"
|
||||
PREFIX: Final = struct.Struct("!4sBI")
|
||||
OPEN, FRAGMENT, END, CANCEL, GAP = range(1, 6)
|
||||
MAX_HEADER: Final = 64 * 1024
|
||||
MAX_FRAGMENT: Final = 1024 * 1024
|
||||
PAYLOAD_LIMITS: Final = {
|
||||
"camera-init": MAX_FRAGMENT,
|
||||
"camera-frame": MAX_FRAGMENT,
|
||||
"lidar": 2 * MAX_FRAGMENT,
|
||||
"pose": 2 * MAX_FRAGMENT,
|
||||
}
|
||||
EVENT_FIELDS: Final = frozenset(
|
||||
(
|
||||
"ingress_sequence",
|
||||
"session_id",
|
||||
"session_generation",
|
||||
"modality",
|
||||
"source_id",
|
||||
"source_sequence",
|
||||
"captured_at_epoch_ns",
|
||||
"received_monotonic_ns",
|
||||
)
|
||||
)
|
||||
INTEGER_FIELDS: Final = EVENT_FIELDS - {"session_id", "modality", "source_id"}
|
||||
_DECIMAL: Final = re.compile(r"^(0|[1-9][0-9]{0,19})$")
|
||||
_DIGEST: Final = re.compile(r"^[a-f0-9]{64}$")
|
||||
|
||||
|
||||
class StreamWireError(ValueError):
|
||||
"""Malformed, stale, oversized or incomplete transport input."""
|
||||
|
||||
|
||||
def uint64(value: object) -> int:
|
||||
if not isinstance(value, str) or not _DECIMAL.fullmatch(value):
|
||||
raise StreamWireError("wire uint64 must be a canonical decimal string")
|
||||
result = int(value)
|
||||
if result > (1 << 64) - 1:
|
||||
raise StreamWireError("wire uint64 overflow")
|
||||
return result
|
||||
|
||||
|
||||
def decimal(value: object) -> str:
|
||||
if type(value) is not int or not 0 <= value < 1 << 64:
|
||||
raise StreamWireError("invalid uint64")
|
||||
return str(value)
|
||||
|
||||
|
||||
def identifier(value: object) -> str:
|
||||
if not isinstance(value, str) or not 1 <= len(value) <= 160:
|
||||
raise StreamWireError("invalid bounded source identity")
|
||||
if not value.isascii() or any(ord(c) < 33 or ord(c) > 126 for c in value):
|
||||
raise StreamWireError("invalid bounded source identity")
|
||||
return value
|
||||
|
||||
|
||||
def digest(value: object) -> str:
|
||||
if not isinstance(value, str) or not _DIGEST.fullmatch(value):
|
||||
raise StreamWireError("invalid payload digest")
|
||||
return value
|
||||
|
||||
|
||||
def canonical(value: dict[str, Any]) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
|
||||
|
||||
|
||||
def start_document(start: StreamStart) -> dict[str, Any]:
|
||||
result = start.to_dict()
|
||||
result["lease_generation"] = decimal(start.lease_generation)
|
||||
return result
|
||||
|
||||
|
||||
def binding(start: StreamStart) -> str:
|
||||
return sha256(canonical(start_document(start))).hexdigest()
|
||||
|
||||
|
||||
def _unique_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise StreamWireError("duplicate metadata field")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def parse_header(raw: bytearray) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(raw, object_pairs_hook=_unique_pairs)
|
||||
except (ValueError, UnicodeError, RecursionError) as exc:
|
||||
raise StreamWireError("invalid bounded metadata") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise StreamWireError("metadata must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def packet(kind: int, header: dict[str, Any]) -> bytes:
|
||||
raw = canonical(header)
|
||||
if kind not in (OPEN, FRAGMENT, END, CANCEL, GAP) or not 0 < len(raw) <= MAX_HEADER:
|
||||
raise StreamWireError("invalid frame header")
|
||||
return PREFIX.pack(MAGIC, kind, len(raw)) + raw
|
||||
|
||||
|
||||
def open_packet(start: StreamStart, session_id: str, session_generation: int) -> bytes:
|
||||
return packet(
|
||||
OPEN,
|
||||
{
|
||||
"schema_version": SCHEMA,
|
||||
"start": start_document(start),
|
||||
"session_id": identifier(session_id),
|
||||
"session_generation": decimal(session_generation),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def event_metadata(event: LiveIngressEvent) -> dict[str, Any]:
|
||||
result = {name: getattr(event, name) for name in EVENT_FIELDS}
|
||||
for name in INTEGER_FIELDS:
|
||||
result[name] = decimal(result[name])
|
||||
validate_event_metadata(result)
|
||||
return result
|
||||
|
||||
|
||||
def validate_event_metadata(value: object) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or set(value) != EVENT_FIELDS:
|
||||
raise StreamWireError("event fields changed")
|
||||
result = dict(value)
|
||||
for name in INTEGER_FIELDS:
|
||||
result[name] = uint64(value[name])
|
||||
for name in ("session_id", "source_id"):
|
||||
identifier(value[name])
|
||||
if (
|
||||
not isinstance(value["modality"], str)
|
||||
or value["modality"] not in PAYLOAD_LIMITS
|
||||
or result["ingress_sequence"] < 1
|
||||
or result["session_generation"] < 1
|
||||
):
|
||||
raise StreamWireError("invalid raw modality or generation")
|
||||
return result
|
||||
|
||||
|
||||
def event_packets(
|
||||
start: StreamStart,
|
||||
event: LiveIngressEvent,
|
||||
) -> Iterator[tuple[bytes, memoryview]]:
|
||||
"""Iterate one already committed raw event; never assemble a route/archive.
|
||||
|
||||
Source adapter owns the original event bytes. Returned views borrow them;
|
||||
sender must finish each fragment before advancing this iterator.
|
||||
"""
|
||||
metadata = event_metadata(event)
|
||||
total = len(event.payload)
|
||||
if not 0 < total <= PAYLOAD_LIMITS[event.modality]:
|
||||
raise StreamWireError("raw event exceeds modality bound")
|
||||
whole_hash = sha256(event.payload).hexdigest()
|
||||
view = memoryview(event.payload)
|
||||
for offset in range(0, total, MAX_FRAGMENT):
|
||||
chunk = view[offset : offset + MAX_FRAGMENT]
|
||||
yield (
|
||||
packet(
|
||||
FRAGMENT,
|
||||
{
|
||||
"binding": binding(start),
|
||||
"event": metadata,
|
||||
"total_bytes": total,
|
||||
"offset": offset,
|
||||
"fragment_bytes": len(chunk),
|
||||
"payload_sha256": whole_hash,
|
||||
"fragment_sha256": sha256(chunk).hexdigest(),
|
||||
},
|
||||
),
|
||||
chunk,
|
||||
)
|
||||
|
||||
|
||||
def terminal_packet(start: StreamStart, *, cancel: bool = False) -> bytes:
|
||||
return packet(CANCEL if cancel else END, {"binding": binding(start)})
|
||||
|
||||
|
||||
def gap_packet(start: StreamStart, *, modality: str, reason: str, count: int) -> bytes:
|
||||
if modality not in PAYLOAD_LIMITS or reason not in ("unavailable", "source-gap", "overload"):
|
||||
raise StreamWireError("invalid gap notice")
|
||||
return packet(
|
||||
GAP,
|
||||
{
|
||||
"binding": binding(start),
|
||||
"modality": modality,
|
||||
"reason": reason,
|
||||
"count": decimal(count),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Bounded synthetic IPC fixtures, no model or local load tests."""
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
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_ingress import StreamingIngress
|
||||
from k1link.perception.streaming_lifecycle import StreamingLifecycle
|
||||
from k1link.perception.streaming_queue import StreamMailbox
|
||||
from k1link.perception.streaming_sender import StreamingSender
|
||||
|
||||
|
||||
def start():
|
||||
return StreamStart(
|
||||
run_id="test-run",
|
||||
source_id="recorded-or-live",
|
||||
worker_id="worker-006",
|
||||
epoch_id="epoch-1",
|
||||
lease_generation=1,
|
||||
profile_sha256="a" * 64,
|
||||
image_sha256="b" * 64,
|
||||
effective_config_sha256="c" * 64,
|
||||
calibration_sha256="d" * 64,
|
||||
clock_domain_id="original-host-clock",
|
||||
input_mode="live",
|
||||
)
|
||||
|
||||
|
||||
def event(seq=1, *, modality="lidar", payload=b"points", source_sequence=None):
|
||||
return LiveIngressEvent(
|
||||
ingress_sequence=seq,
|
||||
session_id="capture-1",
|
||||
session_generation=1,
|
||||
modality=modality,
|
||||
source_id=f"raw/{modality}",
|
||||
source_sequence=seq if source_sequence is None else source_sequence,
|
||||
captured_at_epoch_ns=1_799_999_999_123_456_789,
|
||||
received_monotonic_ns=9_007_199_254_740_993 + seq,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def send_event(sock, observation, *, identity=None):
|
||||
for header, payload in wire.event_packets(identity or start(), observation):
|
||||
sock.sendall(header)
|
||||
sock.sendall(payload)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def harness(tmp_path, *, consume=None, byte_limit=16 * 1024 * 1024, clock=None, **kwargs):
|
||||
mailbox = StreamMailbox(byte_limit=byte_limit)
|
||||
run = StreamingLifecycle(
|
||||
start(),
|
||||
tmp_path,
|
||||
mailbox,
|
||||
threading.Event(),
|
||||
**({"clock_ns": clock} if clock else {}),
|
||||
)
|
||||
left, right = socket.socketpair()
|
||||
left.settimeout(1)
|
||||
received, notices = [], []
|
||||
run.ready()
|
||||
receiver = StreamingIngress(
|
||||
right, run, "capture-1", 1, consume or received.append, notices.append, **kwargs
|
||||
)
|
||||
receiver.start()
|
||||
try:
|
||||
yield left, receiver, run, received, notices
|
||||
finally:
|
||||
left.close()
|
||||
run.request_stop("cancelled")
|
||||
assert receiver.join()
|
||||
assert run.close()
|
||||
|
||||
|
||||
def hello(sock):
|
||||
sock.sendall(wire.open_packet(start(), "capture-1", 1))
|
||||
|
||||
|
||||
def test_unknown_duration_delivers_before_end_and_preserves_every_raw_byte(tmp_path):
|
||||
delivered = threading.Event()
|
||||
seen = []
|
||||
|
||||
def consume(value):
|
||||
seen.append(value)
|
||||
delivered.set()
|
||||
|
||||
with harness(tmp_path, consume=consume) as (sock, receiver, run, _, _):
|
||||
hello(sock)
|
||||
original = event(payload=b"x" * (wire.MAX_FRAGMENT + 19))
|
||||
send_event(sock, original)
|
||||
assert delivered.wait(1)
|
||||
assert receiver.thread.is_alive() and not run.mailbox.done
|
||||
assert seen == [original] # Includes int64 values above JS exact-number range.
|
||||
sock.sendall(wire.terminal_packet(start()))
|
||||
assert receiver.join()
|
||||
assert receiver.terminal == "end" and receiver.error is None
|
||||
assert receiver.counts["fragments"] == 2
|
||||
assert receiver.snapshot()["incomplete_observations"] == 0
|
||||
assert run.mailbox.bytes == 0
|
||||
assert (
|
||||
run.mailbox.peak_bytes
|
||||
<= 2 * len(original.payload) + 3 * wire.MAX_HEADER + wire.PREFIX.size
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [0, 1, (1 << 53) + 1, (1 << 64) - 1])
|
||||
def test_uint64_roundtrip(value):
|
||||
assert wire.uint64(wire.decimal(value)) == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [1, True, "-1", "01", "1.0", str(1 << 64), "1e6"])
|
||||
def test_uint64_rejects_lossy_or_ambiguous_projection(value):
|
||||
with pytest.raises(wire.StreamWireError):
|
||||
wire.uint64(value)
|
||||
|
||||
|
||||
def test_duplicate_json_fields_and_unbounded_headers_fail_before_body(tmp_path):
|
||||
with pytest.raises(wire.StreamWireError):
|
||||
wire.parse_header(bytearray(b'{"binding":"a","binding":"b"}'))
|
||||
with harness(tmp_path) as (sock, receiver, run, _, _):
|
||||
sock.sendall(wire.PREFIX.pack(wire.MAGIC, wire.OPEN, wire.MAX_HEADER + 1))
|
||||
assert receiver.join()
|
||||
assert "header size" in receiver.error
|
||||
assert run.state == GraphState.RUNNING # Unbound peer cannot stop owner.
|
||||
assert run.mailbox.bytes == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value", [("epoch_id", "old"), ("lease_generation", 2), ("image_sha256", "f" * 64)]
|
||||
)
|
||||
def test_wrong_handshake_does_not_cancel_current_owner(tmp_path, field, value):
|
||||
with harness(tmp_path) as (sock, receiver, run, _, _):
|
||||
sock.sendall(wire.open_packet(replace(start(), **{field: value}), "capture-1", 1))
|
||||
assert receiver.join()
|
||||
assert not receiver.opened and run.state == GraphState.RUNNING
|
||||
run.check_current(start())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutation,expected",
|
||||
[
|
||||
(lambda h: h.update(binding="e" * 64), "another StreamStart"),
|
||||
(lambda h: h.update(total_bytes=2 * wire.MAX_FRAGMENT + 1), "length"),
|
||||
(lambda h: h.update(fragment_bytes=True), "length"),
|
||||
(lambda h: h.update(offset=1), "length"),
|
||||
(lambda h: h.update(payload_sha256="e" * 64), "observation integrity"),
|
||||
(lambda h: h.update(fragment_sha256="e" * 64), "fragment integrity"),
|
||||
(lambda h: h["event"].update(session_generation="2"), "acquisition"),
|
||||
(lambda h: h["event"].update(received_monotonic_ns=9007199254740993), "decimal string"),
|
||||
],
|
||||
)
|
||||
def test_invalid_fragments_fail_closed_without_delivery(tmp_path, mutation, expected):
|
||||
with harness(tmp_path) as (sock, receiver, run, received, _):
|
||||
hello(sock)
|
||||
header, payload = next(wire.event_packets(start(), event()))
|
||||
value = json.loads(header[wire.PREFIX.size :])
|
||||
mutation(value)
|
||||
sock.sendall(wire.packet(wire.FRAGMENT, value) + payload)
|
||||
assert receiver.join()
|
||||
assert expected in receiver.error and not received
|
||||
assert run.state == GraphState.STOPPING and run.mailbox.bytes == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", ["truncated", "interleaved", "end", "timeout"])
|
||||
def test_incomplete_reassembly_has_terminal_accounting_and_releases_bytes(tmp_path, case):
|
||||
with harness(tmp_path, fragment_timeout=0.05) as (sock, receiver, run, received, _):
|
||||
hello(sock)
|
||||
pieces = list(wire.event_packets(start(), event(payload=b"x" * (wire.MAX_FRAGMENT + 1))))
|
||||
sock.sendall(pieces[0][0])
|
||||
sock.sendall(pieces[0][1])
|
||||
if case == "truncated":
|
||||
sock.shutdown(socket.SHUT_WR)
|
||||
elif case == "interleaved":
|
||||
send_event(sock, event(seq=2))
|
||||
elif case == "end":
|
||||
sock.sendall(wire.terminal_packet(start()))
|
||||
assert receiver.join()
|
||||
assert receiver.terminal == "failed" and not received
|
||||
assert receiver.snapshot()["incomplete_observations"] == 1
|
||||
assert run.mailbox.bytes == 0 and run.mailbox.quiescent
|
||||
|
||||
|
||||
def test_raw_and_decoded_work_share_one_budget(tmp_path):
|
||||
# Small bound proves rejection without creating large local pressure.
|
||||
with harness(tmp_path, byte_limit=3 * wire.MAX_HEADER + 100) as (
|
||||
sock,
|
||||
receiver,
|
||||
run,
|
||||
received,
|
||||
_,
|
||||
):
|
||||
active = {"sequence": 1, "payload_bytes": 85}
|
||||
assert run.admit(start(), active)
|
||||
assert run.mailbox.take() is active
|
||||
hello(sock)
|
||||
send_event(sock, event()) # Needs 12 extra bytes, only 6 remain.
|
||||
assert receiver.join()
|
||||
assert "byte budget" in receiver.error and not received
|
||||
assert run.mailbox.bytes == 85 and not run.mailbox.quiescent
|
||||
run.mailbox.release(active)
|
||||
|
||||
|
||||
def test_stop_cannot_release_borrowed_payload_until_callback_exits(tmp_path):
|
||||
entered, leave = threading.Event(), threading.Event()
|
||||
|
||||
def consume(_value):
|
||||
entered.set()
|
||||
assert leave.wait(2)
|
||||
|
||||
with harness(tmp_path, consume=consume) as (sock, receiver, run, _, _):
|
||||
hello(sock)
|
||||
send_event(sock, event())
|
||||
assert entered.wait(1)
|
||||
try:
|
||||
assert not run.close("cancelled")
|
||||
assert run.mailbox.bytes > 0 and not run.lease.released
|
||||
finally:
|
||||
leave.set()
|
||||
assert receiver.join() and run.mailbox.bytes == 0
|
||||
assert run.close() and run.lease.released
|
||||
|
||||
|
||||
def test_lease_expiry_wakes_idle_receiver_without_new_bytes(tmp_path):
|
||||
now = [100]
|
||||
with harness(tmp_path, clock=lambda: now[0]) as (sock, receiver, run, _, _):
|
||||
hello(sock)
|
||||
now[0] += 3_000_000_000
|
||||
assert receiver.join()
|
||||
assert run.stop_event.is_set() and run.reason == "lease-lost"
|
||||
assert run.mailbox.bytes == 0
|
||||
|
||||
|
||||
def test_gaps_reset_camera_codec_and_cancel_is_not_successful_end(tmp_path):
|
||||
with harness(tmp_path) as (sock, receiver, run, received, notices):
|
||||
hello(sock)
|
||||
send_event(sock, event(1, modality="camera-init"))
|
||||
send_event(sock, event(2, modality="camera-frame"))
|
||||
sock.sendall(
|
||||
wire.gap_packet(start(), modality="camera-frame", reason="source-gap", count=1)
|
||||
)
|
||||
send_event(sock, event(4, modality="camera-init"))
|
||||
send_event(sock, event(5, modality="camera-frame"))
|
||||
sock.sendall(wire.terminal_packet(start(), cancel=True))
|
||||
assert receiver.join()
|
||||
assert receiver.terminal == "cancelled" and len(received) == 4
|
||||
assert len(notices) == 1 and receiver.counts["declared_gap_observations"] == 1
|
||||
assert receiver.counts["ingress_sequence_gaps"] == 1
|
||||
assert run.reason == "cancelled"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", ["no-init", "duplicate", "camera-gap", "clock-regression"])
|
||||
def test_source_order_and_codec_prerequisites_are_enforced(tmp_path, case):
|
||||
with harness(tmp_path) as (sock, receiver, _, received, _):
|
||||
hello(sock)
|
||||
if case != "no-init":
|
||||
send_event(sock, event(1, modality="camera-init"))
|
||||
send_event(sock, event(2, modality="camera-frame"))
|
||||
bad = event(
|
||||
2 if case == "duplicate" else 3,
|
||||
modality="camera-frame",
|
||||
source_sequence=4 if case == "camera-gap" else None,
|
||||
)
|
||||
if case == "clock-regression":
|
||||
bad = replace(bad, received_monotonic_ns=1)
|
||||
send_event(sock, bad)
|
||||
assert receiver.join() and receiver.terminal == "failed"
|
||||
assert len(received) == (0 if case == "no-init" else 2)
|
||||
|
||||
|
||||
def test_external_reservation_is_not_freed_by_cancel_and_cannot_double_release():
|
||||
queue = StreamMailbox(byte_limit=100)
|
||||
raw = queue.reserve_ingress(20)
|
||||
queue.cancel()
|
||||
assert not queue.quiescent and queue.bytes == 20
|
||||
raw.release()
|
||||
assert queue.quiescent and queue.bytes == 0
|
||||
with pytest.raises(ValueError, match="ownership"):
|
||||
raw.release()
|
||||
|
||||
|
||||
def test_sender_streams_existing_events_and_explicit_gap_then_end(tmp_path):
|
||||
with harness(tmp_path) as (sock, receiver, run, received, notices):
|
||||
sender = StreamingSender(sock, start(), "capture-1", 1, lambda: run.check_current(start()))
|
||||
sender.send(event())
|
||||
sender.gap(modality="pose", reason="unavailable", count=0)
|
||||
sender.send(event(2, modality="pose"))
|
||||
sender.end()
|
||||
assert receiver.join() and receiver.terminal == "end"
|
||||
assert len(received) == 2 and notices[0]["reason"] == "unavailable"
|
||||
with pytest.raises(wire.StreamWireError, match="closed"):
|
||||
sender.send(event(3))
|
||||
|
||||
|
||||
def test_sender_timeout_closes_transport_instead_of_building_backlog():
|
||||
left, right = socket.socketpair()
|
||||
left.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 4096)
|
||||
try:
|
||||
sender = StreamingSender(left, start(), "capture-1", 1, lambda: None, timeout=0.01)
|
||||
with pytest.raises(wire.StreamWireError, match="deadline"):
|
||||
sender.send(event(payload=b"x" * 65536))
|
||||
assert sender.closed and left.fileno() == -1
|
||||
finally:
|
||||
left.close()
|
||||
right.close()
|
||||
Reference in New Issue
Block a user