From 1be4ab03975d7f5fb7a8732e1711b5ab18602a3e Mon Sep 17 00:00:00 2001 From: fy59 Date: Fri, 18 Sep 2026 05:42:48 +0200 Subject: [PATCH] Fix Android generation sync retry trigger --- .../SyncGenerationForegroundCoordinator.kt | 8 +++++++ .../com/labfytools/trainlog/ui/SyncScreen.kt | 21 ++++++++++++++++++- .../data/SyncDeploymentConversationTest.kt | 11 +++++++--- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt index 7917c4b..dd54b75 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt @@ -112,6 +112,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train fun run( directory: File, timeout: Duration = Duration.ofMinutes(5), + afterPeerPublication: (() -> Unit)? = null, afterPublication: (() -> Unit)? = null, ): ForegroundGenerationResult = try { @@ -139,6 +140,13 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train ) .toString(), ) + /* WHY: trainlog-syncd is intentionally driven by the established + * Android request artifact, not by polling generation internals. + * CONTRACT: each explicit foreground attempt publishes the peer + * identity before its caller emits one fresh request signal. + * INVARIANT: a failed attempt's request, run, generations, and + * ACK evidence remain immutable; retry creates new correlation. */ + afterPeerPublication?.invoke() val deadline = System.nanoTime() + timeout.toNanos() val requestRaw = awaitCorrelated(File(directory, "request-v1.json"), deadline) { candidate -> diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt index 5797da1..0cbe9d8 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt @@ -54,7 +54,26 @@ internal suspend fun publishBundleAndRequest( value.opt("version") is Int && value.getInt("version") == 1 && value.opt("enabled") == true } catch (_: Exception) { false } if (!enabled) return@withContext SyncRequestResult.Error("Invalid generation synchronization opt-in.") - return@withContext when (val result = SyncGenerationForegroundCoordinator(repository).run(canonicalExchangeDirectory())) { + val opened = exporter.openStorageSnapshot() + if (opened is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error) { + return@withContext SyncRequestResult.Error("prepare:${opened.message}") + } + val snapshot = (opened as com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready).snapshot + return@withContext when ( + val result = SyncGenerationForegroundCoordinator(repository).run( + canonicalExchangeDirectory(), + afterPeerPublication = { + when (val request = requestOutbox.requestSync(snapshot)) { + is SyncRequestResult.Requested -> Unit + SyncRequestResult.Unsupported -> + throw IllegalStateException("Generation synchronization is unsupported.") + is SyncRequestResult.Error -> throw IllegalStateException(request.message) + is SyncRequestResult.Completed -> + throw IllegalStateException("Unexpected completed request signal.") + } + }, + ) + ) { is ForegroundGenerationResult.Completed -> SyncRequestResult.Completed(result.runId) is ForegroundGenerationResult.Error -> SyncRequestResult.Error(result.message) } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SyncDeploymentConversationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SyncDeploymentConversationTest.kt index de9dd1d..8905f37 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SyncDeploymentConversationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SyncDeploymentConversationTest.kt @@ -103,11 +103,15 @@ class SyncDeploymentConversationTest { repeat(conversations) { val coordinator = SyncGenerationForegroundCoordinator(repository) val executor = java.util.concurrent.Executors.newSingleThreadExecutor() + var requestSignals = 0 val result = executor.submit { - coordinator.run(transport, Duration.ofSeconds(30)) { - release?.let { awaitFile(File(it)) } - } + coordinator.run( + transport, + Duration.ofSeconds(30), + afterPeerPublication = { requestSignals += 1 }, + afterPublication = { release?.let { awaitFile(File(it)) } }, + ) } val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText()) if (suppliedTransport == null) { @@ -138,6 +142,7 @@ class SyncDeploymentConversationTest { "conversation ${it + 1}: $completed", completed is ForegroundGenerationResult.Completed, ) + assertEquals("one fresh request signal per conversation", 1, requestSignals) var workerOutput = "" localWorker?.let { worker -> workerOutput = worker.inputStream.bufferedReader().readText()