Summary
BleManagerHandler.postDelayed() is implemented with new Timer().schedule(...), which starts a
new thread on every call:
|
public void postDelayed(@NonNull final Runnable r, final long delayMillis) { |
|
new Timer().schedule(new TimerTask() { |
|
@Override |
|
public void run() { |
|
r.run(); |
|
} |
|
}, delayMillis); |
|
} |
@Override
public void postDelayed(@NonNull final Runnable r, final long delayMillis) {
new Timer().schedule(new TimerTask() {
@Override
public void run() {
r.run();
}
}, delayMillis);
}
A java.util.Timer thread does not exit when its task finishes. It parks in
TimerThread.mainLoop() until newTasksMayBeScheduled is cleared, and for a Timer that nobody
retains a reference to the only thing that clears it is Timer.threadReaper.finalize(). So each of
these threads stays alive until the GC happens to enqueue the Timer and the finalizer daemon
runs.
The result is that threads accumulate at exactly the rate the library schedules delayed work.
Affected versions
2.7.0 through 2.11.0, and main as of today. 2.6.1 and earlier are unaffected.
Why this is easy to hit
Every readRssi() schedules a 1 s timeout through this path:
|
break; |
|
} |
|
case READ_RSSI: { |
|
final Request r = request; |
|
result = internalReadRssi(); |
|
if (result) { |
|
postDelayed(() -> { |
|
// This check makes sure that only the failed request will be notified, |
|
// not some subsequent one. |
|
if (this.request == r) { |
|
r.notifyFail(bluetoothDevice, FailCallback.REASON_TIMEOUT); |
|
nextRequest(true); |
Any app that polls RSSI — which proximity-based apps do continuously — therefore creates one thread
per poll. At a 500 ms poll interval that is ~7,200 threads per hour, each reserving ~1 MB of stack.
That memory is native, so it is invisible to the Java heap's GC heuristics: nothing pressures the
collector into running, and finalization never catches up.
Eventually pthread_create fails and the process dies. Real crash from a Wear OS app in production
(Android 14 watch, library 2.11.0):
Fatal Exception: java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Try again
at java.lang.Thread.nativeCreate(Thread.java)
at java.lang.Thread.start(Thread.java:976)
at java.util.Timer.<init>(Timer.java:195)
at java.util.Timer.<init>(Timer.java:177)
at java.util.Timer.<init>(Timer.java:150)
at no.nordicsemi.android.ble.BleManagerHandler.postDelayed(BleManagerHandler.java:1748)
at no.nordicsemi.android.ble.BleManagerHandler.nextRequest(BleManagerHandler.java:3888)
at no.nordicsemi.android.ble.BleManagerHandler.enqueue(BleManagerHandler.java:1664)
at no.nordicsemi.android.ble.Request.enqueue(Request.java:1209)
The crash dump contained several thousand parked Timer-* threads, with ids spanning ~1273–4020,
all sitting in TimerThread.mainLoop().
It reproduces trivially in a unit test: call postDelayed(r, 60_000) 50 times and count threads
whose name starts with Timer-. The count goes up by exactly 50.
How it regressed
For anyone bisecting later, this arrived in two commits two days apart:
e2f5738 "Delaying method invocations should not use user's looper" changed
handler.postDelayed(r, delayMillis) to new Handler().postDelayed(r, delayMillis). The intent
is clear and reasonable, but the no-arg Handler constructor binds to Looper.myLooper(), so it
throws whenever the calling thread has no looper — and enqueue() is commonly called from a
background thread.
c0be5ca "Bug fixed: delaying execution from a thread without a looper" fixed that crash by
switching to new Timer(), which introduced this leak. Shipped in 2.7.0.
Suggested fix
A Handler bound to a looper the library owns satisfies both of the above intents at once: it can
be posted to from any thread (unlike new Handler()), and it is neither the caller's looper nor the
looper the user supplied to BleManager(Context, Handler).
private final HandlerThread delayedWorkThread; // started once, e.g. in init(...)
private final Handler delayedWorkHandler; // new Handler(delayedWorkThread.getLooper())
@Override
public void postDelayed(@NonNull final Runnable r, final long delayMillis) {
delayedWorkHandler.postDelayed(r, delayMillis);
}
@Override
public void removeCallbacks(@NonNull final Runnable r) {
delayedWorkHandler.removeCallbacks(r);
}
A shared ScheduledExecutorService would work equally well. The key point is that it must be one
long-lived executor rather than one per call.
Secondary bug: postDelayed() and removeCallbacks() target different queues
Since 2.7.0 these two have been mismatched — postDelayed() schedules on a Timer, while
removeCallbacks() still removes from handler:
|
@Override |
|
public void removeCallbacks(@NonNull final Runnable r) { |
|
handler.removeCallbacks(r); |
|
} |
TimeoutableRequest pairs them:
notifyStarted() → handler.postDelayed(timeoutCallback, timeout)
notifySuccess() / notifyFail() / notifyInvalidRequest() → handler.removeCallbacks(timeoutCallback)
and Request.handler defaults to the requestHandler itself (Request.java:150-151). So a request
timeout set with .timeout(n) can never actually be cancelled. The visible impact is limited
because the callback re-checks if (!finished), but the Timer thread still lives for the full
timeout duration after the request has already completed.
Fixing postDelayed() as above fixes this too, since both would then use the same queue.
Workaround
For anyone hitting this before a release is available: BleManager.getGattCallback() is still
overridable, and the anonymous BleManagerGattCallback it returns can override postDelayed() and
removeCallbacks() to route delayed work onto a single HandlerThread. It should not be routed to
the main looper, as internalConnect() sleeps 200 ms on its reconnect path and is reached from
there.
Summary
BleManagerHandler.postDelayed()is implemented withnew Timer().schedule(...), which starts anew thread on every call:
Android-BLE-Library/ble/src/main/java/no/nordicsemi/android/ble/BleManagerHandler.java
Lines 1747 to 1754 in 4a86e6c
A
java.util.Timerthread does not exit when its task finishes. It parks inTimerThread.mainLoop()untilnewTasksMayBeScheduledis cleared, and for aTimerthat nobodyretains a reference to the only thing that clears it is
Timer.threadReaper.finalize(). So each ofthese threads stays alive until the GC happens to enqueue the
Timerand the finalizer daemonruns.
The result is that threads accumulate at exactly the rate the library schedules delayed work.
Affected versions
2.7.0 through 2.11.0, and
mainas of today. 2.6.1 and earlier are unaffected.Why this is easy to hit
Every
readRssi()schedules a 1 s timeout through this path:Android-BLE-Library/ble/src/main/java/no/nordicsemi/android/ble/BleManagerHandler.java
Lines 3885 to 3896 in 4a86e6c
Any app that polls RSSI — which proximity-based apps do continuously — therefore creates one thread
per poll. At a 500 ms poll interval that is ~7,200 threads per hour, each reserving ~1 MB of stack.
That memory is native, so it is invisible to the Java heap's GC heuristics: nothing pressures the
collector into running, and finalization never catches up.
Eventually
pthread_createfails and the process dies. Real crash from a Wear OS app in production(Android 14 watch, library 2.11.0):
The crash dump contained several thousand parked
Timer-*threads, with ids spanning ~1273–4020,all sitting in
TimerThread.mainLoop().It reproduces trivially in a unit test: call
postDelayed(r, 60_000)50 times and count threadswhose name starts with
Timer-. The count goes up by exactly 50.How it regressed
For anyone bisecting later, this arrived in two commits two days apart:
e2f5738"Delaying method invocations should not use user's looper" changedhandler.postDelayed(r, delayMillis)tonew Handler().postDelayed(r, delayMillis). The intentis clear and reasonable, but the no-arg
Handlerconstructor binds toLooper.myLooper(), so itthrows whenever the calling thread has no looper — and
enqueue()is commonly called from abackground thread.
c0be5ca"Bug fixed: delaying execution from a thread without a looper" fixed that crash byswitching to
new Timer(), which introduced this leak. Shipped in 2.7.0.Suggested fix
A
Handlerbound to a looper the library owns satisfies both of the above intents at once: it canbe posted to from any thread (unlike
new Handler()), and it is neither the caller's looper nor thelooper the user supplied to
BleManager(Context, Handler).A shared
ScheduledExecutorServicewould work equally well. The key point is that it must be onelong-lived executor rather than one per call.
Secondary bug:
postDelayed()andremoveCallbacks()target different queuesSince 2.7.0 these two have been mismatched —
postDelayed()schedules on aTimer, whileremoveCallbacks()still removes fromhandler:Android-BLE-Library/ble/src/main/java/no/nordicsemi/android/ble/BleManagerHandler.java
Lines 1756 to 1759 in 4a86e6c
TimeoutableRequestpairs them:notifyStarted()→handler.postDelayed(timeoutCallback, timeout)notifySuccess()/notifyFail()/notifyInvalidRequest()→handler.removeCallbacks(timeoutCallback)and
Request.handlerdefaults to therequestHandleritself (Request.java:150-151). So a requesttimeout set with
.timeout(n)can never actually be cancelled. The visible impact is limitedbecause the callback re-checks
if (!finished), but the Timer thread still lives for the fulltimeout duration after the request has already completed.
Fixing
postDelayed()as above fixes this too, since both would then use the same queue.Workaround
For anyone hitting this before a release is available:
BleManager.getGattCallback()is stilloverridable, and the anonymous
BleManagerGattCallbackit returns can overridepostDelayed()andremoveCallbacks()to route delayed work onto a singleHandlerThread. It should not be routed tothe main looper, as
internalConnect()sleeps 200 ms on its reconnect path and is reached fromthere.