Let a cancelled conversion stay cancelled

Both workers' outer `catch (e: Throwable)` caught `CancellationException` along with everything
else and answered it with a `Result`. `runMedia3OrFallBack` goes out of its way to rethrow
cancellation rather than fall back to software, and then the catch above it converted it anyway.
A coroutine that reports completion inside a scope which has already been cancelled is
structured concurrency's one rule broken, and it is the kind of break that stays quiet: nothing
downstream complains, and the next thing to hold a resource across that boundary is the thing
that finds out.

Nothing on screen disagreed today, which is why this is a low-severity entry rather than a bug
report. WorkManager cancels the worker's coroutine through `WorkerWrapper.interrupt`, which
cancels `workerJob` with a `WorkerStoppedException`; the surrounding `withContext(workerJob)`
then throws that whatever the worker returned, and `launch()` resolves it as `ResetWorkerStatus`.
The returned `Result` is read only when nothing stopped the worker at all. So the change is
about the shape of the code rather than about a symptom.

One behaviour does move, and it is worth naming rather than discovering later. A cancellation
that is *not* WorkManager stopping us -- FFmpegKit reporting `ReturnCode.isCancel`, which cancels
the continuation -- now leaves `doWork` as a cancellation, and `WorkerWrapper` resolves a
self-cancelled worker as `Resolution.Failed()` with no output data instead of the
`Result.failure(KEY_ERROR ...)` it used to build. Both ViewModels already fall back on blank
output data, deliberately and with a test, so the user sees "Conversion failed." either way. That
is also the honest answer: the only route to `isCancel` is a cancellation someone asked for.

The `staged.delete()` on that path is kept, and moved into the new branch rather than left to the
one below it. A cancelled attempt leaves a partial in staging, the next attempt starts `doWork()`
from the top rather than resuming it, and this catch holds the only handle to the file.

Reaching any of this from a JVM test needed one more thing: `doWork` called `MediaProbe.probe`
directly, and it was the last caller bypassing `ConversionDependencies.probe`. FFprobe's loader
throws a bare `java.lang.Error` with no native library present, so no unit test could reach a
single line below it. The seam's own KDoc says this is what it is for -- coverage of the branches
that only run when something goes wrong -- and the default is the same real probe, so the app and
the instrumented tests are unchanged.

`WorkerCancellationTest` drives the real worker through a real `WorkManager` to the software
engine, forced with `FORCE_SOFTWARE` because it is the one preference that decides without
consulting the input, so the test does not depend on a routing rule it is not about. The engine
stub writes bytes before it throws, which is what makes the delete assertion mean something: a
stub that only threw would let a missing `delete()` pass. Three cases -- cancellation propagates,
cancellation still deletes, and an ordinary failure is still answered with a `Result` carrying
its message, which is the half that would break if the rethrow were widened past cancellation.

Before the fix the first of those failed with "cancellation must leave doWork as cancellation,
not as a Result; got null" -- `runCatching` had nothing to report, because `doWork` had returned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 20:11:44 -05:00
co-authored by Claude Opus 5
parent 5c27801c77
commit dcdcbfd3af
5 changed files with 212 additions and 14 deletions
@@ -10,6 +10,7 @@ import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import kotlinx.coroutines.CancellationException
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.ffmpeg.ConcatEngine
import org.libremediaconverter.model.OutputFormat
@@ -70,6 +71,11 @@ class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker
KEY_STRATEGY to result.strategy.name,
),
)
} catch (e: CancellationException) {
// Rethrown rather than answered with a Result -- see the same branch in
// ConversionWorker for why, and for why the delete stays.
staged.delete()
throw e
} catch (e: Throwable) {
staged.delete()
when (FailureOutcome.forFailure(stopReason, e, runAttemptCount)) {
@@ -11,8 +11,8 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.arthenica.ffmpegkit.FFmpegKitConfig
import kotlinx.coroutines.CancellationException
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.MediaProbe
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ContainerCapabilities
@@ -84,7 +84,12 @@ class ConversionWorker(context: Context, params: WorkerParameters) : CoroutineWo
// was outside for the same reason and had the same problem.
setForeground(foregroundInfo(displayName, percent = 0, indeterminate = true))
val probe = MediaProbe.probe(applicationContext, inputUri)
// Through the seam rather than MediaProbe directly. The seam already existed for the
// ViewModel and the worker was the last caller bypassing it, which is why nothing on
// the JVM could reach a line below this one: FFprobe's loader throws a bare
// java.lang.Error with no native library present. The app and the instrumented tests
// get the real probe, exactly as before.
val probe = ConversionDependencies.probe(applicationContext, inputUri)
val devices = ConversionDependencies.deviceCodecs()
val request = ConversionRequest(
spec = spec,
@@ -117,6 +122,17 @@ class ConversionWorker(context: Context, params: WorkerParameters) : CoroutineWo
KEY_ROUTE_REASON to decision.reason.explanation,
),
)
} catch (e: CancellationException) {
// Cancellation is not a result, and answering it with one breaks structured
// concurrency: this coroutine would report completion inside a scope that has already
// been cancelled. Invisible today only because WorkManager marks the work CANCELLED
// itself and ignores whatever the worker returned.
//
// The delete still has to happen, and has to happen here. A cancelled attempt leaves a
// partial in staging, the next attempt starts from the top rather than resuming it,
// and this is the only code holding the handle.
staged.delete()
throw e
} catch (e: Throwable) {
staged.delete()
outcomeFor(e)
@@ -185,7 +201,7 @@ class ConversionWorker(context: Context, params: WorkerParameters) : CoroutineWo
}
}
private fun isCancellation(e: Throwable): Boolean = e is kotlinx.coroutines.CancellationException || isStopped
private fun isCancellation(e: Throwable): Boolean = e is CancellationException || isStopped
/**
* Turns whatever ended the attempt into a `Result`. [FailureOutcome] owns the rules.
@@ -0,0 +1,18 @@
package org.libremediaconverter.work
import android.content.Context
import org.libremediaconverter.convert.OutputPublisher
/**
* A real [OutputPublisher] that never refuses on space. Shared by the worker unit tests.
*
* The space check reads the host's free disk, which has nothing to do with what any of those tests
* are about and would make them pass or fail on how full the machine is. Where staging lives, and
* the delete, stay the production implementation — the assertions are about the real filesystem.
*
* Only what more than one test needs lives here. The stubs each test uses to force *its own*
* failure stay in that test, next to the assertion they serve.
*/
open class AlwaysRoomPublisher(context: Context) : OutputPublisher(context) {
override fun hasSpaceFor(bytes: Long): Boolean = true
}
@@ -187,14 +187,3 @@ private class FailedFuture(private val failure: Throwable) : ListenableFuture<Vo
override fun get(): Void = throw ExecutionException(failure)
override fun get(timeout: Long, unit: TimeUnit): Void = throw ExecutionException(failure)
}
/**
* A real publisher that never refuses on space.
*
* The space check reads the host's free disk, which has nothing to do with what these tests are
* about and would make them pass or fail on how full the machine is. Where staging lives, and the
* delete, are the production implementation.
*/
private class AlwaysRoomPublisher(context: Context) : OutputPublisher(context) {
override fun hasSpaceFor(bytes: Long): Boolean = true
}
@@ -0,0 +1,169 @@
package org.libremediaconverter.work
import android.app.Application
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.Data
import androidx.work.ListenableWorker
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.workDataOf
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.OutputPublisher
import org.libremediaconverter.convert.SoftwareTranscoder
import org.libremediaconverter.convert.installTestWorkManager
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.DeviceCodecs
import org.libremediaconverter.model.EnginePreference
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
import java.util.UUID
/**
* That a cancelled conversion stays cancelled, and still cleans up after itself.
*
* The worker's outer catch is `catch (e: Throwable)`, which caught `CancellationException` along
* with everything else and answered it with a `Result`. That is a coroutine reporting completion
* inside a scope that has already been cancelled — structured concurrency's one rule, broken
* quietly. What made it invisible is that WorkManager marks the work `CANCELLED` itself and
* ignores the returned `Result`, so nothing on screen ever disagreed.
*
* The delete on that path is not incidental and has to survive the fix: an attempt that was
* cancelled leaves a partial file in staging, the worker starts from the top rather than resuming
* it, and this is the only code holding its handle.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class WorkerCancellationTest {
private lateinit var app: Application
private lateinit var publisher: OutputPublisher
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
publisher = AlwaysRoomPublisher(app)
ConversionDependencies.publisher = { publisher }
// FFprobe's loader throws a bare java.lang.Error on the JVM, and the device-codec query
// reads whatever MediaCodecList the runtime fabricates. Neither is what these tests are
// about; both would decide the routing for reasons no assertion mentions.
ConversionDependencies.probe = { _, _ -> InputProbe() }
ConversionDependencies.deviceCodecs = { DeviceCodecs.PERMISSIVE }
installTestWorkManager(app, Data.EMPTY)
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
@Test
fun `a cancelled conversion propagates instead of being turned into a Result`() {
val worker = conversionWorker { throw CancellationException("stopped mid-transcode") }
val thrown = runCatching { runBlocking { worker.doWork() } }.exceptionOrNull()
assertTrue(
"cancellation must leave doWork as cancellation, not as a Result; got $thrown",
thrown is CancellationException,
)
}
@Test
fun `a cancelled conversion still deletes the partial it had already written`() {
val worker = conversionWorker { throw CancellationException("stopped mid-transcode") }
runCatching { runBlocking { worker.doWork() } }
// The engine stub writes before it throws, so this file really existed. Rethrowing without
// deleting would trade one defect for another.
assertFalse("a cancelled attempt must not leave its partial behind", stagedFile().exists())
}
@Test
fun `an ordinary engine failure is still answered with a Result`() {
val worker = conversionWorker { error("the muxer was never started") }
val result = runBlocking { worker.doWork() }
// The other half of the rule: only cancellation propagates. Widening the rethrow to every
// exception would take the user's error message away with it.
assertEquals(
ListenableWorker.Result.failure(
workDataOf(ConversionWorker.KEY_ERROR to "the muxer was never started"),
),
result,
)
assertFalse("a failed attempt must not leave its partial behind", stagedFile().exists())
}
/**
* A worker routed to the software engine, which is [failure] and nothing else.
*
* `FORCE_SOFTWARE` rather than letting the router choose: it is the one preference that decides
* without consulting the input at all, so the test says which engine it is replacing instead of
* depending on a routing rule it is not about. The input is a `file://` URI for the same kind
* of reason — a `content://` one would send the worker through FFmpegKit's SAF bridge, which is
* native.
*/
private fun conversionWorker(failure: () -> Nothing): ConversionWorker {
ConversionDependencies.software = { PartialThenFailingTranscoder(failure) }
return TestListenableWorkerBuilder<ConversionWorker>(
context = app,
inputData = workDataOf(
ConversionWorker.KEY_INPUT_URI to INPUT.toString(),
ConversionWorker.KEY_DISPLAY_NAME to DISPLAY_NAME,
ConversionWorker.KEY_SIZE_BYTES to INPUT_BYTES,
ConversionWorker.KEY_CONTAINER to SPEC.container.name,
ConversionWorker.KEY_VIDEO_CODEC to SPEC.videoCodec.name,
ConversionWorker.KEY_AUDIO_CODEC to SPEC.audioCodec.name,
ConversionWorker.KEY_ENGINE_PREFERENCE to EnginePreference.FORCE_SOFTWARE.name,
),
runAttemptCount = 0,
).setId(JOB_ID).build()
}
/** The staging path the worker will compute, asked for rather than spelled out here. */
private fun stagedFile(): File = publisher.createStagingFile(ConversionWorker.outputNameFor(DISPLAY_NAME, SPEC))
private companion object {
val INPUT: Uri = Uri.parse("file:///tmp/holiday.mp4")
const val DISPLAY_NAME = "holiday.mp4"
const val INPUT_BYTES = 1024L
val SPEC = OutputFormat.MP4_H265.spec
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000003")
}
}
/**
* An engine that writes something and then fails, which is what every real interruption looks like.
*
* Writing first is the point: a stub that only threw would let a missing `delete()` pass.
*/
private class PartialThenFailingTranscoder(private val failure: () -> Nothing) : SoftwareTranscoder {
override suspend fun run(
request: ConversionRequest,
inputPath: String,
output: File,
durationMs: Long,
onProgress: (Int) -> Unit,
) {
output.writeBytes(ByteArray(PARTIAL_BYTES))
failure()
}
private companion object {
const val PARTIAL_BYTES = 2048
}
}