Compare commits
No commits in common. "eb6d70fb62a0c726bc5fd7c0f7e022391f8dd6bb" and "ca2ea9379bf234a27842dc08da214d1ca69e37ac" have entirely different histories.
eb6d70fb62
...
ca2ea9379b
16 changed files with 108 additions and 808 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -4,17 +4,6 @@
|
|||
|
||||
Development opened after the stable v0.1.2 release.
|
||||
|
||||
- Made explicit Android synchronization intent authoritative over automatic
|
||||
background resumption. A process-wide conversation arbiter now yields an
|
||||
older background wait before the foreground publishes its fresh request
|
||||
UUID, and `NEW_EXPLICIT` ignores the request/run that existed before the
|
||||
click. Background USB and Drive workers classify new requests, new
|
||||
correlated remote evidence, legitimate handoff, and stale resumable state;
|
||||
terminal runs are no longer retried every five seconds without new proof.
|
||||
Durable generations are retained across yield and handoff, and desktop
|
||||
request-ID deduplication remains strict. No schema, protocol, causal rule or
|
||||
exchange format changed.
|
||||
|
||||
- Hardened direct-MTP full-generation conversations after a private Android
|
||||
run was frozen before generation capture. Android now emits bounded
|
||||
run/phase metadata, refreshes the user-enabled background listener before a
|
||||
|
|
|
|||
|
|
@ -291,15 +291,10 @@ class DriveSyncWorker(context: Context, parameters: WorkerParameters) :
|
|||
transport.pull(local)
|
||||
stage = "coordinate"
|
||||
val coordinator = SyncGenerationCoordinator(repository)
|
||||
val terminalLedger = BackgroundSyncRunLedger(applicationContext)
|
||||
coordinator.publishPeer(local)
|
||||
stage = "peer-publication"
|
||||
transport.pushPeer(local)
|
||||
val actionability = coordinator.classifyBackgroundRequest(local, terminalLedger.terminal())
|
||||
if (
|
||||
actionability == BackgroundRequestActionability.NOT_ACTIONABLE ||
|
||||
actionability == BackgroundRequestActionability.RESUMABLE_BUT_STALE
|
||||
) {
|
||||
if (!coordinator.hasActionableRequest(local)) {
|
||||
if (coordinator.hasReplayableAcknowledgement(local)) {
|
||||
stage = "acknowledgement-replay"
|
||||
transport.pushAcknowledgement(local)
|
||||
|
|
@ -327,31 +322,8 @@ class DriveSyncWorker(context: Context, parameters: WorkerParameters) :
|
|||
stage = "acknowledgement"
|
||||
transport.pushAcknowledgement(local)
|
||||
},
|
||||
intent = SyncConversationIntent.RESUME_BACKGROUND,
|
||||
)
|
||||
when (result) {
|
||||
is ForegroundGenerationResult.Completed -> {
|
||||
terminalLedger.recordTerminal(
|
||||
result.runId,
|
||||
coordinator.remoteEvidence(local, result.runId),
|
||||
)
|
||||
Result.success()
|
||||
}
|
||||
ForegroundGenerationResult.Busy,
|
||||
is ForegroundGenerationResult.Cancelled -> Result.success()
|
||||
is ForegroundGenerationResult.Superseded -> {
|
||||
result.runId?.let {
|
||||
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(local, it))
|
||||
}
|
||||
Result.success()
|
||||
}
|
||||
is ForegroundGenerationResult.Failed -> {
|
||||
result.runId?.let {
|
||||
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(local, it))
|
||||
}
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
if (result is ForegroundGenerationResult.Completed) Result.success() else Result.retry()
|
||||
} catch (error: Exception) {
|
||||
/* SECURITY: stage and exception class are fixed application
|
||||
* metadata. Provider messages may contain private URIs and are
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
|
||||
internal enum class BackgroundRequestActionability {
|
||||
NEW_REQUEST,
|
||||
NEW_REMOTE_EVIDENCE,
|
||||
ACTIVE_HANDOFF,
|
||||
RESUMABLE_BUT_STALE,
|
||||
NOT_ACTIONABLE,
|
||||
}
|
||||
|
||||
internal data class BackgroundTerminalRun(
|
||||
val runId: String,
|
||||
val remoteEvidence: String,
|
||||
)
|
||||
|
||||
/** One-entry durable suppression ledger for the current background request. */
|
||||
internal class BackgroundSyncRunLedger(context: Context) {
|
||||
private val preferences =
|
||||
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
|
||||
fun terminal(): BackgroundTerminalRun? {
|
||||
val runId = preferences.getString(RUN_ID, null) ?: return null
|
||||
return BackgroundTerminalRun(runId, preferences.getString(REMOTE_EVIDENCE, "").orEmpty())
|
||||
}
|
||||
|
||||
fun recordTerminal(runId: String, remoteEvidence: String) {
|
||||
/* CONTRACT: only the request currently present on the transport needs
|
||||
* suppression. A replacement request has a distinct run_id and is
|
||||
* immediately actionable, so this ledger remains permanently bounded
|
||||
* to one record and is not a second synchronization database. */
|
||||
preferences.edit()
|
||||
.putString(RUN_ID, runId)
|
||||
.putString(REMOTE_EVIDENCE, remoteEvidence)
|
||||
.apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFERENCES = "trainlog-background-sync-terminal"
|
||||
private const val RUN_ID = "run_id"
|
||||
private const val REMOTE_EVIDENCE = "remote_evidence"
|
||||
}
|
||||
}
|
||||
|
|
@ -76,7 +76,6 @@ class SyncBackgroundService : Service() {
|
|||
requireNotNull(repository),
|
||||
AndroidMtpPublicationVisibility(applicationContext),
|
||||
)
|
||||
val terminalLedger = BackgroundSyncRunLedger(applicationContext)
|
||||
while (!stopped.get() && BackgroundSyncSettings(this).enabled) {
|
||||
val directory = canonicalExchangeDirectory()
|
||||
/* WHY: waiting inside run() owns the process-wide conversation
|
||||
|
|
@ -85,36 +84,8 @@ class SyncBackgroundService : Service() {
|
|||
* the coordinator still validates all protocol evidence.
|
||||
* INVARIANT: no request means no business work and no lock. */
|
||||
coordinator.publishPeer(directory)
|
||||
val actionability =
|
||||
coordinator.classifyBackgroundRequest(directory, terminalLedger.terminal())
|
||||
if (
|
||||
actionability == BackgroundRequestActionability.NEW_REQUEST ||
|
||||
actionability == BackgroundRequestActionability.NEW_REMOTE_EVIDENCE ||
|
||||
actionability == BackgroundRequestActionability.ACTIVE_HANDOFF
|
||||
) {
|
||||
when (
|
||||
val result = coordinator.run(
|
||||
directory,
|
||||
Duration.ofMinutes(5),
|
||||
intent = SyncConversationIntent.RESUME_BACKGROUND,
|
||||
)
|
||||
) {
|
||||
is ForegroundGenerationResult.Completed ->
|
||||
terminalLedger.recordTerminal(
|
||||
result.runId,
|
||||
coordinator.remoteEvidence(directory, result.runId),
|
||||
)
|
||||
is ForegroundGenerationResult.Failed ->
|
||||
result.runId?.let {
|
||||
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(directory, it))
|
||||
}
|
||||
is ForegroundGenerationResult.Superseded ->
|
||||
result.runId?.let {
|
||||
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(directory, it))
|
||||
}
|
||||
ForegroundGenerationResult.Busy,
|
||||
is ForegroundGenerationResult.Cancelled -> Unit
|
||||
}
|
||||
if (coordinator.hasActionableRequest(directory)) {
|
||||
coordinator.run(directory, Duration.ofMinutes(5))
|
||||
}
|
||||
if (!stopped.get()) runCatching { Thread.sleep(5_000) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,116 +0,0 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
internal enum class SyncConversationOwner {
|
||||
FOREGROUND_EXPLICIT,
|
||||
BACKGROUND_AUTOMATIC,
|
||||
}
|
||||
|
||||
internal class SyncConversationYieldedException : Exception("conversation superseded by explicit sync")
|
||||
|
||||
/** Process-wide ownership authority for Android generation conversations. */
|
||||
internal class SyncConversationArbiter {
|
||||
private val lock = ReentrantLock()
|
||||
private val ownershipChanged = lock.newCondition()
|
||||
private var active: Lease? = null
|
||||
private var pendingExplicit = 0
|
||||
|
||||
/**
|
||||
* Announces user intent before the foreground service is refreshed.
|
||||
*
|
||||
* WHY: starting the service first allowed it to acquire the old binary
|
||||
* lock and silently absorb the click. CONTRACT: an announcement prevents
|
||||
* new background ownership and cooperatively supersedes an existing
|
||||
* background lease. INVARIANT: ownership is still exclusive until that
|
||||
* lease observes yield and releases in its finally block.
|
||||
*/
|
||||
fun announceExplicit(): ExplicitClaim = lock.withLock {
|
||||
pendingExplicit += 1
|
||||
active?.takeIf { it.owner == SyncConversationOwner.BACKGROUND_AUTOMATIC }
|
||||
?.requestYield()
|
||||
ownershipChanged.signalAll()
|
||||
ExplicitClaim(this)
|
||||
}
|
||||
|
||||
fun tryAcquireBackground(): Lease? = lock.withLock {
|
||||
if (active != null || pendingExplicit > 0) return null
|
||||
Lease(this, SyncConversationOwner.BACKGROUND_AUTOMATIC).also { active = it }
|
||||
}
|
||||
|
||||
private fun acquireExplicit(claim: ExplicitClaim, timeout: Duration): Lease? {
|
||||
return lock.withLock {
|
||||
check(claim.announced)
|
||||
var remaining = timeout.toNanos()
|
||||
while (active != null) {
|
||||
if (remaining <= 0L) {
|
||||
releaseAnnouncement(claim)
|
||||
return@withLock null
|
||||
}
|
||||
remaining = ownershipChanged.awaitNanos(remaining)
|
||||
}
|
||||
releaseAnnouncement(claim)
|
||||
Lease(this, SyncConversationOwner.FOREGROUND_EXPLICIT).also { active = it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseAnnouncement(claim: ExplicitClaim) {
|
||||
if (!claim.announced) return
|
||||
claim.announced = false
|
||||
pendingExplicit -= 1
|
||||
check(pendingExplicit >= 0)
|
||||
ownershipChanged.signalAll()
|
||||
}
|
||||
|
||||
private fun release(lease: Lease) = lock.withLock {
|
||||
if (active === lease) {
|
||||
active = null
|
||||
ownershipChanged.signalAll()
|
||||
}
|
||||
}
|
||||
|
||||
internal class ExplicitClaim internal constructor(private val arbiter: SyncConversationArbiter) :
|
||||
AutoCloseable {
|
||||
internal var announced = true
|
||||
private var acquired = false
|
||||
|
||||
fun acquire(timeout: Duration): Lease? {
|
||||
check(!acquired)
|
||||
val lease = arbiter.acquireExplicit(this, timeout)
|
||||
acquired = lease != null
|
||||
return lease
|
||||
}
|
||||
|
||||
override fun close() = arbiter.lock.withLock {
|
||||
if (!acquired) arbiter.releaseAnnouncement(this)
|
||||
}
|
||||
}
|
||||
|
||||
internal class Lease internal constructor(
|
||||
private val arbiter: SyncConversationArbiter,
|
||||
val owner: SyncConversationOwner,
|
||||
) : AutoCloseable {
|
||||
@Volatile private var yieldRequested = false
|
||||
@Volatile private var closed = false
|
||||
|
||||
internal fun requestYield() {
|
||||
yieldRequested = true
|
||||
}
|
||||
|
||||
fun throwIfYieldRequested() {
|
||||
if (yieldRequested) throw SyncConversationYieldedException()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
arbiter.release(this)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val process = SyncConversationArbiter()
|
||||
}
|
||||
}
|
||||
|
|
@ -13,15 +13,8 @@ import org.json.JSONObject
|
|||
|
||||
internal sealed interface ForegroundGenerationResult {
|
||||
data class Completed(val runId: String) : ForegroundGenerationResult
|
||||
data object Busy : ForegroundGenerationResult
|
||||
data class Superseded(val runId: String?) : ForegroundGenerationResult
|
||||
data class Cancelled(val runId: String?) : ForegroundGenerationResult
|
||||
data class Failed(val message: String, val runId: String?) : ForegroundGenerationResult
|
||||
}
|
||||
|
||||
internal enum class SyncConversationIntent {
|
||||
NEW_EXPLICIT,
|
||||
RESUME_BACKGROUND,
|
||||
data class Error(val message: String) : ForegroundGenerationResult
|
||||
}
|
||||
|
||||
internal fun interface SyncGenerationTrace {
|
||||
|
|
@ -50,7 +43,6 @@ internal class SyncGenerationCoordinator(
|
|||
private val repository: TrainlogRepository,
|
||||
private val visibility: MtpPublicationVisibility = ImmediateMtpPublicationVisibility,
|
||||
private val trace: SyncGenerationTrace = AndroidSyncGenerationTrace,
|
||||
private val arbiter: SyncConversationArbiter = SyncConversationArbiter.process,
|
||||
) {
|
||||
private val service = SyncGenerationService(repository)
|
||||
|
||||
|
|
@ -62,6 +54,14 @@ internal class SyncGenerationCoordinator(
|
|||
result: String? = null,
|
||||
) = trace.record(runId, phase, artifact, generationId, result)
|
||||
|
||||
companion object {
|
||||
/* WHY: UI and background triggers share one repository protocol and
|
||||
* must never create concurrent runs. CONTRACT: this process-wide lock
|
||||
* guards only one bounded conversation; SQLite remains the durable
|
||||
* replay authority after process restart. */
|
||||
private val conversationLock = java.util.concurrent.locks.ReentrantLock()
|
||||
}
|
||||
|
||||
internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? =
|
||||
repository.inSyncGenerationTransaction { db ->
|
||||
db.rawQuery(
|
||||
|
|
@ -90,10 +90,10 @@ internal class SyncGenerationCoordinator(
|
|||
}
|
||||
}
|
||||
|
||||
private fun request(directory: File): JSONObject? {
|
||||
internal fun hasActionableRequest(directory: File): Boolean {
|
||||
val path = File(directory, "request-v1.json")
|
||||
if (!path.isFile || path.length() !in 1..64 * 1024) return null
|
||||
val request = runCatching { JSONObject(path.readText()) }.getOrNull() ?: return null
|
||||
if (!path.isFile || path.length() !in 1..64 * 1024) return false
|
||||
val request = runCatching { JSONObject(path.readText()) }.getOrNull() ?: return false
|
||||
val runId = request.optString("run_id")
|
||||
val desktopPeerId = request.optString("desktop_peer_id")
|
||||
if (
|
||||
|
|
@ -102,64 +102,16 @@ internal class SyncGenerationCoordinator(
|
|||
!runId.matches(Regex("^sy_[0-9a-f-]{36}$")) ||
|
||||
!desktopPeerId.matches(Regex("^peer_[0-9a-f-]{36}$")) ||
|
||||
request.optString("android_peer_id") != service.peerId()
|
||||
) return null
|
||||
return request
|
||||
}
|
||||
|
||||
private fun hasGeneration(runId: String): Boolean =
|
||||
repository.inSyncGenerationTransaction { db ->
|
||||
) return false
|
||||
if (resumableGeneration(runId, desktopPeerId) != null) return true
|
||||
return !repository.inSyncGenerationTransaction { db ->
|
||||
db.rawQuery(
|
||||
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
|
||||
arrayOf(runId),
|
||||
).use { it.moveToFirst() }
|
||||
}
|
||||
|
||||
internal fun remoteEvidence(directory: File, runId: String): String {
|
||||
val evidence =
|
||||
listOf(
|
||||
Triple("desktop-consumption-ack-v1.json", "trainlog-sync-ack", 1),
|
||||
Triple("desktop-generation-v1.json", "trainlog-sync-generation-reference", 1),
|
||||
)
|
||||
.mapNotNull { (name, format, version) ->
|
||||
val path = File(directory, name)
|
||||
if (!path.isFile || path.length() !in 1..64 * 1024) return@mapNotNull null
|
||||
val raw = runCatching { path.readBytes() }.getOrNull() ?: return@mapNotNull null
|
||||
val value = runCatching { JSONObject(raw.toString(Charsets.UTF_8)) }.getOrNull()
|
||||
?: return@mapNotNull null
|
||||
if (
|
||||
value.optString("format") != format ||
|
||||
value.optInt("version", -1) != version ||
|
||||
value.optString("run_id") != runId
|
||||
) return@mapNotNull null
|
||||
"$name:${java.security.MessageDigest.getInstance("SHA-256").digest(raw)
|
||||
.joinToString("") { "%02x".format(it) }}"
|
||||
}
|
||||
return evidence.sorted().joinToString("|")
|
||||
}
|
||||
|
||||
internal fun classifyBackgroundRequest(
|
||||
directory: File,
|
||||
terminal: BackgroundTerminalRun?,
|
||||
): BackgroundRequestActionability {
|
||||
val request = request(directory) ?: return BackgroundRequestActionability.NOT_ACTIONABLE
|
||||
val runId = request.getString("run_id")
|
||||
val desktopPeerId = request.getString("desktop_peer_id")
|
||||
val resumable = resumableGeneration(runId, desktopPeerId) != null
|
||||
if (terminal?.runId == runId) {
|
||||
val currentEvidence = remoteEvidence(directory, runId)
|
||||
return if (currentEvidence.isNotEmpty() && currentEvidence != terminal.remoteEvidence)
|
||||
BackgroundRequestActionability.NEW_REMOTE_EVIDENCE
|
||||
else if (resumable) BackgroundRequestActionability.RESUMABLE_BUT_STALE
|
||||
else BackgroundRequestActionability.NOT_ACTIONABLE
|
||||
}
|
||||
if (!hasGeneration(runId)) return BackgroundRequestActionability.NEW_REQUEST
|
||||
if (resumable) return BackgroundRequestActionability.ACTIVE_HANDOFF
|
||||
return BackgroundRequestActionability.NOT_ACTIONABLE
|
||||
}
|
||||
|
||||
internal fun currentRequestRunId(directory: File): String? =
|
||||
request(directory)?.optString("run_id")
|
||||
|
||||
internal fun hasReplayableAcknowledgement(directory: File): Boolean {
|
||||
val request =
|
||||
runCatching { JSONObject(File(directory, "request-v1.json").readText()) }.getOrNull()
|
||||
|
|
@ -335,10 +287,8 @@ internal class SyncGenerationCoordinator(
|
|||
deadline: Long,
|
||||
predicate: (JSONObject) -> Boolean,
|
||||
pump: (() -> Unit)? = null,
|
||||
lease: SyncConversationArbiter.Lease,
|
||||
): ByteArray {
|
||||
while (true) {
|
||||
lease.throwIfYieldRequested()
|
||||
if (path.isFile && path.length() <= 64 * 1024) {
|
||||
try {
|
||||
val raw = path.readBytes()
|
||||
|
|
@ -362,27 +312,11 @@ internal class SyncGenerationCoordinator(
|
|||
afterPublication: (() -> Unit)? = null,
|
||||
pollTransport: (() -> Unit)? = null,
|
||||
afterAcknowledgement: (() -> Unit)? = null,
|
||||
intent: SyncConversationIntent = SyncConversationIntent.RESUME_BACKGROUND,
|
||||
explicitClaim: SyncConversationArbiter.ExplicitClaim? = null,
|
||||
): ForegroundGenerationResult {
|
||||
val claim =
|
||||
if (intent == SyncConversationIntent.NEW_EXPLICIT)
|
||||
explicitClaim ?: arbiter.announceExplicit()
|
||||
else null
|
||||
val lease = try {
|
||||
claim?.acquire(timeout) ?: if (claim == null) arbiter.tryAcquireBackground() else null
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
claim?.close()
|
||||
return ForegroundGenerationResult.Cancelled(null)
|
||||
if (!conversationLock.tryLock()) {
|
||||
return ForegroundGenerationResult.Error("Synchronization already in progress.")
|
||||
}
|
||||
if (lease == null) {
|
||||
claim?.close()
|
||||
return ForegroundGenerationResult.Busy
|
||||
}
|
||||
var currentRunId: String? = null
|
||||
return try {
|
||||
val baselineRunId = request(directory)?.optString("run_id")
|
||||
val peer = publishPeer(directory)
|
||||
phase(null, "peer_published", "android-peer-v1.json")
|
||||
/* WHY: trainlog-syncd is intentionally driven by the established
|
||||
|
|
@ -391,23 +325,23 @@ internal class SyncGenerationCoordinator(
|
|||
* 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?.let {
|
||||
it.invoke()
|
||||
phase(null, "legacy_trigger_published", "trainlog-sync-request-v1.json")
|
||||
}
|
||||
afterPeerPublication?.invoke()
|
||||
phase(null, "legacy_trigger_published", "trainlog-sync-request-v1.json")
|
||||
val deadline = System.nanoTime() + timeout.toNanos()
|
||||
val requestRaw =
|
||||
awaitCorrelated(File(directory, "request-v1.json"), deadline, { candidate ->
|
||||
val runId = candidate.optString("run_id")
|
||||
runId.startsWith("sy_") && when (intent) {
|
||||
SyncConversationIntent.NEW_EXPLICIT -> runId != baselineRunId
|
||||
SyncConversationIntent.RESUME_BACKGROUND ->
|
||||
resumableGeneration(runId) != null || !hasGeneration(runId)
|
||||
}
|
||||
}, pollTransport, lease)
|
||||
runId.startsWith("sy_") &&
|
||||
(resumableGeneration(runId) != null ||
|
||||
!repository.inSyncGenerationTransaction { db ->
|
||||
db.rawQuery(
|
||||
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
|
||||
arrayOf(runId),
|
||||
).use { it.moveToFirst() }
|
||||
})
|
||||
}, pollTransport)
|
||||
val request = JSONObject(requestRaw.toString(Charsets.UTF_8))
|
||||
val runId = request.getString("run_id")
|
||||
currentRunId = runId
|
||||
phase(runId, "generation_request_observed", "request-v1.json")
|
||||
if (request.getString("android_peer_id") != peer)
|
||||
throw SyncGenerationException("request targets another Android peer")
|
||||
|
|
@ -420,7 +354,6 @@ internal class SyncGenerationCoordinator(
|
|||
it.optString("android_peer_id") == peer
|
||||
},
|
||||
pollTransport,
|
||||
lease,
|
||||
)
|
||||
reconcileArchiveAcknowledgements(
|
||||
archiveAckRaw,
|
||||
|
|
@ -437,7 +370,6 @@ internal class SyncGenerationCoordinator(
|
|||
* an acknowledged outbound generation remains resumable only to
|
||||
* finish its missing inbound half. */
|
||||
phase(runId, "generation_capture_started")
|
||||
lease.throwIfYieldRequested()
|
||||
val captured =
|
||||
resumableGeneration(
|
||||
request.getString("run_id"),
|
||||
|
|
@ -447,13 +379,11 @@ internal class SyncGenerationCoordinator(
|
|||
request.getString("desktop_peer_id"),
|
||||
request.getString("run_id"),
|
||||
)
|
||||
lease.throwIfYieldRequested()
|
||||
phase(runId, "generation_captured", generationId = captured.generationId)
|
||||
val published = service.publish(captured, File(directory, "android-objects"))
|
||||
visibility.confirm(
|
||||
published.walkTopDown().filter(File::isFile).sortedBy { it.name == "manifest.json" }.toList()
|
||||
)
|
||||
lease.throwIfYieldRequested()
|
||||
phase(
|
||||
runId,
|
||||
"generation_objects_published",
|
||||
|
|
@ -480,7 +410,6 @@ internal class SyncGenerationCoordinator(
|
|||
captured.generationId,
|
||||
)
|
||||
afterPublication?.invoke()
|
||||
lease.throwIfYieldRequested()
|
||||
// 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.
|
||||
|
|
@ -488,13 +417,13 @@ internal class SyncGenerationCoordinator(
|
|||
awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline, {
|
||||
it.optString("run_id") == captured.runId &&
|
||||
it.optString("generation_id") == captured.generationId
|
||||
}, pollTransport, lease)
|
||||
}, pollTransport)
|
||||
service.acceptAcknowledgement(desktopAck)
|
||||
phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId)
|
||||
val referenceRaw =
|
||||
awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline, {
|
||||
it.optString("run_id") == captured.runId
|
||||
}, pollTransport, lease)
|
||||
}, pollTransport)
|
||||
val reference = JSONObject(referenceRaw.toString(Charsets.UTF_8))
|
||||
phase(
|
||||
runId,
|
||||
|
|
@ -529,13 +458,6 @@ internal class SyncGenerationCoordinator(
|
|||
afterAcknowledgement?.invoke()
|
||||
phase(runId, "completed", result = "completed")
|
||||
ForegroundGenerationResult.Completed(captured.runId)
|
||||
} catch (_: SyncConversationYieldedException) {
|
||||
phase(currentRunId, "superseded", result = "foreground_priority")
|
||||
ForegroundGenerationResult.Superseded(currentRunId)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
phase(currentRunId, "cancelled", result = "interrupted")
|
||||
ForegroundGenerationResult.Cancelled(currentRunId)
|
||||
} catch (error: Exception) {
|
||||
if (error.message == "generation retention capacity exhausted") {
|
||||
try {
|
||||
|
|
@ -564,13 +486,9 @@ internal class SyncGenerationCoordinator(
|
|||
"error",
|
||||
result = error.javaClass.simpleName,
|
||||
)
|
||||
ForegroundGenerationResult.Failed(
|
||||
error.message ?: "generation synchronization failed",
|
||||
currentRunId,
|
||||
)
|
||||
ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed")
|
||||
} finally {
|
||||
lease.close()
|
||||
claim?.close()
|
||||
conversationLock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,9 +29,6 @@ import com.labfytools.trainlog.data.SyncRequestOutbox
|
|||
import com.labfytools.trainlog.data.SyncRequestResult
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.data.ForegroundGenerationResult
|
||||
import com.labfytools.trainlog.data.BackgroundSyncRunLedger
|
||||
import com.labfytools.trainlog.data.SyncConversationArbiter
|
||||
import com.labfytools.trainlog.data.SyncConversationIntent
|
||||
import com.labfytools.trainlog.data.SyncGenerationForegroundCoordinator
|
||||
import com.labfytools.trainlog.data.BackgroundSyncSettings
|
||||
import com.labfytools.trainlog.data.SyncBackgroundService
|
||||
|
|
@ -55,72 +52,39 @@ internal suspend fun publishBundleAndRequest(
|
|||
exporter: SyncExporter,
|
||||
requestOutbox: SyncRequestOutbox,
|
||||
visibility: MtpPublicationVisibility = com.labfytools.trainlog.data.ImmediateMtpPublicationVisibility,
|
||||
explicitClaim: SyncConversationArbiter.ExplicitClaim =
|
||||
SyncConversationArbiter.process.announceExplicit(),
|
||||
terminalLedger: BackgroundSyncRunLedger? = null,
|
||||
): SyncRequestResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
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
|
||||
/* CONTRACT: the request signal remains compatible with an unconfigured
|
||||
* desktop, so publish the strict standalone V3 snapshot before admission.
|
||||
* A configured daemon still routes the signal exclusively to the complete
|
||||
* generation below; this compatibility publication never selects V3. */
|
||||
when (val publication = exporter.exportMobileBundle(snapshot)) {
|
||||
SyncExportResult.Unsupported -> return@withContext SyncRequestResult.Unsupported
|
||||
is SyncExportResult.Error ->
|
||||
return@withContext SyncRequestResult.Error("prepare:${publication.message}")
|
||||
is SyncExportResult.Exported -> Unit
|
||||
}
|
||||
val directory = canonicalExchangeDirectory()
|
||||
val coordinator = SyncGenerationForegroundCoordinator(repository, visibility)
|
||||
return@withContext when (
|
||||
val result = coordinator.run(
|
||||
directory,
|
||||
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.")
|
||||
}
|
||||
},
|
||||
intent = SyncConversationIntent.NEW_EXPLICIT,
|
||||
explicitClaim = explicitClaim,
|
||||
)
|
||||
) {
|
||||
is ForegroundGenerationResult.Completed -> SyncRequestResult.Completed(result.runId)
|
||||
ForegroundGenerationResult.Busy ->
|
||||
SyncRequestResult.Error("sync_interrupted")
|
||||
is ForegroundGenerationResult.Superseded ->
|
||||
SyncRequestResult.Error("sync_interrupted").also {
|
||||
result.runId?.let { runId ->
|
||||
terminalLedger?.recordTerminal(
|
||||
runId,
|
||||
coordinator.remoteEvidence(directory, runId),
|
||||
)
|
||||
}
|
||||
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
|
||||
/* CONTRACT: the request signal remains compatible with an unconfigured
|
||||
* desktop, so publish the strict standalone V3 snapshot before admission.
|
||||
* A configured daemon still routes the signal exclusively to the complete
|
||||
* generation below; this compatibility publication never selects V3. */
|
||||
when (val publication = exporter.exportMobileBundle(snapshot)) {
|
||||
SyncExportResult.Unsupported -> return@withContext SyncRequestResult.Unsupported
|
||||
is SyncExportResult.Error ->
|
||||
return@withContext SyncRequestResult.Error("prepare:${publication.message}")
|
||||
is SyncExportResult.Exported -> Unit
|
||||
}
|
||||
return@withContext when (
|
||||
val result = SyncGenerationForegroundCoordinator(repository, visibility).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.Cancelled ->
|
||||
SyncRequestResult.Error("sync_interrupted")
|
||||
is ForegroundGenerationResult.Failed ->
|
||||
SyncRequestResult.Error(result.message).also {
|
||||
result.runId?.let { runId ->
|
||||
terminalLedger?.recordTerminal(
|
||||
runId,
|
||||
coordinator.remoteEvidence(directory, runId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
explicitClaim.close()
|
||||
},
|
||||
)
|
||||
) {
|
||||
is ForegroundGenerationResult.Completed -> SyncRequestResult.Completed(result.runId)
|
||||
is ForegroundGenerationResult.Error -> SyncRequestResult.Error(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -183,11 +147,8 @@ fun SyncScreen(
|
|||
add(strings.resources.getQuantityString(R.plurals.catalog_reconciled, reconciled, reconciled))
|
||||
skipped?.let { add(strings.resources.getQuantityString(R.plurals.catalog_present, it, it)) }
|
||||
}.joinToString(", ")
|
||||
fun visibleError(raw: String): String = when {
|
||||
raw == "sync_interrupted" -> strings.getString(R.string.sync_interrupted)
|
||||
raw.startsWith("prepare:") ->
|
||||
strings.getString(R.string.sync_prepare_error, raw.removePrefix("prepare:"))
|
||||
else -> raw
|
||||
fun visibleError(raw: String): String = raw.removePrefix("prepare:").let {
|
||||
if (raw.startsWith("prepare:")) strings.getString(R.string.sync_prepare_error, it) else raw
|
||||
}
|
||||
|
||||
var status by
|
||||
|
|
@ -432,15 +393,15 @@ fun SyncScreen(
|
|||
* published from one prepared repository snapshot before
|
||||
* that signal becomes visible; otherwise the PC can mix a
|
||||
* fresh V3 file with stale or absent causal companions. */
|
||||
/* WHY: Samsung may freeze the activity after USB mode
|
||||
* changes or a permission surface opens. CONTRACT:
|
||||
* announce explicit priority before waking the service.
|
||||
* The service may keep Android alive, but cannot become
|
||||
* conversation owner while this claim is pending. */
|
||||
val explicitClaim = SyncConversationArbiter.process.announceExplicit()
|
||||
if (backgroundSettings.enabled) SyncBackgroundService.start(context)
|
||||
if (backgroundSettings.enabled) {
|
||||
/* WHY: Samsung may freeze the activity after USB mode
|
||||
* changes or a permission surface opens. CONTRACT: a
|
||||
* user-enabled foreground listener is refreshed before
|
||||
* the UI-owned conversation begins; the process lock
|
||||
* prevents it from creating a second generation. */
|
||||
SyncBackgroundService.start(context)
|
||||
}
|
||||
syncRunning = true
|
||||
status = strings.getString(R.string.sync_running)
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
when (
|
||||
|
|
@ -449,8 +410,6 @@ fun SyncScreen(
|
|||
exporter,
|
||||
requestOutbox,
|
||||
AndroidMtpPublicationVisibility(context),
|
||||
explicitClaim,
|
||||
BackgroundSyncRunLedger(context),
|
||||
)
|
||||
) {
|
||||
is SyncRequestResult.Completed -> {
|
||||
|
|
@ -476,7 +435,6 @@ fun SyncScreen(
|
|||
}
|
||||
}
|
||||
} finally {
|
||||
explicitClaim.close()
|
||||
syncRunning = false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,6 @@
|
|||
<string name="sync_action">Synchronize</string>
|
||||
<string name="sync_snapshot_automatic">The Android snapshot is maintained automatically.</string>
|
||||
<string name="sync_running">Synchronization in progress…</string>
|
||||
<string name="sync_interrupted">Synchronization interrupted. You can start a new attempt.</string>
|
||||
<string name="sync_now">Synchronize now</string>
|
||||
<string name="background_sync_title">Background synchronization</string>
|
||||
<string name="background_sync_description">Allows Trainlog to receive PC synchronizations without opening the app.</string>
|
||||
|
|
|
|||
|
|
@ -94,7 +94,6 @@
|
|||
<string name="sync_action">Synchroniser</string>
|
||||
<string name="sync_snapshot_automatic">Le snapshot Android est maintenu automatiquement.</string>
|
||||
<string name="sync_running">Synchronisation en cours…</string>
|
||||
<string name="sync_interrupted">Synchronisation interrompue. Une nouvelle tentative peut être lancée.</string>
|
||||
<string name="sync_now">Synchroniser maintenant</string>
|
||||
<string name="background_sync_title">Synchronisation en arrière-plan</string>
|
||||
<string name="background_sync_description">Permet à Trainlog de recevoir les synchronisations du PC sans ouvrir l’application.</string>
|
||||
|
|
|
|||
|
|
@ -1,251 +0,0 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.time.Duration
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class SyncConversationOwnershipTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
private fun request(root: File, runId: String, androidPeer: String, desktopPeer: String) {
|
||||
File(root, "request-v1.json").writeText(
|
||||
JSONObject()
|
||||
.put("format", "trainlog-sync-generation-request")
|
||||
.put("version", 1)
|
||||
.put("run_id", runId)
|
||||
.put("android_peer_id", androidPeer)
|
||||
.put("desktop_peer_id", desktopPeer)
|
||||
.toString()
|
||||
)
|
||||
}
|
||||
|
||||
private fun archiveAcknowledgements(
|
||||
root: File,
|
||||
runId: String,
|
||||
androidPeer: String,
|
||||
desktopPeer: String,
|
||||
) {
|
||||
File(root, "desktop-archive-acknowledgements-v1.json").writeText(
|
||||
JSONObject()
|
||||
.put("format", "trainlog-sync-archive-acknowledgements")
|
||||
.put("version", 1)
|
||||
.put("run_id", runId)
|
||||
.put("android_peer_id", androidPeer)
|
||||
.put("desktop_peer_id", desktopPeer)
|
||||
.put("acknowledgements", JSONArray())
|
||||
.toString()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitClickYieldsOldBackgroundAndPublishesFreshTriggerForNewRun() {
|
||||
val databaseName = "generation-ownership-${UUID.randomUUID()}.db"
|
||||
val root = Files.createTempDirectory("trainlog-generation-ownership-").toFile()
|
||||
val repository = TrainlogRepository(context, databaseName)
|
||||
val arbiter = SyncConversationArbiter()
|
||||
val oldObserved = CountDownLatch(1)
|
||||
val capturedRuns = mutableListOf<String>()
|
||||
val desktopPeer = "peer_${UUID.randomUUID()}"
|
||||
val oldRun = "sy_${UUID.randomUUID()}"
|
||||
val newRun = "sy_${UUID.randomUUID()}"
|
||||
val service = SyncGenerationService(repository)
|
||||
val background =
|
||||
SyncGenerationCoordinator(
|
||||
repository,
|
||||
arbiter = arbiter,
|
||||
trace = SyncGenerationTrace { runId, phase, _, _, _ ->
|
||||
if (phase == "generation_request_observed" && runId == oldRun) oldObserved.countDown()
|
||||
},
|
||||
)
|
||||
val foreground =
|
||||
SyncGenerationCoordinator(
|
||||
repository,
|
||||
arbiter = arbiter,
|
||||
trace = SyncGenerationTrace { runId, phase, _, _, _ ->
|
||||
if (phase == "generation_captured") synchronized(capturedRuns) {
|
||||
capturedRuns += checkNotNull(runId)
|
||||
}
|
||||
},
|
||||
)
|
||||
val executor = java.util.concurrent.Executors.newFixedThreadPool(2)
|
||||
try {
|
||||
request(root, oldRun, service.peerId(), desktopPeer)
|
||||
File(root, "trainlog-sync-request-v1.json").writeText(
|
||||
"""{"format":"trainlog-sync-request","version":1,"request_id":"sr_old"}"""
|
||||
)
|
||||
val old = executor.submit<ForegroundGenerationResult> {
|
||||
background.run(root, Duration.ofSeconds(3))
|
||||
}
|
||||
assertTrue(oldObserved.await(2, TimeUnit.SECONDS))
|
||||
|
||||
val claim = arbiter.announceExplicit()
|
||||
val snapshot =
|
||||
(DirectExchangePublisher { directExchangeDirectoryForPath(root) }.snapshot()
|
||||
as DirectExchangeSnapshotResult.Ready).snapshot
|
||||
val outbox = SyncRequestOutbox { directExchangeDirectoryForPath(root) }
|
||||
var freshRequestId = ""
|
||||
val explicit = executor.submit<ForegroundGenerationResult> {
|
||||
foreground.run(
|
||||
root,
|
||||
Duration.ofMillis(700),
|
||||
afterPeerPublication = {
|
||||
freshRequestId =
|
||||
(outbox.requestSync(snapshot) as SyncRequestResult.Requested).requestId
|
||||
request(root, newRun, service.peerId(), desktopPeer)
|
||||
archiveAcknowledgements(root, newRun, service.peerId(), desktopPeer)
|
||||
},
|
||||
intent = SyncConversationIntent.NEW_EXPLICIT,
|
||||
explicitClaim = claim,
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(old.get(2, TimeUnit.SECONDS) is ForegroundGenerationResult.Superseded)
|
||||
assertTrue(explicit.get(3, TimeUnit.SECONDS) is ForegroundGenerationResult.Failed)
|
||||
assertNotEquals("sr_old", freshRequestId)
|
||||
assertEquals(
|
||||
freshRequestId,
|
||||
JSONObject(File(root, "trainlog-sync-request-v1.json").readText())
|
||||
.getString("request_id"),
|
||||
)
|
||||
assertEquals(listOf(newRun), capturedRuns)
|
||||
repository.inSyncGenerationTransaction { database ->
|
||||
database.rawQuery("SELECT run_id FROM sync_generations", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(newRun, cursor.getString(0))
|
||||
assertEquals(false, cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
executor.shutdownNow()
|
||||
repository.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalBackgroundRunStaysSuppressedUntilNewRequestOrRemoteEvidence() {
|
||||
val databaseName = "generation-suppression-${UUID.randomUUID()}.db"
|
||||
val root = Files.createTempDirectory("trainlog-generation-suppression-").toFile()
|
||||
val repository = TrainlogRepository(context, databaseName)
|
||||
try {
|
||||
val service = SyncGenerationService(repository)
|
||||
val coordinator = SyncGenerationCoordinator(repository, arbiter = SyncConversationArbiter())
|
||||
val desktopPeer = "peer_${UUID.randomUUID()}"
|
||||
val runId = "sy_${UUID.randomUUID()}"
|
||||
request(root, runId, service.peerId(), desktopPeer)
|
||||
service.capture(root, desktopPeer, runId)
|
||||
val terminal = BackgroundTerminalRun(runId, coordinator.remoteEvidence(root, runId))
|
||||
|
||||
repeat(5) {
|
||||
assertEquals(
|
||||
BackgroundRequestActionability.RESUMABLE_BUT_STALE,
|
||||
coordinator.classifyBackgroundRequest(root, terminal),
|
||||
)
|
||||
}
|
||||
|
||||
File(root, "desktop-consumption-ack-v1.json").writeText(
|
||||
JSONObject()
|
||||
.put("format", "trainlog-sync-ack")
|
||||
.put("version", 1)
|
||||
.put("run_id", runId)
|
||||
.put("proof", "new")
|
||||
.toString()
|
||||
)
|
||||
assertEquals(
|
||||
BackgroundRequestActionability.NEW_REMOTE_EVIDENCE,
|
||||
coordinator.classifyBackgroundRequest(root, terminal),
|
||||
)
|
||||
|
||||
request(root, "sy_${UUID.randomUUID()}", service.peerId(), desktopPeer)
|
||||
assertEquals(
|
||||
BackgroundRequestActionability.NEW_REQUEST,
|
||||
coordinator.classifyBackgroundRequest(root, terminal),
|
||||
)
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancelledExplicitConversationHandsSameGenerationToBackgroundWithoutDuplication() {
|
||||
val databaseName = "generation-handoff-${UUID.randomUUID()}.db"
|
||||
val root = Files.createTempDirectory("trainlog-generation-handoff-").toFile()
|
||||
val repository = TrainlogRepository(context, databaseName)
|
||||
val arbiter = SyncConversationArbiter()
|
||||
val published = CountDownLatch(1)
|
||||
val result = AtomicReference<ForegroundGenerationResult>()
|
||||
val desktopPeer = "peer_${UUID.randomUUID()}"
|
||||
val oldRunId = "sy_${UUID.randomUUID()}"
|
||||
val runId = "sy_${UUID.randomUUID()}"
|
||||
try {
|
||||
val service = SyncGenerationService(repository)
|
||||
request(root, oldRunId, service.peerId(), desktopPeer)
|
||||
val coordinator =
|
||||
SyncGenerationCoordinator(
|
||||
repository,
|
||||
arbiter = arbiter,
|
||||
trace = SyncGenerationTrace { _, phase, _, _, _ ->
|
||||
if (phase == "generation_reference_published") published.countDown()
|
||||
},
|
||||
)
|
||||
val claim = arbiter.announceExplicit()
|
||||
val thread = Thread {
|
||||
result.set(
|
||||
coordinator.run(
|
||||
root,
|
||||
Duration.ofSeconds(5),
|
||||
afterPeerPublication = {
|
||||
request(root, runId, service.peerId(), desktopPeer)
|
||||
archiveAcknowledgements(root, runId, service.peerId(), desktopPeer)
|
||||
},
|
||||
intent = SyncConversationIntent.NEW_EXPLICIT,
|
||||
explicitClaim = claim,
|
||||
)
|
||||
)
|
||||
}
|
||||
thread.start()
|
||||
assertTrue(published.await(3, TimeUnit.SECONDS))
|
||||
thread.interrupt()
|
||||
thread.join(3_000)
|
||||
assertTrue(result.get() is ForegroundGenerationResult.Cancelled)
|
||||
assertEquals(
|
||||
BackgroundRequestActionability.ACTIVE_HANDOFF,
|
||||
coordinator.classifyBackgroundRequest(root, null),
|
||||
)
|
||||
|
||||
val resumed = coordinator.run(root, Duration.ofMillis(250))
|
||||
assertTrue(resumed is ForegroundGenerationResult.Failed)
|
||||
repository.inSyncGenerationTransaction { database ->
|
||||
database.rawQuery("SELECT COUNT(*) FROM sync_generations WHERE run_id=?", arrayOf(runId))
|
||||
.use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(1, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -38,8 +38,12 @@ class SyncGenerationConversationLockTest {
|
|||
}
|
||||
assertTrue(entered.await(2, TimeUnit.SECONDS))
|
||||
val duplicate = background.run(root, Duration.ofMillis(10))
|
||||
assertEquals(ForegroundGenerationResult.Busy, duplicate)
|
||||
assertTrue(active.get(2, TimeUnit.SECONDS) is ForegroundGenerationResult.Failed)
|
||||
assertTrue(duplicate is ForegroundGenerationResult.Error)
|
||||
assertEquals(
|
||||
"Synchronization already in progress.",
|
||||
(duplicate as ForegroundGenerationResult.Error).message,
|
||||
)
|
||||
assertTrue(active.get(2, TimeUnit.SECONDS) is ForegroundGenerationResult.Error)
|
||||
repository.inSyncGenerationTransaction { database ->
|
||||
database.rawQuery("SELECT COUNT(*) FROM sync_generations", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -357,15 +357,9 @@ class SyncGenerationServiceTest {
|
|||
val repository = TrainlogRepository(context, databaseName)
|
||||
try {
|
||||
val coordinator = SyncGenerationCoordinator(repository)
|
||||
assertEquals(
|
||||
BackgroundRequestActionability.NOT_ACTIONABLE,
|
||||
coordinator.classifyBackgroundRequest(root, null),
|
||||
)
|
||||
assertEquals(false, coordinator.hasActionableRequest(root))
|
||||
File(root, "request-v1.json").writeText("not-json")
|
||||
assertEquals(
|
||||
BackgroundRequestActionability.NOT_ACTIONABLE,
|
||||
coordinator.classifyBackgroundRequest(root, null),
|
||||
)
|
||||
assertEquals(false, coordinator.hasActionableRequest(root))
|
||||
|
||||
val peer = SyncGenerationService(repository).peerId()
|
||||
val request =
|
||||
|
|
@ -376,17 +370,11 @@ class SyncGenerationServiceTest {
|
|||
.put("android_peer_id", "peer_${UUID.randomUUID()}")
|
||||
.put("desktop_peer_id", "peer_${UUID.randomUUID()}")
|
||||
File(root, "request-v1.json").writeText(request.toString())
|
||||
assertEquals(
|
||||
BackgroundRequestActionability.NOT_ACTIONABLE,
|
||||
coordinator.classifyBackgroundRequest(root, null),
|
||||
)
|
||||
assertEquals(false, coordinator.hasActionableRequest(root))
|
||||
|
||||
request.put("android_peer_id", peer)
|
||||
File(root, "request-v1.json").writeText(request.toString())
|
||||
assertEquals(
|
||||
BackgroundRequestActionability.NEW_REQUEST,
|
||||
coordinator.classifyBackgroundRequest(root, null),
|
||||
)
|
||||
assertEquals(true, coordinator.hasActionableRequest(root))
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
|
|
|
|||
|
|
@ -1077,24 +1077,11 @@ Manifest/reference remain the commit markers; scanning does not change their
|
|||
bytes or protocol meaning. Production logging records only `run_id`, phase,
|
||||
artifact name, generation ID and result.
|
||||
|
||||
When background synchronization is enabled, an explicit UI request announces
|
||||
foreground ownership before refreshing the foreground listener. Service
|
||||
liveness and conversation ownership are separate: the service may keep the
|
||||
process alive, but it cannot acquire automatic ownership while explicit intent
|
||||
is pending. The process-wide arbiter gives `NEW_EXPLICIT` priority over
|
||||
`RESUME_BACKGROUND`; a background waiter observes cooperative yield, returns a
|
||||
typed superseded result, and releases ownership before the foreground emits a
|
||||
fresh request UUID. The explicit coordinator baselines the existing desktop
|
||||
run and accepts only a different run after that trigger, so it cannot attach
|
||||
the click to a stale `request-v1.json`.
|
||||
|
||||
An interrupted explicit conversation leaves captured/published generations
|
||||
durable. The listener may resume that same run without recapture or a second
|
||||
generation. Automatic actionability separately classifies a new request, new
|
||||
correlated desktop ACK/reference evidence, a legitimate active handoff, and a
|
||||
stale resumable generation. A locally terminal run is suppressed until its
|
||||
run ID changes or its correlated remote evidence advances; mere presence of a
|
||||
resumable generation does not schedule the same run every five seconds.
|
||||
When background synchronization is enabled, an explicit UI request refreshes
|
||||
the foreground listener before starting the UI-owned conversation. The shared
|
||||
process lock prevents a second run while the UI coordinator is alive; if the
|
||||
activity is frozen or destroyed, the listener can validate and resume the
|
||||
durable request without inventing another generation.
|
||||
|
||||
The Mensurations grid uses two 140 dp compact fields plus a 10 dp gutter at
|
||||
normal phone widths. The deployed 1080 px/480 dpi phone supplies approximately
|
||||
|
|
|
|||
|
|
@ -7,14 +7,7 @@ basis without rolling back USB. Both transports move the same generation/ACK
|
|||
bytes; neither transports SQLite. Android has an explicitly enabled foreground
|
||||
USB listener with mandatory notification and a private-folder Drive grant with
|
||||
bounded WorkManager checks. Both use the same concurrency-guarded generation
|
||||
coordinator as SyncScreen. That coordinator has one process-wide ownership
|
||||
arbiter: explicit foreground intent has priority over automatic background
|
||||
resumption, while the foreground service may remain alive without owning the
|
||||
conversation. An automatic run yields cooperatively at safe boundaries; its
|
||||
durable generation is retained for a correlated handoff rather than deleted.
|
||||
Background actionability distinguishes a new request or new correlated remote
|
||||
evidence from a merely stale resumable generation, preventing five-second
|
||||
terminal retry loops.
|
||||
coordinator as SyncScreen.
|
||||
|
||||
Direct-MTP publication now has an explicit Android visibility boundary.
|
||||
Durable immutable generation files are submitted to MediaProvider and all scan
|
||||
|
|
@ -27,18 +20,13 @@ individual adapter budget is distinct from the overall conversation deadline,
|
|||
so an absent correlated generation reports a protocol wait timeout while an
|
||||
actually blocked adapter reports `transport_timeout`.
|
||||
|
||||
The resumed private run `sy_9639f06e-58ba-4bc0-ab1b-1488aefe011c` received its
|
||||
desktop request and archive-ACK files, captured generation
|
||||
`gen_532da95e-713f-4163-acc1-d838673c33bf`, and published its immutable objects
|
||||
and reference with manifest SHA-256
|
||||
`170b55ad5bf3e44501fb506107cd26ab807d0179c95a5f1bf73f8251ea1a14e8`.
|
||||
Filesystem and MTP exposed the same bytes. It then waited for a desktop ACK
|
||||
that could not arrive because the daemon correctly refused to create another
|
||||
conversation for the already-consumed request UUID. No causal or business
|
||||
mutation occurred. The remaining defect was Android ownership: an automatic
|
||||
resume could hold the old run while the explicit foreground attempt failed
|
||||
before publishing a fresh request UUID. The arbiter and explicit run baseline
|
||||
now prevent that state.
|
||||
The failed private run `sy_9639f06e-58ba-4bc0-ab1b-1488aefe011c` received its
|
||||
desktop request and archive-ACK files on the Android filesystem, but created no
|
||||
Android generation row/reference/object. System evidence shows the activity
|
||||
was moved to Android's file-access settings and frozen before capture. The
|
||||
retained Android generation reference and MTP bytes both belonged to an older
|
||||
run; no hidden new generation was found. No causal or business mutation
|
||||
occurred.
|
||||
|
||||
Existing preparation rows expose separate editing and Android-delivery states
|
||||
and an accessible send action. Draft/local advances the same preparation to a
|
||||
|
|
|
|||
|
|
@ -1555,24 +1555,14 @@ the measured 312 dp phone content width as two columns, a true 280 dp narrow
|
|||
layout as one column, 1.5 font-scale fallback, long French labels and `kg`/`cm`
|
||||
suffixes.
|
||||
|
||||
Android conversation-ownership regressions reproduce the production race: an
|
||||
automatic owner waiting on an old resumable run yields to explicit foreground
|
||||
intent, the click publishes a fresh request UUID, the explicit coordinator
|
||||
ignores the pre-click desktop run, and exactly one generation is captured for
|
||||
the new run. Additional fixtures cover typed cancellation and durable handoff,
|
||||
terminal-run suppression across five scheduling cycles, reactivation by new
|
||||
correlated desktop evidence, and exclusive ownership. Desktop routing tests
|
||||
retain the complementary invariant that an already-consumed Android request
|
||||
UUID cannot create a second daemon run.
|
||||
|
||||
The resumed run `sy_9639f06e-58ba-4bc0-ab1b-1488aefe011c` subsequently proved
|
||||
the MTP publication path: Android captured
|
||||
`gen_532da95e-713f-4163-acc1-d838673c33bf`, published the immutable objects and
|
||||
reference, and exposed bytes identical to the filesystem through MTP. It
|
||||
stopped after `generation_reference_published` because no ACK could be created
|
||||
for the daemon-deduplicated old request UUID. This evidence supersedes the
|
||||
earlier pre-resume forensic snapshot without rewriting or deleting the
|
||||
incomplete generation.
|
||||
Read-only forensics for failed run
|
||||
`sy_9639f06e-58ba-4bc0-ab1b-1488aefe011c` found the correlated desktop request
|
||||
and archive acknowledgements on Android, but no correlated generation
|
||||
reference, immutable directory or error record. The direct filesystem and MTP
|
||||
views contained identical bytes for the retained older Android reference.
|
||||
Logcat showed Android moving Trainlog behind the file-access settings surface
|
||||
and freezing it before capture. The post-v19 backup predates this run and is
|
||||
not presented as SQLite evidence for it.
|
||||
|
||||
The authorized 2026-09-17 rollout added regression coverage for production
|
||||
failures found only after installation: feedback import under `sqlite3.Row`,
|
||||
|
|
|
|||
|
|
@ -74,58 +74,6 @@ class SyncdRoutingTest(unittest.TestCase):
|
|||
self.assertEqual(trigger, state["trigger"])
|
||||
self.assertEqual("full_generation_v1", state["effective_mode"])
|
||||
|
||||
def test_consumed_request_id_does_not_create_a_second_desktop_run(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
transport = root / "transport"
|
||||
transport.mkdir()
|
||||
request = "sr_22222222-2222-4222-8222-222222222222"
|
||||
config = root / "config.json"
|
||||
config.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"format": "trainlog-sync-orchestrator-config",
|
||||
"version": 1,
|
||||
"enabled": True,
|
||||
"mode": "mtp",
|
||||
"expected_peer_id": "peer_11111111-1111-4111-8111-111111111111",
|
||||
"transport_root": str(transport),
|
||||
"owned_root": str(root / "owned"),
|
||||
"timeout_seconds": 30,
|
||||
}
|
||||
)
|
||||
)
|
||||
state = root / "trainlog.db.sync-run.json"
|
||||
state.write_text(json.dumps({"request_id": request, "run_id": "sy_old"}))
|
||||
args = argparse.Namespace(
|
||||
mtp_adapter=root / "adapter",
|
||||
state=state,
|
||||
trigger="android",
|
||||
orchestrator=root / "sync_orchestrator.py",
|
||||
database=root / "trainlog.db",
|
||||
)
|
||||
commands = []
|
||||
|
||||
def execute(command, **_kwargs):
|
||||
commands.append(command)
|
||||
(transport / syncd.REQUEST_NAME).write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"format": "trainlog-sync-request",
|
||||
"version": 1,
|
||||
"request_id": request,
|
||||
"requested_at": "2026-09-20T12:00:00Z",
|
||||
}
|
||||
)
|
||||
)
|
||||
return subprocess.CompletedProcess(command, 0, "PASS\n", "")
|
||||
|
||||
with mock.patch.object(syncd.subprocess, "run", side_effect=execute):
|
||||
self.assertEqual(3, syncd.run_full_generation(args, config))
|
||||
self.assertEqual(str(args.mtp_adapter), commands[0][0])
|
||||
self.assertEqual(1, len(commands))
|
||||
self.assertEqual("sy_old", json.loads(state.read_text())["run_id"])
|
||||
|
||||
def test_android_production_trigger_no_longer_uses_opt_in_or_legacy_export(self):
|
||||
source = (ROOT / "android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt").read_text()
|
||||
production = source.split("internal suspend fun publishBundleAndRequest", 1)[1].split(
|
||||
|
|
|
|||
Loading…
Reference in a new issue