- structured-logger.ts header bullet "External system integration via
hooks with circuit breaker" -> updated to describe current
fire-and-forget hook semantics + hookFailures metric counter.
- retry-utils.ts retryConfigs.rsocket comment referenced the
prometheus-metrics -> circuit-breaker.ts circular import chain.
circuit-breaker.ts is gone; trimmed the parenthetical to keep just
the core reasoning (avoid ESM init-order issues via prometheus-metrics).
No code change. Build clean, 35/35 suites, 809/809 tests pass.
PR #393 review (Copilot): the class-level JSDoc still listed "Circuit
breaker protection" as a feature, but the protection was removed in
commit b531b46999 in favour of best-effort single-attempt semantics
(telemetry is non-critical — failures are swallowed and counted in the
hookFailures metric).
Updated the bullet point to describe what actually happens now.
Note: not addressing the breaking-API change comments (removed
enableCircuitBreaker option from UnifiedSessionOptions, removed
circuitBreakers/getCircuitBreakerStats/resetCircuitBreakers from
makeSocket() return, removed circuit_breaker_* Prometheus metrics)
per maintainer policy — this fork has a single consumer (zpro.io),
downstream forks absorb on merge.
Two doc-only fixes from PR #393 review (Copilot):
1. **bounded-retry.ts BoundedRetryOptions.signal**
The previous one-line "if aborted, give up immediately" was technically
correct from the loop's perspective but misleading about what happens
to an in-flight operation. Expanded to describe the three observation
points: sleep wakes immediately, in-flight attempt is forwarded the
per-attempt signal (only truly immediate if the operation itself
observes it), and the next loop iteration throws BoundedRetryAbortedError.
2. **retry-utils.ts module-level JSDoc**
The header still listed "Circuit breaker integration" as a feature.
The circuitBreaker option was removed in commit b531b46999 and the
withRetry/retryable/RetryManager helpers in 0f2402da58. Updated the
header to describe the actual current surface (used for Bad MAC
recovery on decrypt path) and recommend withBoundedRetry for socket
operations.
Build clean, 35/35 suites, 809/809 tests pass.
Note: not addressing the suppressed comment about removed APIs being a
breaking change — per maintainer policy this fork has a single consumer,
downstream forks absorb on merge.
All 5 issues verified as REAL (not false positives) and fixed:
1. **CRITICAL: query() swallows timeouts → bounded-retry treats as success**
(CodeRabbit Critical, line 319 socket.ts)
waitForMessage() returns undefined on timeout instead of throwing.
queryInternal() returns that undefined unchanged. uploadToServer
was `await query(...)` then logging success regardless — a timed-out
upload would update lastUploadTime with no retry triggered.
Fix: validate response shape inside uploadToServer:
if (!result) throw new Boom('Pre-key upload query timed out (no response)', { statusCode: 408 })
Bounded-retry now sees the throw and properly retries.
2. **MAJOR: Outer Promise.race + bounded-retry can run in parallel**
(CodeRabbit Major + Copilot, line 674 socket.ts)
With safety margin (ttlMs=28s vs UPLOAD_TIMEOUT=30s) bounded-retry
should always reach give-up first, but if anything bypasses that
bound the outer race rejects while bounded-retry keeps running.
Fix: wire AbortController. Pass its signal to bounded-retry.
On outer race timeout, abort the controller → bounded-retry stops
cleanly with BoundedRetryAbortedError. uploadToServer also honours
the signal before logging success.
3. **MINOR: sleep-leak test does not observe the leak**
(CodeRabbit Minor)
Test only triggered 4 sleeps — below Node's 11-listener warning —
and never captured warning events.
Fix: spy on signal.addEventListener / removeEventListener and
assert add count == remove count.
4. **MINOR: structured-logger thenable .catch can throw**
(Copilot)
Code called (hookResult as Promise).catch(...) directly. Fails on
thenables that only implement .then.
Fix: use Promise.resolve(hookResult).catch(...) which adapts any
thenable safely.
5. **Internal: bounded-retry catch was double-removing outer-abort listener**
Found by the new spy-based test. Both catch and finally called
removeEventListener. Removed redundant catch-block remove; finally
is single source of truth.
Build clean, 35/35 suites, 809/809 tests pass.
Bug found while reviewing pre-key auto-replenishment behavior with the
user. uploadPreKeys is wrapped in:
Promise.race([uploadLogic(), 30s timeout]) ← UPLOAD_TIMEOUT
Inside uploadLogic, my previous commit configured bounded-retry with
ttlMs=60_000 (60s). Result: outer race always wins at 30s, bounded-retry
never reaches its natural give-up. We lose the structured logs / metrics
emitted on TTL exhaustion, AND if a retry was about to succeed at second
29, it gets killed by the outer race.
Fix:
- ttlMs reduced to UPLOAD_TIMEOUT - 2_000 = 28_000ms (safety margin so
bounded-retry always reaches give-up before the outer race).
- perAttemptTimeoutMs reduced 20s -> 8s so multiple attempts fit in 28s.
- delays trimmed to [1000, 2000, 4000, 8000] (4 attempts, ≈15s with
delays plus per-attempt time = comfortably within 28s budget).
- query(node) timeout aligned with PER_ATTEMPT_TIMEOUT_MS=8s.
Pre-key replenishment system (existed before this PR, untouched):
- MIN_PREKEY_COUNT=200 (replenishment threshold)
- INITIAL_PREKEY_COUNT=800 (top-up target — matches WA Business CDP
capture: prekey-store=812 on registration)
- Triggered on: CB:success login, every 6h auto-sync, Bad MAC recovery,
specific peer prekey miss.
Bounded-retry only handles HOW the upload retries on transient failure,
not WHEN. The two layers are orthogonal.
Build clean, 35/35 suites, 809/809 tests pass.
PR #393 review (low-confidence comment): the retryCount parameter and
its `=== 0` guard were leftovers from the previous manual recursive
backoff. Now that bounded-retry handles retries internally, the
recursion is gone and retryCount is always 0.
Drop the parameter, remove the guard (the check applies always), and
simplify the log statement (no more "retryCount" in the structured
data — bounded-retry's own logger logs attempt counts).
Build clean, 35/35 suites, 809/809 tests pass.
Addresses 7 issues from Codex P2 + Copilot + CodeRabbit Critical/Major:
1. **TTL enforced BEFORE each attempt** (Codex P2 + Copilot + CodeRabbit Major)
Previously TTL was only checked AFTER a failure, so an attempt could
start with 0ms remaining and run for the full perAttemptTimeoutMs.
Now: check at top of loop. If already exceeded, throw immediately.
2. **Per-attempt timeout capped by remaining TTL** (Copilot)
`attemptTimeoutMs = min(perAttemptTimeoutMs, ttlMs - elapsed)` so a
single attempt cannot run past the wall-clock deadline.
3. **Operation receives AbortSignal for cancellation** (CodeRabbit Critical)
`withTimeout` now aborts an `AbortController` when the timeout fires.
The signal is forwarded to the operation, so callers can opt into
cancelling in-flight work (e.g. `(signal) => fetch({ signal })`).
Without this, a timed-out attempt's network call kept running while
bounded-retry started the next attempt — wasted work + duplicate
listeners.
4. **sleep() removes abort listener on normal completion** (Codex P2 +
Copilot + CodeRabbit)
Previously `signal.addEventListener('abort', onAbort, { once: true })`
was added on every retry but never removed when the timer resolved.
Long-lived signals reused across retries accumulated listeners. Now
explicit cleanup on both timer-resolve and abort paths.
5. **Outer abort listener properly cleaned up in main loop**
Adds + removes via try/finally to prevent leaks when the loop throws.
6. **Metrics use optional chaining** (Copilot)
`metrics.socketEvents?.inc(...)` everywhere, matching the rest of the
codebase pattern (handles partial mocks in tests).
7. **Structured logging** (user request)
New optional `logger?: ILogger` field. Emits:
- debug: each retry scheduling (`{op, attempt, delayMs, elapsedMs, error}`)
- info: recovery after retries (`{op, attempts, elapsedMs}`)
- warn: give-up via TTL or shouldRetry predicate
8. **socket.ts: align uploadPreKeys query timeout with bounded-retry**
`query(node, PER_ATTEMPT_TIMEOUT_MS)` so a stale attempt does not
keep an iq listener registered past bounded-retry's per-attempt
deadline (Copilot).
9. **Doc updated**: removed claim about `maxAttempts` (option doesn't
exist; bounded by ttlMs + delay-cap + per-attempt-timeout).
Tests: 18 pass (was 12; added 6 covering each fix).
Full suite: 35/35 suites, 809/809 tests pass.
NB: The "breaking API change" comments (Types/Socket.ts, Utils/index.ts,
structured-logger.ts) are intentional and not addressed — this fork has
a single consumer; downstream forks merging will absorb the change.
- bounded-retry.ts:103 — drop unused generic parameter <T> from
BoundedRetryOptions interface (and its single use at line 220).
The generic served no purpose: the caller's return type is inferred
from the operation function, not from the options.
- retry-utils.test.ts:5 — remove `beforeEach` from imports. It was
only used by the RetryManager describe block, removed in commit
0f2402da58.
Build clean, 58 tests pass (12 bounded-retry + 46 retry-utils).
Audit follow-up. After verifying the codebase, three exports of
retry-utils.ts have ZERO production callers:
- `withRetry` decorator (lines 419-440): unused
- `retryable` wrapper (lines 442-453): unused (1 self-test only)
- `RetryManager` class (lines 455-549): unused (1 self-test only)
Note: `MessageRetryManager` in messages-send.ts is a separate class,
not the same thing.
The kept exports are still used:
- `retry()` function: used by decode-wa-message.ts for Bad MAC recovery
- `RetryExhaustedError`, `RetryOptions`, `RetryContext`: used same place
- `RETRY_BACKOFF_DELAYS`, `RETRY_JITTER_FACTOR`: re-exported via Defaults
- `retryPredicates`, `retryConfigs`: helper presets (kept; small)
Removed `EventEmitter` import (was only used by the deleted RetryManager).
Removed `retryable` and `RetryManager` from test imports + their describe
blocks (5 tests removed, all targeting the deleted code).
Net: -148 lines from retry-utils.ts, -90 lines from its test file.
803 tests pass (was 809; 6 deleted tests covered the removed code).
Now the InfiniteAPI retry surface is:
- `retry-utils.ts:retry()` for Bad MAC recovery (decryption layer)
- `bounded-retry.ts:withBoundedRetry()` for socket operation retry
(uploadPreKeys), with WhatsApp Android empirical defaults
No redundancy: each helper handles a distinct concern.
The structured-logger had its OWN private CircuitBreaker class for
external-hook protection — gate hook calls when the user's hook keeps
throwing, to prevent log explosion.
Replaced by simple try/catch + hookFailures metric increment:
- If the user's hook throws synchronously → catch + increment metric.
- If the hook returns a rejected promise → .catch + increment metric.
- We do NOT gate further calls. If the hook is broken, the metric
goes up rapidly and operators see it. Consistent with the rest of
the codebase after the socket-layer circuit-breaker removal:
fast-fail, no state machines, no cross-call gating.
Removed:
- The internal CircuitBreaker class (lines 244-298, 55 lines)
- circuitBreakerThreshold + circuitBreakerResetMs config options
- circuitBreakerTrips metric field (LoggerMetrics)
- circuitBreakerState statistics field (LoggerStatistics)
- circuitBreaker init in constructor + null assignment in destroy()
Build passes; 19 structured-logger tests pass; 35/35 suites pass.
Sweep after the main substitution found three stale references that
predated this PR but became misleading once circuit breaker was removed:
- src/Socket/socket.ts: keep-alive ping comment claimed it bypassed the
circuit breaker. Rewritten to reflect the actual reason for using
sendNode() (avoid response correlator overhead) with a brief
historical note.
- src/Socket/socket.ts: comment in sendNode() init mentioned avoiding
duplicating circuit breakers; updated to "manager state".
- src/Socket/socket.ts: queryInternal comment described a self-tripping
loop in the circuit breaker; trimmed to keep only the relevant point
about the outer-timeout race.
- src/Utils/prometheus-metrics.ts: 6 circuit_breaker_* metrics removed
(state/trips/recoveries/rejections/successes/failures) — no longer
emitted by anything in the codebase. Also removed the
initialize-with-zero call in initializeMetricsWithLabels.
Expand the file-level JSDoc on bounded-retry.ts to document:
- Why circuit breaker was removed (cascading failures in production:
5 timeouts in 60s blocked all queries for 30s).
- Empirical justification: Frida hook captures of WhatsApp Android
showing per-operation exponential backoff (3s -> 10s -> 60s ->
~64s -> 120s cap), bounded memory, no retry storm on recovery,
no global state machine.
- Design properties (per-op isolation, bounded by attempts+TTL,
per-attempt timeout, abort support, memory bound).
- Guidance: when to use plain query() vs. withBoundedRetry().
- Three usage examples covering common patterns.
No code changes — comment-only.
Delete src/Utils/circuit-breaker.ts (767 lines) and the corresponding
test file src/__tests__/Utils/circuit-breaker.test.ts (371 lines).
Replaced by src/Utils/bounded-retry.ts (added in earlier commit). All
remaining circuit-breaker references in the codebase are explanatory
comments in code that previously used circuit breakers — kept for
git-history context.
Final state: 35/35 test suites pass, 809 tests pass.
- Remove circuitBreakerTrips from the prometheus-metrics mock (metric
no longer emitted now that telemetry path skips the circuit breaker).
- Drop the enableCircuitBreaker option from the default test setup.
- Replace the "with CircuitBreaker" describe block with an equivalent
suite that asserts the same observable behavior (single send per call,
failures swallowed gracefully) without requiring the option.
All 31 unified-session tests pass.
Removes the 5 optional circuit-breaker config fields exposed via SocketConfig
and the corresponding default in DEFAULT_CONNECTION_CONFIG:
- enableCircuitBreaker
- queryCircuitBreaker
- connectionCircuitBreaker
- preKeyCircuitBreaker
- messageCircuitBreaker
Also drops the CircuitBreakerOptions import.
Backward compatibility: these were ALL optional. Callers that set them
will see no compile error (TypeScript accepts unknown extra props on
loose object literals through structural typing) and the values are
simply ignored. A future major version may want to mark these as
forbidden with a more aggressive type, but for now silent ignore is
the least intrusive transition.
The preKeyCircuitBreaker was passed into signalStorage() as an optional
parameter and was used in only ONE place: .reset() after detecting an
identity-key change (line 391, the 'contact may have reinstalled WhatsApp'
recovery path).
Bounded-retry is stateless — each retry is independent — so the equivalent
of "forget the past failures" happens automatically on the next operation.
There is no state to reset.
Changes:
- Drop CircuitBreaker import.
- Remove preKeyCircuitBreaker field from LibSignalRepositoryOptions.
- Remove preKeyCircuitBreaker parameter from signalStorage().
- Remove the .reset() call in the identity-key-change recovery path
(replaced with explanatory comment).
Build passes.
- Utils/index.ts: re-export bounded-retry instead of circuit-breaker
- Utils/unified-session.ts: drop the optional circuit-breaker wrapping.
Telemetry is non-critical; a single send attempt with the underlying
sendNode timeout is sufficient. Removed enableCircuitBreaker option,
circuitBreaker field, and CircuitOpenError handling.
- Utils/retry-utils.ts: drop the optional circuitBreaker option from
withRetry. Retry mechanics are now self-contained; callers that need
bounded retries should prefer withBoundedRetry directly.
- Utils/health-status.ts: stop importing globalCircuitRegistry. Keep the
CircuitBreakerHealth shape for k8s probe schema stability — always
reports an empty list now.
Note: src/Utils/structured-logger.ts has its own *private* CircuitBreaker
class for log-throttling — independent of the socket-level circuit breaker
being removed. Untouched.
baileys-logger.ts logCircuitBreaker() is a generic logging helper that
takes state strings as input — no import dependency. Untouched (no-op
for callers that no longer have circuit breakers).
Remove queryCircuitBreaker, connectionCircuitBreaker, and preKeyCircuitBreaker
from src/Socket/socket.ts. Empirical capture (Frida) of WhatsApp Android
shows it uses per-operation exponential backoff, not a global state machine
that gates unrelated operations after N failures.
Changes:
- query(): unwrap. Each iq stanza has its own waitForMessage timeout.
Callers that need retry semantics (assertSessions etc.) wrap query()
with withBoundedRetry() at their level.
- sendRawMessage(): unwrap. WS lifecycle errors were already filtered
out of the circuit's failure count — the only failures it tripped on
were rare native send() errors, where a state-machine adds no value
over a fast propagated error. Reconnection logic in makeSocket
handles WS recovery independently.
- uploadPreKeys(): replace circuit breaker AND the manual exponential
backoff retry loop with withBoundedRetry. Cleaner: one mechanism
instead of two stacked.
- Remove circuitBreakers / getCircuitBreakerStats / resetCircuitBreakers
from the socket return type.
- Remove enableCircuitBreaker / queryCircuitBreaker / connectionCircuitBreaker /
preKeyCircuitBreaker config destructures and the init block.
- Pass-through: enableCircuitBreaker option no longer forwarded to
unifiedSessionManager (cleaned in next commit).
Build passes.
Per-operation exponential backoff with jitter, max attempts, and TTL.
Replaces the circuit breaker pattern, which was causing cascading
failures (5 timeouts in 60s blocked all queries for 30s).
Empirical defaults captured via Frida from WhatsApp Android:
- delays: [3000, 10000, 60000, 60000, 120000] ms (cap at 2 min)
- TTL: 600_000 ms (10 min — empirical 3-5 min stabilisation + buffer)
- per-attempt timeout: 30_000 ms
- jitter: ±15%
Properties (vs circuit breaker):
- Per-operation independent — failures in op A do NOT block op B
- No global state machine (no CLOSED/OPEN/HALF-OPEN cascade)
- Bounded by maxAttempts + ttlMs (no unbounded retry accumulation)
- AbortSignal cancellation support
- Aligned with WhatsApp Android empirical retry behavior
12 tests cover: default sequence, TTL exhaustion, per-attempt timeout,
shouldRetry predicate, AbortSignal, per-op isolation, delay cap, jitter,
budget cap. All pass in ~4s.
* perf(inbound-latency): restore async LID mapping + fire-and-forget tctoken history sync
PRODUCTION ISSUE: Inbound messages from smartphone to ZPRO frontend were
arriving with seconds of delay. Outbound (ZPRO → smartphone) was instant.
Started after PR #386 (tctoken lifecycle) deploy.
ROOT CAUSE: Three compounding factors:
1. The historical fix d73cd28d39 (2026-02-03, "fix inbound latency by making
LID mapping async") was partially reverted the same day by c3fc792351
("hybrid approach") due to a valid race-condition concern with decrypt().
The reversion was over-protective: storeLIDPNMappings does NOT need to be
sync — only migrateSession does. The hybrid kept all 3 awaits sync.
2. PR #386 added `await storeTcTokensFromHistorySync(...)` BEFORE the
`messaging-history.set` emit. Per chunk this drains the event buffer with
2-4 store ops, which compounds when many chunks arrive at once (restart,
QR scan, multi-device login).
3. Each pre-check `await getPNForLID(alt)` / `getLIDForPN(alt)` before
storeLIDPNMappings was redundant — the store has its own LRU cache + dedup.
Combined under production load (multi-instance store contention, post-PR #386
extra ops per send) the per-message hot-path penalty became user-visible delay.
THIS FIX:
#1+#3: messages-recv.ts ~line 2332 — `storeLIDPNMappings` becomes
fire-and-forget, pre-check `getPNForLID/getLIDForPN` removed. `migrateSession`
stays SYNC (REQUIRED for decrypt — see Codex/Copilot review on PR #72 / commit
c3fc792351). normalizeMessageJids has a fast-path that uses key.*Alt directly
without hitting the store, so the just-arrived message normalizes correctly
even before the background store completes.
#2: process-message.ts ~line 451 — `storeTcTokensFromHistorySync` becomes
fire-and-forget. Trade-off: a listener firing an outbound send IMMEDIATELY
after the emit may race the background persistence and hit error 463 on that
specific send. Existing 463 handler in messages-recv.ts triggers
getPrivacyTokens() refetch that auto-recovers in seconds. Net UX is much
better than per-chunk stalls.
INVARIANTS PRESERVED:
- migrateSession remains SYNC — decrypt() depends on it (race condition guard)
- normalizeMessageJids remains SYNC — events need correct JIDs before emit
- messageMutex remains SYNC — per-chat ordering preserved
- All 824 tests still pass
* perf: optimize history sync memory and CPU usage
Aligns with Baileys upstream PR #2333 . Surgical port of
4 micro-optimizations + 2 test files. No collision with InfiniteAPI's
LID/PN, carousel, buttons, Bad MAC or app state sync resilience code.
Changes:
1. WAProto longToString/longToNumber: use native BigInt instead of
Long library division loops. ~4.6x faster per call (237ns -> 52ns).
BigInt.toString() delegates to V8 native C++. Same change applied
to fix-imports.js (template) and index.js (generated artifact) so
future regenerations stay in sync.
2. downloadHistory: stream-pipe createInflate instead of buffering
the full compressed payload then inflating. Cuts RSS peak ~50%
on 50MB history payloads.
3. processHistoryMessage: drop the { ...chat } shallow copy before
pushing to chats[]. The decoded protobuf object is not referenced
after the push.
4. downloadEncryptedContent transform: skip Buffer.concat when
remainingBytes is empty (the common case — chunks usually arrive
AES-aligned). ~19x faster per chunk (78ms -> 4ms over 25k chunks).
Tests:
- bigint-validation.test.ts (NEW): 32 parameterized cases covering
zero, MAX_SAFE_INTEGER boundary, max int64/uint64, negative values,
tricky bit patterns and powers of 2; plus 200-iter fuzz comparing
old Long vs new BigInt implementations for equivalence.
- proto-tojson-long.test.ts: kept the existing fork-only test for
string-in-Long-field graceful handling, appended 4 upstream tests
covering Long->string fast path, large unsigned > MAX_SAFE_INTEGER,
zero/small values and encode/decode roundtrip.
* test(binary): cover reconnection sync skip for both signals
Ports the upstream reconnection-sync-skip test from Baileys PR #2350
and adapts it to cover InfiniteAPI's dual-signal reconnect detection
in chats.ts:
1. accountSyncCounter > 0 (a previous full sync completed)
2. socketSkippedOfflineBuffer (forwarded from socket.ts when
hadStaleRoutingInfo is true, e.g. routingInfo discarded on start)
Both are required because they cover different reconnect scenarios
and the absence of the second branch causes a buffer mismatch where
socket.ts skips the offline buffer while chats.ts still waits in
AwaitingInitialSync — stalling live messages for up to 4 seconds.
Co-Authored-By: Renato Alcara
* perf(messages-recv): early-ignore JIDs before buffer/queue
Aligns with Baileys upstream PR #2352. Moves the shouldIgnoreJid check
out of handleMessage/handleReceipt/handleNotification and into
processNode so ignored stanzas are acked and dropped before entering
ev.buffer(), the offline queue, or the keyed mutexes. Skips the LID->PN
resolution work in handleReceipt for ignored receipts as a side effect.
Diverges from upstream by excluding type='call' from the early-ignore
check — InfiniteAPI's handleCall has never used shouldIgnoreJid, so
keeping calls outside this filter preserves existing behavior for
integrators that filter messages but still want call events.
Carousel, buttons, LID/PN normalization, Bad MAC fix and retry manager
untouched.
Co-Authored-By: Renato Alcara
* feat: add inbound username support and USync username protocol
Aligns with Baileys upstream PR #2480. WhatsApp is rolling out an
inbound username field that maps to the user's LID. This change is
purely additive — it captures and propagates the new optional field
through types, decoders, USync queries, and group/contact events.
Skipped intentionally to preserve InfiniteAPI's LID/PN customization:
- handleGroupNotification in messages-recv.ts (custom LID->PN flow on
groups.upsert / participants.map). Username for these specific
events still flows via process-message's emitParticipantsUpdate
(reads message.key.participantUsername added in decode-wa-message).
Carousel/buttons code (messages.ts, messages-send.ts) untouched.
Co-Authored-By: Renato Alcara