From 42cee73632b9eb1507d5c4e89fa81ad2fb942efe Mon Sep 17 00:00:00 2001 From: fy59 Date: Fri, 25 Sep 2026 09:14:12 +0200 Subject: [PATCH] fix(sync): prevent Bluetooth autosync from blocking --- CHANGELOG.md | 8 +++++ docs/current_state.md | 8 +++++ tests/test_syncd_routing.py | 67 +++++++++++++++++++++++++++++++++++++ tools/trainlog_syncd.py | 27 +++++++++++++-- 4 files changed, 108 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 473e19e..4a550b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ observed bug fixes, regression fixes, directly related small UI corrections, documentation corrections, robustness, and associated tests. It does not admit new features, modules, protocols, or speculative refactors. +- Fixed automatic Bluetooth synchronization recovery when RFCOMM disconnects + after `trainlog-syncd` admits Android's arrival request but before the + generation conversation can start. Requests whose durable terminal result + is explicitly transient (`device_unavailable`, `transport_timeout`, + `sync_in_progress`, or interruption) remain eligible after reconnect; + completed requests and business failures remain consumed. Generation, ACK, + causal, schema, MTP, Drive, and manual-sync contracts are unchanged. + ## 0.1.6 — 2026-09-25 - Corrected delayed session finalization so a heart-rate capture cannot extend diff --git a/docs/current_state.md b/docs/current_state.md index d98092b..e7b5fbb 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -20,6 +20,14 @@ retained. The desktop service installer retires the exact obsolete private ExecStart override that omitted required runtime paths and installs both `trainlog-syncd` and the BlueZ RFCOMM agent. +The daemon's durable arrival ledger distinguishes admission from terminal +consumption. If RFCOMM disappears after an Android request is admitted but +before the generation conversation can complete, an explicitly transient +terminal result leaves that same stable request eligible on reconnect. Success +and non-transient business failures remain consumed, while every retry receives +a fresh run identity and revalidates the existing idempotent generation/ACK +evidence. + Android synchronization paths share the same concurrency-guarded generation coordinator as SyncScreen. That coordinator has one process-wide ownership arbiter: explicit foreground intent has priority over automatic background diff --git a/tests/test_syncd_routing.py b/tests/test_syncd_routing.py index 5f99f12..f1ab543 100644 --- a/tests/test_syncd_routing.py +++ b/tests/test_syncd_routing.py @@ -126,6 +126,73 @@ class SyncdRoutingTest(unittest.TestCase): self.assertEqual(1, len(commands)) self.assertEqual("sy_old", json.loads(state.read_text())["run_id"]) + def test_transient_terminal_request_remains_eligible_after_reconnect(self): + terminal_states = ( + ("failed", "device_unavailable"), + ("failed", "sync_in_progress"), + ("failed", "transport_timeout"), + ("interrupted", "interrupted"), + ) + for phase, error_code in terminal_states: + with ( + self.subTest(phase=phase, error_code=error_code), + tempfile.TemporaryDirectory() as directory, + ): + root = Path(directory) + request = "sr_22222222-2222-4222-8222-222222222222" + state = root / "trainlog.db.sync-run.json" + state.write_text( + json.dumps( + { + "phase": phase, + "result": phase, + "error_code": error_code, + "request_id": request, + "seen_request_ids": [request], + } + ) + ) + self._write_request( + root / syncd.FULL_GENERATION_REQUEST_NAME, + request, + "2026-09-25T07:49:01Z", + ) + + _, seen = syncd.load_request_state(state) + + self.assertNotIn(request, seen) + self.assertEqual( + (request, "full_generation", []), + syncd.select_request(root, seen), + ) + + def test_non_transient_terminal_request_remains_consumed(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + request = "sr_22222222-2222-4222-8222-222222222222" + state = root / "trainlog.db.sync-run.json" + state.write_text( + json.dumps( + { + "phase": "failed", + "result": "failed", + "error_code": "data_conflict", + "request_id": request, + "seen_request_ids": [request], + } + ) + ) + self._write_request( + root / syncd.FULL_GENERATION_REQUEST_NAME, + request, + "2026-09-25T07:49:01Z", + ) + + _, seen = syncd.load_request_state(state) + + self.assertIn(request, seen) + self.assertIsNone(syncd.select_request(root, seen)) + def test_daemon_skips_mtp_probe_while_transport_lock_is_busy(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tools/trainlog_syncd.py b/tools/trainlog_syncd.py index 7826fbb..78cac34 100755 --- a/tools/trainlog_syncd.py +++ b/tools/trainlog_syncd.py @@ -23,6 +23,11 @@ REQUEST_NAME = "trainlog-sync-request-v1.json" FULL_GENERATION_REQUEST_NAME = "trainlog-sync-full-generation-request-v1.json" SEEN_REQUEST_LIMIT = 64 REQUEST_ID = re.compile(r"sr_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}") +RETRYABLE_REQUEST_ERROR_CODES = { + "device_unavailable", + "sync_in_progress", + "transport_timeout", +} def request_stop( @@ -132,8 +137,26 @@ def load_request_state(path: Path) -> tuple[dict, list[str]]: seen = [item for item in state.get("seen_request_ids", []) if isinstance(item, str) and REQUEST_ID.fullmatch(item)] historical = state.get("request_id") - if isinstance(historical, str) and REQUEST_ID.fullmatch(historical) and historical not in seen: - seen.append(historical) + retryable = ( + state.get("phase") == "interrupted" + or ( + state.get("phase") == "failed" + and state.get("error_code") in RETRYABLE_REQUEST_ERROR_CODES + ) + ) + if isinstance(historical, str) and REQUEST_ID.fullmatch(historical): + # WHY: the daemon records admission before starting the bounded + # generation conversation. A transient disconnect can therefore leave + # the only Android arrival request in the seen ledger even though no + # terminal exchange was possible. CONTRACT: retry only explicit + # transient terminal states; completed and business-rejected requests + # remain exactly-once admissions. INVARIANT: retry keeps the stable + # request_id while the generation engine creates a fresh, idempotent + # run_id and revalidates all durable generation/ACK evidence. + if retryable: + seen = [item for item in seen if item != historical] + elif historical not in seen: + seen.append(historical) return state, seen[-SEEN_REQUEST_LIMIT:]