fix(sync): release Android lease after desktop rejection

This commit is contained in:
fy59 2026-09-28 12:14:09 +02:00
parent 8b3a91cf50
commit 3d5dfa224a
4 changed files with 128 additions and 0 deletions

View file

@ -14,6 +14,13 @@ new features, modules, protocols, or speculative refactors.
`sync_in_progress`, or interruption) remain eligible after reconnect; `sync_in_progress`, or interruption) remain eligible after reconnect;
completed requests and business failures remain consumed. Generation, ACK, completed requests and business failures remain consumed. Generation, ACK,
causal, schema, MTP, Drive, and manual-sync contracts are unchanged. causal, schema, MTP, Drive, and manual-sync contracts are unchanged.
- Fixed Android generation coordination after a durable desktop rejection.
The Android producer now retains and reports the correlated `rejected` ACK
but immediately releases its conversation lease instead of waiting for a
reciprocal desktop generation that the rejecting worker cannot publish.
This prevents a rejected stale revision from starving a subsequent Web or
automatic Bluetooth request; rejection, generation, ACK, causal, schema,
and transport contracts are unchanged.
## 0.1.6 — 2026-09-25 ## 0.1.6 — 2026-09-25

View file

@ -606,6 +606,18 @@ internal class SyncGenerationCoordinator(
}, pollTransport, lease) }, pollTransport, lease)
service.acceptAcknowledgement(desktopAck) service.acceptAcknowledgement(desktopAck)
phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId) phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId)
/* WHY: a durable desktop rejection closes this Android-produced
* generation, so the desktop worker deliberately cannot publish a
* reciprocal generation for this conversation. CONTRACT: retain
* and validate the rejection ACK, then end this run immediately.
* INVARIANT: a rejected generation never holds the process-wide
* conversation lease while waiting for an impossible reference. */
val desktopAcknowledgement = JSONObject(desktopAck.toString(Charsets.UTF_8))
if (desktopAcknowledgement.getString("result") == "rejected") {
val diagnostic = desktopAcknowledgement.getString("diagnostic")
phase(runId, "desktop_rejected", "desktop-consumption-ack-v1.json", captured.generationId)
return ForegroundGenerationResult.Failed(diagnostic, captured.runId)
}
val referenceRaw = val referenceRaw =
awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline, { awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline, {
it.optString("run_id") == captured.runId it.optString("run_id") == captured.runId

View file

@ -4,6 +4,7 @@ import android.content.Context
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import java.io.File import java.io.File
import java.nio.file.Files import java.nio.file.Files
import java.security.MessageDigest
import java.time.Duration import java.time.Duration
import java.util.UUID import java.util.UUID
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
@ -54,6 +55,109 @@ class SyncConversationOwnershipTest {
) )
} }
private fun canonical(value: Any?): String =
when (value) {
null,
JSONObject.NULL -> "null"
is JSONObject ->
value.keys().asSequence().toList().sorted().joinToString(",", "{", "}") {
JSONObject.quote(it) + ":" + canonical(value.get(it))
}
is JSONArray ->
(0 until value.length()).joinToString(",", "[", "]") { canonical(value.get(it)) }
is String -> JSONObject.quote(value)
is Boolean,
is Int,
is Long -> value.toString()
else -> error("unsupported test ACK value")
}
private fun rejectedDesktopAcknowledgement(
runId: String,
generationId: String,
producerPeerId: String,
consumerPeerId: String,
manifestSha256: String,
): String {
/* This fixture signs the exact wire ACK because the production
* validator must persist it before the coordinator may release its
* lease; an unsigned rejection would only test malformed-input logic. */
val acknowledgement =
JSONObject()
.put("format", "trainlog-sync-ack")
.put("version", 1)
.put("ack_id", "ack_${generationId.removePrefix("gen_")}")
.put("run_id", runId)
.put("generation_id", generationId)
.put("producer_peer_id", producerPeerId)
.put("consumer_peer_id", consumerPeerId)
.put("manifest_sha256", manifestSha256)
.put("result", "rejected")
.put("durability", "sqlite-commit-rejection")
.put("consumed_at", "2026-09-28T12:00:00+02:00")
.put("diagnostic", "concurrent sleep diary revision")
val digest = MessageDigest.getInstance("SHA-256")
.digest(canonical(acknowledgement).toByteArray())
.joinToString("") { "%02x".format(it) }
return acknowledgement.put("payload_sha256", digest).toString()
}
@Test
fun rejectedDesktopAcknowledgementEndsConversationAndReleasesBackgroundLease() {
val databaseName = "generation-rejection-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-rejection-").toFile()
val repository = TrainlogRepository(context, databaseName)
val arbiter = SyncConversationArbiter()
val referencePublished = CountDownLatch(1)
val desktopPeer = "peer_${UUID.randomUUID()}"
val runId = "sy_${UUID.randomUUID()}"
try {
val service = SyncGenerationService(repository)
request(root, runId, service.peerId(), desktopPeer)
archiveAcknowledgements(root, runId, service.peerId(), desktopPeer)
val coordinator =
SyncGenerationCoordinator(
repository,
arbiter = arbiter,
trace = SyncGenerationTrace { _, phase, _, _, _ ->
if (phase == "generation_reference_published") referencePublished.countDown()
},
)
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
try {
val active = executor.submit<ForegroundGenerationResult> {
coordinator.run(root, Duration.ofSeconds(5))
}
assertTrue(referencePublished.await(3, TimeUnit.SECONDS))
val reference = JSONObject(File(root, "android-generation-v1.json").readText())
File(root, "desktop-consumption-ack-v1.json").writeText(
rejectedDesktopAcknowledgement(
runId,
reference.getString("generation_id"),
service.peerId(),
desktopPeer,
reference.getString("manifest_sha256"),
)
)
val result = active.get(2, TimeUnit.SECONDS)
assertEquals(
ForegroundGenerationResult.Failed("concurrent sleep diary revision", runId),
result,
)
assertTrue(
"The rejected run must release the lease for its next request.",
coordinator.run(root, Duration.ofMillis(100)) !is ForegroundGenerationResult.Busy,
)
} finally {
executor.shutdownNow()
}
} finally {
repository.close()
context.deleteDatabase(databaseName)
root.deleteRecursively()
}
}
@Test @Test
fun explicitClickYieldsOldBackgroundAndPublishesFreshTriggerForNewRun() { fun explicitClickYieldsOldBackgroundAndPublishesFreshTriggerForNewRun() {
val databaseName = "generation-ownership-${UUID.randomUUID()}.db" val databaseName = "generation-ownership-${UUID.randomUUID()}.db"

View file

@ -34,6 +34,11 @@ arbiter: explicit foreground intent has priority over automatic background
resumption, while a foreground service may remain alive without owning the resumption, while a foreground service may remain alive without owning the
conversation. An automatic run yields cooperatively at safe boundaries; its conversation. An automatic run yields cooperatively at safe boundaries; its
durable generation is retained for a correlated handoff rather than deleted. durable generation is retained for a correlated handoff rather than deleted.
A correlated desktop `rejected` ACK is also terminal for the Android producer:
it is retained as durable rejection evidence, then releases the conversation
lease immediately because no reciprocal desktop generation can follow that
rejection. This keeps an explicit Web request or a later automatic Bluetooth
request from being starved by an impossible post-rejection wait.
Direct-MTP publication now has an explicit Android visibility boundary. Direct-MTP publication now has an explicit Android visibility boundary.
Durable immutable generation files are submitted to MediaProvider and all scan Durable immutable generation files are submitted to MediaProvider and all scan