Skip to content

Android: clearWatch does not cancel the watch coroutine — a cleared watch keeps polling and later fires a timeout into the app #100

Description

@asbiemoen

Summary

On Android, clearWatch removes the location listener and releases the PluginCall, but it never cancels the coroutine started in startWatch. The underlying flow keeps running. If the watch never delivered a location before it was cleared, emitOrTimeoutBeforeFirstEmission keeps counting and eventually emits a timeout — which is still delivered to the app's JavaScript callback, long after the watch was cleared.

Versions

  • @capacitor/geolocation 8.2.0
  • io.ionic.libs:iongeolocation-android 2.2.0 (pinned in android/build.gradle)
  • @capacitor/core / @capacitor/android 8.4.x

Mechanism

GeolocationPlugin.startWatch:

private fun startWatch(call: PluginCall) {
    coroutineScope.launch {
        controller.addWatch(activity, locationOptions, watchId).collect { … }
    }
    watchingCalls[call.callbackId] = call
}

clearWatch does:

watchingCalls.remove(id)?.release(bridge)
val watchCleared = controller.clearWatch(id)

controller.clearWatch removes the LocationHandler and stops location updates, but nothing cancels the launch { … collect } above. The callbackFlow in watchLocationUpdatesFlow is never closed, so the collector stays suspended forever.

Meanwhile IONGLOCController.addWatch wraps the flow in:

.emitOrTimeoutBeforeFirstEmission(timeoutMillis = options.timeout)

and emitOrTimeoutBeforeFirstEmission runs withTimeoutOrNull(timeoutMillis) { while (firstValue == null) delay(10) }. That polling loop is unaffected by clearWatch, so for a watch that never produced a first emission it keeps polling on Dispatchers.Main for the full timeout, then sends IONGLOCLocationRetrievalTimeoutException.

That exception reaches onLocationError(exception, call) → call.sendError(...) → reject(...). PluginCall.reject does not check isReleased, and MessageHandler.sendResponseMessage posts the message to the WebView regardless. On the JS side the callback for that watch id is still registered, because clearWatch never produces a save: false response for it.

Consequences

  1. A timeout error arrives for a watch the app cleared long ago. An app that restarts its watch on error will treat this as a fresh failure and restart a healthy watch — which, if it also has no fix yet, leaks another pending timeout. It is self-sustaining.
  2. Main-thread cost. Every cleared-but-unfinished watch keeps a delay(10) loop (100 wakeups/second) on Dispatchers.Main until its timeout expires. Apps that use a long timeout to avoid the watch being torn down accumulate several of these at once.
  3. Leaked collectors and LocationCallback references accumulate for the process lifetime.

This is easiest to hit with a long timeout on watchPosition, which is a reasonable configuration since the timeout tears the watch down when it fires.

Reproduction

  1. watchPosition({ enableHighAccuracy: true, timeout: 600000, interval: 1000, minimumUpdateInterval: 1000 }, cb) on Android.
  2. Ensure no location is produced (indoors / no fix), so the watch makes no first emission.
  3. clearWatch({ id }) after a few seconds. Do not start another watch, so the observation is unambiguous.
  4. Wait out the timeout.

Observed: cb is invoked with OS-PLUG-GLOC-0010 about 10 minutes after the watch was cleared.
Expected: a cleared watch produces no further callbacks, and its pending work is cancelled.

Field data

Measured over 14 days in a production app: 2 601 such timeout callbacks across 261 Android users, against 20 on iOS over the same period with the same JavaScript. Every one of them is for a watch that had already been cleared.

Suggested fix

Keep the Job returned by startWatch's launch alongside the PluginCall, and cancel it in clearWatch:

private val watchingJobs: MutableMap<String, Job> = mutableMapOf()

private fun startWatch(call: PluginCall) {
    watchingJobs[call.callbackId] = coroutineScope.launch { … }
    watchingCalls[call.callbackId] = call
}

fun clearWatch(call: PluginCall) {
    …
    watchingJobs.remove(id)?.cancel()
    watchingCalls.remove(id)?.release(bridge)
    …
}

Cancelling the collector also cancels the channelFlow in emitOrTimeoutBeforeFirstEmission, which stops both the polling loop and the late timeout emission.

Separately, it may be worth having emitOrTimeoutBeforeFirstEmission await the first emission instead of polling every 10 ms — the polling cost is paid on the main thread for the whole pre-fix period even when the watch is working normally.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions