"""Spoof-recovery signal sink (AZ-396 / E-C8 runtime-root wiring). C5's spoof-promotion gate (AZ-385) publishes a ``spoof_promotion_recovered`` signal when a previously-spoofed FC GPS source clears the recovery window. The runtime root forwards that signal to the C8 AP adapter's :meth:`request_source_set_switch` on the OUTBOUND emit thread so the single-writer invariant (Invariant 8) is preserved. This module owns ONLY the wiring side; the publisher side lives in C5 (AZ-385 — not yet landed). The sink exposes a Protocol the runtime root binds at composition-root build time: - :class:`SpoofRecoveryPublisher` — Protocol the C5 gate implements. - :class:`SpoofRecoverySink` — registers itself with a publisher and fires ``request_source_set_switch`` on the C8 adapter. Single-writer enforcement: the sink ALWAYS dispatches the C8 adapter call on a single bound thread (the C8 outbound thread). It uses a thread-safe queue so the C5 thread can publish without blocking on the C8 wire. """ from __future__ import annotations import queue import threading from typing import Protocol from gps_denied_onboard.components.c8_fc_adapter.errors import ( FcAdapterError, SourceSetSwitchError, ) from gps_denied_onboard.components.c8_fc_adapter.interface import FcAdapter from gps_denied_onboard.logging import get_logger __all__ = [ "SpoofRecoveryPublisher", "SpoofRecoverySink", ] class SpoofRecoveryPublisher(Protocol): """Protocol C5 (AZ-385) implements to surface the recovery signal.""" def subscribe_spoof_promotion_recovered(self, callback: _Callback) -> object: """Register ``callback`` to be invoked when the gate recovers. Returns a handle whose ``cancel()`` method removes the subscription. """ class _Callback(Protocol): def __call__(self) -> None: ... class SpoofRecoverySink: """Dispatches C5 spoof-recovery signals to the AP adapter outbound thread. Usage from the composition root:: sink = SpoofRecoverySink(fc_adapter) sink.start() publisher.subscribe_spoof_promotion_recovered(sink.publish) # ... at shutdown ... sink.stop() """ def __init__(self, fc_adapter: FcAdapter) -> None: self._fc = fc_adapter self._queue: queue.Queue[None] = queue.Queue(maxsize=16) self._stop = threading.Event() self._thread: threading.Thread | None = None self._log = get_logger("runtime_root.spoof_recovery_sink") def start(self) -> None: """Start the dispatch thread; idempotent.""" if self._thread is not None and self._thread.is_alive(): return self._stop.clear() self._thread = threading.Thread( target=self._run, name="c8.spoof_recovery_sink", daemon=True ) self._thread.start() def stop(self, *, join_timeout_s: float = 1.0) -> None: """Signal the dispatch thread to exit and join.""" self._stop.set() # Wake the queue.get(...) blocker without leaking a real signal. try: self._queue.put_nowait(None) except queue.Full: pass if self._thread is not None: self._thread.join(timeout=join_timeout_s) self._thread = None def publish(self) -> None: """Invoked by the C5 publisher thread; enqueues a switch request.""" try: self._queue.put_nowait(None) except queue.Full: # Bounded queue: 16 pending switches is far more than the # recovery gate can produce; if we ever hit this, something # upstream is flooding. Emit a WARN and drop — the C8 # idempotence gate (Invariant 11) would suppress them anyway. self._log.warning( "c8.spoof_recovery_sink_queue_full", extra={"kind": "c8.spoof_recovery_sink_queue_full", "kv": {}}, ) def _run(self) -> None: while not self._stop.is_set(): try: _ = self._queue.get(timeout=0.5) except queue.Empty: continue if self._stop.is_set(): return try: self._fc.request_source_set_switch() except SourceSetSwitchError as exc: # Per spec § 5 error-handling: ERROR log + STATUSTEXT # already happened inside the adapter; we re-log at # DEBUG here so the sink-level audit shows the catch. self._log.debug( f"c8.spoof_recovery_sink_switch_failed: {exc!r}", extra={ "kind": "c8.spoof_recovery_sink_switch_failed", "kv": {"error": repr(exc)}, }, ) except FcAdapterError as exc: self._log.warning( f"c8.spoof_recovery_sink_adapter_error: {exc!r}", extra={ "kind": "c8.spoof_recovery_sink_adapter_error", "kv": {"error": repr(exc)}, }, )