Cancel a running FFmpeg session, which nothing had ever done #236

Merged
JMR-dev merged 1 commits from test/cancelling-a-running-session into main 2026-09-06 04:53:14 +00:00
JMR-dev commented 2026-09-06 04:45:36 +00:00 (Migrated from github.com)

Part of #224 — the FFmpegEngine half. ConcatEngine and Media3Engine carry the same shape and are not covered here, so the ticket stays open; see "What is left" below.

Every cancel in app/src/androidTest is WorkManager.cancelWorkById against work that is queued or already finished — ReattachOnLaunchTest cancels a job carrying a one-hour initial delay, and another immediately after enqueue. On the JVM, WorkerCancellationTest and HardwareFallbackTest's cancellation case drive a SoftwareTranscoder double that records the call. No test on any source set had asked a real native session to stop. That is docs/defect-audit.md D10's forcing condition.

It is the one path where cancelling wrong is silently expensive rather than loudly broken: a missed FFmpegKit.cancel leaves the native process encoding to completion while the UI says the job is cancelled, and nothing reports the battery and thermal cost.

Two things were measured rather than assumed, and both changed the test

The output file cannot be the assertion — it would have been vacuous. invokeOnCancellation deletes the path, and on POSIX unlinking a file ffmpeg still holds open leaves ffmpeg writing to the unlinked inode. The path stays gone whether or not the cancel reached the session, so removing FFmpegKit.cancel passes that check every time. The session's own verdict is what separates them: a cancelled session ends with the cancel return code, a completed one does not.

Cancelling from the first progress callback loses the race. Tried first, and it failed on a local API 34 emulator with state=COMPLETED rc=0 — every committed fixture is 2–3 s at 320×240, and the encode finishes before the first statistics callback is delivered and acted on. FFmpegKit.listSessions shows the session RUNNING far earlier, so that is what the test waits for. QualityTier.BEST is deliberate for the same reason: -preset medium leaves more of the encode ahead of the cancel.

Verification — local API 34 emulator

Stability, because this test interrupts a race and one green run would not be evidence:

run 1: tests=62 failures=0 errors=0 skipped=3
run 2: tests=62 failures=0 errors=0 skipped=3
run 3: tests=62 failures=0 errors=0 skipped=3

plus the run that first confirmed it, four consecutive clean.

Mutation — remove FFmpegKit.cancel(session.getSessionId()), keep output.delete():

FFmpegEngineTest > cancellingARunningConversionCancelsTheNativeSession FAILED
  the native session was not cancelled: state=COMPLETED rc=1

What is left on #224

  • ConcatEngine.kt:80 — same invokeOnCancellation, and notably no output.delete().
  • Media3Engine.kt:120-123 — transformer.cancel() on the engine's HandlerThread. The ticket already argues this deserves its own case: its failure mode is a hung continuation rather than a runaway process, and reaching the Media3 path on an emulator needs the device profile pinned, which #223 has now shown makes the export behave differently.

🤖 Generated with Claude Code

Part of #224 — the `FFmpegEngine` half. `ConcatEngine` and `Media3Engine` carry the same shape and are **not** covered here, so the ticket stays open; see "What is left" below. Every `cancel` in `app/src/androidTest` is `WorkManager.cancelWorkById` against work that is **queued or already finished** — `ReattachOnLaunchTest` cancels a job carrying a one-hour initial delay, and another immediately after enqueue. On the JVM, `WorkerCancellationTest` and `HardwareFallbackTest`'s cancellation case drive a `SoftwareTranscoder` double that records the call. No test on any source set had asked a **real native session** to stop. That is `docs/defect-audit.md` **D10**'s forcing condition. It is the one path where cancelling wrong is silently expensive rather than loudly broken: a missed `FFmpegKit.cancel` leaves the native process encoding to completion while the UI says the job is cancelled, and nothing reports the battery and thermal cost. ## Two things were measured rather than assumed, and both changed the test **The output file cannot be the assertion — it would have been vacuous.** `invokeOnCancellation` deletes the path, and on POSIX unlinking a file ffmpeg still holds open leaves ffmpeg writing to the unlinked inode. The path stays gone whether or not the cancel reached the session, so removing `FFmpegKit.cancel` passes that check every time. The session's own verdict is what separates them: a cancelled session ends with the cancel return code, a completed one does not. **Cancelling from the first progress callback loses the race.** Tried first, and it failed on a local API 34 emulator with `state=COMPLETED rc=0` — every committed fixture is 2–3 s at 320×240, and the encode finishes before the first statistics callback is delivered and acted on. `FFmpegKit.listSessions` shows the session `RUNNING` far earlier, so that is what the test waits for. `QualityTier.BEST` is deliberate for the same reason: `-preset medium` leaves more of the encode ahead of the cancel. ## Verification — local API 34 emulator Stability, because this test interrupts a race and one green run would not be evidence: ``` run 1: tests=62 failures=0 errors=0 skipped=3 run 2: tests=62 failures=0 errors=0 skipped=3 run 3: tests=62 failures=0 errors=0 skipped=3 ``` plus the run that first confirmed it, four consecutive clean. Mutation — remove `FFmpegKit.cancel(session.getSessionId())`, keep `output.delete()`: ``` FFmpegEngineTest > cancellingARunningConversionCancelsTheNativeSession FAILED the native session was not cancelled: state=COMPLETED rc=1 ``` ## What is left on #224 - `ConcatEngine.kt:80` — same `invokeOnCancellation`, and notably **no** `output.delete()`. - `Media3Engine.kt:120-123` — `transformer.cancel()` on the engine's `HandlerThread`. The ticket already argues this deserves its own case: its failure mode is a hung continuation rather than a runaway process, and reaching the Media3 path on an emulator needs the device profile pinned, which #223 has now shown makes the export behave differently. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.