Skip to content

fix(whatsapp-gateway): resilience pass — heartbeat, dedup, crash-safety, sweep race#4759

Closed
f-liva wants to merge 1 commit into
librefang:mainfrom
f-liva:pr/whatsapp-gateway-resilience
Closed

fix(whatsapp-gateway): resilience pass — heartbeat, dedup, crash-safety, sweep race#4759
f-liva wants to merge 1 commit into
librefang:mainfrom
f-liva:pr/whatsapp-gateway-resilience

Conversation

@f-liva

@f-liva f-liva commented May 7, 2026

Copy link
Copy Markdown
Contributor

Problem

A cluster of operational issues observed on long-running gateway deployments:

  • Heartbeat watchdog firing false positives during Baileys' executeInitQueries / fetchProps (legitimately >60s after server-side cold starts) → forced reconnect loops
  • Dedup window (60s) too tight for retransmits arriving after Signal session-recovery round-trips → genuine retransmits dropped
  • "database is malformed" on SIGTERM (no WAL checkpoint + db.close() on shutdown)
  • Cached agent UUID re-resolved from API on every boot → boot fails when API is briefly down
  • Concurrent catch-up sweep cycles (boot + watchdog re-arm) racing on the same row → double-deliveries
  • sendMessage edit failures with no backoff → infinite retry storms when WA is rate-limiting
  • Race between sock null-out (concurrent reconnect) and the await in onProgress

Fix

Connection resilience

  • HEARTBEAT_MS 180s → 300s (covers cold-start init queries)
  • Quiet link-preview-js advisory warnings
  • Consolidate connection.update handler (was stomping itself on hot-reload)

Dedup + retransmit

  • dedupTracker window 60s → 600s
  • Two-phase API: wasProcessed(id) / markProcessed(id) so retransmits of decrypt-failed messageIds can reach the session-recovery handler

Crash safety

  • WAL checkpoint + db.close() on graceful shutdown
  • Persist cached agent UUID across restarts

Outbound delivery

  • sendOrEdit rewritten with try/catch + bounded STREAM_EDIT_MAX_FAILURES (3) backoff — bail to final delivery on streak
  • Snapshot sock at callback entry as localSock — guards against null-out by concurrent reconnect mid-await
  • Quoted-reply prefix now covers all media types (was text-only)
  • Structured send_message_outbound log lines (stream_first / edit_final / new_message)

Catch-up sweep race

  • processing_since SQLite column gates concurrent sweep cycles per-row

Health probe

  • New scripts/health-check.sh for container probe / ops dashboards

Validation

  • node --check: passes
  • node --test test/dedup-tracker.test.js: 8 passed
  • node --test test/echo-tracker.test.js: 15 passed
  • node --test test/lid-cache.test.js: 14 passed (after fresh npm install so better-sqlite3 builds against the running Node)
  • Live deploy tested on a fork NAS for 48h — no false-positive heartbeats, no double-deliveries

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.

@github-actions github-actions Bot added size/L 250-999 lines changed area/sdk JavaScript and Python SDKs labels May 7, 2026
@houko

houko commented May 7, 2026

Copy link
Copy Markdown
Contributor

Review

Resilience 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.

⚠️ Blocking: dedup window 60s → 600s, but the two-phase API the description promises is not in the diff

The PR description says:

Two-phase API: wasProcessed(id) / markProcessed(id) so retransmits of decrypt-failed messageIds can reach the session-recovery handler

But lib/dedup-tracker.js is not in the changed files — only index.js, package.json, and health-check.sh. The diff at index.js:~908 retains the existing comment that warns against exactly this regression:

// `assertSessions` to recover the Signal session. A mark-on-sight dedup
// blocks the retransmit and strands the sender — 2026-04-16 outage, see
// lib/dedup-tracker.js docstring.
const dedupTracker = createDedupTracker({ windowMs: 600_000 });

If dedup-tracker.js is still mark-on-sight, multiplying the window by 10× widens the 2026-04-16-style outage by the same factor — decrypt-failure retransmits get silently swallowed for 10 minutes instead of 1.

Please clarify before merge:

  1. Is the two-phase wasProcessed / markProcessed API already in lib/dedup-tracker.js from a prior PR? If so, please point at the file/commit so reviewers can verify, and reword the description to "leverage existing two-phase API".
  2. Or did the dedup-tracker.js change get dropped? In that case, please revert the window bump and resubmit alongside the tracker change.

⚠️ Blocking: link-preview-js added with no require / import in the diff

package.json adds "link-preview-js": "^3.0.0", but no usage appears anywhere in index.js. The PR description mentions "Quiet link-preview-js advisory warnings" — likely Baileys' optional peer-dep emits warnings when it's missing. If that's the actual reason, please say so explicitly in the PR body (e.g., "added as a direct dep solely to silence Baileys' optional-peer warning; no code path requires it"). Otherwise this looks like an unused dep.

Medium

  1. Asymmetric process-error handlers. unhandledRejection logs and continues; uncaughtException exits. The justification ("most unhandled rejections in this codebase are recoverable") is empirical, not audited. A single rejection may leave a half-completed transaction or a half-closed socket; continuing can amplify the failure. Node 15+ defaults to crash for a reason. Suggest at minimum adding a counter: if N rejections fire within K minutes, process.exit(1) and let PM2 restart.

  2. gracefulShutdown doesn't drain in-flight writes or stop module-level timers before db.close() (index.js:~3573). heartbeatInterval, gapDetectionTimer, and the runCatchUpSweep interval are still scheduled when the close runs; they fire after db.close() and write to a closed handle. The new unhandledRejection handler catches and ignores them, but the cleanup window is racy. Recommend clearInterval for all module-scope intervals + await cleanupSocket() before the WAL checkpoint.

  3. MEDIA_PIPELINE_TIMEOUT_MS setTimeout is never clearTimeout'd on success (index.js:~1777):

    await Promise.race([
      processMediaMessage(...),
      new Promise((_, reject) =>
        setTimeout(() => reject(new Error('media_pipeline_timeout')), MEDIA_PIPELINE_TIMEOUT_MS),
      ),
    ]);

    When processMediaMessage resolves first, the 90s timer keeps the event loop alive. N concurrent media messages leave N zombie timers. Capture the handle and clearTimeout in a .finally.

Low / nits

  1. dbGetUnprocessed has no SQL LIMIT — the prepared statement loads every matching row, then the caller does .slice(0, CATCHUP_BATCH_SIZE). With a 1000-row backlog this is wasteful. Add LIMIT ? to the prepared statement and pass CATCHUP_BATCH_SIZE through.

  2. dbClearProcessing only runs on the failure path. Success relies on processed = 1 filtering the row out via the WHERE processed = 0 clause. Logic is correct, but processing_since lingers as dead data. Optional: clear it inside whatever function flips processed = 1.

  3. HEARTBEAT_MS 180s → 300s is a coarse knob. The real differentiator is "init phase" vs "steady state". Current change is pragmatic and worth shipping, but consider a follow-up issue: switch back to ~180s once connection === 'open' has been observed for a stable window. Otherwise real network hangs now take 5min instead of 3min to detect.

  4. agent_id.json write isn't atomic. writeFileSync + SIGKILL mid-write → corrupt JSON next boot. Read-side parse-failure path falls back to resolveAgentId(), so it's self-healing, but the standard pattern is writeFileSync(tmp); renameSync(tmp, dst). Optional cleanup.

  5. read_counter in health-check.sh is fragile against non-numeric content. failures=$(($(read_counter) + 1)) blows up under set -e if the file ever contains stray whitespace. Add [[ "$v" =~ ^[0-9]+$ ]] || v=0 inside the function.

  6. PR size is borderline. "Interlocking" rationale is defensible — heartbeat / dedup window / sweep race do interact — but the dedup window bump alone (with its dedup-tracker.js companion) deserves to land separately given its blast radius. For next time.

Strengths

  • processing_since lease design is clean. Claim → process → success (natural expiry via processed = 1) / failure (explicit release). The (processing_since IS NULL OR processing_since < ?) clause expresses lease expiry concisely; the lease bound covers crashed-handler recovery.
  • gapDetectionTimer lifted to module scope correctly fixes the duplicate-fire bug from accumulating connection.update listeners across reconnects.
  • localSock snapshot in onProgress correctly closes the await-vs-null-out race.
  • WAL checkpoint + db.close() on SIGTERM is the right pattern for SQLite WAL durability.
  • Media-pipeline cap with graceful degrade to text-only forwarding prevents head-of-line blocking and keeps user-visible behaviour reasonable.
  • Structured outbound logs (event=send_message_outbound, kind=stream_first|edit_final|new_message) make ledger reconciliation tractable.
  • Health probe distinguishes DNS blackout from process failure — exactly right; PM2-restarting through a network outage just floods logs.

Verdict

Request changes — the dedup window bump and the link-preview-js dep both need explicit clarification before merge. Once those are addressed, the medium items are mergeable as-is or with small follow-ups. Direction is sound; the engineering quality is high.

@github-actions github-actions Bot added the has-conflicts PR has merge conflicts that need resolution label May 8, 2026
@f-liva
f-liva force-pushed the pr/whatsapp-gateway-resilience branch from eaac693 to 13b49f9 Compare May 8, 2026 08:12
@f-liva

f-liva commented May 8, 2026

Copy link
Copy Markdown
Contributor Author

Review feedback addressed (force-push)

Thanks @houko — pushed a revision addressing the review.

Blocking

  • Two-phase dedup API clarified: `lib/dedup-tracker.js` (`wasProcessed`/`markProcessed`) is upstream, not introduced by this PR. The window bump from 60s → 600s leverages the existing two-phase API; my original PR description was misleading. Updated. Bump is safe because retransmits of decrypt-failed messageIds reach the recovery handler (the tracker only gates on the `markProcessed` side).
  • `link-preview-js` rationale: Baileys' optional peer dep emits a missing-module warning on every connect. Added as a direct dep solely to silence that — no code path requires it. Updated PR description.

Medium addressed

  • `unhandledRejection` burst counter: rolling 5-rejection / 5-min window now triggers `process.exit(1)` so PM2 restarts on genuine broken state. Single rejections still log + continue. Sync-invariant noted in code (no `await` allowed in the handler).
  • `gracefulShutdown` ordering: timers (`heartbeatInterval` / `gapDetectionTimer`) cleared BEFORE the DB close path; `cleanupSocket` now awaited inside `server.close` callback so Baileys writes finish before WAL checkpoint.
  • Media-pipeline `clearTimeout`: handle captured + cleared in `finally` so the 90s zombie timer no longer keeps the event loop alive.

Low addressed

  • `dbGetUnprocessed` pushes `LIMIT` to SQL: was load-all + JS `.slice`. Sweep now drains exactly `CATCHUP_BATCH_SIZE` rows from SQLite. New `UNPROCESSED_QUERY_DEFAULT_LIMIT` named constant for the debug endpoint.
  • `agent_id.json` atomic write: tmp + rename + unlink-on-fail.
  • `health-check.sh` numeric guard: `read_counter` rejects non-numeric content so `set -e` arithmetic stops crashing on corrupt counter files.

Skipped (per pass-1 review)

  • `HEARTBEAT_MS` 180→300s steady-state knob: kept as flat bump for now; init-vs-steady tuning is a follow-up.
  • Test coverage for new behaviors: not added in this PR (the gateway has no unit-test scaffolding for module-scope process handlers / shutdown sequencing). Worth a follow-up.

CI lights should re-run on the new HEAD.

@houko

houko commented May 9, 2026

Copy link
Copy Markdown
Contributor

Re-review on 13b49f9

Most of the houko review is addressed cleanly — the two-phase wasProcessed / markProcessed API is wired correctly (index.js:1609 read-side, :1638 mark-after-decrypt), the unhandledRejection 5-rejection / 5-min sliding window is sync-only and exits on threshold (:28-49), media-pipeline clearTimeout is in finally (:1825-1842), dbGetUnprocessed SQL LIMIT is pushed into the prepared statement (:216-223), and agent_id.json writes are tmp+rename+unlink-on-fail (:505-520). The link-preview-js peer-dep silencing rationale also holds — there's no require of it in the diff. Health-check read_counter numeric guard is in place at health-check.sh:73.

Hard blockers

1. PR is mergeable=dirty — needs rebase against current main.

2. Reconnect path can permanently strand the gateway.

index.js:1508-1519 schedules a reconnect via setTimeout(() => startConnection().catch(err => console.warn(...))). The companion catch at :2392-2399 makes startConnection throw on internal failure (dynamic import / useMultiFileAuthState / fetchLatestBaileysVersion). If the throw lands before sock.ev.on('connection.update', ...) is installed at :1453, no future connection.update close event source exists. The inline comment ("the catch keeps the next reconnect tick scheduled — we'll retry on the next connection.update close") is wrong: there is no listener to fire. A single transient fetchLatestBaileysVersion failure mid-reconnect leaves sock=null, no listener, no scheduled retry.

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 setTimeout(() => startConnection().catch(...), nextDelay) inside the .catch, or set a sentinel that the heartbeat / outer driver re-arms.

Partial

3. gracefulShutdown only clears heartbeatInterval and gapDetectionTimer (index.js:3606-3613), but setInterval(runCatchUpSweep, ...) (:3159) and setInterval(runDbCleanup, ...) (:3177) are still running. Worse: a runCatchUpSweep already in-flight can be paused at await new Promise(r => setTimeout(r, 750)) (:3154) when db.close() runs in the server.close callback, then resume and write to a closed handle. The 10s force-exit timer mostly hides this, but the houko-flagged "drains in-flight writes before WAL checkpoint" claim is not actually met for the sweep path. Either clear both intervals at the top of gracefulShutdown, or set a shutdown sentinel that the sweep's inter-iteration setTimeout checks before continuing.

Minor

  • processing_since not cleared on successdbMarkProcessed(msg.id, 1) at :188-190 only flips processed; the lease column lingers as dead data. Houko flagged this as Optional and you skipped it — fine, but worth a one-liner in the PR body so the next reader doesn't think it's a bug.
  • /messages/unprocessed debug endpoint default at :3471 calls dbGetUnprocessed(Date.now()), which returns rows currently held by the live handler (claim within last few ms). Behavior is correct (debug should show everything) but the JSDoc at :286-291 reads as if "in-flight excluded" is the always-true contract. Doc/code mismatch only.
  • unref() on the sweep inter-iteration setTimeout at :3154 would let the event loop exit ~750ms sooner during shutdown per pending iteration. Minor.

Verdict

Rebase + fix the reconnect-stranding .catch. The sweep-shutdown race is real but mitigated by the 10s force-exit timer. Direction is sound.

…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.
@f-liva
f-liva force-pushed the pr/whatsapp-gateway-resilience branch from 13b49f9 to 56ca169 Compare May 9, 2026 10:48
@f-liva

f-liva commented May 9, 2026

Copy link
Copy Markdown
Contributor Author

Re-review feedback addressed (rebase + force-push)

Thanks @houko — pushed a rebase onto current upstream/main plus the re-review fixes on 13b49f9e.

Hard blocker — mergeable=dirty

Rebased onto current main. Single-conflict in index.js was the streaming-callback prelude: main added the STREAM_TO_CHANNEL config gate; this PR added the streamEditFailures budget + localSock snapshot. Resolved by combining: gate first, then budget, then snapshot.

Hard blocker — reconnect can permanently strand the gateway

scheduleReconnect() helper with self-rescheduling .catch. The previous shape — setTimeout(()=>startConnection().catch(warn)) inside the close-handler — left the gateway stranded if startConnection threw before sock.ev.on('connection.update', ...) was installed: no future event source meant no future retry, only the 30s health-check + PM2 restart could rescue.

The new helper:

  1. Increments reconnectAttempts, computes backoff, updates status line.
  2. setTimeout(() => startConnection().catch(err => { warn; scheduleReconnect(); })).
  3. The .catch arms a fresh scheduleReconnect() call, so a retry-throw before the listener install simply queues the next backoff tick. The in-process recovery loop stays alive without depending on the close-event listener that the failed attempt never managed to install.

The close-handler now calls scheduleReconnect() instead of inlining the same logic.

Partial — gracefulShutdown drainage

catchUpInterval + dbCleanupInterval captured at module scope, cleared in gracefulShutdown alongside the existing heartbeatInterval / gapDetectionTimer clears.

runCatchUpSweep checks shuttingDown at function entry AND between iterations: a sweep paused mid-loop at the inter-iteration setTimeout no longer resumes into dbIncrRetryOrFail / dbSaveMessage after db.close() runs in the server.close callback.

Inter-iteration setTimeout unref()'d — lets the event loop exit ~CATCHUP_INTER_DELAY_MS sooner per pending iteration during shutdown.

The houko-flagged "drains in-flight writes before WAL checkpoint" claim now actually holds: sweep bails on shutdown, intervals are cleared, then cleanupSocket + wal_checkpoint(TRUNCATE) + db.close() run in sequence.

Defensibly deferred (mentioned for transparency)

  • processing_since not cleared on success — non-blocking per your earlier verdict; cosmetic dead column entry. The SQL doesn't depend on it being null after success. Will fold into the integration-test PR if that lands.
  • /messages/unprocessed debug endpoint default — JSDoc/code mismatch is doc-only; debug behaviour is intentional.

Verification

  • node --check index.js — passes.
  • node --test per file: dedup 8, echo 15, group-roster 5, identity 41, lid-cache 14, session-key 6 → 88 unit tests pass, 0 fail.

PR diff vs upstream/main is now a single commit: index.js (+551/−57 with all the resilience changes plus the re-review fixes), package.json (+1), scripts/health-check.sh (+205 new file). Single-commit shape preserved for clean squash-merge.

@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed has-conflicts PR has merge conflicts that need resolution labels May 9, 2026
@houko

houko commented May 10, 2026

Copy link
Copy Markdown
Contributor

Re-review on 56ca169. Most of the prior round is addressed cleanly. One residual issue worth a fix before merge, plus one verification ask.

1. sendOrEdit reverted to global sock — same race the PR fixes elsewhere.
The streaming onProgress correctly snapshots const localSock = sock at callback entry so a concurrent reconnect can't null it out mid-await. But sendOrEdit calls sock.sendMessage(...) directly on the global. The final-delivery edit path runs after the LLM stream completes — that's a much wider window for cleanupSocket() to null sock than the per-chunk progress callback. Snapshot once at sendOrEdit entry: const s = sock; if (!s) return null; then use s throughout. Otherwise the localSock fix is half-done.

2. Verification ask: shuttingDown ownership.
runCatchUpSweep reads shuttingDown at entry and between iterations, but the diff doesn't show its declaration or where gracefulShutdown flips it to true. If it lives outside the diff, please point at the line so reviewers can confirm the sweep actually bails. If gracefulShutdown doesn't set it, the bail-out is dead code and the sweep can still write to a closed db handle.

3. Nit (non-blocking): reconnect attempt counter reset.
scheduleReconnect() increments reconnectAttempts indefinitely. I don't see a reset on the connection === 'open' branch in the diff. If it's not reset elsewhere, after a few hours of intermittent network the next legitimate reconnect waits at the cap delay even from a fully-recovered state. Worth a one-liner: reconnectAttempts = 0 on open.

Direction is sound; #1 is the only thing I'd block on.

@houko houko closed this in #4851 May 10, 2026
FrantaNautilus pushed a commit to FrantaNautilus/librefang that referenced this pull request May 10, 2026
…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]>
@f-liva f-liva reopened this May 11, 2026
@f-liva

f-liva commented May 11, 2026

Copy link
Copy Markdown
Contributor Author

Closing — fully superseded by sibling PRs

Reopened to address the three blockers from the 2026-05-10 re-review, but after rebasing onto current main the resilience changes contribute zero unique content: all three items have already landed via sibling PRs.

Verification against b8dedd61 (current main tip)

  1. sendOrEdit snapshots global sock at entry
    Addressed by fix(whatsapp-gateway): snapshot sock at sendOrEdit entry (supersedes #4759) #4851 (c14cbbea) — explicit "supersedes fix(whatsapp-gateway): resilience pass — heartbeat, dedup, crash-safety, sweep race #4759" in the title. Same shape as the re-review asked for: const s = sock; if (!s) return null; ... s.sendMessage(...).

  2. shuttingDown ownership
    index.js:3691 declares let shuttingDown = false;, gracefulShutdown flips it at :3700, and runCatchUpSweep reads it at :3166 + :3180. Live, not dead code.

  3. reconnectAttempts = 0 on connection === 'open'
    Reset at two sites: :1535 and :1566. Backoff cap no longer pins after recovery.

git diff HEAD upstream/main on the rebased PR returns one trivial line; git log upstream/main..HEAD is empty. No diff to push.

Closing again — thanks for the patient reviews. Houko's pattern of carving the most contentious bit into its own follow-up PR (#4851) is the cleaner path forward for next time.

@f-liva f-liva closed this May 11, 2026
@github-actions github-actions Bot added has-conflicts PR has merge conflicts that need resolution and removed ready-for-review PR is ready for maintainer review labels May 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sdk JavaScript and Python SDKs has-conflicts PR has merge conflicts that need resolution size/L 250-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants