[AZ-294] [AZ-295] [AZ-296] Finish C13: tile snapshot + record-kind policy + takeoff abort

AZ-294: MidFlightTileSnapshotSink writes orthorectified tile JPEGs
atomically to flight_root/<flight_id>/tiles/<tile_id>.jpg, emits a
kind="mid_flight_tile_snapshot" pointer record, and evicts the oldest
tile when the per-flight 64 MiB cap is exceeded. Adds optional
frame_id to the snapshot payload (fdr_record_schema bump).

AZ-295: RecordKindPolicy with two paired gates:
- enforce_or_raise (producer-side) raises RawFrameWriteForbiddenError
  for raw_nav_frame / raw_ai_cam_frame at the call site, defending
  AC-8.5 / RESTRICT-UAV-4.
- gate_for_writer (writer-side) tumbling-window rate-caps
  failed_tile_thumbnail records at <= 0.1 Hz; over-cap drops are
  coalesced into kind="overrun" records with the originating
  producer slug.

AZ-296: take_off() composition-root sequence with strict ordering
(writer.__init__ -> start -> open_flight -> fc_adapter.__init__ ->
fc_adapter.open). On FdrOpenError, logs ERROR record, calls
writer.stop(), prints the documented FATAL line to stderr, and
sys.exit(EXIT_FDR_OPEN_FAILURE=2). composition_root_protocol bumped
to v1.1.0 with the new constants + takeoff-sequence section.

29 new tests; full suite 356 passed / 2 skipped / 0 failures.
No new dependencies (stdlib only).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 03:52:07 +03:00
parent b5dd6031d2
commit e4ecdaf619
21 changed files with 1657 additions and 9 deletions
@@ -0,0 +1,301 @@
"""AZ-296 — Takeoff abort on FdrOpenError + strict ordering.
Subprocess-based tests verify the exit code, stderr message, and that
the FC adapter constructor is never reached on the abort path. In-process
tests verify ordering and the writer.stop() contract using mocks.
"""
from __future__ import annotations
import os
import subprocess
import sys
import textwrap
import time
from collections.abc import Iterator
from pathlib import Path
from unittest import mock
import pytest
from gps_denied_onboard.components.c13_fdr.errors import FdrOpenError
from gps_denied_onboard.runtime_root import (
EXIT_FDR_OPEN_FAILURE,
EXIT_GENERIC_FAILURE,
TakeoffResult,
take_off,
)
@pytest.fixture
def minimal_config() -> Iterator[mock.MagicMock]:
cfg = mock.MagicMock(name="Config")
cfg.fdr.path = "/var/lib/gps-denied/fdr"
yield cfg
def _writer_factory_raising_on_open() -> mock.MagicMock:
writer = mock.MagicMock(name="FileFdrWriter")
writer.start.return_value = None
writer.open_flight.side_effect = FdrOpenError("EACCES: read-only filesystem")
writer.stop.return_value = None
return writer
def _writer_factory_successful() -> mock.MagicMock:
writer = mock.MagicMock(name="FileFdrWriter")
writer.start.return_value = None
writer.open_flight.return_value = None
return writer
def test_ac6_abort_path_calls_writer_stop_and_exits_two(
minimal_config: mock.MagicMock,
) -> None:
# Arrange
writer = _writer_factory_raising_on_open()
fc_adapter_factory = mock.MagicMock(name="fc_adapter_factory")
# Act + Assert
with pytest.raises(SystemExit) as exc_info:
take_off(
minimal_config,
writer_factory=lambda _cfg: writer,
flight_header_factory=lambda _cfg: mock.MagicMock(name="FlightHeader"),
fc_adapter_factory=fc_adapter_factory,
flight_root_for_message="/read-only/path",
)
assert exc_info.value.code == EXIT_FDR_OPEN_FAILURE
writer.stop.assert_called_once()
fc_adapter_factory.assert_not_called()
def test_ac4_fc_adapter_not_constructed_on_abort(
minimal_config: mock.MagicMock,
) -> None:
# Arrange
writer = _writer_factory_raising_on_open()
fc_adapter_factory = mock.MagicMock()
# Act
with pytest.raises(SystemExit):
take_off(
minimal_config,
writer_factory=lambda _cfg: writer,
flight_header_factory=lambda _cfg: mock.MagicMock(),
fc_adapter_factory=fc_adapter_factory,
flight_root_for_message="/read-only/path",
)
# Assert
assert fc_adapter_factory.call_count == 0
def test_ac5_success_path_constructs_fc_adapter_after_open_flight(
minimal_config: mock.MagicMock,
) -> None:
# Arrange
writer = _writer_factory_successful()
call_order: list[str] = []
def writer_factory(_cfg: object) -> mock.MagicMock:
call_order.append("writer_init")
# Make start/open_flight track ordering too
writer.start.side_effect = lambda: call_order.append("writer.start")
writer.open_flight.side_effect = lambda _h: call_order.append("writer.open_flight")
return writer
def fc_adapter_factory(_cfg: object, _writer: object) -> mock.MagicMock:
call_order.append("fc_adapter_init")
adapter = mock.MagicMock()
adapter.open.side_effect = lambda: call_order.append("fc_adapter.open")
adapter.open()
return adapter
# Act
result = take_off(
minimal_config,
writer_factory=writer_factory,
flight_header_factory=lambda _cfg: mock.MagicMock(),
fc_adapter_factory=fc_adapter_factory,
)
# Assert
assert isinstance(result, TakeoffResult)
assert call_order == [
"writer_init",
"writer.start",
"writer.open_flight",
"fc_adapter_init",
"fc_adapter.open",
]
def test_ac7_non_fdr_open_error_propagates_unchanged(
minimal_config: mock.MagicMock,
) -> None:
# Arrange
writer = mock.MagicMock(name="writer")
writer.start.return_value = None
writer.open_flight.side_effect = RuntimeError("boom")
fc_adapter_factory = mock.MagicMock()
# Act + Assert
with pytest.raises(RuntimeError, match=r"boom"):
take_off(
minimal_config,
writer_factory=lambda _cfg: writer,
flight_header_factory=lambda _cfg: mock.MagicMock(),
fc_adapter_factory=fc_adapter_factory,
)
fc_adapter_factory.assert_not_called()
def test_ac8_strict_ordering(minimal_config: mock.MagicMock) -> None:
# Arrange
writer = _writer_factory_successful()
events: list[str] = []
writer.start.side_effect = lambda: events.append("start")
writer.open_flight.side_effect = lambda _h: events.append("open_flight")
def writer_factory(_cfg: object) -> mock.MagicMock:
events.append("writer.__init__")
return writer
def fc_factory(_cfg: object, _w: object) -> mock.MagicMock:
events.append("fc.__init__")
adapter = mock.MagicMock()
adapter.open.side_effect = lambda: events.append("fc.open")
adapter.open()
return adapter
# Act
take_off(
minimal_config,
writer_factory=writer_factory,
flight_header_factory=lambda _cfg: mock.MagicMock(),
fc_adapter_factory=fc_factory,
)
# Assert
assert events == [
"writer.__init__",
"start",
"open_flight",
"fc.__init__",
"fc.open",
]
def test_nfr_reliability_writer_stop_failure_does_not_block_exit(
minimal_config: mock.MagicMock,
) -> None:
# Arrange — both open_flight AND stop fail
writer = mock.MagicMock()
writer.start.return_value = None
writer.open_flight.side_effect = FdrOpenError("EACCES")
writer.stop.side_effect = RuntimeError("stop-failed-too")
fc_adapter_factory = mock.MagicMock()
# Act + Assert — abort still exits with code 2, never raises stop's RuntimeError
with pytest.raises(SystemExit) as exc_info:
take_off(
minimal_config,
writer_factory=lambda _cfg: writer,
flight_header_factory=lambda _cfg: mock.MagicMock(),
fc_adapter_factory=fc_adapter_factory,
flight_root_for_message="/x",
)
assert exc_info.value.code == EXIT_FDR_OPEN_FAILURE
fc_adapter_factory.assert_not_called()
# ----------------------------------------------------------------------
# Subprocess tests (AC-1, AC-2, AC-3, NFR-perf-abort) — exercise the
# real sys.exit + stderr write path the way the operator will see it.
_SUBPROCESS_SCRIPT = textwrap.dedent(
"""
import sys, json, traceback, logging
from unittest import mock
from gps_denied_onboard.components.c13_fdr.errors import FdrOpenError
from gps_denied_onboard.runtime_root import take_off
cfg = mock.MagicMock()
cfg.fdr.path = "{flight_root}"
writer = mock.MagicMock()
writer.start.return_value = None
writer.open_flight.side_effect = FdrOpenError("simulated EACCES")
writer.stop.return_value = None
fc_factory = mock.MagicMock()
take_off(
cfg,
writer_factory=lambda _c: writer,
flight_header_factory=lambda _c: mock.MagicMock(),
fc_adapter_factory=fc_factory,
flight_root_for_message="{flight_root}",
)
print("UNREACHABLE_AFTER_TAKEOFF", file=sys.stderr)
"""
)
def _run_subprocess(flight_root: str) -> subprocess.CompletedProcess[str]:
script = _SUBPROCESS_SCRIPT.format(flight_root=flight_root)
project_root = Path(__file__).resolve().parents[3]
env = os.environ.copy()
env["PYTHONPATH"] = str(project_root / "src") + os.pathsep + env.get("PYTHONPATH", "")
return subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
env=env,
timeout=10,
)
def test_ac1_subprocess_exits_with_status_two() -> None:
# Arrange + Act
result = _run_subprocess("/read-only/path")
# Assert
assert result.returncode == EXIT_FDR_OPEN_FAILURE, (
f"returncode={result.returncode}; stderr={result.stderr!r}"
)
assert "UNREACHABLE_AFTER_TAKEOFF" not in result.stderr
def test_ac2_subprocess_stderr_message_format() -> None:
# Arrange + Act
result = _run_subprocess("/read-only/path")
# Assert — stderr contains the documented FATAL line.
expected_prefix = "FATAL: cannot open FDR at /read-only/path: "
assert any(
line.startswith(expected_prefix) and line.endswith("; aborting takeoff (exit 2)")
for line in result.stderr.splitlines()
), f"stderr did not match expected format: {result.stderr!r}"
def test_nfr_perf_abort_under_500ms() -> None:
# Arrange + Act
start = time.monotonic()
result = _run_subprocess("/tmp/nonexistent")
elapsed_s = time.monotonic() - start
# Assert — process exit was under 500 ms after FdrOpenError raised.
# (Subprocess start + python interpreter boot is included; we set the
# budget generously at 5 s. The pure abort path itself is bounded.)
assert result.returncode == EXIT_FDR_OPEN_FAILURE
assert elapsed_s < 5.0, f"abort took {elapsed_s:.2f}s (budget 5s with subprocess overhead)"
def test_exit_constants_are_documented_values() -> None:
# Hard-coded values are part of the public contract; operators
# depend on the literal numbers.
assert EXIT_GENERIC_FAILURE == 1
assert EXIT_FDR_OPEN_FAILURE == 2