[No QA] Fix leader-election deadlock in ActiveClientManager (SequentialQueue unable to flush)#95148
Conversation
A stale/ghost client GUID left in ACTIVE_CLIENTS (from an unclean shutdown) could hold leadership, leaving the only live tab unable to flush the SequentialQueue so all requests pile up indefinitely. - Re-append this client to ACTIVE_CLIENTS if a late hydration event drops it, so a ghost GUID can't strip the live tab out of the list on boot. - Add a self-promotion safety net: if the queue is stuck as a non-leader with queued work past a timeout, claim leadership and flush. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@thesahindia Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 17bd5ad095
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| return; | ||
| } | ||
|
|
||
| const hasWork = getAllPersistedRequests().length > 0 || !!getPersistedOngoingRequest(); |
There was a problem hiding this comment.
Avoid promoting over a live ongoing request
With two tabs, the follower schedules this timer when it sees queued work; if the current leader has already moved the write into PERSISTED_ONGOING_REQUESTS but the API call takes longer than 30 seconds (for example a large upload or slow backend), this getPersistedOngoingRequest() branch still marks the queue as stuck and promotes the follower. processNextPersistedRequest() returns the same ongoing request, so the follower can issue a duplicate write while the original leader is still in flight.
Useful? React with 👍 / 👎.
| if (hasInitialized && !isLeavingTab && !activeClients.includes(clientID)) { | ||
| activeClients.push(clientID); |
There was a problem hiding this comment.
Don't re-add clients pruned by the cap
When the active-client list is at the 20-client cap, the trimming loop above evicts a live client by design; this new unconditional re-add makes that evicted tab immediately append itself again. With 20+ open tabs, the next callback trims the list again and clients can keep rewriting ACTIVE_CLIENTS and rotating leadership instead of settling, so the recovery path should avoid re-adding IDs that were intentionally pruned by the max-client limit.
Useful? React with 👍 / 👎.
- Self-promote only when there are pending requests and no ongoing request, so a live leader's in-flight request is never re-issued as a duplicate. - Re-add this client to ACTIVE_CLIENTS only when a stale write dropped it, not when the max-client cap trim evicted it, avoiding leadership thrash with 20+ tabs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mountiny
left a comment
There was a problem hiding this comment.
Overall, I'm not sure if I have any concerns, but I feel like the PR should be marked as no QA because I don't think the testers will be able to test it really.
done |
|
@thesahindia Can you test the PR |
|
🚧 mountiny has triggered a test Expensify/App build. You can view the workflow run here. |
This comment has been minimized.
This comment has been minimized.
|
PR doesn’t need product input as a refactor PR. Unassigning and unsubscribing myself. |
# Conflicts: # src/libs/ActiveClientManager/index.ts
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codecov Report✅ Changes either increased or maintained existing code coverage, great job!
|
I'd push back on this, I don't think this is anything close to reliable as we're only depending on a 30s timer, nothing deterministic. |
Where is it being tracked? |
…nt guard The 30s self-promotion safety net relied on a non-deterministic timer and risked duplicate writes. Guard #1 (re-appending the live client in the ACTIVE_CLIENTS connect callback) deterministically closes the leader-election deadlock, so drop the timer, promoteToLeader, its constant, and its test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
I removed it as its not relevant after I removed the stuck queue |
I removed this |
|
@thesahindia can you give it a test? |
|
Also, for any leader election I think this needs to be reflected in the |
|
|
||
| activeClients = val; | ||
|
|
||
| // Remove from the beginning of the list any clients that are past the limit, to avoid having thousands of them |
There was a problem hiding this comment.
is this comment still relevant after the removed -> changed update?
There was a problem hiding this comment.
Nop, I removed it
| // A late disk-hydration event can overwrite the list and drop this live client, handing | ||
| // leadership to a stale/ghost GUID and stalling the SequentialQueue. Re-append ourselves when a | ||
| // stale write dropped us (we were never in the incoming list) — but not when the cap trim above | ||
| // intentionally evicted us, which would otherwise thrash leadership with 20+ open tabs. |
There was a problem hiding this comment.
I find this part to be very jargon-y, also, is 'late disk-hydration' really the only scenario that can update the list? I think this part would benefit from a much simpler language (or the problem is really that complex?).
There was a problem hiding this comment.
Sure I changed that
On it. |
I think it should be: Send a message from a different account and verify that it appears in the first tab. Sending message was working fine but user wasn't able to receive any message/updates. The PR tested well. Screen.Recording.2026-07-04.at.10.58.41.PM.mov |
…n self-heal - Reword the cap-trim comment so it no longer implies `changed` only means "removed" - Simplify the re-add comment: drop disk-hydration jargon, describe any stale write - Document the active-clients self-heal in SEQUENTIAL_QUEUE.md leader-election section Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Done |
|
@thesahindia can you prioritize this pr now? thank you! |
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppAndroid: mWeb ChromeiOS: HybridAppiOS: mWeb SafariMacOS: Chrome / SafariScreen.Recording.2026-07-08.at.8.31.28.AM.movScreen.Recording.2026-07-04.at.10.58.41.PM.mov |
|
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
|
🚧 luacmartins has triggered a test Expensify/App build. You can view the workflow run here. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
🚀 Deployed to staging by https://github.com/luacmartins in version: 9.4.32-0 🚀
|
|
🤖 No help site changes required — no draft PR created. I reviewed the changes in this PR against the help site content under Why no docs update is needed: This is a purely internal reliability fix to the client leader-election logic. It has no user-facing surface:
There is no new feature, UI element, setting, tab, button, or user-facing workflow introduced. The fix simply lets the web/PWA app self-recover from a stuck loading state (looping Inbox loading bar) that previously required a full sign-out/sign-in. That behavior isn't documented on the help site, so nothing there needs to change. If you believe a specific help article is affected, let me know which one and I'll take another look. |
|
🚀 Deployed to production by https://github.com/grgia in version: 9.4.32-3 🚀
Bundle Size Analysis (Sentry): |
Explanation of Change
The web/PWA app could intermittently become unresponsive after focusing the window: the Inbox loading bar loops forever, no new data arrives, and only a full sign-out/sign-in recovers it (
Clear cache and restartdoes not help).Root cause is a leader-election deadlock in
ActiveClientManager. Leadership is decided purely by "last GUID in the sharedACTIVE_CLIENTSlist wins". The only cleanup path is abeforeunloadhandler, which doesn't fire on crashes, force-quit, or mobile/PWA background kills — so a stale/ghost GUID from a dead tab can remain in the persisted list. When that ghost sits last, the only live tab believes it is not the leader, soSequentialQueue.flush()bails andOpenApp/ReconnectApp/PusherPingpile up forever with no leader to flush them.ACTIVE_CLIENTSis persisted, which is why cache-clear doesn't recover but sign-in (which resets the list) does.This PR fixes it by never dropping the live client (
ActiveClientManager): a late disk-hydration event on boot can overwrite the list and strip the live client out (the SafarisetTimeout(init, 400)widens this window). TheACTIVE_CLIENTSconnect callback now re-appends our own GUID if it's missing while we're still alive, so a ghost can't take over leadership from us on startup.Fixed Issues
$ #94963
PROPOSAL: N/A — internal bug fix.
Tests
Primary validation is the automated tests. The production bug is an intermittent lifecycle/timing race, and it can't be held open by hand: the fix self-heals on the next event, and
ActiveClientManager.init()re-runs on focus/visibility changes (Expensify.tsx), which re-adds this client and masks any injected stuck state. The unit tests use deterministic control (direct state injection) to exercise both the deadlock condition and the recovery.Run:
tests/unit/ActiveClientManagerTest.ts— leader registration; the boot-race re-add guard (a stale hydration that drops the live client is repaired).Best-effort manual check (web) — note it may self-heal before you observe anything, which is itself the fix working:
npm run web, open the dev URL in Chrome with DevTools, sign in.beforeunload, leaving a ghost GUID holding leadership).OpenApp/ReconnectApp/PusherPing.Offline tests
No new offline behavior. The change only affects which tab flushes the shared request queue; offline queuing/replay is unchanged. Spot-check: toggle Offline in DevTools, send a message (queues optimistically), toggle back online, confirm it sends and no white screen/stale UI.
QA Steps
Same as tests. Focus on the recovery behavior: an app that previously stuck with a looping loading bar after focusing the window should now recover on its own.
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand notonIconClick)Avatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
N/A — web/PWA leader-election path; no native code changes (native
ActiveClientManageris a no-op).Android: mWeb Chrome
iOS: Native
N/A — web/PWA leader-election path; no native code changes (native
ActiveClientManageris a no-op).iOS: mWeb Safari
MacOS: Chrome / Safari