Stop the Android backend from holding the executor's workers - #181
Merged
Conversation
ruccho
force-pushed
the
feature/android-mc-fix
branch
2 times, most recently
from
August 25, 2026 07:32
7a967c2 to
dfb4c07
Compare
ruccho
force-pushed
the
feature/android-mc-fix
branch
2 times, most recently
from
August 25, 2026 08:05
069c288 to
cf178cc
Compare
`unienc_android_mc` waited for MediaCodec buffers in three loops, and none of them released the executor worker it was running on. The audio push loop slept on the worker outright; the video push loop and the pull loop called `yield_now().await`, which does not hand the worker to another task on `futures::executor::ThreadPool`: waking your own waker from inside `poll` moves the task's `UnparkMutex` from `POLLING` to `REPOLL`, nothing is sent to the pool's queue, and `Task::run` re-polls the same future in place. Three tasks that only `yield_now()` in a loop, on a two-worker pool, poll about 2.9 M times each for the first two and zero times for the third. The pipeline runs four tasks: video push, audio push, video pull, audio pull. Where the pool has two workers, the two push tasks took both and never gave them up, so the pull tasks were never polled, `releaseOutputBuffer` was never called, the codec never freed an input buffer, and the push loops retried forever. `unienc_c` sizes the same pool to the machine, so a two-core device on the CPU readback path meets that condition. Run the dequeues on the blocking pool instead. Awaiting a blocking-pool result parks the task properly, which frees the worker and removes the busy loop with it; each dequeue can then use a real timeout rather than polling with a zero one. `MediaCodecAudioEncoder` and `MediaCodecVideoEncoderOutput` gain the `Runtime` handle this needs, and `yield_now` goes away. The end-to-end harness now passes on an emulator with one, two and three workers as well as with the default pool. The remaining synchronous wait is the end-of-stream signal in `Drop`, which cannot await; it is bounded and happens once per stream, and both sites now say so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ruccho
force-pushed
the
feature/android-mc-fix
branch
from
August 25, 2026 08:20
cf178cc to
66d6308
Compare
ruccho
marked this pull request as ready for review
August 26, 2026 02:35
hkmt-mmy
approved these changes
Aug 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The end-to-end harness deadlocks on Android whenever the executor has two
workers. It stops immediately after the video encoder accepts its first frame
and never recovers. GitHub's Linux runners have two cores, so the Android job
added by #179 hangs on every run.
unienc_android_mcwaits for MediaCodec buffers in three loops, and none of themreleases the executor worker it is running on:
audio::push_implstd::thread::sleep(10 ms)— blocks the worker outrightvideo::push_video_implyield_now().awaitcommon::pull_encoded_data_with_codecyield_now().awaityield_nowwakes its own waker from insidepolland returnsPending. Onfutures::executor::ThreadPoolthat does not hand the worker to another task.UnparkMutex::notifysees the task inPOLLING, moves it toREPOLLandreturns
Err(()), so nothing is queued;Task::runthen getsErr(task)backfrom
mutex.waitand re-polls the same future in place. The task neverre-enters the pool's queue. Three tasks that do nothing but
yield_now()in aloop, on a two-worker pool, poll about 2.9 M times each for the first two and
zero times for the third.
The pipeline runs four tasks — video push, audio push, video pull, audio pull.
With two workers the two push tasks take both and hold them, so the pull tasks
are never polled,
releaseOutputBufferis never called, the codec never frees aninput buffer, and the push loops retry forever. Three workers pass because a pull
task gets a worker too and drains its stream, which lets the matching push task
finish and release a worker for the fourth task.
This is not confined to CI.
unienc_cbuilds the sameThreadPoolsized to themachine, so a device with two usable cores on the CPU readback path meets the
same condition.
What this does
Runs
dequeueInputBufferanddequeueOutputBufferon the blocking pool throughSpawnBlocking, which the runtime already provides for exactly this. Awaiting ablocking-pool result parks the task rather than occupying a worker, so the worker
is free for the rest of the pipeline, and the busy loop goes away with it: each
dequeue can now use a real timeout instead of polling with a zero one and
retrying.
MediaCodecAudioEncoderandMediaCodecVideoEncoderOutputgain theRuntimehandle this needs.
yield_nowis deleted rather than fixed, because there is nocorrect self-waking yield on this executor.
The two dequeue helpers return an owned boxed future instead of being
async fn,so that no borrow of the runtime is held across the await;
RuntimeisSendbut not
Sync.Verification
scripts/android-device-test.shagainst an arm64 API 34 emulator with thegoogle_apisimage, atUNIENC_TEST_THREADSof 1, 2 and 3 and with the defaultpool. All four pass and produce the expected file:
Before this change, two workers hung and three passed, repeatably.
The diagnosis came from
debuggerd -bon the hung process: both workers sat inMediaCodec.dequeueInputBuffer, one belowvideo::push_video_impland the otherbelow
audio::push_impl, in twelve samples out of twelve, with no pull frame inany of them.
What this does not cover
The end-of-stream signal in
Dropstill waits synchronously, becauseDropcannot await. It is bounded and happens once per stream rather than once per
buffer, but it is the same shape, and both sites now say so in a comment.
Stack created with GitHub Stacks CLI • Give Feedback 💬
🤖 Generated with Claude Code