fix(sync): prevent Bluetooth autosync from blocking

This commit is contained in:
fy59 2026-09-25 09:14:12 +02:00
parent acdf5ae06d
commit 42cee73632
4 changed files with 108 additions and 2 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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:]