Compare commits

..
Author SHA1 Message Date
JMR-devandClaude Opus 5 31f249ae04 Cancel a running Media3 export, completing #224's third engine
The two FFmpeg engines were done in ad2a75d and d293646. This is
Media3Engine.transcode's invokeOnCancellation, which posts
transformer.cancel() onto the engine's own HandlerThread because cancel()
has the same single-thread requirement as start().

The assertion is the output file here, where it could not be for FFmpeg.
That side deletes the partial on cancellation, and on POSIX ffmpeg keeps
writing to the unlinked inode, so the path stays gone whether or not the
cancel landed -- it asserts the session's return code instead. Media3Engine
deletes nothing, the partial being ConversionWorker's to clean up, so the
file is the evidence.

A cancelled export reports itself two ways and both mean interrupted: no
video track, or MediaExtractor refusing the file outright with "Failed to
instantiate extractor" because there is no moov atom. The first version
treated only the null as success and the exception failed the test, which
is how that was measured. Only a playable file counts as a miss.

The wait before reading is several times the export's own length, so a
cancel that did not land has certainly finished by then: the failure
direction is "the file became playable", never "we did not wait long
enough". The attempt is retried for the reason the other two engines
measured -- a 3 s 320x240 export outruns a naive cancel on a loaded runner
-- and an export that never wrote a file at all is recorded as
inconclusive rather than allowed to pass as a cancellation.

It carries @FailsOnEmulatorApi37, so FAILS_ON_EMULATOR_API37_BASELINE moves
3 -> 4 in this diff. That file also said removing the marker would grow the
gating leg "by two", which has been wrong since the third marker landed; it
now names the constant instead of restating it.

Verified on a local API 34 emulator: 68 tests, 0 failures, 3 skipped; and
with transformer.cancel() removed all five attempts produce a playable
video/hevc and the test fails, naming each one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 02:28:18 -05:00
Jason Ross d6e1e3bf86 Merge pull request #242 from JMR-dev/test/reattach-to-a-running-job
Reattach to a conversion that is still running
2026-09-06 02:17:06 -05:00
JMR-devandClaude Opus 5 6992f0e783 Reattach to a conversion that is still running (#230)
Reattachment.rank gives RUNNING the highest rank of all -- "live work
outranks a finished result because a running job is holding a foreground
service" -- and no test on either source set had ever produced one.
ReattachOnLaunchTest covers a job that finished, one whose staged file is
gone, an ambiguous pair, one still queued, and one the user cancelled.
ReattachmentTest exercises the ranking as a pure function over fabricated
snapshots. What was missing is a ViewModel meeting a real running job,
which is also the likeliest reattachment there is: the user starts a
conversion, leaves, and comes back while it is still going.

The engine is a fake, deliberately. The job has to still be running when
the ViewModel is built, and every real conversion in this suite finishes in
about a second -- racing that is what made the cancellation tests flaky
enough to need retries. A SoftwareTranscoder that blocks until released
removes the race outright. Nothing about reattachment depends on which
engine is transcoding: the tag query, Reattachment.choose over live
WorkManager state, and observe's mapping to Converting all run identically
whatever is doing the work.

This is what #230 can actually deliver, and the ticket asked for the answer
either way. Process death itself stays device-manual. D3/D13 already record
that am kill refuses a process holding a foreground service, and there is a
more basic obstacle underneath it: instrumentation runs in the app's own
process, so any route that really killed it would take the test runner with
it and leave nothing to assert with. Observing a relaunch needs two
instrumentation runs, which the runner does not provide. So the closest
observable analogue is a fresh ViewModel, with no memory of the work,
meeting a job that is genuinely mid-flight.

The teardown now resets ConversionDependencies. The suite runs without
Android Test Orchestrator, so a BlockingTranscoder left in place would hang
the next class that converts anything.

Verified on a local API 34 emulator: 67 tests, 0 failures, 3 skipped; and
making RUNNING unreattachable in Reattachment.rank fails this test and
nothing else -- which is also the evidence that the JVM ranking test was
not already covering it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 01:58:24 -05:00
Jason Ross bb920b5bd0 Merge pull request #239 from JMR-dev/test/content-uri-reaches-ffmpeg
Let a join read the files the user actually picked
2026-09-06 01:51:10 -05:00
3 changed files with 204 additions and 3 deletions
@@ -17,7 +17,7 @@ package org.libremediaconverter
*
* Removing it is the goal, and the trigger is written down: a new API 37.x system image, or an
* ATD image for 37. Delete the annotation from the tests, and the advisory job goes empty and
* the gating one grows by two.
* the gating one grows by [FAILS_ON_EMULATOR_API37_BASELINE].
*
* **How many tests carry it is committed below**, as [FAILS_ON_EMULATOR_API37_BASELINE], and the
* advisory job checks the run against it. Adding or removing a marker means changing that number
@@ -52,4 +52,4 @@ annotation class FailsOnEmulatorApi37
* `INSTRUMENTATION_ABORTED`, so the count is a number taken from a partial run. The report
* records the truncation next to the counts for that reason.
*/
const val FAILS_ON_EMULATOR_API37_BASELINE = 3
const val FAILS_ON_EMULATOR_API37_BASELINE = 4
@@ -7,6 +7,10 @@ import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
@@ -14,6 +18,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -262,6 +267,92 @@ class Media3EngineTest {
}
}
/**
* Cancelling a *running* export stops it, completing #224's third engine.
*
* The two FFmpeg engines were done first (`ad2a75d`, `d293646`); this is
* `Media3Engine.transcode`'s `invokeOnCancellation`, which posts `transformer.cancel()` onto the
* engine's own `HandlerThread` because `cancel()` has the same single-thread requirement as
* `start()`.
*
* ## Why the assertion is the output file here, and was not for FFmpeg
*
* The FFmpeg side could not use the file: `invokeOnCancellation` unlinks it, and on POSIX ffmpeg
* keeps writing to the unlinked inode, so the path stays gone whether or not the cancel landed.
* It asserted the session's return code instead.
*
* `Media3Engine` deletes nothing — the partial is `ConversionWorker`'s to clean up — so the file
* *is* the evidence. An export that was cancelled leaves no moov atom, so `MediaExtractor`
* either finds no video track or refuses the file outright with
* `IOException: Failed to instantiate extractor` — measured, and both mean interrupted. One
* that ran to completion leaves a playable HEVC file, which is the only outcome treated as a
* miss. The wait before
* reading it is deliberately several times the length of the export, so a *non*-cancelled export
* has certainly finished by then: the failure direction is "the file became valid", never "we
* did not wait long enough".
*
* ## Why it retries
*
* Same reason as the other two, measured there: the committed fixture is 3 s at 320x240 and the
* export outruns a naive cancel on a loaded runner. An attempt whose export finished before the
* cancel landed has tested nothing, so it is a miss and is retried; only exhausting
* [CANCEL_ATTEMPTS] fails. With `transformer.cancel()` removed every attempt produces a playable
* file, so the mutation still bites — it just takes five tries to say so.
*
* Progress having been reported is what proves the export really started, so a miss is
* distinguishable from an export that never ran at all — which matters on the API 37 image,
* where the decoder is what fails.
*/
@Test
@FailsOnEmulatorApi37
fun cancellingARunningExportStopsIt(): Unit = runBlocking {
val outcomes = mutableListOf<String>()
repeat(CANCEL_ATTEMPTS) { attempt ->
val partial = File(context.cacheDir, "cancelled_export_$attempt.mp4").apply { delete() }
val job = launch(Dispatchers.IO) {
engine.transcode(
input = Uri.fromFile(input),
output = partial,
request = ConversionRequest(OutputFormat.MP4_H265.spec),
)
}
// The muxer creating the file is proof the export really started, and it is the
// earliest such proof available -- earlier than the first progress tick.
withTimeout(TIMEOUT_MS) {
while (!partial.exists() && job.isActive) delay(POLL_MS)
}
val started = partial.exists()
job.cancelAndJoin()
if (!started) {
// The export failed before writing anything. That is not a cancellation result
// either way, so it is not allowed to pass as one.
outcomes += "attempt $attempt never produced an output file to cancel"
return@repeat
}
// Several times the export's own length, so a cancel that did not land has certainly
// finished. The failure direction is "the file became playable", never "too soon".
delay(SETTLE_MS)
// A cancelled export reports itself two ways and both mean the same thing: no video
// track, or MediaExtractor refusing the file outright with "Failed to instantiate
// extractor" because there is no moov atom to read. Only a *playable* file is a miss.
val video = runCatching { videoMimeTypeOf(partial) }.getOrNull()
partial.delete()
if (video == null) return@runBlocking
outcomes += "attempt $attempt produced a playable $video"
}
fail(
"never interrupted a running export in $CANCEL_ATTEMPTS attempts, so either every " +
"export finished first or cancellation does not reach the transformer: $outcomes",
)
}
private fun videoMimeTypeOf(file: File): String? {
val extractor = MediaExtractor()
try {
@@ -280,6 +371,19 @@ class Media3EngineTest {
private companion object {
const val TIMEOUT_SECONDS = 120L
/** Bounds the wait for the muxer to create the file; a hang here is a defect. */
const val TIMEOUT_MS = 30_000L
const val POLL_MS = 25L
/**
* How long to let a *failed* cancel finish. Several times the export's own length, so
* "the file is not playable" cannot mean "not yet".
*/
const val SETTLE_MS = 10_000L
/** See the KDoc: a miss is the loaded-runner case, not a defect. */
const val CANCEL_ATTEMPTS = 5
/**
* Short on purpose. Nothing is decoded or encoded on this path — the builder refuses the
* input outright — so anything approaching this is a hang, which is what the test is
@@ -13,6 +13,7 @@ import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
@@ -27,7 +28,10 @@ import org.junit.runner.RunWith
import org.libremediaconverter.join.JoinState
import org.libremediaconverter.join.JoinViewModel
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import org.libremediaconverter.work.JobTags
@@ -64,6 +68,26 @@ class EchoWorker(context: Context, params: WorkerParameters) : Worker(context, p
* path, foreground service included — into a synchronous test double, depending on class order.
*/
@UnstableApi
/**
* A [SoftwareTranscoder] that holds the worker in [WorkInfo.State.RUNNING] until released.
*
* Declared here rather than in `FakeFailures` because it is the only test that needs a job to stay
* live on demand, and the shape is specific to that: the others fake a *failure*, this fakes
* *duration*.
*/
private class BlockingTranscoder(private val released: CompletableDeferred<Unit>) : SoftwareTranscoder {
override suspend fun run(
request: ConversionRequest,
inputPath: String,
output: File,
durationMs: Long,
onProgress: (Int) -> Unit,
) {
released.await()
output.writeBytes(ByteArray(1_024))
}
}
@RunWith(AndroidJUnit4::class)
class ReattachOnLaunchTest {
@@ -75,7 +99,13 @@ class ReattachOnLaunchTest {
fun clearTheQueue() = emptyQueueAndStaging()
@After
fun leaveNothingBehind() = emptyQueueAndStaging()
fun leaveNothingBehind() {
// The suite runs without Android Test Orchestrator, so every class shares one process and
// a swapped seam outlives the class that set it. Only one test here swaps one, but a
// BlockingTranscoder left in place would hang the next class that converts anything.
ConversionDependencies.reset()
emptyQueueAndStaging()
}
/**
* The claim the whole fix rests on, checked against the production request builder rather
@@ -261,6 +291,69 @@ class ReattachOnLaunchTest {
return request.id
}
/**
* Reattaching to a conversion that is **running right now**, which nothing had ever driven.
*
* This class covers a job that finished, one whose staged file is gone, an ambiguous pair, one
* still queued, and one the user cancelled. [Reattachment.rank] gives
* [WorkInfo.State.RUNNING] the **highest** rank of all — "live work outranks a finished result
* because a running job is holding a foreground service" — and no test on either source set
* ever produced one. `ReattachmentTest` exercises the ranking as a pure function over
* fabricated snapshots; what was missing is a ViewModel meeting a real running job.
*
* It is also the likeliest reattachment there is: the user starts a conversion, leaves, and
* comes back while it is still going.
*
* ## Why the engine is a fake here, and why that is not a weakening
*
* The job has to still be running when the ViewModel is built, and every real conversion in
* this suite finishes in about a second — racing that is what made the cancellation tests flaky
* enough to need retries (#224). A [SoftwareTranscoder] that blocks until released removes the
* race outright: the job is `RUNNING` for exactly as long as the test wants.
*
* Nothing about reattachment depends on which engine is transcoding. What is under test is the
* tag query, [Reattachment.choose] over live WorkManager state, and `observe` mapping it to
* [ConversionState.Converting] — all of which run identically whatever is doing the work.
*
* ## What this does not do, and cannot (#230)
*
* It does not kill the process. `docs/defect-audit.md` D3/D13 record that `am kill` refuses a
* process holding a foreground service, and there is a more basic obstacle: **instrumentation
* runs in the app's own process**, so any route that really killed it would take the test
* runner with it and there would be nothing left to assert with. A relaunch-and-observe test
* needs two instrumentation runs, which the runner does not provide.
*
* So process death stays device-manual, and this is the closest observable analogue: a fresh
* ViewModel, with no memory of the work, meeting a job that is genuinely mid-flight.
*/
@Test
fun reattachesToAConversionThatIsStillRunning(): Unit = runBlocking {
val released = CompletableDeferred<Unit>()
ConversionDependencies.software = { BlockingTranscoder(released) }
val request = ConversionWorker.request(
inputUri = Uri.fromFile(stage("running_input.mp3")),
displayName = RUNNING_NAME,
sizeBytes = RUNNING_SIZE,
spec = OutputFormat.MP3.spec,
quality = QualityTier.FAST,
)
workManager.enqueue(request).result.get()
// Deterministic: the worker cannot finish until this test lets it.
withTimeout(TIMEOUT_MS) {
workManager.getWorkInfoByIdFlow(request.id).first { it?.state == WorkInfo.State.RUNNING }
}
val reattached = awaitConversion<ConversionState.Converting>()
assertEquals(RUNNING_NAME, reattached.input.displayName)
assertEquals(RUNNING_SIZE, reattached.input.sizeBytes)
released.complete(Unit)
workManager.cancelWorkById(request.id).result.get()
}
/**
* Enqueues a job that stays [WorkInfo.State.ENQUEUED]. The delay is what holds it there: it
* is long enough that nothing can run it during a test, and it is cancelled either way.
@@ -326,5 +419,9 @@ class ReattachOnLaunchTest {
* against WorkManager's database, so this is generous rather than tuned.
*/
const val SETTLE_MS = 5_000L
/** Read back off the job's tags by the reattaching ViewModel, so both have to survive. */
const val RUNNING_NAME = "still_running.mp3"
const val RUNNING_SIZE = 4_242L
}
}