Guard the native boundary against what it actually throws

D14: picking a file died instead of reporting an unreadable one when
FFmpegKit's native library could not load. `probeWithFFprobe` guarded its
call with `catch (e: Exception)`, and `ConversionViewModel.onInputPicked`
guarded nothing, so the failure escaped a `viewModelScope.launch` -- which
has no handler, and on a device ends the process.

All three of the obvious narrow guards catch nothing, which is why this
needed reading the AAR rather than guessing. `NativeLoader.loadLibrary`
catches the `UnsatisfiedLinkError` that `System.loadLibrary` raises and
rethrows a *bare* `java.lang.Error` wrapping it, so `UnsatisfiedLinkError`
never escapes and the escaping type carries no information at all. Every
touch of the class after the first is a different type again --
`NoClassDefFoundError` -- so a guard written for the first shape lets the
second pick onwards crash, which is the harder half to notice. Both are in
the test output verbatim.

`catch (Throwable)` was the wrong answer for the reason the audit gave: it
would swallow a genuine `OutOfMemoryError` in a method that spawns a native
process, turning "this device is out of memory" into "this file looks
unreadable" and letting the app act on it. So the line is drawn by a named
predicate, `isNativeLoadFailure`, rather than by the catch clause -- every
class-loading shape is a `LinkageError`, and nothing that means the JVM is
failing is one. That disjointness is what makes the guard narrow.

This is consistent with the position `config/detekt/detekt.yml` already
takes for `TooGenericExceptionCaught`: the boundary's failure types are
undocumented, so guessing crashes the app on a file it could have reported.
One level up the opposite mistake is available too, and the predicate is
what lets both be avoided at once.

`TooGenericExceptionThrown` is relaxed for the test source sets only. A test
that reproduces a failed native load has to throw what the library throws,
and a tidier subclass would leave it passing against a defect it no longer
reproduces. Main source is untouched by that and throws nothing generic.

`ConversionDependencies.probe`'s KDoc is rewritten rather than left. It said
this hazard was "deliberately not fixed here ... its own commit, with its
own test", which this is -- leaving it would have replaced one true comment
with a false one, which is the same defect class as the D11 work.

Both halves are covered independently: reverting the `MediaProbe` catch
reds only the two `MediaProbeNativeLoadTest` cases, reverting the ViewModel
guard reds only the two injected-seam cases, and widening the ViewModel
guard to `Throwable` reds the OutOfMemoryError case -- so the narrowness is
pinned, not just the catch.

Audited the sibling boundaries named in the audit and left all three alone:
`FFmpegEngine` and `ConcatEngine` both construct and run under
`catch (e: Throwable)` in their workers, and `Media3Engine` has no native
loader of this kind and already routes failures through `runCatching`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 19:52:55 -05:00
co-authored by Claude Opus 5
parent eb37f9ebea
commit 97fdc49f01
8 changed files with 475 additions and 18 deletions
@@ -3,6 +3,7 @@ package org.libremediaconverter.convert
import android.app.Application
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
@@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.libremediaconverter.ffmpeg.isNativeLoadFailure
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ContainerCapabilities
@@ -220,7 +222,7 @@ class ConversionViewModel @JvmOverloads constructor(
// would read as the app having ignored the tap.
_state.value = ConversionState.Ready(file)
val probe = withContext(Dispatchers.IO) { ConversionDependencies.probe(getApplication(), uri) }
val probe = withContext(Dispatchers.IO) { probeOrUnreadable(uri) }
// Only fill in the probe if the user has not moved on in the meantime.
_state.update { current ->
if (current is ConversionState.Ready && current.input.uri == uri) {
@@ -232,6 +234,34 @@ class ConversionViewModel @JvmOverloads constructor(
}
}
/**
* Probing, with the one failure the pick must survive rather than propagate.
*
* This runs inside `viewModelScope.launch`, which has no exception handler, so anything
* that escapes here abandons the launch — the file card never fills in — and reaches the
* thread's default handler, which on a device takes the process down. Picking a file is
* not a place to crash from.
*
* The one condition that reaches this is FFmpegKit's native library failing to load,
* which arrives as an `Error` rather than an `Exception`; [MediaProbe] handles its own
* FFprobe call now, and this covers the seam and the platform extractor beside it. The
* answer is [MediaProbe.UNREADABLE] — the same value [MediaProbe.probe] returns when
* neither of its probes could read the file, because that is what has happened.
*
* Anything else is rethrown deliberately. An [OutOfMemoryError] here is about this
* process, not about this file, and reporting it as an unreadable video would let the app
* carry on in a state it cannot honour. See
* [org.libremediaconverter.ffmpeg.isNativeLoadFailure] for which is which and why the
* distinction is drawn by a predicate rather than by the catch clause.
*/
private fun probeOrUnreadable(uri: Uri): InputProbe = try {
ConversionDependencies.probe(getApplication(), uri)
} catch (e: Error) {
if (!isNativeLoadFailure(e)) throw e
Log.w(TAG, "Could not probe $uri; reporting it as unreadable.", e)
MediaProbe.UNREADABLE
}
/**
* Enqueues the conversion rather than running it inline.
*
@@ -420,5 +450,7 @@ class ConversionViewModel @JvmOverloads constructor(
* it "unknown" would read as an error rather than as a gap in what survived.
*/
const val UNKNOWN_INPUT_NAME = "Media file"
const val TAG = "ConversionViewModel"
}
}
@@ -8,6 +8,7 @@ import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.FFprobeKit
import com.arthenica.ffmpegkit.MediaInformation
import org.libremediaconverter.ffmpeg.isNativeLoadFailure
import org.libremediaconverter.model.ConcatInput
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.InputKind
@@ -33,6 +34,21 @@ import org.libremediaconverter.model.InputProbe
*/
object MediaProbe {
/**
* What [probe] reports when nothing could read the input.
*
* Named rather than inlined because a caller that has to handle [probe] itself failing
* needs to land on the same answer — see `ConversionViewModel.onInputPicked`. Two
* different spellings of "unreadable" would be two different behaviours downstream, since
* the router keys off [InputProbe.UNPARSEABLE] and the source-info card off the kind.
*/
val UNREADABLE = InputProbe(
videoCodec = InputProbe.UNPARSEABLE,
hasVideo = true,
durationMs = 0,
kind = InputKind.UNPARSEABLE,
)
fun probe(context: Context, uri: Uri): InputProbe {
val extracted = probeWithExtractor(context, uri)
val info = probeWithFFprobe(context, uri)
@@ -45,12 +61,7 @@ object MediaProbe {
// Not a failure: an unparseable input is a strong signal that this job belongs on
// FFmpeg. Reporting an unknown codec makes the router say so.
Log.i(TAG, "Neither MediaExtractor nor FFprobe could read $uri; routing to FFmpeg.")
return InputProbe(
videoCodec = InputProbe.UNPARSEABLE,
hasVideo = true,
durationMs = 0,
kind = InputKind.UNPARSEABLE,
)
return UNREADABLE
}
return InputProbe(
@@ -145,6 +156,15 @@ object MediaProbe {
} catch (e: Exception) {
Log.i(TAG, "FFprobe could not read $uri.", e)
null
} catch (e: Error) {
// Touching FFmpegKit at all loads its native library, and a failure there arrives as
// an Error, which the clause above cannot see -- so an unloadable library used to
// take the whole file pick down instead of reporting an unreadable file. Anything
// that is not that library failing to load is still this JVM's problem, not this
// file's, and is rethrown: see isNativeLoadFailure.
if (!isNativeLoadFailure(e)) throw e
Log.w(TAG, "FFmpegKit's native library could not be loaded; probing $uri without FFprobe.", e)
null
}
private fun readMediaInformation(path: String): FFprobeInfo? {
@@ -80,18 +80,20 @@ object ConversionDependencies {
*
* Here for a reason the others are not, and the reason is worth recording rather than
* just working around. [MediaProbe] spawns FFprobe, and when FFmpegKit's native library
* cannot load its initialiser throws a bare `java.lang.Error` — which
* `probeWithFFprobe`'s own `catch (e: Exception)` does not catch, and which
* `ConversionViewModel.onInputPicked` does not catch either. Picking a file would then
* fail with an uncaught error rather than the "could not read this file" the code was
* written to give.
* cannot load, the failure arrives as a `java.lang.Error` rather than an `Exception` —
* which is why `probeWithFFprobe`'s `catch (e: Exception)` did not see it, and why
* `ConversionViewModel.onInputPicked` used to abandon its `viewModelScope.launch`
* instead of reporting a file it could not read.
*
* **That is a latent production hazard, found here and deliberately not fixed here.**
* It cannot fire on a device that ships the `.so` files, which is every real install,
* so making `MediaProbe` catch `Throwable` would be a behaviour change to the pick path
* on the strength of a condition no user meets — its own commit, with its own test.
* What this seam does is narrower: it keeps the JVM out of that path, which is what
* makes the ViewModel reachable from a unit test at all.
* **Both of those are guarded now**, by
* [org.libremediaconverter.ffmpeg.isNativeLoadFailure] — which also documents what the
* boundary actually throws, since all three of the obvious guesses turn out to be
* wrong. This seam is no longer what stands between a JVM test and an uncaught error.
*
* It still earns its place: injecting a probe is how a test reaches a *chosen* outcome
* for a file rather than the unreadable verdict the JVM has no libraries to improve on,
* and how the error path itself is forced — see `ConversionViewModelProbeFailureTest`,
* which drives an `OutOfMemoryError` through here to pin that the guard stays narrow.
*
* Instrumented tests and the app itself get the real probe, exactly as before.
*/
@@ -0,0 +1,59 @@
package org.libremediaconverter.ffmpeg
/**
* Whether [error] is FFmpegKit failing to load its native library, rather than this JVM
* being in trouble.
*
* ## Why a predicate rather than a catch clause
*
* `config/detekt/detekt.yml` turns `TooGenericExceptionCaught` off with a written argument:
* the engine boundaries sit in front of native code whose failure types are undocumented,
* "enumerating it would mean guessing, and a guess that is wrong crashes the app on a file
* it could have simply reported as unreadable." That argument is about *exceptions*, and it
* applies unchanged one level up — except that on the `Error` side the opposite mistake is
* available too. `catch (Throwable)` at a boundary that spawns a native process would
* swallow a genuine [OutOfMemoryError] and let the app carry on pretending it had merely
* met an unreadable file.
*
* So this names the failure instead of the catch clause. Everything it does not recognise is
* rethrown.
*
* ## What the boundary actually throws
*
* Read off the shipped AAR and confirmed by `MediaProbeNativeLoadTest`, because all three of
* the obvious guesses are wrong:
*
* - `NativeLoader.loadLibrary` catches the `UnsatisfiedLinkError` that `System.loadLibrary`
* raises and rethrows `java.lang.Error(message, cause)` — a **bare** `Error`, which is
* neither an `Exception` nor a [LinkageError]. `catch (e: UnsatisfiedLinkError)` sees
* nothing. Its `cause` is the original `UnsatisfiedLinkError`, which is what identifies it
* here; matching on the message would be matching on a format string.
* - That throw happens under `FFmpegKitConfig.<clinit>`, so what a caller sees also depends
* on how the runtime treats an initializer that fails: observed as
* `ExceptionInInitializerError` on the JVM under Robolectric, and recorded as the bare
* `Error` in `docs/defect-audit.md`. Both shapes are handled rather than either being
* assumed.
* - Every touch **after** the first is a third type again — `NoClassDefFoundError: Could not
* initialize class …`, the JVM's own record that the class is poisoned. A guard written
* for the first shape alone would let the second pick onwards crash, which is the harder
* half to notice.
*
* All of the class-loading shapes are [LinkageError]s, and none of the errors that mean this
* JVM is failing — [OutOfMemoryError], `StackOverflowError`, the rest of
* `VirtualMachineError` — is one. That disjointness is what makes this narrow rather than a
* blanket `catch (Throwable)`.
*
* ## When it can fire
*
* Not on a healthy install: the `.so` files ship in the APK. A corrupted install or an ABI
* mismatch is the realistic device path, and the JVM unit tests are the other, where the
* libraries are absent by construction.
*/
internal fun isNativeLoadFailure(error: Error): Boolean = when {
// NoClassDefFoundError, ExceptionInInitializerError, UnsatisfiedLinkError: the JVM's
// whole vocabulary for "the code could not be loaded".
error is LinkageError -> true
// FFmpegKit's own bare java.lang.Error, identified by what it wraps.
error.cause is UnsatisfiedLinkError -> true
else -> false
}
@@ -0,0 +1,151 @@
package org.libremediaconverter.convert
import android.app.Application
import android.net.Uri
import android.os.Looper
import androidx.media3.common.util.UnstableApi
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.InputKind
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.work.ConversionWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import java.util.concurrent.TimeUnit
/**
* That a probe which throws leaves a screen the user can act on, not a dead coroutine.
*
* [MediaProbeNativeLoadTest] covers the boundary itself. This covers the other half of the
* same defect: `onInputPicked` runs inside `viewModelScope.launch`, so anything the probe
* throws and does not handle leaves the launch with no result at all — the file card never
* fills in, and on a device the default handler takes the process down.
*
* The seam is what makes that testable. Injecting a prober that throws reproduces the
* condition exactly, without depending on which types FFmpegKit happens to throw today.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class ConversionViewModelProbeFailureTest {
private lateinit var app: Application
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
installTestWorkManager(app, workDataOf(ConversionWorker.KEY_OUTPUT_PATH to "/dev/null"))
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
/**
* The exact observed failure: FFmpegKit's loader rethrows a bare [Error] whose cause is
* the `UnsatisfiedLinkError` `System.loadLibrary` raised.
*/
@Test
fun `a native load failure during the probe reports an unreadable file`() {
ConversionDependencies.probe = { _, _ ->
throw Error(
"FFmpegKit failed to start on brand: robolectric.",
UnsatisfiedLinkError("dlopen failed: library \"libffmpegkit.so\" not found"),
)
}
val probe = pickedProbe()
assertNotNull("the pick must finish; a thrown Error used to abandon the launch", probe)
assertEquals(InputKind.UNPARSEABLE, probe?.kind)
assertEquals(InputProbe.UNPARSEABLE, probe?.videoCodec)
}
/** Every touch after the first throws this instead, so the guard has to cover it too. */
@Test
fun `a NoClassDefFoundError from a poisoned class reports an unreadable file`() {
ConversionDependencies.probe = { _, _ ->
throw NoClassDefFoundError("Could not initialize class com.arthenica.ffmpegkit.FFmpegKitConfig")
}
assertEquals(InputKind.UNPARSEABLE, pickedProbe()?.kind)
}
/**
* The line the guard must not cross.
*
* `MediaProbe` spawns a native process, so an [OutOfMemoryError] raised in it is a real
* one about this JVM, not a report about the file. Swallowing it would turn "the device
* is out of memory" into "this video looks unreadable" and let the app carry on in a
* state it cannot honour — which is the regression a blanket `catch (Throwable)` would
* have introduced, and the reason this defect was left open rather than fixed carelessly.
*/
@Test
fun `an OutOfMemoryError is not swallowed`() {
ConversionDependencies.probe = { _, _ -> throw OutOfMemoryError("Failed to allocate 512 MB") }
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
viewModel.onInputPicked(INPUT)
// The observable difference, and the reason this is asserted on state rather than on a
// caught throwable: the probe hop is on Dispatchers.IO, so an error that escapes lands
// on that thread's handler rather than at this call. What must not happen is the card
// filling in with an "unreadable" verdict the app would then act on.
val settled = settle(viewModel)
assertEquals(ConversionState.Ready(InputFile(INPUT, "input", 0L)), settled)
assertNull("an OOM must not be reported as a probe result", (settled as ConversionState.Ready).input.probe)
}
/** A working probe is untouched by any of this. */
@Test
fun `a probe that succeeds still fills the card in`() {
ConversionDependencies.probe = { _, _ -> InputProbe(videoCodec = "h264", kind = InputKind.VIDEO) }
assertEquals("h264", pickedProbe()?.videoCodec)
}
/**
* Drives a real pick and returns the probe the card ended up with.
*
* Asserting on the probe rather than merely on `Ready` is deliberate: `onInputPicked`
* sets `Ready` *before* it probes, so a test that only checked the state would have
* passed against the unguarded code.
*/
private fun pickedProbe(): InputProbe? {
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
viewModel.onInputPicked(INPUT)
val ready = awaitState(viewModel.state, "Ready with a probe") {
it is ConversionState.Ready && it.input.probe != null
}
assertNull("nothing here should reach a terminal failure", (ready as? ConversionState.Failed))
return (ready as ConversionState.Ready).input.probe
}
/**
* Pumps the looper the way [awaitState] does, but for a fixed span and without requiring
* anything to happen — here "the pick never came back" is the expected outcome, so there
* is no predicate to wait on.
*/
private fun settle(viewModel: ConversionViewModel): ConversionState {
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(SETTLE_MS)
while (System.nanoTime() < deadline) {
shadowOf(Looper.getMainLooper()).idle()
Thread.sleep(POLL_MS)
}
return viewModel.state.value
}
private companion object {
val INPUT: Uri = Uri.parse("content://test/holiday.mp4")
const val SETTLE_MS = 500L
const val POLL_MS = 5L
}
}
@@ -0,0 +1,93 @@
package org.libremediaconverter.convert
import android.net.Uri
import com.arthenica.ffmpegkit.FFmpegKitConfig
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.InputKind
import org.libremediaconverter.model.InputProbe
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
/**
* That a failed native load is reported, not thrown.
*
* The JVM is the only place this is reachable: there are no `.so` files here by
* construction, which is exactly the shape a corrupted install or an ABI mismatch has on a
* device. So the condition that cannot be provoked on working hardware is free here, and
* these tests are the only ones that can exercise it at all.
*/
@RunWith(RobolectricTestRunner::class)
class MediaProbeNativeLoadTest {
/**
* What the boundary actually throws, pinned against the library rather than assumed.
*
* This is the test that justifies the shape of the guard, and it contradicts the obvious
* guess. `NativeLoader.loadLibrary` catches `UnsatisfiedLinkError` from
* `System.loadLibrary` and rethrows `java.lang.Error(message, cause)` — so
* `UnsatisfiedLinkError` never escapes, and because a bare `Error` *is* an `Error`, JLS
* 12.4.2 propagates it out of the static initialiser unwrapped rather than boxing it in
* `ExceptionInInitializerError`. Catching either of those two named types would catch
* nothing at all.
*
* The second touch of the class is a different type again — `NoClassDefFoundError`, the
* JVM's own "this class already failed to initialise" — so a guard written for one shape
* lets the other through. Both are asserted, in whichever order this classloader reaches
* them.
*/
@Test
fun `loading FFmpegKit without its native library throws an Error, not an Exception`() {
val thrown: Throwable? = runCatching { FFmpegKitConfig.getLogLevel() }.exceptionOrNull()
// The whole defect in one assertion: `catch (e: Exception)` could never have seen this.
assertTrue(
"expected the native load to fail with something no catch (e: Exception) can see, got $thrown",
thrown !is Exception,
)
assertTrue("expected an Error, got $thrown", thrown is Error)
val error = thrown as Error
// Either the first touch (bare Error wrapping UnsatisfiedLinkError) or a later one
// (NoClassDefFoundError). Both are native-load failures; neither is a VirtualMachineError.
assertTrue(
"expected a bare Error caused by UnsatisfiedLinkError or a NoClassDefFoundError, got $error",
error is NoClassDefFoundError || error.cause is UnsatisfiedLinkError,
)
}
/**
* The defect itself: picking a file must not die because FFprobe could not start.
*
* `probeWithFFprobe` guarded its call with `catch (e: Exception)`, which an `Error` walks
* straight through. With neither probe able to read the file, the designed answer is the
* unparseable probe — "nothing could read it, route it to FFmpeg" — not a throw.
*/
@Test
fun `probe reports an unreadable input instead of throwing when FFprobe cannot start`() {
val probe = MediaProbe.probe(RuntimeEnvironment.getApplication(), CONTENT_URI)
assertEquals(InputKind.UNPARSEABLE, probe.kind)
assertEquals(InputProbe.UNPARSEABLE, probe.videoCodec)
}
/**
* The second call takes the other branch — `NoClassDefFoundError` rather than the bare
* `Error` — so a guard that covered only the first shape would still crash every pick
* after the first one.
*/
@Test
fun `a second probe is guarded too, though the JVM throws a different Error by then`() {
val first = MediaProbe.probe(RuntimeEnvironment.getApplication(), CONTENT_URI)
val second = MediaProbe.probe(RuntimeEnvironment.getApplication(), CONTENT_URI)
assertEquals(InputKind.UNPARSEABLE, first.kind)
assertEquals(InputKind.UNPARSEABLE, second.kind)
}
private companion object {
/** `content://` so the probe takes the SAF branch, which is what a real pick does. */
val CONTENT_URI: Uri = Uri.parse("content://test/holiday.mp4")
}
}
@@ -0,0 +1,89 @@
package org.libremediaconverter.ffmpeg
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Where the guard draws its line.
*
* The whole point of naming the predicate was that "catch what a failed native load throws"
* and "do not swallow an OutOfMemoryError in a method that spawns a native process" are two
* requirements a catch clause cannot express together. These are that pair, written down.
*/
class NativeLoadFailureTest {
// --- Recognised: the installation is broken, not this JVM ------------------------------
/**
* FFmpegKit's own shape. `NativeLoader.loadLibrary` catches the `UnsatisfiedLinkError`
* that `System.loadLibrary` raises and rethrows a bare `java.lang.Error` wrapping it, so
* the type carries no information and the cause is what identifies it.
*/
@Test
fun `a bare Error wrapping an UnsatisfiedLinkError is a native load failure`() {
val error = Error("FFmpegKit failed to start on brand: robolectric.", UnsatisfiedLinkError("dlopen failed"))
assertTrue(isNativeLoadFailure(error))
}
/** Every touch of the class after the first one, which is the easier half to miss. */
@Test
fun `a NoClassDefFoundError is a native load failure`() {
val error = NoClassDefFoundError("Could not initialize class com.arthenica.ffmpegkit.FFmpegKitConfig")
assertTrue(isNativeLoadFailure(error))
}
/** What the first touch looked like when the JVM wrapped the failing initializer. */
@Test
fun `an ExceptionInInitializerError is a native load failure`() {
assertTrue(isNativeLoadFailure(ExceptionInInitializerError("Exception java.lang.Error: FFmpegKit failed")))
}
/** If a later FFmpegKit stops wrapping, the raw error is recognised on its own. */
@Test
fun `a plain UnsatisfiedLinkError is a native load failure`() {
assertTrue(isNativeLoadFailure(UnsatisfiedLinkError("dlopen failed: libffmpegkit.so not found")))
}
// --- Not recognised: this JVM is in trouble and must be allowed to say so ---------------
/**
* The regression the narrow guard exists to prevent. `catch (Throwable)` here would report
* "out of memory" to the user as "this file looks unreadable".
*/
@Test
fun `an OutOfMemoryError is not a native load failure`() {
assertFalse(isNativeLoadFailure(OutOfMemoryError("Failed to allocate a 512 MB allocation")))
}
@Test
fun `a StackOverflowError is not a native load failure`() {
assertFalse(isNativeLoadFailure(StackOverflowError()))
}
@Test
fun `an AssertionError is not a native load failure`() {
assertFalse(isNativeLoadFailure(AssertionError("a broken invariant is not a broken install")))
}
/**
* A bare `Error` on its own says nothing. Only the `UnsatisfiedLinkError` underneath it
* makes it FFmpegKit's, so matching the type alone would be a blanket catch wearing a
* predicate's clothes.
*/
@Test
fun `a bare Error with no cause is not a native load failure`() {
assertFalse(isNativeLoadFailure(Error("something else went wrong")))
}
/** An OOM does not become catchable by acquiring a cause. */
@Test
fun `an OutOfMemoryError caused by something else is still not a native load failure`() {
val error = OutOfMemoryError("Java heap space")
error.initCause(IllegalStateException("some unrelated cause"))
assertFalse(isNativeLoadFailure(error))
}
}
+11
View File
@@ -102,3 +102,14 @@ exceptions:
# and handles -- falls back to FFmpeg, or fails the job with a reason -- and the
# SwallowedException rule stays active to keep it that way.
active: false
TooGenericExceptionThrown:
# Still on for main source, which throws nothing generic and should not start.
#
# Relaxed for tests only, and for one reason: a test that reproduces a failed native
# load has to throw what the library actually throws, and FFmpegKit throws a *bare*
# `java.lang.Error` -- `NativeLoader` catches the UnsatisfiedLinkError from
# System.loadLibrary and rethrows `Error(message, cause)`. That is not incidental, it
# is the whole finding `ffmpeg/NativeLoadFailure.kt` exists to handle, and the reason
# the obvious narrower guards catch nothing. Substituting a tidier subclass here would
# leave the test passing against a defect it no longer reproduces.
excludes: ['**/test/**', '**/androidTest/**']