From 4573fe9a4240b7ff233da29c1facd5666462659a Mon Sep 17 00:00:00 2001 From: fy59 Date: Fri, 18 Sep 2026 15:20:58 +0200 Subject: [PATCH] Refresh MTP peer identity before validation --- tests/test_sync_error_classification.py | 49 +++++++++++++++++++++++++ tools/sync_peer_worker.py | 44 +++++++++++++++++++--- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/tests/test_sync_error_classification.py b/tests/test_sync_error_classification.py index 1ebb679..21bc875 100644 --- a/tests/test_sync_error_classification.py +++ b/tests/test_sync_error_classification.py @@ -1,3 +1,4 @@ +import json import subprocess import sys import tempfile @@ -14,6 +15,54 @@ import sync_peer_worker # noqa: E402 class SyncErrorClassificationTest(unittest.TestCase): + def test_mtp_refreshes_a_retained_peer_advertisement_before_validation(self): + expected_peer = "peer_00000000-0000-4000-8000-000000000001" + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + peer_path = root / "android-peer-v1.json" + peer_path.write_text( + json.dumps( + { + "format": "trainlog-sync-peer", + "version": 1, + "peer_id": expected_peer, + "capabilities": sorted( + sync_peer_worker.CAPS - {"session-preparations-v2"} + ), + } + ) + ) + pulls = [] + + def refresh(_adapter, operation, peer, transport, _deadline, allow_missing): + pulls.append((operation, peer, transport, allow_missing)) + peer_path.write_text( + json.dumps( + { + "format": "trainlog-sync-peer", + "version": 1, + "peer_id": expected_peer, + "capabilities": sorted(sync_peer_worker.CAPS), + } + ) + ) + return True + + with mock.patch.object(sync_peer_worker, "run_adapter", side_effect=refresh): + sync_peer_worker.refresh_mtp_peer( + Path("/adapter"), + expected_peer, + root, + sync_peer_worker.time.monotonic() + 60, + ) + + value = sync_peer_worker.load_peer(root, expected_peer) + self.assertEqual(value["peer_id"], expected_peer) + self.assertEqual( + pulls, + [("pull", expected_peer, root, True)], + ) + def test_transport_timeout_has_a_stable_code_and_bounded_diagnostic(self): timeout = subprocess.TimeoutExpired(["adapter", "push"], 30) with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=timeout): diff --git a/tools/sync_peer_worker.py b/tools/sync_peer_worker.py index dcdca0e..1c2ec0a 100644 --- a/tools/sync_peer_worker.py +++ b/tools/sync_peer_worker.py @@ -194,6 +194,39 @@ def wait_json(path: Path, deadline: float, predicate, pump=None) -> dict: time.sleep(0.05) +def refresh_mtp_peer( + adapter: Path, + peer: str, + root: Path, + deadline: float, +) -> None: + """Require a successful device pull before trusting a retained peer file. + + WHY: the durable transport root legitimately retains coordination objects + from earlier runs. A local peer advertisement can therefore predate an + Android upgrade that adds a required capability. + CONTRACT: MTP mode validates only an advertisement obtained after at least + one successful pull of the expected physical peer during this run. + INVARIANT: an unavailable peer never makes a stale local advertisement + authoritative and remains a bounded wait state. + """ + peer_path = root / "android-peer-v1.json" + while True: + pulled = run_adapter( + adapter, + "pull", + peer, + root, + deadline, + True, + ) + if pulled and peer_path.is_file(): + return + if time.monotonic() >= deadline: + raise RuntimeError("timeout waiting for android-peer-v1.json") + time.sleep(0.05) + + def correlated_peer_error(root: Path, run_id: str) -> None: path = root / "android-generation-error-v1.json" if not path.is_file(): @@ -254,11 +287,12 @@ def main() -> int: deadline, True, ) - # Android publishes its durable identity only while participating. A - # missing expected advertisement is therefore a bounded wait state; - # device, transport, ambiguity and malformed-object failures remain - # immediate and distinct adapter errors. - wait_file(args.transport_root / "android-peer-v1.json", deadline, pump) + refresh_mtp_peer( + args.mtp_adapter, + args.expected_peer, + args.transport_root, + deadline, + ) peer = load_peer(args.transport_root, args.expected_peer) with closing(generation.connect_database(args.database)) as db: generation.require_schema(db)