[Bug]: A whatsapp-web.js stop announces disconnected before the engine is released, leaving a stale QR modal and card actions
On the whatsapp-web.js engine, stopping a session from another client (an API call or a second dashboard tab) while a Sessions page has that session's QR modal open leaves the page in a stale state: the modal keeps showing the last QR code, and the card keeps offering Stop, Unlink and Kill Stuck, although GET /api/sessions already reports engineLoaded: false. The gateway announces session.status: disconnected once, while the engine is still registered, and nothing is announced after the engine is released.
Steps to reproduce
- Run OpenWA with
ENGINE_TYPE=whatsapp-web.js. - Create a session and start it without linking it, so it reaches
qr_ready. - In a dashboard tab, open Sessions and click Show QR for that session.
- From another client, stop it:
curl -X POST -H "X-API-Key: $KEY" "$BASE/api/sessions/$ID/stop". The call answersdisconnectedwithengineLoaded: false. - Watch the dashboard tab for a few seconds, then close the modal and look at the card.
Expected
The dashboard tab closes the QR modal, since the engine that produced the code is gone, and the card offers Start.
Actual
Twelve seconds after the stop, the modal was still open on the last QR code. After closing it, the card showed the Disconnected badge but still offered Stop, Unlink and Kill Stuck, while GET /api/sessions returned engineLoaded: false for the session. The Disconnected badge shows the event did reach the tab, so this is an ordering race rather than a missed event.
Root cause
Server: the disconnected status is announced before the engine is evicted, and the write that follows the eviction is dropped as a duplicate.
stop()tears the engine down first and evicts it afterwards:teardownEngineSafely(..., e => e.disconnect(), ...)atsrc/modules/session/session-engine-controls.ts:290,deleteIfLiveat:303, thenupdateStatus(DISCONNECTED)at:325.logout()(:381-383),forceKill()(:445-452) anddelete()(:489-490) use the same order, as do the automatic pathsstopOrphanEngines(:650-656) andexecuteReconnect(src/modules/session/session-engine-lifecycle.service.ts:1131-1139), which must keep announcing. Only the stop was reproduced.- The whatsapp-web.js teardown reports
DISCONNECTEDsynchronously on entry, before Chromium closes:beginClientTeardown()sets the status, which firesonStateChanged;client.destroy()runs afterwards and awaitsbrowser.close()(src/engine/adapters/wwebjs-lifecycle.ts). - The engine is still registered, so
onStateChangedpasses its liveness check (src/modules/session/session-engine-event-wiring.ts:338) and persists the status, andSessionStatusBroadcasterannouncesdisconnectedover the WebSocket and thesession.statuswebhook (src/modules/session/session-status-broadcaster.ts:42-50,:60-69). - After the teardown,
stop()evicts the engine and writesDISCONNECTEDagain, butannounce()skips the fan-out because the last announced status is alreadyDISCONNECTED(session-status-broadcaster.ts:64).
Dashboard:
- The
disconnectedhandler re-reads the list to learn whether an engine is still loaded, and closes the modal only when the re-read reportsengineLoaded: false(dashboard/src/pages/Sessions.tsx:248-258).engineLoadedisengines.has(id)at response time (src/modules/session/session.controller.ts:62-66), and the re-read lands while Chromium is still closing, so it reportstrue. The card keeps its started actions, becausedashboard/src/utils/sessionActions.ts:18-19readssession.engineLoaded ?? STARTED_STATUSES_FALLBACK.has(session.status)and the staletrueoutranks the status. - The modal's 5-second poll cannot recover:
fetchQRreturns early unless the local status isqr_ready(dashboard/src/hooks/useSessionPairing.ts:73), and a repeateddisconnectedwould be ignored as unchanged (Sessions.tsx:231).
Baileys is not affected in practice: its disconnect() also reports DISCONNECTED synchronously but resolves immediately, so the eviction happens before the re-read. That is a won race rather than a guarantee. The same scenario on Baileys closed the modal.
API and webhook consumers see the same ordering: the session.status: disconnected webhook for a stop can arrive while GET /api/sessions/{id} still reports engineLoaded: true.
Suggested fix direction
Server (primary): announce disconnected for an operator-initiated teardown only after the engine is evicted. Tie the suppression to the engine instance being torn down, not to the session id: the control verb records that instance right before teardownEngineSafely and clears it after its final updateStatus, and onStateChanged skips DISCONNECTED only when the reporting engine is that recorded instance. A stop mark left behind by a refused stop must not mute a later real disconnect; a spec should pin that.
Two shortcuts do not work and are worth ruling out up front:
- Evicting before the teardown is prohibited in-tree.
session-engine-lifecycle.service.ts:678-682records thatengines.has(id)staying true for the duration of the teardown await is the only deterministic block on a concurrentstart(), which clears the stop mark rather than rejecting on it. - Clearing the de-dup entry so the post-eviction write announces a second time does not reach the page:
Sessions.tsx:231drops a status envelope equal to the one already applied, so the re-read never runs again. The first announcement is the one that has to land after the eviction.
delete() needs a carve-out: session.status: disconnected is the only webhook a delete emits, and it comes from the force-destroy's engine-driven write, since delete() clears the de-dup map (session-engine-controls.ts:567-570) and writes no final DISCONNECTED on the success path. Suppressing it there would remove that signal.
Dashboard (backstop, independent of timing): on any disconnected for the modal's session, clear the displayed code back to the loading state so a dead QR is never shown, keep the existing engineLoaded === false dismissal, and let fetchQR re-read the session when its status leaves the pairing states instead of returning.
Regression test: a stop() spec whose engine calls onStateChanged(DISCONNECTED) synchronously and resolves disconnect() later, asserting that session.status: disconnected is emitted only after engines.has(id) is false.
Environment
- OpenWA built from
mainat cd6761b4, dashboard served by the same instance; code references checked againstmainat 62dbe393 - Engine: whatsapp-web.js 1.34.7
- Trigger:
POST /api/sessions/{id}/stopfrom another client
Source: rmyndharis/OpenWA