From d87d7cd302f39ba3d0245c009d786a4233537f60 Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 17 Sep 2026 19:37:43 +0200 Subject: [PATCH] Publish desktop generation before its MTP reference --- tests/test_sync_error_classification.py | 41 +++++++++++++++++++++++++ tools/sync_peer_worker.py | 37 +++++++++++++++++----- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/tests/test_sync_error_classification.py b/tests/test_sync_error_classification.py index 9e24100..1ebb679 100644 --- a/tests/test_sync_error_classification.py +++ b/tests/test_sync_error_classification.py @@ -73,6 +73,47 @@ class SyncErrorClassificationTest(unittest.TestCase): ) self.assertEqual(observed, ["request-v1.json"]) + def test_desktop_generation_directory_precedes_reference(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + generation = ( + "desktop-objects/generations/" + "gen_11111111-1111-4111-8111-111111111111" + ) + (root / generation).mkdir(parents=True) + (root / generation / "manifest.json").write_text("manifest") + for name in ( + "request-v1.json", + "desktop-archive-acknowledgements-v1.json", + "desktop-consumption-ack-v1.json", + "desktop-generation-v1.json", + ): + (root / name).write_text("{}") + observed = [] + + def inspect(_adapter, _operation, _peer, outbox, _deadline): + observed.append( + sorted( + str(path.relative_to(outbox)) + for path in outbox.rglob("*") + if path.is_file() + ) + ) + return True + + with mock.patch.object(sync_peer_worker, "run_adapter", side_effect=inspect): + sync_peer_worker.push_desktop_generation( + Path("adapter"), + "peer", + root, + generation, + sync_peer_worker.time.monotonic() + 60, + ) + self.assertEqual(len(observed), 2) + self.assertEqual(observed[0], [generation + "/manifest.json"]) + self.assertNotIn("desktop-generation-v1.json", observed[0]) + self.assertIn("desktop-generation-v1.json", observed[1]) + if __name__ == "__main__": unittest.main() diff --git a/tools/sync_peer_worker.py b/tools/sync_peer_worker.py index e91b7c6..6465d8f 100644 --- a/tools/sync_peer_worker.py +++ b/tools/sync_peer_worker.py @@ -145,6 +145,29 @@ def push_adapter( run_adapter(adapter, "push", peer, outbox, deadline) +def push_desktop_generation( + adapter: Path, + peer: str, + root: Path, + relative_path: str, + deadline: float, +) -> None: + """Commit immutable bytes before making their correlated reference visible.""" + push_adapter(adapter, peer, root, [relative_path], deadline) + push_adapter( + adapter, + peer, + root, + [ + "request-v1.json", + "desktop-archive-acknowledgements-v1.json", + "desktop-consumption-ack-v1.json", + "desktop-generation-v1.json", + ], + deadline, + ) + + def wait_file(path: Path, deadline: float, pump=None) -> None: while not path.is_file(): if time.monotonic() >= deadline: @@ -331,17 +354,15 @@ def main() -> int: } publish_json(args.transport_root / "desktop-generation-v1.json", ref) if args.mode == "mtp": - push_adapter( + # CONTRACT: the immutable generation must be fully visible before its + # mutable run-correlated reference. Publishing both in one MTP walk + # lets Android observe the reference while artifacts are still being + # transferred, turning a complete generation into a false rejection. + push_desktop_generation( args.mtp_adapter, args.expected_peer, args.transport_root, - [ - "request-v1.json", - "desktop-archive-acknowledgements-v1.json", - "desktop-consumption-ack-v1.json", - "desktop-generation-v1.json", - ref["relative_path"], - ], + ref["relative_path"], deadline, ) emit(