fix(filter): suppress subscription renewal in background mode

Adds backgroundMode to Sub and SetBackgroundMode(bool) to both Sub and
FilterManager. When background=true, subscriptionLoop skips the 5-second
health-check ticker and drops expired subscription IDs from the closing
channel without resubscribing. When background=false (foreground return),
a resubscription is immediately triggered for any expired filters.

Background context (status-im/status-app#21045):
On Android, each Waku filter subscription has a relay-side TTL (~13.5 min
observed). When it expires, the closing channel fires, checkAndResubscribe
runs, and a new wf.Subscribe() RPC wakes the LTE modem. With a loaded
account this happens every ~13.5 min overnight, producing a 55% radio
duty cycle (~144 mAh/hr) while the screen is locked.

With background mode active, no network I/O occurs during subscription
expiry. On foreground return, all expired filters are resubscribed in
one burst — the user sees a brief reconnect, then full message delivery.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alisher 2026-06-02 18:00:03 +02:00
parent d6d39395ac
commit ab4609ef15
2 changed files with 50 additions and 3 deletions

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
@ -57,6 +58,9 @@ type Sub struct {
resubscribeInProgress bool
id string
errcnt int
// backgroundMode suppresses subscription renewal when the app UI is not visible.
// Toggled via SetBackgroundMode; read from subscriptionLoop goroutine.
backgroundMode atomic.Bool
// rateLimitedUntil is set when subscribe() observes a *SubscribeError whose
// FailedPeers contain at least one HTTP 429. While time.Now().Before(rateLimitedUntil),
// subscriptionLoop suppresses retry triggers (ticker push and checkAndResubscribe).
@ -139,6 +143,11 @@ func (apiSub *Sub) subscriptionLoop(loopInterval time.Duration) {
select {
case <-ticker.C:
apiSub.errcnt = 0 //reset errorCount
if apiSub.backgroundMode.Load() {
// In background: skip health check to avoid waking the LTE radio.
// SetBackgroundMode(false) triggers resubscription on foreground.
continue
}
if shouldHonourRateLimitBackoff(apiSub.rateLimitedUntil, time.Now()) {
apiSub.log.Debug("ticker push suppressed by rate-limit backoff",
zap.Time("rate-limited-until", apiSub.rateLimitedUntil),
@ -154,6 +163,14 @@ func (apiSub *Sub) subscriptionLoop(loopInterval time.Duration) {
apiSub.cleanup()
return
case subId := <-apiSub.closing:
if apiSub.backgroundMode.Load() {
// In background: subscription expired but don't resubscribe now.
// SetBackgroundMode(false) will trigger resubscription on foreground.
apiSub.log.Debug("resubscribe suppressed: app in background",
zap.String("sub-id", subId),
)
continue
}
if shouldHonourRateLimitBackoff(apiSub.rateLimitedUntil, time.Now()) {
apiSub.log.Debug("checkAndResubscribe suppressed by rate-limit backoff",
zap.Time("rate-limited-until", apiSub.rateLimitedUntil),
@ -174,6 +191,23 @@ func (apiSub *Sub) subscriptionLoop(loopInterval time.Duration) {
}
}
// SetBackgroundMode controls whether this subscription suppresses renewal.
// Call with background=true when the app UI is not visible (screen locked).
// Call with background=false when returning to foreground; this triggers an
// immediate resubscription attempt for any subscriptions that expired while
// backgrounded.
func (apiSub *Sub) SetBackgroundMode(background bool) {
apiSub.backgroundMode.Store(background)
if !background {
// Returning to foreground: prod the loop to resubscribe.
select {
case apiSub.closing <- "":
default:
// A resubscription is already queued.
}
}
}
func (apiSub *Sub) checkAndResubscribe(subId string) {
var failedPeer peer.ID
if subId != "" {

View File

@ -47,9 +47,9 @@ type FilterManager struct {
// a deadlock where SubscribeFilter would block on a full channel while still
// holding mgr.Lock(), preventing the only drainer (checkAndProcessQueue, also
// invoked under the same lock) from running.
waitingToSubQueue []filterConfig
envProcessor EnevelopeProcessor
networkConnType byte
waitingToSubQueue []filterConfig
envProcessor EnevelopeProcessor
networkConnType byte
}
type SubDetails struct {
@ -189,6 +189,19 @@ func (mgr *FilterManager) NetworkChange() {
mgr.node.PingPeers() // ping all peers to check if subscriptions are alive
}
// SetBackgroundMode notifies all active subscriptions of the app's visibility
// state. When background=true, subscriptions suppress renewal keepalives to
// avoid waking the LTE radio while the screen is locked. When background=false
// (returning to foreground), each subscription immediately attempts to
// resubscribe if its filter has expired.
func (mgr *FilterManager) SetBackgroundMode(background bool) {
mgr.Lock()
defer mgr.Unlock()
for _, subDetails := range mgr.filterSubscriptions {
subDetails.sub.SetBackgroundMode(background)
}
}
// checkAndProcessQueue drains the offline-pending filter queue. For each batch
// that matches the given pubsubTopic (or always, when pubsubTopic == ""), a
// subscribe goroutine is spawned; non-matching batches are retained for a