From 238142d9cc43708d04e4f7380c7479eb5f395c17 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Tue, 25 Aug 2026 20:49:28 -0500 Subject: [PATCH] Guard the whole Media3 export instead of only its two ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit transcode() posts its work to a HandlerThread, and everything on that thread has no caller to throw back to: an escaping exception reaches the thread's uncaught handler and takes the process down, while the continuation is never resumed. Both halves of that are bad, and the second is arguably worse — a worker left suspended forever holds a foreground service. The guarding was two narrow runCatching blocks, one around buildTransformer and one around transformer.start, with the two Media3 builders sitting unguarded between them. That gap was not theoretical. EditedMediaItem.Builder rejects a composition with both tracks removed, which is exactly what a plan of (Drop, Drop) asks for, and it does so with a plain IllegalStateException from the constructor. Validation now refuses the spec that produces such a plan, so neither the picker nor ConversionWorker will start one. Routing is a separate question and still answers Media3 for it — a dropped track makes nothing un-hardware-able — so a request that skips validation still arrives here: a job queued before the settings changed, or one made through ConversionWorker.request directly. CopyPlanner's own KDoc already names that path as the reason it re-checks what validation has checked; this is the same belt for the same braces. One guard around the whole body costs nothing on success and turns any such refusal into a failed job with a reason attached. The export body moves into startExport, whose contract is the thing that makes one guard enough: returning normally means the export is running and the listener owns the continuation, throwing means it never started and the caller does. Cancellation is still registered before start. Covered twice on purpose. Robolectric runs the real HandlerThread and the real Media3 builders, so the JVM test exercises the whole sequence and can be run anywhere; the instrumented one repeats it against the real framework. Neither asserts only that the failure is an IllegalStateException, because withTimeout raises TimeoutCancellationException and java.util.concurrent.CancellationException extends IllegalStateException — so that assertion alone calls an unresumed continuation a pass. Both were written that way first, and reverting the guard is what exposed it. Co-Authored-By: Claude Opus 5 (1M context) --- .../convert/Media3EngineTest.kt | 75 +++++++++++++ .../convert/Media3Engine.kt | 88 +++++++++------ .../Media3EngineEmptyCompositionTest.kt | 105 ++++++++++++++++++ .../model/ConversionRouterTest.kt | 28 +++++ 4 files changed, 263 insertions(+), 33 deletions(-) create mode 100644 app/src/test/java/org/libremediaconverter/convert/Media3EngineEmptyCompositionTest.kt diff --git a/app/src/androidTest/java/org/libremediaconverter/convert/Media3EngineTest.kt b/app/src/androidTest/java/org/libremediaconverter/convert/Media3EngineTest.kt index 08c5da7..3ae4173 100644 --- a/app/src/androidTest/java/org/libremediaconverter/convert/Media3EngineTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/convert/Media3EngineTest.kt @@ -11,15 +11,26 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.libremediaconverter.FailsOnEmulatorApi37 +import org.libremediaconverter.model.AudioCodec +import org.libremediaconverter.model.AudioPlan +import org.libremediaconverter.model.Container import org.libremediaconverter.model.ConversionRequest +import org.libremediaconverter.model.CopyPlanner +import org.libremediaconverter.model.InputKind +import org.libremediaconverter.model.InputProbe import org.libremediaconverter.model.OutputFormat +import org.libremediaconverter.model.OutputSpec +import org.libremediaconverter.model.VideoCodec +import org.libremediaconverter.model.VideoPlan import java.io.File +import java.util.concurrent.CancellationException import java.util.concurrent.Executors import java.util.concurrent.TimeUnit @@ -170,6 +181,63 @@ class Media3EngineTest { ) } + /** + * The builders that used to throw where nothing could catch them. + * + * `EditedMediaItem.Builder` rejects a composition with both tracks removed — + * checkState("Audio and video cannot both be removed") — and the engine builds it on its own + * HandlerThread. That build sat *between* two narrow `runCatching` blocks, one around + * `buildTransformer` and one around `start`, so the exception reached the thread's uncaught + * handler and took the process with it while the continuation was never resumed. + * + * `ContainerCapabilities.validate` now refuses the spec that gets here from the picker; this + * is the other half — the engine surviving a request that arrives without being validated. + * Deliberately not `@FailsOnEmulatorApi37`: nothing here decodes or encodes, so no emulator + * codec is involved. The builder refuses the input before any media is touched. + */ + @Test + fun aPlanThatRemovesBothTracksFailsInsteadOfKillingTheProcess() { + val request = ConversionRequest( + spec = OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE), + probe = InputProbe( + videoCodec = null, + audioCodec = "mp3", + hasVideo = false, + container = Container.MP3, + kind = InputKind.AUDIO_ONLY, + ), + ) + // Asserted rather than assumed: ConversionRequest's default probe says hasVideo = true, + // and with it this same spec plans to (Encode, Drop) and nothing throws at all — which + // would make the whole test vacuous without a word of warning. + val plan = CopyPlanner.plan(request.spec, request.probe) + assertEquals(VideoPlan.Drop, plan.video) + assertEquals(AudioPlan.Drop, plan.audio) + + val failure = runCatching { + runBlocking { + withTimeout(BUILDER_TIMEOUT_MS) { + engine.transcode(Uri.fromFile(input), output, request) {} + } + } + }.exceptionOrNull() + + // Two assertions, and the second is not pedantry. withTimeout raises + // TimeoutCancellationException, and `java.util.concurrent.CancellationException` *extends* + // IllegalStateException — so testing only the type below would call an unresumed + // continuation a pass. A hang is the other half of this defect and every bit as bad as the + // crash: the worker would sit holding a foreground service forever. + assertFalse( + "the continuation was never resumed — the failure escaped instead of being reported: " + + "$failure", + failure is CancellationException, + ) + assertTrue( + "the builder's refusal must surface as a failed job, not a dead process; got $failure", + failure is IllegalStateException, + ) + } + private fun durationMsOf(file: File): Long { val extractor = MediaExtractor() return try { @@ -211,5 +279,12 @@ class Media3EngineTest { private companion object { const val TIMEOUT_SECONDS = 120L + + /** + * 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 + * looking for. + */ + const val BUILDER_TIMEOUT_MS = 30_000L } } diff --git a/app/src/main/java/org/libremediaconverter/convert/Media3Engine.kt b/app/src/main/java/org/libremediaconverter/convert/Media3Engine.kt index 8ade749..fefcb57 100644 --- a/app/src/main/java/org/libremediaconverter/convert/Media3Engine.kt +++ b/app/src/main/java/org/libremediaconverter/convert/Media3Engine.kt @@ -68,42 +68,64 @@ class Media3Engine(private val context: Context) : HardwareTranscoder { ): Unit = suspendCancellableCoroutine { cont -> val plan = CopyPlanner.plan(request.spec, request.probe) handler.post { - val transformer = runCatching { buildTransformer(plan, cont) } - .getOrElse { - cont.resumeWithException(it) - return@post - } - - // Dropping the tracks the target does not have is what stops an audio-only export - // from carrying a re-encoded video track. Without setRemoveVideo, asking for M4A - // produced an HEVC stream in a file named .m4a. - val item = EditedMediaItem.Builder(MediaItem.fromUri(input)) - .setRemoveVideo(plan.video == VideoPlan.Drop) - .setRemoveAudio(plan.audio == AudioPlan.Drop) - .build() - - // A Composition is the only way to ask for transmuxing; the plain - // start(EditedMediaItem, path) overload always re-encodes. This is the remux path. - val composition = Composition.Builder(EditedMediaItemSequence.Builder(item).build()) - .setTransmuxVideo(plan.video == VideoPlan.Copy) - .setTransmuxAudio(plan.audio == AudioPlan.Copy) - .build() - - cont.invokeOnCancellation { - // cancel() has the same single-thread requirement as start(). - handler.post { runCatching { transformer.cancel() } } - } - - runCatching { transformer.start(composition, output.absolutePath) } - .onFailure { - cont.resumeWithException(it) - return@post - } - - pollProgress(transformer, cont, onProgress) + // One guard around the whole body, deliberately. + // + // This used to be two narrow ones — around `buildTransformer` and around + // `transformer.start` — with the two Media3 builders sitting unguarded between them. + // On this thread that is not a small gap: nothing here has a caller to throw back to, + // so an escaping exception reaches the HandlerThread's uncaught handler and takes the + // process down, while [cont] is never resumed either way. `EditedMediaItem.Builder` + // does exactly that for a plan that drops both tracks + // ("Audio and video cannot both be removed"), which a queued job can still carry. + // Widening the guard costs nothing on success and turns every such refusal into a + // failed job with a reason. + runCatching { startExport(input, output, plan, cont, onProgress) } + .onFailure { if (cont.isActive) cont.resumeWithException(it) } } } + /** + * Builds the export and hands it to Transformer. Runs on the HandlerThread; may throw. + * + * Everything Transformer's single-thread contract covers lives here, so that the caller has + * exactly one place to catch. Returning normally means the export is running and [cont] belongs + * to the listener; throwing means it never started and the caller owns resuming. + */ + private fun startExport( + input: Uri, + output: File, + plan: ConversionPlan, + cont: CancellableContinuation, + onProgress: (Int) -> Unit, + ) { + val transformer = buildTransformer(plan, cont) + + // Dropping the tracks the target does not have is what stops an audio-only export + // from carrying a re-encoded video track. Without setRemoveVideo, asking for M4A + // produced an HEVC stream in a file named .m4a. + val item = EditedMediaItem.Builder(MediaItem.fromUri(input)) + .setRemoveVideo(plan.video == VideoPlan.Drop) + .setRemoveAudio(plan.audio == AudioPlan.Drop) + .build() + + // A Composition is the only way to ask for transmuxing; the plain + // start(EditedMediaItem, path) overload always re-encodes. This is the remux path. + val composition = Composition.Builder(EditedMediaItemSequence.Builder(item).build()) + .setTransmuxVideo(plan.video == VideoPlan.Copy) + .setTransmuxAudio(plan.audio == AudioPlan.Copy) + .build() + + // Registered before start(), so a cancellation racing the export always finds a + // transformer to cancel. + cont.invokeOnCancellation { + // cancel() has the same single-thread requirement as start(). + handler.post { runCatching { transformer.cancel() } } + } + + transformer.start(composition, output.absolutePath) + pollProgress(transformer, cont, onProgress) + } + /** * @throws IllegalArgumentException if [plan] names a container Media3 cannot mux. That is a * routing bug rather than a runtime condition — [org.libremediaconverter.model.ConversionRouter] diff --git a/app/src/test/java/org/libremediaconverter/convert/Media3EngineEmptyCompositionTest.kt b/app/src/test/java/org/libremediaconverter/convert/Media3EngineEmptyCompositionTest.kt new file mode 100644 index 0000000..5fa41f7 --- /dev/null +++ b/app/src/test/java/org/libremediaconverter/convert/Media3EngineEmptyCompositionTest.kt @@ -0,0 +1,105 @@ +package org.libremediaconverter.convert + +import android.net.Uri +import androidx.media3.common.util.UnstableApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.libremediaconverter.model.AudioCodec +import org.libremediaconverter.model.AudioPlan +import org.libremediaconverter.model.Container +import org.libremediaconverter.model.ConversionRequest +import org.libremediaconverter.model.CopyPlanner +import org.libremediaconverter.model.InputKind +import org.libremediaconverter.model.InputProbe +import org.libremediaconverter.model.OutputSpec +import org.libremediaconverter.model.VideoCodec +import org.libremediaconverter.model.VideoPlan +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.io.File +import java.util.concurrent.CancellationException + +/** + * What happens when Media3 refuses the export before it starts. + * + * `EditedMediaItem.Builder` rejects a composition with both tracks removed — + * checkState("Audio and video cannot both be removed") — and [Media3Engine] builds it on its own + * HandlerThread. That build used to sit *between* two narrow `runCatching` blocks, one around + * `buildTransformer` and one around `start`, so the exception escaped `handler.post`'s body: it + * reached the thread's uncaught handler, which on Android takes the process down, and the + * continuation was left unresumed either way. + * + * Robolectric runs the real [android.os.HandlerThread] and the real Media3 builders, so the whole + * sequence happens here — the engine really posts, really builds, and really throws. What it cannot + * reproduce is the *consequence* of an escaped throw: a JVM background thread dying is not process + * death. So the assertion is on the half that is observable everywhere and is the half that + * matters to the user — the suspension is resolved, with the reason, rather than left hanging. + * `Media3EngineTest.aPlanThatRemovesBothTracksFailsInsteadOfKillingTheProcess` is the same case on + * a device. + */ +@UnstableApi +@RunWith(RobolectricTestRunner::class) +class Media3EngineEmptyCompositionTest { + + @Test + fun `a plan that removes both tracks fails the job instead of escaping the handler thread`() { + val context = RuntimeEnvironment.getApplication() + val engine = Media3Engine(context) + val request = ConversionRequest( + spec = OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE), + probe = InputProbe( + videoCodec = null, + audioCodec = "mp3", + hasVideo = false, + container = Container.MP3, + kind = InputKind.AUDIO_ONLY, + ), + ) + + // Asserted rather than assumed: ConversionRequest's default probe says hasVideo = true, and + // with it this same spec plans to (Encode, Drop), nothing throws, and the test would pass + // over a code path it never entered. + val plan = CopyPlanner.plan(request.spec, request.probe) + assertEquals(VideoPlan.Drop, plan.video) + assertEquals(AudioPlan.Drop, plan.audio) + + val failure = try { + runCatching { + runBlocking { + withTimeout(TIMEOUT_MS) { + engine.transcode(Uri.parse("file:///dev/null"), File(context.cacheDir, "empty.mp4"), request) {} + } + } + }.exceptionOrNull() + } finally { + engine.close() + } + + // Both halves are load-bearing, and the second is not pedantry: withTimeout raises + // TimeoutCancellationException, and `java.util.concurrent.CancellationException` *extends* + // IllegalStateException — so testing only the first would call an unresumed continuation a + // pass. This assertion was written that way, and the mutation is what found it. + assertFalse( + "the continuation was never resumed — the failure escaped instead of being reported: $failure", + failure is CancellationException, + ) + assertTrue( + "the builder's refusal must surface as a failed job; got $failure", + failure is IllegalStateException, + ) + } + + private companion object { + /** + * Short on purpose. Nothing is decoded, encoded or muxed on this path — the builder refuses + * the input outright — so anything approaching this is a hang, which is the failure mode + * this test is looking for. + */ + const val TIMEOUT_MS = 10_000L + } +} diff --git a/app/src/test/java/org/libremediaconverter/model/ConversionRouterTest.kt b/app/src/test/java/org/libremediaconverter/model/ConversionRouterTest.kt index f762f14..b5a915c 100644 --- a/app/src/test/java/org/libremediaconverter/model/ConversionRouterTest.kt +++ b/app/src/test/java/org/libremediaconverter/model/ConversionRouterTest.kt @@ -350,6 +350,34 @@ class ConversionRouterTest { } } + /** + * Why `Media3Engine` still needs a guard of its own. + * + * `ContainerCapabilities.validate` now refuses "a video codec with the audio off" for an input + * with no video track, so neither the picker nor `ConversionWorker` will start one. Routing is + * a separate question and still answers MEDIA3 — nothing about a dropped track makes the job + * un-hardware-able — so a request that skips validation, from a direct + * `ConversionWorker.request(...)` or a job queued before the settings changed, arrives at the + * engine with a plan Media3 cannot build. That has to fail the job, not the process. + */ + @Test + fun `a plan that drops both tracks still routes to media3`() { + val audioOnly = InputProbe( + videoCodec = null, + audioCodec = "mp3", + hasVideo = false, + container = Container.MP3, + kind = InputKind.AUDIO_ONLY, + ) + val spec = OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE) + + val plan = CopyPlanner.plan(spec, audioOnly) + assertEquals(VideoPlan.Drop, plan.video) + assertEquals(AudioPlan.Drop, plan.audio) + + assertEquals(Engine.MEDIA3, route(spec, probe = audioOnly).engine) + } + @Test fun `audio-only formats are flagged as such`() { assertEquals(true, OutputFormat.MP3.isAudioOnly)