Repository navigation
fix(android): finalize the active recording when the foreground service is destroyed by the system - #1321
Open
wildseansy wants to merge 2 commits into
Conversation
…ce is destroyed by the system With android:stopWithTask="true" (the Expo plugin default) swiping the app out of recents makes the system destroy CentralizedForegroundService directly: onTaskRemoved is never delivered, only onDestroy runs, and the process may be frozen as soon as it returns. Upstream's onDestroy only calls ForegroundServiceManager.onServiceDestroyed(), which restarts the service while subscribers remain and never touches the recorder, so an in-progress recording was left unfinalized. onDestroy now stops the recorder through the existing NativeRecorderControl.stop() JNI entry point (already registered in JNI_OnLoad and safe on an empty slot) instead of adding new JNI, and on a successful stop hides the recording notification. The hide runs before onServiceDestroyed() so the notification is already unsubscribed when stillWanted is evaluated and a dying process does not try to restart the service. The stop is deliberately synchronous on the calling thread: posting to an executor could leave the finalize unexecuted once the process is frozen, ActiveRecorderHandle holds its own mutex while the recorder's locks are taken and the audio thread only try_to_lock's, and an AAC finalize is a flush and close. The stop is gated on a new explicitStop flag, set only when the library itself ends the service: the ACTION_STOP path once stopSelfResult() succeeds (a superseded stop leaves the service alive), and the placeholder path before its unconditional stopSelf(). Without the gate a JS-initiated hide() while still recording would stop the recorder, because that path also ends with the service being destroyed. Known benign side effect: hideRecordingNotification() -> ForegroundServiceManager.unsubscribe -> stopServiceIfNotNeeded -> context.startService(ACTION_STOP) from inside onDestroy either throws the background-start IllegalStateException (caught and logged by stopForegroundService) or bounces a fresh service instance that takes the explicitStop path and exits immediately. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
mdydek
marked this pull request as ready for review
September 28, 2026 14:31
WPT non-regression comparisonPASS — no regressions · 0 improved section(s) · overall 3427 → 3427 (0) Unchanged sections (28)
Baseline: Workflow run · this comment is updated on every push. |
…e-recording-on-service-destroy
This branch has not been deployed
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.
Closes # — no existing issue tracks this.
CentralizedForegroundServicewhile a recording is active.Introduced changes
android:stopWithTask="true"(the Expo plugin default) swiping the app out of recents makes the system destroyCentralizedForegroundServicedirectly:onTaskRemovedis never delivered, onlyonDestroyruns, and the process may be frozen as soon as it returns.onDestroycurrently only callsForegroundServiceManager.onServiceDestroyed(), which restarts the service while subscribers remain and never touches the recorder, so an in-progress recording is left unfinalized. Observed in production: Android silences the mic input while the process sits in the background, the file accumulates a silent tail, and when the app is reopened capture resumes, leaving a silent gap in the saved recording.onDestroynow stops the recorder through the existingNativeRecorderControl.stop()JNI entry point (already registered inJNI_OnLoad, safe on an empty slot) and, on a successful stop, hides the recording notification. The hide runs beforeonServiceDestroyed()so the notification is already unsubscribed whenstillWantedis evaluated and a dying process does not try to restart the service.ActiveRecorderHandleholds its own mutex while the recorder's locks are taken and the audio thread onlytry_to_locks, so this cannot deadlock, and an AAC finalize is a flush and close.explicitStopflag, set only when the library itself ends the service: theACTION_STOPpath oncestopSelfResult()succeeds (a superseded stop leaves the service alive), and the placeholder path before its unconditionalstopSelf(). Without the gate a JS-initiatedhide()while still recording would stop the recorder, because that path also ends with the service being destroyed.hideRecordingNotification()→ForegroundServiceManager.unsubscribe→stopServiceIfNotNeeded→context.startService(ACTION_STOP)from insideonDestroyeither throws the background-startIllegalStateException(caught and logged bystopForegroundService) or bounces a fresh service instance that takes theexplicitStoppath and exits immediately.Checklist
explicitStop) shipped in an earlier fork of this library built on a custom recorder registry; this PR rebuilds it onNativeRecorderControl.stop(). Acceptance matrix I intend to run before marking ready: swipe-kill from recents while recording and while paused → logcat showsCentralized foreground service destroyedwith noService destroyed while … still need it, restarting, notification gone within 2 s, file finalized and playable; notification Stop while the app is alive → clean stop, hook not triggered; JShide()while recording → recorder keeps running.Verification so far
explicitStopsemantics checked in both directions: the only explicit-stop path (ForegroundServiceManager.stopForegroundService) always deliversACTION_STOPbeforestopSelf(), so a fresh instance that only ever seesACTION_STOPskips the hook, while START-then-system-death runs it. Both callbacks run on the main thread and each lifecycle gets a new instance, so instance state is sufficient;START_NOT_STICKYmeans no null-intent restarts.ktlint1.7.0 andcommitlint(config-conventional) clean.🤖 Generated with Claude Code