fix(sync): prevent Bluetooth autosync from blocking
This commit is contained in:
parent
acdf5ae06d
commit
42cee73632
4 changed files with 108 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:]
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue