#1649·OpenWA

[Bug]: A whatsapp-web.js stop announces disconnected before the engine is released, leaving a stale QR modal and card actions

Author: rmyndharisCreated Sep 18, 2026Updated Sep 18, 2026

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

  1. Run OpenWA with ENGINE_TYPE=whatsapp-web.js.
  2. Create a session and start it without linking it, so it reaches qr_ready.
  3. In a dashboard tab, open Sessions and click Show QR for that session.
  4. From another client, stop it: curl -X POST -H "X-API-Key: $KEY" "$BASE/api/sessions/$ID/stop". The call answers disconnected with engineLoaded: false.
  5. 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.

  1. stop() tears the engine down first and evicts it afterwards: teardownEngineSafely(..., e => e.disconnect(), ...) at src/modules/session/session-engine-controls.ts:290, deleteIfLive at :303, then updateStatus(DISCONNECTED) at :325. logout() (:381-383), forceKill() (:445-452) and delete() (:489-490) use the same order, as do the automatic paths stopOrphanEngines (:650-656) and executeReconnect (src/modules/session/session-engine-lifecycle.service.ts:1131-1139), which must keep announcing. Only the stop was reproduced.
  2. The whatsapp-web.js teardown reports DISCONNECTED synchronously on entry, before Chromium closes: beginClientTeardown() sets the status, which fires onStateChanged; client.destroy() runs afterwards and awaits browser.close() (src/engine/adapters/wwebjs-lifecycle.ts).
  3. The engine is still registered, so onStateChanged passes its liveness check (src/modules/session/session-engine-event-wiring.ts:338) and persists the status, and SessionStatusBroadcaster announces disconnected over the WebSocket and the session.status webhook (src/modules/session/session-status-broadcaster.ts:42-50, :60-69).
  4. After the teardown, stop() evicts the engine and writes DISCONNECTED again, but announce() skips the fan-out because the last announced status is already DISCONNECTED (session-status-broadcaster.ts:64).

Dashboard:

  1. The disconnected handler re-reads the list to learn whether an engine is still loaded, and closes the modal only when the re-read reports engineLoaded: false (dashboard/src/pages/Sessions.tsx:248-258). engineLoaded is engines.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 reports true. The card keeps its started actions, because dashboard/src/utils/sessionActions.ts:18-19 reads session.engineLoaded ?? STARTED_STATUSES_FALLBACK.has(session.status) and the stale true outranks the status.
  2. The modal's 5-second poll cannot recover: fetchQR returns early unless the local status is qr_ready (dashboard/src/hooks/useSessionPairing.ts:73), and a repeated disconnected would 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-682 records that engines.has(id) staying true for the duration of the teardown await is the only deterministic block on a concurrent start(), 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:231 drops 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 main at cd6761b4, dashboard served by the same instance; code references checked against main at 62dbe393
  • Engine: whatsapp-web.js 1.34.7
  • Trigger: POST /api/sessions/{id}/stop from another client