From bad79ed926d15144b6910288febc2d4687bf1384 Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 17 Sep 2026 17:11:35 +0200 Subject: [PATCH] fix(android): ignore stale sync coordination objects --- .../SyncGenerationForegroundCoordinator.kt | 42 +++++++++++++++---- .../data/SyncDeploymentConversationTest.kt | 8 ++++ 2 files changed, 43 insertions(+), 7 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 5c0a3d2..e49ebbc 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 @@ -36,6 +36,27 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train return path } + private fun awaitCorrelated( + path: File, + deadline: Long, + predicate: (JSONObject) -> Boolean, + ): ByteArray { + while (true) { + if (path.isFile && path.length() <= 64 * 1024) { + try { + val raw = path.readBytes() + if (predicate(JSONObject(raw.toString(Charsets.UTF_8)))) return raw + } catch (_: Exception) { + // Publication replaces the file atomically, but an old or + // concurrently observed object is not evidence of failure. + } + } + if (System.nanoTime() >= deadline) + throw SyncGenerationException("timeout waiting for correlated ${path.name}") + Thread.sleep(50) + } + } + fun run( directory: File, timeout: Duration = Duration.ofMinutes(5), @@ -88,13 +109,20 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train .toString(), ) afterPublication?.invoke() - service.acceptAcknowledgement( - await(File(directory, "desktop-consumption-ack-v1.json"), deadline).readBytes() - ) - val reference = - JSONObject( - await(File(directory, "desktop-generation-v1.json"), deadline).readText() - ) + // CONTRACT: durable objects from earlier conversations remain in + // the exchange directory. Only this run's exact generation ACK + // can advance its lineage; stale bytes are retained and ignored. + val desktopAck = + awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline) { + it.optString("run_id") == captured.runId && + it.optString("generation_id") == captured.generationId + } + service.acceptAcknowledgement(desktopAck) + val referenceRaw = + awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline) { + it.optString("run_id") == captured.runId + } + val reference = JSONObject(referenceRaw.toString(Charsets.UTF_8)) val acknowledgement = service.consume(File(directory, reference.getString("relative_path"))) publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement) 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 25a704b..9d33d8b 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 @@ -57,6 +57,14 @@ class SyncDeploymentConversationTest { var localWorker: Process? = null try { transport.mkdirs() + // A previous interrupted exchange leaves durable coordination + // objects behind. The next run must wait for correlated replacements. + File(transport, "desktop-consumption-ack-v1.json").writeText( + """{"run_id":"sy_11111111-1111-4111-8111-111111111111","generation_id":"gen_11111111-1111-4111-8111-111111111111"}""" + ) + File(transport, "desktop-generation-v1.json").writeText( + """{"run_id":"sy_11111111-1111-4111-8111-111111111111","generation_id":"gen_22222222-2222-4222-8222-222222222222"}""" + ) val exercise = (repository.createExercise( NewExerciseProfile(