Skip to content

postDelayed() creates a new Timer (and thread) per call - thread exhaustion (OutOfMemoryError: pthread_create) on long-lived connections #654

Description

@joshendy

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:

  1. 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.
  2. 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.

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