fix(whatsapp-gateway): resilience pass — heartbeat, dedup, crash-safety, sweep race#4759
fix(whatsapp-gateway): resilience pass — heartbeat, dedup, crash-safety, sweep race#4759f-liva wants to merge 1 commit into
Conversation
ReviewResilience pass is well-motivated and most of the engineering is solid. CI's Rust lanes correctly skip (no Rust touched). Two blocking concerns and a few medium/low items below.
|
eaac693 to
13b49f9
Compare
Review feedback addressed (force-push)Thanks @houko — pushed a revision addressing the review. Blocking
Medium addressed
Low addressed
Skipped (per pass-1 review)
CI lights should re-run on the new HEAD. |
Re-review on 13b49f9Most of the houko review is addressed cleanly — the two-phase Hard blockers1. PR is 2. Reconnect path can permanently strand the gateway.
The 30s health-check + PM2 restart eventually rescues, so this isn't an outright break — it's a degraded recovery path that bypasses the in-process resilience the comment promises. Fix: re-schedule another Partial3. Minor
VerdictRebase + fix the reconnect-stranding |
…ty, sweep race Hardens the gateway against operational issues observed on long-running deployments. Bundled changes: **Connection resilience** - HEARTBEAT_MS raised from 180s → 300s. Baileys' executeInitQueries and fetchProps legitimately exceed 60s after long sessions; 180s burned the gateway on every reconnect. - scheduleReconnect() helper. The previous setTimeout(()=>startConnection().catch(...)) inside the close-handler stranded the gateway when startConnection threw before the new sock's connection.update listener was installed: no future event source meant no future retry. The helper self-reschedules on retry-throw so the in-process recovery loop stays alive end-to-end. - Quiet link-preview-js warnings. Advisory-only, fill stderr. - connection.update handler consolidated. Multiple registrations were stomping each other on hot-reload. **Dedup + retransmit** - dedupTracker window 60s → 600s. The 1min window was too tight for retransmits arriving after a Signal session-recovery round-trip. - Two-phase API: wasProcessed(id) / markProcessed(id) so retransmits of decrypt-failed messageIds reach the recovery handler instead of being dedup-skipped. **Crash safety** - WAL checkpoint + db.close() on graceful shutdown — prevents 'database is malformed' after SIGTERM. - Persist cached agent UUID across restarts (was re-resolved from API on every boot, which failed when the API was slow to come up). **Outbound delivery** - sendOrEdit rewritten with try/catch + bounded STREAM_EDIT_MAX_FAILURES backoff. After 3 consecutive edit failures the streaming path bails and the final delivery handles full text in one send. - Snapshot sock at callback entry as localSock — guards against the global sock being nulled out by a concurrent reconnect between existence check and sendMessage await. - Quoted-reply prefix now covers all media types (was text-only). - Structured send_message_outbound log lines (stream_first, edit_final, new_message) for prod observability. **Catch-up sweep race + shutdown drainage** - processing_since SQLite column on the message store. Two concurrent sweep cycles (boot + watchdog re-arm) were racing each other. - catchUpInterval + dbCleanupInterval handles captured at module scope so gracefulShutdown can clearInterval them alongside heartbeatInterval / gapDetectionTimer. Without this, runCatchUpSweep / runDbCleanup ticks could resume after db.close() and write to a closed handle. - runCatchUpSweep checks shuttingDown at entry AND between iterations, so a paused inter-iteration setTimeout doesn't resume into dbIncrRetryOrFail / dbSaveMessage post-shutdown. - Inter-iteration setTimeout is unref()'d so the event loop exits ~CATCHUP_INTER_DELAY_MS sooner per pending iteration during shutdown. **Health probe** - New scripts/health-check.sh for container probe and ops dashboards. Verification: - node --check: passes - node --test: 88 unit tests pass (dedup 8, echo 15, group-roster 5, identity 41, lid-cache 14, session-key 6) - Live deploy tested on the fork's NAS for 48h — no false-positive heartbeats, no double-deliveries.
13b49f9 to
56ca169
Compare
Re-review feedback addressed (rebase + force-push)Thanks @houko — pushed a rebase onto current Hard blocker — mergeable=dirty✅ Rebased onto current main. Single-conflict in Hard blocker — reconnect can permanently strand the gateway✅ The new helper:
The close-handler now calls Partial — gracefulShutdown drainage✅ ✅ ✅ Inter-iteration The houko-flagged "drains in-flight writes before WAL checkpoint" claim now actually holds: sweep bails on shutdown, intervals are cleared, then Defensibly deferred (mentioned for transparency)
Verification
PR diff vs |
|
Re-review on 1. 2. Verification ask: 3. Nit (non-blocking): reconnect attempt counter reset. Direction is sound; #1 is the only thing I'd block on. |
…ibrefang#4759) (librefang#4851) * fix(whatsapp-gateway): resilience pass — heartbeat, dedup, crash-safety, sweep race Hardens the gateway against operational issues observed on long-running deployments. Bundled changes: **Connection resilience** - HEARTBEAT_MS raised from 180s → 300s. Baileys' executeInitQueries and fetchProps legitimately exceed 60s after long sessions; 180s burned the gateway on every reconnect. - scheduleReconnect() helper. The previous setTimeout(()=>startConnection().catch(...)) inside the close-handler stranded the gateway when startConnection threw before the new sock's connection.update listener was installed: no future event source meant no future retry. The helper self-reschedules on retry-throw so the in-process recovery loop stays alive end-to-end. - Quiet link-preview-js warnings. Advisory-only, fill stderr. - connection.update handler consolidated. Multiple registrations were stomping each other on hot-reload. **Dedup + retransmit** - dedupTracker window 60s → 600s. The 1min window was too tight for retransmits arriving after a Signal session-recovery round-trip. - Two-phase API: wasProcessed(id) / markProcessed(id) so retransmits of decrypt-failed messageIds reach the recovery handler instead of being dedup-skipped. **Crash safety** - WAL checkpoint + db.close() on graceful shutdown — prevents 'database is malformed' after SIGTERM. - Persist cached agent UUID across restarts (was re-resolved from API on every boot, which failed when the API was slow to come up). **Outbound delivery** - sendOrEdit rewritten with try/catch + bounded STREAM_EDIT_MAX_FAILURES backoff. After 3 consecutive edit failures the streaming path bails and the final delivery handles full text in one send. - Snapshot sock at callback entry as localSock — guards against the global sock being nulled out by a concurrent reconnect between existence check and sendMessage await. - Quoted-reply prefix now covers all media types (was text-only). - Structured send_message_outbound log lines (stream_first, edit_final, new_message) for prod observability. **Catch-up sweep race + shutdown drainage** - processing_since SQLite column on the message store. Two concurrent sweep cycles (boot + watchdog re-arm) were racing each other. - catchUpInterval + dbCleanupInterval handles captured at module scope so gracefulShutdown can clearInterval them alongside heartbeatInterval / gapDetectionTimer. Without this, runCatchUpSweep / runDbCleanup ticks could resume after db.close() and write to a closed handle. - runCatchUpSweep checks shuttingDown at entry AND between iterations, so a paused inter-iteration setTimeout doesn't resume into dbIncrRetryOrFail / dbSaveMessage post-shutdown. - Inter-iteration setTimeout is unref()'d so the event loop exits ~CATCHUP_INTER_DELAY_MS sooner per pending iteration during shutdown. **Health probe** - New scripts/health-check.sh for container probe and ops dashboards. Verification: - node --check: passes - node --test: 88 unit tests pass (dedup 8, echo 15, group-roster 5, identity 41, lid-cache 14, session-key 6) - Live deploy tested on the fork's NAS for 48h — no false-positive heartbeats, no double-deliveries. (cherry picked from commit 56ca169) * fix(whatsapp-gateway): snapshot sock at sendOrEdit entry to close final-delivery race One fix on top of librefang#4759 (cherry-picked from f-liva): the streaming `onProgress` callback already snapshots `const localSock = sock` to guard against `cleanupSocket()` nulling the global mid-await, but `sendOrEdit` reverted to the global. The final delivery edit / send runs AFTER the LLM stream completes — that's a much wider window for a concurrent reconnect than per-chunk progress, and the symptoms are the same race: a "Cannot read properties of null" when `sock` is nulled between the check and the `sendMessage`, or a send through a half-closed socket. Snapshot once at entry, fail fast (returning the existing `streamMsgKey` so the user still sees the last visible chunk on WhatsApp), use the snapshot for both the edit branch and the new-message branch. Worth noting from the prior round of review (now stand down): - `shuttingDown` declaration / `gracefulShutdown` toggle ARE in the file (lines 3651 / 3660 on the PR head); the diff just didn't surface them. No fix needed. - `reconnectAttempts = 0` IS reset on the `connection === 'open'` branch (lines 1535 / 1566 on the PR head). No fix needed. Closes librefang#4759. * fix(whatsapp-gateway): symmetric send-failure handling + health-check polish Three small follow-ups from review on librefang#4851: 1. **sendOrEdit new-message branch try/catch.** The edit branch already swallows mid-await throws (stale streamMsgKey, sock half-closing between snapshot and sendMessage) and returns the streamed key as "last visible chunk is final". The new-message branch left the throw uncaught, so the same race in a fallback delivery would crash the message handler. Mirror the edit branch: try/catch, log `kind: 'new_message_failed'`, return null. Caller already handles a null return (same shape as the entry-snapshot bail-out). 2. **health-check.sh portability comment.** Top-of-file note that the script is Linux + PM2 only — uses GNU `stat -c %s` and `nslookup -timeout=3`, won't run on a macOS dev host. Saves the next reader 5 minutes of confusion when a quick local sanity-run silently does the wrong thing. 3. **pm2_restart faithful exit code.** `pm2 ... | while read` lets the `while`/`done` exit status (always 0) mask pm2's real status. Capture `${PIPESTATUS[0]}` so the function's boolean signal isn't a lie. `set -o pipefail` is already on, so this just makes the pre-existing intent explicit. Today's caller does `|| true`; this prevents a future caller from acting on a fake success. Verification: - node --check packages/whatsapp-gateway/index.js (clean) - bash -n packages/whatsapp-gateway/scripts/health-check.sh (clean) - node --test packages/whatsapp-gateway/index.test.js → 132/134 pass — the same 2 pre-existing flakes (`onOwnerNotice` ECONNRESET and `LIBREFANG_DISPATCH_LOG=off` forward_dispatch) the prior commit identified, both in HTTP/forward harness layers unrelated to the sendOrEdit closure or the bash script. --------- Co-authored-by: Federico Liva <[email protected]>
Closing — fully superseded by sibling PRsReopened to address the three blockers from the 2026-05-10 re-review, but after rebasing onto current Verification against
|
Problem
A cluster of operational issues observed on long-running gateway deployments:
executeInitQueries/fetchProps(legitimately >60s after server-side cold starts) → forced reconnect loopsdb.close()on shutdown)sendMessageedit failures with no backoff → infinite retry storms when WA is rate-limitingsocknull-out (concurrent reconnect) and the await in onProgressFix
Connection resilience
HEARTBEAT_MS180s → 300s (covers cold-start init queries)link-preview-jsadvisory warningsconnection.updatehandler (was stomping itself on hot-reload)Dedup + retransmit
dedupTrackerwindow 60s → 600swasProcessed(id)/markProcessed(id)so retransmits of decrypt-failed messageIds can reach the session-recovery handlerCrash safety
db.close()on graceful shutdownOutbound delivery
sendOrEditrewritten with try/catch + boundedSTREAM_EDIT_MAX_FAILURES(3) backoff — bail to final delivery on streaksockat callback entry aslocalSock— guards against null-out by concurrent reconnect mid-awaitsend_message_outboundlog lines (stream_first/edit_final/new_message)Catch-up sweep race
processing_sinceSQLite column gates concurrent sweep cycles per-rowHealth probe
scripts/health-check.shfor container probe / ops dashboardsValidation
node --check: passesnode --test test/dedup-tracker.test.js: 8 passednode --test test/echo-tracker.test.js: 15 passednode --test test/lid-cache.test.js: 14 passed (after freshnpm installsobetter-sqlite3builds against the running Node)Notes
Bundled into a single PR because the changes are interlocking — the heartbeat bump only makes sense alongside the dedup window bump and the sweep race fix; isolating them produces unstable in-between states.