Compare commits
No commits in common. "ca2ea9379bf234a27842dc08da214d1ca69e37ac" and "ba5021b436ca37ebf133b3356c1ea2f741a8af56" have entirely different histories.
ca2ea9379b
...
ba5021b436
19 changed files with 42 additions and 666 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -4,19 +4,6 @@
|
|||
|
||||
Development opened after the stable v0.1.2 release.
|
||||
|
||||
- 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
|
||||
foreground request, and confirms MediaProvider/MTP visibility for immutable
|
||||
generation objects before publishing their reference. Desktop MTP pulls are
|
||||
throttled to one complete refresh every three seconds, keep a separate
|
||||
30-second operation budget, and report expiration of the conversation as a
|
||||
correlated protocol timeout instead of a misleading final one-second
|
||||
transport timeout. Explicit requests continue to receive fresh UUIDs.
|
||||
Mensurations now retains two compact columns at the deployed phone's 312 dp
|
||||
content width, with a narrow/large-font fallback. Schemas, causal semantics,
|
||||
generation formats, ACK formats and standalone V3 strictness are unchanged.
|
||||
|
||||
- Routed Android foreground and daemon-triggered daily synchronization through
|
||||
the existing full-generation orchestrator whenever its trusted local
|
||||
configuration is present. The standalone mobile V3 importer remains strict
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaScannerConnection
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Confirms that durable external-storage files have reached Android's MTP index. */
|
||||
internal fun interface MtpPublicationVisibility {
|
||||
fun confirm(files: List<File>)
|
||||
}
|
||||
|
||||
internal object ImmediateMtpPublicationVisibility : MtpPublicationVisibility {
|
||||
override fun confirm(files: List<File>) = Unit
|
||||
}
|
||||
|
||||
internal class AndroidMtpPublicationVisibility(context: Context) : MtpPublicationVisibility {
|
||||
private val applicationContext = context.applicationContext
|
||||
|
||||
override fun confirm(files: List<File>) {
|
||||
val paths = files.asSequence().filter(File::isFile).map { it.absolutePath }.distinct().toList()
|
||||
if (paths.isEmpty()) return
|
||||
val completed = CountDownLatch(paths.size)
|
||||
/* WHY: direct File writes are durable before MediaProvider necessarily
|
||||
* advertises them through Samsung's MTP database. CONTRACT: every
|
||||
* immutable artifact is scanned before its mutable reference, and a
|
||||
* publication is not reported complete until every callback arrives.
|
||||
* INVARIANT: scanning changes transport visibility only; canonical
|
||||
* names, bytes, generation identities and commit markers are intact. */
|
||||
MediaScannerConnection.scanFile(
|
||||
applicationContext,
|
||||
paths.toTypedArray(),
|
||||
Array(paths.size) { "application/json" },
|
||||
) { _, _ -> completed.countDown() }
|
||||
if (!completed.await(15, TimeUnit.SECONDS)) {
|
||||
throw SyncGenerationException("timeout confirming MTP publication visibility")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -72,10 +72,7 @@ class SyncBackgroundService : Service() {
|
|||
}
|
||||
if (!launched.compareAndSet(false, true)) return START_NOT_STICKY
|
||||
executor.execute {
|
||||
val coordinator = SyncGenerationCoordinator(
|
||||
requireNotNull(repository),
|
||||
AndroidMtpPublicationVisibility(applicationContext),
|
||||
)
|
||||
val coordinator = SyncGenerationCoordinator(requireNotNull(repository))
|
||||
while (!stopped.get() && BackgroundSyncSettings(this).enabled) {
|
||||
val directory = canonicalExchangeDirectory()
|
||||
/* WHY: waiting inside run() owns the process-wide conversation
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.nio.file.AtomicMoveNotSupportedException
|
||||
|
|
@ -17,43 +16,10 @@ internal sealed interface ForegroundGenerationResult {
|
|||
data class Error(val message: String) : ForegroundGenerationResult
|
||||
}
|
||||
|
||||
internal fun interface SyncGenerationTrace {
|
||||
fun record(runId: String?, phase: String, artifact: String?, generationId: String?, result: String?)
|
||||
}
|
||||
|
||||
internal object AndroidSyncGenerationTrace : SyncGenerationTrace {
|
||||
override fun record(
|
||||
runId: String?, phase: String, artifact: String?, generationId: String?, result: String?
|
||||
) {
|
||||
Log.i(
|
||||
"TrainlogSyncGeneration",
|
||||
listOfNotNull(
|
||||
"run_id=${runId ?: "pending"}",
|
||||
"phase=$phase",
|
||||
artifact?.let { "artifact=$it" },
|
||||
generationId?.let { "generation_id=$it" },
|
||||
result?.let { "result=$it" },
|
||||
).joinToString(" "),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Owns the bounded, user-initiated Android side of a generation conversation. */
|
||||
internal class SyncGenerationCoordinator(
|
||||
private val repository: TrainlogRepository,
|
||||
private val visibility: MtpPublicationVisibility = ImmediateMtpPublicationVisibility,
|
||||
private val trace: SyncGenerationTrace = AndroidSyncGenerationTrace,
|
||||
) {
|
||||
internal class SyncGenerationCoordinator(private val repository: TrainlogRepository) {
|
||||
private val service = SyncGenerationService(repository)
|
||||
|
||||
private fun phase(
|
||||
runId: String?,
|
||||
phase: String,
|
||||
artifact: String? = null,
|
||||
generationId: String? = null,
|
||||
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
|
||||
|
|
@ -263,12 +229,9 @@ internal class SyncGenerationCoordinator(
|
|||
* an identical canonical descriptor is already a complete durable
|
||||
* publication. INVARIANT: changed or unreadable bytes still use the
|
||||
* crash-safe replacement path above. */
|
||||
if (runCatching { path.isFile && path.readText() == descriptor }.getOrDefault(false)) {
|
||||
visibility.confirm(listOf(path))
|
||||
if (runCatching { path.isFile && path.readText() == descriptor }.getOrDefault(false))
|
||||
return peer
|
||||
}
|
||||
publish(path, descriptor)
|
||||
visibility.confirm(listOf(path))
|
||||
return peer
|
||||
}
|
||||
|
||||
|
|
@ -318,7 +281,6 @@ internal class SyncGenerationCoordinator(
|
|||
}
|
||||
return try {
|
||||
val peer = publishPeer(directory)
|
||||
phase(null, "peer_published", "android-peer-v1.json")
|
||||
/* 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
|
||||
|
|
@ -326,7 +288,6 @@ internal class SyncGenerationCoordinator(
|
|||
* INVARIANT: a failed attempt's request, run, generations, and
|
||||
* ACK evidence remain immutable; retry creates new correlation. */
|
||||
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 ->
|
||||
|
|
@ -341,8 +302,6 @@ internal class SyncGenerationCoordinator(
|
|||
})
|
||||
}, pollTransport)
|
||||
val request = JSONObject(requestRaw.toString(Charsets.UTF_8))
|
||||
val runId = request.getString("run_id")
|
||||
phase(runId, "generation_request_observed", "request-v1.json")
|
||||
if (request.getString("android_peer_id") != peer)
|
||||
throw SyncGenerationException("request targets another Android peer")
|
||||
val archiveAckRaw =
|
||||
|
|
@ -361,7 +320,6 @@ internal class SyncGenerationCoordinator(
|
|||
peer,
|
||||
request.getString("desktop_peer_id"),
|
||||
)
|
||||
phase(runId, "archive_ack_observed", "desktop-archive-acknowledgements-v1.json")
|
||||
/* WHY: transport can fail after immutable generation publication.
|
||||
* CONTRACT: an explicit retry for the same run republishes that
|
||||
* exact generation and resumes ACK handling; it never recaptures
|
||||
|
|
@ -369,7 +327,6 @@ internal class SyncGenerationCoordinator(
|
|||
* INVARIANT: a completed inbound ledger makes the run ineligible;
|
||||
* an acknowledged outbound generation remains resumable only to
|
||||
* finish its missing inbound half. */
|
||||
phase(runId, "generation_capture_started")
|
||||
val captured =
|
||||
resumableGeneration(
|
||||
request.getString("run_id"),
|
||||
|
|
@ -379,20 +336,9 @@ internal class SyncGenerationCoordinator(
|
|||
request.getString("desktop_peer_id"),
|
||||
request.getString("run_id"),
|
||||
)
|
||||
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()
|
||||
)
|
||||
phase(
|
||||
runId,
|
||||
"generation_objects_published",
|
||||
published.relativeTo(directory).path,
|
||||
captured.generationId,
|
||||
)
|
||||
val generationReference = File(directory, "android-generation-v1.json")
|
||||
publish(
|
||||
generationReference,
|
||||
File(directory, "android-generation-v1.json"),
|
||||
JSONObject()
|
||||
.put("format", "trainlog-sync-generation-reference")
|
||||
.put("version", 1)
|
||||
|
|
@ -402,13 +348,6 @@ internal class SyncGenerationCoordinator(
|
|||
.put("relative_path", published.relativeTo(directory).path)
|
||||
.toString(),
|
||||
)
|
||||
visibility.confirm(listOf(generationReference))
|
||||
phase(
|
||||
runId,
|
||||
"generation_reference_published",
|
||||
generationReference.name,
|
||||
captured.generationId,
|
||||
)
|
||||
afterPublication?.invoke()
|
||||
// CONTRACT: durable objects from earlier conversations remain in
|
||||
// the exchange directory. Only this run's exact generation ACK
|
||||
|
|
@ -419,18 +358,11 @@ internal class SyncGenerationCoordinator(
|
|||
it.optString("generation_id") == captured.generationId
|
||||
}, 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)
|
||||
val reference = JSONObject(referenceRaw.toString(Charsets.UTF_8))
|
||||
phase(
|
||||
runId,
|
||||
"desktop_generation_observed",
|
||||
"desktop-generation-v1.json",
|
||||
reference.optString("generation_id"),
|
||||
)
|
||||
/* WHY: non-atomic transports can expose a newly downloaded
|
||||
* reference locally before the corresponding immutable objects
|
||||
* have reached this process. CONTRACT: correlation validates the
|
||||
|
|
@ -441,22 +373,8 @@ internal class SyncGenerationCoordinator(
|
|||
pollTransport?.invoke()
|
||||
val acknowledgement =
|
||||
service.consume(File(directory, reference.getString("relative_path")))
|
||||
phase(
|
||||
runId,
|
||||
"desktop_generation_consumed",
|
||||
generationId = reference.optString("generation_id"),
|
||||
)
|
||||
val acknowledgementPath = File(directory, "android-consumption-ack-v1.json")
|
||||
publish(acknowledgementPath, acknowledgement)
|
||||
visibility.confirm(listOf(acknowledgementPath))
|
||||
phase(
|
||||
runId,
|
||||
"android_ack_published",
|
||||
acknowledgementPath.name,
|
||||
reference.optString("generation_id"),
|
||||
)
|
||||
publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement)
|
||||
afterAcknowledgement?.invoke()
|
||||
phase(runId, "completed", result = "completed")
|
||||
ForegroundGenerationResult.Completed(captured.runId)
|
||||
} catch (error: Exception) {
|
||||
if (error.message == "generation retention capacity exhausted") {
|
||||
|
|
@ -473,19 +391,11 @@ internal class SyncGenerationCoordinator(
|
|||
.put("action", "Archive acknowledged generations, then retry explicitly.")
|
||||
.toString(),
|
||||
)
|
||||
visibility.confirm(listOf(File(directory, "android-generation-error-v1.json")))
|
||||
} catch (_: Exception) {
|
||||
// Preserve the original local failure when error reporting
|
||||
// cannot itself be durably published.
|
||||
}
|
||||
}
|
||||
phase(
|
||||
runCatching {
|
||||
JSONObject(File(directory, "request-v1.json").readText()).optString("run_id")
|
||||
}.getOrNull(),
|
||||
"error",
|
||||
result = error.javaClass.simpleName,
|
||||
)
|
||||
ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed")
|
||||
} finally {
|
||||
conversationLock.unlock()
|
||||
|
|
|
|||
|
|
@ -33,16 +33,13 @@ sealed interface SyncRequestResult {
|
|||
|
||||
class SyncRequestOutbox private constructor(
|
||||
private val publisher: DirectExchangePublisher,
|
||||
private val visibility: MtpPublicationVisibility,
|
||||
) {
|
||||
constructor(context: Context) : this(
|
||||
DirectExchangePublisher { directExchangeDirectory(context) },
|
||||
AndroidMtpPublicationVisibility(context),
|
||||
)
|
||||
|
||||
internal constructor(directoryProvider: () -> DirectExchangeDirectoryAccess?) : this(
|
||||
DirectExchangePublisher(directoryProvider),
|
||||
ImmediateMtpPublicationVisibility,
|
||||
)
|
||||
|
||||
suspend fun requestSync(): SyncRequestResult = withContext(Dispatchers.IO) {
|
||||
|
|
@ -90,12 +87,7 @@ class SyncRequestOutbox private constructor(
|
|||
"trainlog-sync-request-v1.json"
|
||||
val error = snapshot.writeJson(displayName, payload)
|
||||
return if (error == null) {
|
||||
try {
|
||||
visibility.confirm(listOf(java.io.File(canonicalExchangeDirectory(), displayName)))
|
||||
SyncRequestResult.Requested(requestId)
|
||||
} catch (error: Exception) {
|
||||
SyncRequestResult.Error(error.message ?: "Publication MTP impossible.")
|
||||
}
|
||||
SyncRequestResult.Requested(requestId)
|
||||
} else {
|
||||
SyncRequestResult.Error(error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
|
|
@ -379,7 +378,7 @@ private fun BodyMetricField(
|
|||
)
|
||||
}
|
||||
|
||||
internal data class BodyMetricItem(
|
||||
private data class BodyMetricItem(
|
||||
val label: String,
|
||||
val unit: String,
|
||||
val value: String,
|
||||
|
|
@ -387,19 +386,10 @@ internal data class BodyMetricItem(
|
|||
)
|
||||
|
||||
/** Two columns retain scanability; very narrow accessibility layouts stack. */
|
||||
internal fun bodyMetricColumnCount(availableWidthDp: Float, fontScale: Float): Int {
|
||||
val twoCompactFieldsDp = 2 * 140f + 10f
|
||||
return if (availableWidthDp >= twoCompactFieldsDp && fontScale < 1.5f) 2 else 1
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ColumnScope.BodyMetricGrid(items: List<BodyMetricItem>) {
|
||||
private fun ColumnScope.BodyMetricGrid(items: List<BodyMetricItem>) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
/* WHY: this 360 dp device has 312 dp after screen padding; the old
|
||||
* 330 dp threshold therefore stacked fields that need only 140 dp.
|
||||
* CONTRACT: two compact fields plus their 10 dp gutter are preferred
|
||||
* unless width or accessibility font scaling makes them unreadable. */
|
||||
val columns = bodyMetricColumnCount(maxWidth.value, LocalDensity.current.fontScale)
|
||||
val columns = if (maxWidth < 330.dp) 1 else 2
|
||||
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||
items.chunked(columns).forEach { rowItems ->
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
|
|
|
|||
|
|
@ -34,8 +34,6 @@ import com.labfytools.trainlog.data.BackgroundSyncSettings
|
|||
import com.labfytools.trainlog.data.SyncBackgroundService
|
||||
import com.labfytools.trainlog.data.DriveSyncSettings
|
||||
import com.labfytools.trainlog.data.canonicalExchangeDirectory
|
||||
import com.labfytools.trainlog.data.AndroidMtpPublicationVisibility
|
||||
import com.labfytools.trainlog.data.MtpPublicationVisibility
|
||||
import com.labfytools.trainlog.data.logDirectExchange
|
||||
import com.labfytools.trainlog.data.directStoragePermissionIntent
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
|
|
@ -51,7 +49,6 @@ internal suspend fun publishBundleAndRequest(
|
|||
repository: TrainlogRepository,
|
||||
exporter: SyncExporter,
|
||||
requestOutbox: SyncRequestOutbox,
|
||||
visibility: MtpPublicationVisibility = com.labfytools.trainlog.data.ImmediateMtpPublicationVisibility,
|
||||
): SyncRequestResult = withContext(Dispatchers.IO) {
|
||||
val opened = exporter.openStorageSnapshot()
|
||||
if (opened is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error) {
|
||||
|
|
@ -69,7 +66,7 @@ internal suspend fun publishBundleAndRequest(
|
|||
is SyncExportResult.Exported -> Unit
|
||||
}
|
||||
return@withContext when (
|
||||
val result = SyncGenerationForegroundCoordinator(repository, visibility).run(
|
||||
val result = SyncGenerationForegroundCoordinator(repository).run(
|
||||
canonicalExchangeDirectory(),
|
||||
afterPeerPublication = {
|
||||
when (val request = requestOutbox.requestSync(snapshot)) {
|
||||
|
|
@ -393,25 +390,10 @@ 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. */
|
||||
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
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
when (
|
||||
val result = publishBundleAndRequest(
|
||||
repository,
|
||||
exporter,
|
||||
requestOutbox,
|
||||
AndroidMtpPublicationVisibility(context),
|
||||
)
|
||||
) {
|
||||
when (val result = publishBundleAndRequest(repository, exporter, requestOutbox)) {
|
||||
is SyncRequestResult.Completed -> {
|
||||
// The generation coordinator has already consumed the
|
||||
// desktop return and published its durable ACK. Waiting
|
||||
|
|
|
|||
|
|
@ -101,15 +101,7 @@ class SyncDeploymentConversationTest {
|
|||
}
|
||||
val conversations = if (suppliedTransport == null) 24 else 1
|
||||
repeat(conversations) {
|
||||
val visibilityEvents = mutableListOf<List<String>>()
|
||||
val tracePhases = mutableListOf<String>()
|
||||
val coordinator = SyncGenerationForegroundCoordinator(
|
||||
repository,
|
||||
MtpPublicationVisibility { files ->
|
||||
visibilityEvents += files.map(File::getName)
|
||||
},
|
||||
SyncGenerationTrace { _, phase, _, _, _ -> tracePhases += phase },
|
||||
)
|
||||
val coordinator = SyncGenerationForegroundCoordinator(repository)
|
||||
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
||||
var requestSignals = 0
|
||||
var refreshedAfterDesktopReference = false
|
||||
|
|
@ -164,34 +156,6 @@ class SyncDeploymentConversationTest {
|
|||
"transport is refreshed after the correlated desktop reference",
|
||||
refreshedAfterDesktopReference,
|
||||
)
|
||||
val referenceVisibility = visibilityEvents.indexOfFirst {
|
||||
it == listOf("android-generation-v1.json")
|
||||
}
|
||||
val objectVisibility = visibilityEvents.indexOfFirst { "manifest.json" in it }
|
||||
assertTrue("generation objects must become visible", objectVisibility >= 0)
|
||||
assertTrue(
|
||||
"reference must follow complete object visibility",
|
||||
referenceVisibility > objectVisibility,
|
||||
)
|
||||
assertTrue(
|
||||
tracePhases.containsAll(
|
||||
listOf(
|
||||
"peer_published",
|
||||
"legacy_trigger_published",
|
||||
"generation_request_observed",
|
||||
"archive_ack_observed",
|
||||
"generation_capture_started",
|
||||
"generation_captured",
|
||||
"generation_objects_published",
|
||||
"generation_reference_published",
|
||||
"desktop_ack_observed",
|
||||
"desktop_generation_observed",
|
||||
"desktop_generation_consumed",
|
||||
"android_ack_published",
|
||||
"completed",
|
||||
)
|
||||
)
|
||||
)
|
||||
var workerOutput = ""
|
||||
localWorker?.let { worker ->
|
||||
workerOutput = worker.inputStream.bufferedReader().readText()
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import java.nio.file.Files
|
||||
import java.time.Duration
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.junit.Assert.assertEquals
|
||||
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 SyncGenerationConversationLockTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun foregroundConversationCannotBeStolenByBackgroundCoordinator() {
|
||||
val databaseName = "generation-lock-${UUID.randomUUID()}.db"
|
||||
val root = Files.createTempDirectory("trainlog-generation-lock-").toFile()
|
||||
val repository = TrainlogRepository(context, databaseName)
|
||||
val entered = CountDownLatch(1)
|
||||
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
||||
try {
|
||||
val foreground = SyncGenerationCoordinator(repository)
|
||||
val background = SyncGenerationCoordinator(repository)
|
||||
val active = executor.submit<ForegroundGenerationResult> {
|
||||
foreground.run(
|
||||
root,
|
||||
Duration.ofMillis(300),
|
||||
afterPeerPublication = { entered.countDown() },
|
||||
)
|
||||
}
|
||||
assertTrue(entered.await(2, TimeUnit.SECONDS))
|
||||
val duplicate = background.run(root, Duration.ofMillis(10))
|
||||
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())
|
||||
assertEquals(0, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
executor.shutdownNow()
|
||||
repository.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import java.nio.file.Files
|
||||
import org.json.JSONObject
|
||||
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 SyncRequestOutboxTest {
|
||||
@Test
|
||||
fun distinctExplicitRequestsAlwaysPublishFreshIdentities() {
|
||||
val directory = Files.createTempDirectory("trainlog-request-outbox-").toFile()
|
||||
try {
|
||||
val outbox = SyncRequestOutbox { directExchangeDirectoryForPath(directory) }
|
||||
val snapshot =
|
||||
(DirectExchangePublisher { directExchangeDirectoryForPath(directory) }.snapshot()
|
||||
as DirectExchangeSnapshotResult.Ready).snapshot
|
||||
val first = outbox.requestSync(snapshot) as SyncRequestResult.Requested
|
||||
val firstBytes = java.io.File(directory, "trainlog-sync-request-v1.json").readText()
|
||||
val second = outbox.requestSync(snapshot) as SyncRequestResult.Requested
|
||||
val secondBytes = java.io.File(directory, "trainlog-sync-request-v1.json").readText()
|
||||
|
||||
assertNotEquals(first.requestId, second.requestId)
|
||||
assertNotEquals(firstBytes, secondBytes)
|
||||
assertTrue(JSONObject(secondBytes).getString("request_id") == second.requestId)
|
||||
} finally {
|
||||
directory.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
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 BodyMetricGridTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
|
||||
private val items = listOf(
|
||||
BodyMetricItem("POIDS", "kg", "", {}),
|
||||
BodyMetricItem("COU", "cm", "", {}),
|
||||
BodyMetricItem("TOUR DE TAILLE", "cm", "", {}),
|
||||
BodyMetricItem("HANCHES", "cm", "", {}),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun deployedPhoneContentWidthUsesTwoColumns() {
|
||||
// 1080 px / 480 dpi = 360 dp; the screen frame leaves about 312 dp.
|
||||
assertEquals(2, bodyMetricColumnCount(312f, 0.9f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun genuinelyNarrowWidthStacksFields() {
|
||||
assertEquals(1, bodyMetricColumnCount(280f, 1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun largeAccessibilityTextStacksFields() {
|
||||
assertEquals(1, bodyMetricColumnCount(312f, 1.5f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deployedPhoneWidthRendersPairedFieldsWithoutClippingUnits() {
|
||||
compose.setContent {
|
||||
TrainlogTheme {
|
||||
Column(Modifier.width(312.dp)) { BodyMetricGrid(items) }
|
||||
}
|
||||
}
|
||||
val weight = compose.onNodeWithText("POIDS").fetchSemanticsNode().boundsInRoot
|
||||
val neck = compose.onNodeWithText("COU").fetchSemanticsNode().boundsInRoot
|
||||
assertTrue(neck.left > weight.left)
|
||||
assertTrue(kotlin.math.abs(neck.top - weight.top) < 2f)
|
||||
compose.onNodeWithText("kg").fetchSemanticsNode()
|
||||
assertTrue(compose.onAllNodesWithText("cm", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty())
|
||||
compose.onNodeWithText("TOUR DE TAILLE").fetchSemanticsNode()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trulyNarrowLayoutStacksPairedFields() {
|
||||
compose.setContent {
|
||||
TrainlogTheme {
|
||||
Column(Modifier.width(280.dp)) { BodyMetricGrid(items.take(2)) }
|
||||
}
|
||||
}
|
||||
val weight = compose.onNodeWithText("POIDS").fetchSemanticsNode().boundsInRoot
|
||||
val neck = compose.onNodeWithText("COU").fetchSemanticsNode().boundsInRoot
|
||||
assertTrue(neck.top > weight.top)
|
||||
}
|
||||
}
|
||||
|
|
@ -1067,28 +1067,6 @@ generation remains resumable only until the desktop generation for that run is
|
|||
durably consumed; a fully completed run is then excluded from foreground
|
||||
selection. This preserves late-ACK recovery without replaying completed runs.
|
||||
|
||||
The direct-MTP path confirms transport visibility independently from ordinary
|
||||
`File.exists()` checks. Android fsyncs and atomically commits every immutable
|
||||
generation artifact, asks MediaProvider to scan those files, waits for all
|
||||
bounded callbacks, and only then atomically publishes and scans
|
||||
`android-generation-v1.json`. The same boundary covers the peer advertisement,
|
||||
explicit request signal, Android consumption ACK and bounded error reference.
|
||||
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 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
|
||||
312 dp inside the screen frame and therefore receives two columns. Layouts
|
||||
below 290 dp, or font scales of 1.5 and above, retain the readable one-column
|
||||
fallback.
|
||||
|
||||
Backup manifests retain format version 1 and the current Android schema. Their
|
||||
`product_version` is read from installed package metadata, so development
|
||||
builds record `0.1.3` without a second hard-coded product-version source.
|
||||
|
|
|
|||
|
|
@ -9,25 +9,6 @@ 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.
|
||||
|
||||
Direct-MTP publication now has an explicit Android visibility boundary.
|
||||
Durable immutable generation files are submitted to MediaProvider and all scan
|
||||
callbacks complete before the mutable generation reference is published; the
|
||||
reference and Android ACK use the same confirmation. Bounded diagnostic logs
|
||||
contain only run/generation identities, artifact names, phases and results.
|
||||
The desktop worker performs lightweight local correlation checks continuously
|
||||
but opens a full MTP pull no more than once every three seconds. A 30-second
|
||||
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 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
|
||||
ready revision before delivery; ready/local delivers the current revision.
|
||||
|
|
|
|||
|
|
@ -959,19 +959,6 @@ It reads the peer advertisement, current Android generation reference and
|
|||
current Android consumption ACK, then downloads only the generation directory
|
||||
named by that validated reference. It does not recursively mirror retained
|
||||
Android generations, desktop publications, or unrelated exchange history.
|
||||
Local correlation is checked every 50 ms without USB work; complete MTP pulls
|
||||
are throttled to a three-second cadence. Each MTP operation has its own
|
||||
30-second ceiling while the conversation retains its configured global
|
||||
deadline. Exhausting the global deadline while waiting for a correlated
|
||||
reference reports `timeout waiting for correlated android-generation-v1.json`;
|
||||
only an adapter that exceeds its independent operation budget reports
|
||||
`transport_timeout`.
|
||||
|
||||
Android direct-file publication has a separate MediaProvider visibility
|
||||
barrier. Immutable generation files, including the manifest, become visible
|
||||
before `android-generation-v1.json` is committed. The reference therefore
|
||||
cannot advertise a generation whose files have not completed the Android MTP
|
||||
visibility callbacks. No remote object is removed during polling or scanning.
|
||||
|
||||
`GET /api/v1/prepared-items` is a separate read-only Web projection. It does not
|
||||
change the frozen Dashboard `next_session.available` field or any exchange
|
||||
|
|
|
|||
|
|
@ -1544,26 +1544,6 @@ fixture's sample draft.
|
|||
|
||||
## Private full-generation rollout validation
|
||||
|
||||
The MTP visibility regression suite uses injected clocks and transport
|
||||
callbacks, with no multi-second sleeps. It covers delayed Android publication,
|
||||
multiple throttled polls, stale run references, a never-visible generation,
|
||||
an independently blocked adapter, and exact protocol-versus-transport timeout
|
||||
classification. Android tests prove fresh request UUIDs, foreground/background
|
||||
lock ownership, objects-visible-before-reference ordering, bounded phase
|
||||
tracing, and a completed full-generation conversation. Compose tests exercise
|
||||
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.
|
||||
|
||||
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`,
|
||||
the complete exporter inventory of the relocatable package, stale Android ACK
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import importlib.util
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
|
@ -31,108 +30,6 @@ class SyncPeerWorkerTest(unittest.TestCase):
|
|||
self.assertGreaterEqual(timeout, 178)
|
||||
self.assertLessEqual(timeout, 180)
|
||||
|
||||
def test_mtp_adapter_uses_fixed_operation_budget(self):
|
||||
completed = subprocess.CompletedProcess([], 0, b"", b"")
|
||||
with mock.patch.object(worker.subprocess, "run", return_value=completed) as run:
|
||||
worker.run_adapter(
|
||||
Path("/fixed/adapter"), "pull", "peer_fixed",
|
||||
Path("/private/transport"), 100.0, clock=lambda: 50.0,
|
||||
)
|
||||
self.assertEqual(worker.MTP_OPERATION_TIMEOUT_SECONDS, run.call_args.kwargs["timeout"])
|
||||
|
||||
def test_mtp_pull_pump_is_throttled(self):
|
||||
now = [0.0]
|
||||
pulls = []
|
||||
pump = worker.MtpPullPump(
|
||||
lambda: pulls.append(now[0]), interval=3.0, clock=lambda: now[0]
|
||||
)
|
||||
for timestamp in (0.05, 0.5, 2.99, 3.0, 3.1, 5.99, 6.0):
|
||||
now[0] = timestamp
|
||||
pump()
|
||||
self.assertEqual([3.0, 6.0], pulls)
|
||||
|
||||
def test_global_deadline_is_not_reported_as_transport_timeout(self):
|
||||
with self.assertRaises(worker.ConversationDeadlineExpired):
|
||||
worker.run_adapter(
|
||||
Path("/fixed/adapter"), "pull", "peer_fixed",
|
||||
Path("/private/transport"), 10.0, clock=lambda: 10.0,
|
||||
)
|
||||
|
||||
def test_deadline_limited_pull_becomes_protocol_timeout(self):
|
||||
with mock.patch.object(
|
||||
worker.subprocess, "run", side_effect=subprocess.TimeoutExpired(["adapter"], 0.5)
|
||||
):
|
||||
with self.assertRaises(worker.ConversationDeadlineExpired):
|
||||
worker.run_adapter(
|
||||
Path("/fixed/adapter"), "pull", "peer_fixed",
|
||||
Path("/private/transport"), 10.5, clock=lambda: 10.0,
|
||||
)
|
||||
|
||||
def test_blocked_full_budget_pull_is_transport_timeout(self):
|
||||
with mock.patch.object(
|
||||
worker.subprocess, "run", side_effect=subprocess.TimeoutExpired(["adapter"], 30)
|
||||
):
|
||||
with self.assertRaisesRegex(RuntimeError, "transport_timeout"):
|
||||
worker.run_adapter(
|
||||
Path("/fixed/adapter"), "pull", "peer_fixed",
|
||||
Path("/private/transport"), 100.0, clock=lambda: 50.0,
|
||||
)
|
||||
|
||||
def test_delayed_correlated_generation_survives_multiple_polls(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
reference = Path(directory) / "android-generation-v1.json"
|
||||
now = [0.0]
|
||||
pulls = []
|
||||
|
||||
def pump():
|
||||
pulls.append(now[0])
|
||||
if len(pulls) == 3:
|
||||
reference.write_text('{"run_id":"sy_fresh"}', encoding="utf-8")
|
||||
|
||||
value = worker.wait_json(
|
||||
reference, 10.0,
|
||||
lambda candidate: candidate.get("run_id") == "sy_fresh",
|
||||
pump, clock=lambda: now[0],
|
||||
sleeper=lambda duration: now.__setitem__(0, now[0] + duration),
|
||||
)
|
||||
self.assertEqual("sy_fresh", value["run_id"])
|
||||
self.assertEqual(3, len(pulls))
|
||||
|
||||
def test_stale_reference_is_ignored_until_correlated_replacement(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
reference = Path(directory) / "android-generation-v1.json"
|
||||
reference.write_text('{"run_id":"sy_stale"}', encoding="utf-8")
|
||||
now = [0.0]
|
||||
polls = [0]
|
||||
|
||||
def pump():
|
||||
polls[0] += 1
|
||||
if polls[0] == 2:
|
||||
reference.write_text('{"run_id":"sy_fresh"}', encoding="utf-8")
|
||||
|
||||
value = worker.wait_json(
|
||||
reference, 1.0,
|
||||
lambda candidate: candidate.get("run_id") == "sy_fresh",
|
||||
pump, clock=lambda: now[0],
|
||||
sleeper=lambda duration: now.__setitem__(0, now[0] + duration),
|
||||
)
|
||||
self.assertEqual("sy_fresh", value["run_id"])
|
||||
|
||||
def test_missing_generation_reports_correlated_protocol_timeout(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
reference = Path(directory) / "android-generation-v1.json"
|
||||
reference.write_text('{"run_id":"sy_stale"}', encoding="utf-8")
|
||||
now = [0.0]
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError, "timeout waiting for correlated android-generation-v1.json"
|
||||
):
|
||||
worker.wait_json(
|
||||
reference, 0.2,
|
||||
lambda candidate: candidate.get("run_id") == "sy_fresh",
|
||||
clock=lambda: now[0],
|
||||
sleeper=lambda duration: now.__setitem__(0, now[0] + duration),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -79,9 +79,7 @@ class SyncdRoutingTest(unittest.TestCase):
|
|||
production = source.split("internal suspend fun publishBundleAndRequest", 1)[1].split(
|
||||
"internal suspend fun publishLegacyBundleAndRequest", 1
|
||||
)[0]
|
||||
self.assertIn(
|
||||
"SyncGenerationForegroundCoordinator(repository, visibility).run", production
|
||||
)
|
||||
self.assertIn("SyncGenerationForegroundCoordinator(repository).run", production)
|
||||
self.assertNotIn("generation-opt-in", production)
|
||||
self.assertIn("exportMobileBundle", production)
|
||||
self.assertIn("generation below", production)
|
||||
|
|
|
|||
|
|
@ -34,30 +34,6 @@ CAPS = {
|
|||
"session-preparations-v2",
|
||||
}
|
||||
|
||||
MTP_OPERATION_TIMEOUT_SECONDS = 30.0
|
||||
MTP_POLL_INTERVAL_SECONDS = 3.0
|
||||
|
||||
|
||||
class ConversationDeadlineExpired(RuntimeError):
|
||||
"""Internal signal that the protocol deadline, not the transport, expired."""
|
||||
|
||||
|
||||
class MtpPullPump:
|
||||
"""Throttle complete MTP refreshes while local correlation is polled."""
|
||||
|
||||
def __init__(self, pull, interval=MTP_POLL_INTERVAL_SECONDS, clock=time.monotonic):
|
||||
self._pull = pull
|
||||
self._interval = interval
|
||||
self._clock = clock
|
||||
self._last_pull = clock()
|
||||
|
||||
def __call__(self, force: bool = False) -> None:
|
||||
now = self._clock()
|
||||
if not force and now - self._last_pull < self._interval:
|
||||
return
|
||||
self._pull()
|
||||
self._last_pull = self._clock()
|
||||
|
||||
|
||||
def emit(run_id: str, phase: str, **values: object) -> None:
|
||||
value = {
|
||||
|
|
@ -121,13 +97,9 @@ def run_adapter(
|
|||
root: Path,
|
||||
deadline: float,
|
||||
allow_missing_peer: bool = False,
|
||||
clock=time.monotonic,
|
||||
) -> bool:
|
||||
remaining = deadline - clock()
|
||||
if remaining <= 0:
|
||||
raise ConversationDeadlineExpired()
|
||||
timeout = min(remaining, MTP_OPERATION_TIMEOUT_SECONDS)
|
||||
deadline_limited = remaining < MTP_OPERATION_TIMEOUT_SECONDS
|
||||
remaining = max(1, int(deadline - time.monotonic()))
|
||||
timeout = min(remaining, 30)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(adapter), operation, peer, str(root)],
|
||||
|
|
@ -137,13 +109,10 @@ def run_adapter(
|
|||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
if deadline_limited:
|
||||
raise ConversationDeadlineExpired() from error
|
||||
# CONTRACT: an adapter timeout is an ambiguous transport failure. It
|
||||
# never implies rollback, replay, or permission to retry a mutation.
|
||||
raise RuntimeError(
|
||||
f"transport_timeout: MTP {operation} did not finish within "
|
||||
f"{MTP_OPERATION_TIMEOUT_SECONDS:g} seconds"
|
||||
f"transport_timeout: MTP {operation} did not finish within {timeout} seconds"
|
||||
) from error
|
||||
if result.returncode != 0:
|
||||
diagnostic = (
|
||||
|
|
@ -279,44 +248,29 @@ def push_drive(
|
|||
run_drive_adapter(adapter, "push", remote, outbox, deadline)
|
||||
|
||||
|
||||
def wait_file(path: Path, deadline: float, pump=None, clock=time.monotonic, sleeper=time.sleep) -> None:
|
||||
def wait_file(path: Path, deadline: float, pump=None) -> None:
|
||||
while not path.is_file():
|
||||
if clock() >= deadline:
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError(f"timeout waiting for {path.name}")
|
||||
if pump is not None:
|
||||
try:
|
||||
pump()
|
||||
except ConversationDeadlineExpired as error:
|
||||
raise RuntimeError(f"timeout waiting for {path.name}") from error
|
||||
sleeper(0.05)
|
||||
pump()
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def wait_json(
|
||||
path: Path,
|
||||
deadline: float,
|
||||
predicate,
|
||||
pump=None,
|
||||
clock=time.monotonic,
|
||||
sleeper=time.sleep,
|
||||
) -> dict:
|
||||
def wait_json(path: Path, deadline: float, predicate, pump=None) -> dict:
|
||||
while True:
|
||||
wait_file(path, deadline, pump, clock, sleeper)
|
||||
wait_file(path, deadline, pump)
|
||||
try:
|
||||
value = json.loads(bounded(path))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
value = None
|
||||
if isinstance(value, dict) and predicate(value):
|
||||
return value
|
||||
if clock() >= deadline:
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError(f"timeout waiting for correlated {path.name}")
|
||||
if pump is not None:
|
||||
try:
|
||||
pump()
|
||||
except ConversationDeadlineExpired as error:
|
||||
raise RuntimeError(
|
||||
f"timeout waiting for correlated {path.name}"
|
||||
) from error
|
||||
sleeper(0.05)
|
||||
pump()
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def refresh_mtp_peer(
|
||||
|
|
@ -337,24 +291,19 @@ def refresh_mtp_peer(
|
|||
"""
|
||||
peer_path = root / "android-peer-v1.json"
|
||||
while True:
|
||||
try:
|
||||
pulled = run_adapter(
|
||||
adapter,
|
||||
"pull",
|
||||
peer,
|
||||
root,
|
||||
deadline,
|
||||
True,
|
||||
)
|
||||
except ConversationDeadlineExpired as error:
|
||||
raise RuntimeError("timeout waiting for android-peer-v1.json") from error
|
||||
pulled = run_adapter(
|
||||
adapter,
|
||||
"pull",
|
||||
peer,
|
||||
root,
|
||||
deadline,
|
||||
True,
|
||||
)
|
||||
if pulled and peer_path.is_file():
|
||||
return
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError("timeout waiting for android-peer-v1.json")
|
||||
# A complete libmtp discovery is the expensive poll. Keep retries
|
||||
# interactive without continuously reopening the USB/MTP session.
|
||||
time.sleep(min(MTP_POLL_INTERVAL_SECONDS, max(0.0, deadline - time.monotonic())))
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def correlated_peer_error(root: Path, run_id: str) -> None:
|
||||
|
|
@ -411,22 +360,20 @@ def main() -> int:
|
|||
if args.mode == "mtp":
|
||||
if args.mtp_adapter is None:
|
||||
raise RuntimeError("MTP adapter is required")
|
||||
pump = lambda: run_adapter(
|
||||
args.mtp_adapter,
|
||||
"pull",
|
||||
args.expected_peer,
|
||||
args.transport_root,
|
||||
deadline,
|
||||
True,
|
||||
)
|
||||
refresh_mtp_peer(
|
||||
args.mtp_adapter,
|
||||
args.expected_peer,
|
||||
args.transport_root,
|
||||
deadline,
|
||||
)
|
||||
pump = MtpPullPump(
|
||||
lambda: run_adapter(
|
||||
args.mtp_adapter,
|
||||
"pull",
|
||||
args.expected_peer,
|
||||
args.transport_root,
|
||||
deadline,
|
||||
True,
|
||||
)
|
||||
)
|
||||
elif args.mode == "drive":
|
||||
if args.drive_adapter is None or not args.drive_remote:
|
||||
raise RuntimeError("Drive adapter and remote are required")
|
||||
|
|
|
|||
|
|
@ -876,11 +876,6 @@ test(
|
|||
find_program('python3'),
|
||||
args: [meson.project_source_root() / 'tests/test_sync_orchestrator.py'],
|
||||
)
|
||||
test(
|
||||
'sync_peer_worker',
|
||||
find_program('python3'),
|
||||
args: [meson.project_source_root() / 'tests/test_sync_peer_worker.py'],
|
||||
)
|
||||
test(
|
||||
'sync_drive_transport',
|
||||
find_program('python3'),
|
||||
|
|
|
|||
Loading…
Reference in a new issue