Skip to content

Fix the five second stall in lifecycle monitor start and stop - #77

Merged
aleksandar-apostolov merged 4 commits into
developfrom
fix/and-1468-non-blocking-lifecycle-hop
Sep 3, 2026
Merged

aleksandar-apostolov merged 4 commits into
developfrom
fix/and-1468-non-blocking-lifecycle-hop

Conversation

@aleksandar-apostolov

Copy link
Copy Markdown
Collaborator

Goal

Fixes AND-1468

StreamLifecycleMonitorImpl.start()/stop() bridged addObserver/removeObserver with a
blocking main-looper round trip: post, then wait on a CountDownLatch with a five second
safety timeout. A caller that already occupies the main looper can never release it, so the
posted message never runs and the wait can only end in the timeout. Surfaced in video as a
five second UI freeze on every logout (AND-1466, stream-video-android#1792, worked around
there at one call site).

Two callers exist, both here, and the connect side has the same shape as the disconnect side.

Implementation

StreamLifecycleMonitorImpl — attach and detach are dispatched to the main looper without
waiting for completion. The call runs inline only when the caller is already on the main
looper and nothing of ours is queued: an inline call sitting behind a message posted
earlier from another thread would run ahead of it and invert the pair, leaving started
and the observer disagreeing. A pendingOnMain counter tracks the outstanding messages.

runOn — the posted Runnable was never removed when the wait timed out, so it stayed
queued and executed once the looper freed up, long after the caller had already failed and
moved on. It is now held in a local and dropped with removeCallbacks on timeout.

start() and stop() now mean "queued", not "applied", when called off the main looper.
Nothing depends on the old ordering: getCurrentState() reads Lifecycle.currentState
directly, and started is already CAS-guarded.

After this change runOn/runOnMainLooper have no production callers left in this repo.
They stay as-is for downstream @StreamInternalApi consumers.

Testing

:stream-android-core:testDebugUnitTest green with --rerun-tasks.

Two new tests: one proves start() returns while the main looper is never idled, one proves
a main-thread call queues behind a registration posted from another thread instead of
overtaking it. One new runOn test occupies a HandlerThread past the five second bound and
asserts the timed-out block never runs afterwards.

Each guard was reverted individually to confirm its test goes red. Removing removeCallbacks
reddens only the drop test; removing the pending-message check reddens only the ordering test;
restoring the blocking hop reddens five tests, including the four existing ones updated here —
which confirms those updates assert the non-blocking behaviour rather than absorbing it.

Four existing tests changed: they waited on a loop that idled the looper until the worker
returned, which no longer idles anything now that the call returns immediately. They now
assert the call returned promptly, then idle explicitly.

Detekt: 1309 weighted issues, matching the develop baseline measured in a clean worktree.
The repo's detekt gate is already red on develop; this change adds nothing to it.

Checklist

  • Issue linked (if any)
  • Tests/docs updated
  • I have signed the Stream CLA (required for external contributors)

The posted Runnable stayed queued after the five second wait expired, so it
ran once the looper freed up, long after the caller had already failed and
moved on. Keep a reference to the message and remove it on timeout.
StreamLifecycleMonitorImpl bridged addObserver/removeObserver with a blocking
main-looper round trip. A caller that already occupies the main looper can
never release it, so the wait could only end in the five second timeout.

Post the attach/detach without awaiting completion, and run inline only when
the caller is on the main looper with nothing of ours already queued -- an
inline call behind a queued one would invert the two.
@aleksandar-apostolov aleksandar-apostolov added the pr:bug Bug fix label Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

PR checklist ✅

All required conditions are satisfied:

  • Title length is OK (or ignored by label).
  • At least one pr: label exists.
  • Sections ### Goal, ### Implementation, and ### Testing are filled, or the PR is bot-authored.
  • An issue is linked (Linear ticket or GitHub issue), or the PR is bot-authored.

🎉 Great job! This PR is ready for review.

@aleksandar-apostolov
aleksandar-apostolov marked this pull request as ready for review September 3, 2026 08:35
@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: 2f40aa16-2bf4-4f9c-865b-96c1474539b0


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@andremion andremion left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, this is the right place for the fix. Two comments inline, neither blocking.

@andremion

Copy link
Copy Markdown

Filed the video follow-up for once this is released: https://linear.app/stream/issue/AND-1484. It bumps streamCore and rewords the comments in StreamVideoClient.cleanup(), which still describe the 5 second latch. The non-blocking teardown there stays, since the socket disconnect should not run inside runBlocking on the main thread anyway.

The compareAndSet on `started` and the inline-or-post decision were separate
steps. A background start() that won the flip but had not yet incremented
pendingOnMain looked like nothing was queued, so a stop() arriving on the main
thread in that window detached inline and the attach landed after it, leaving
the observer attached with started == false.

Take both under one lock. The block itself still runs outside it: the inline
path holds the main thread, so nothing of ours can overtake it there, and the
lock stays clear of the listener callbacks the attach fans out to.
The attach now lands after start() returns, so a listener subscribed in between
is already in place for the ON_RESUME that LifecycleRegistry replays to a new
observer. Nothing acts on it -- the recovery evaluator needs an earlier
successful connection -- but the delivery is behaviour now, so pin it.
@sonarqubecloud

sonarqubecloud Bot commented Sep 3, 2026

Copy link
Copy Markdown

@aleksandar-apostolov
aleksandar-apostolov merged commit 2ec1c12 into develop Sep 3, 2026
10 checks passed
@aleksandar-apostolov
aleksandar-apostolov deleted the fix/and-1468-non-blocking-lifecycle-hop branch September 3, 2026 14:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants