• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

wger-project / flutter / 30854254088

03 Aug 2026 09:21PM UTC coverage: 40.895% (-15.0%) from 55.93%
30854254088

Pull #1260

github

web-flow
Merge d93794de5 into e3affdd4f
Pull Request #1260: WIP: health sync

9980 of 24404 relevant lines covered (40.89%)

1.72 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.0
/lib/powersync/sync_watchdog.dart
1
/*
2
 * This file is part of wger Workout Manager <https://github.com/wger-project>.
3
 * Copyright (c) 2026 wger Team
4
 *
5
 * wger Workout Manager is free software: you can redistribute it and/or modify
6
 * it under the terms of the GNU Affero General Public License as published by
7
 * the Free Software Foundation, either version 3 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
 * GNU Affero General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU Affero General Public License
16
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
17
 */
18

19
import 'dart:async';
20

21
import 'package:flutter/foundation.dart';
22
import 'package:logging/logging.dart';
23
import 'package:powersync/powersync.dart' show SyncStatus;
24

25
final _logger = Logger('powersync');
3✔
26

27
/// Flags a sync stream that keeps reconnecting without ever delivering a
28
/// checkpoint.
29
///
30
/// That is the signature of a middlebox (VPN, ad blocker, antivirus, carrier
31
/// proxy) silently swallowing the long-lived stream request while short REST
32
/// requests still pass: the SDK sees a clean end-of-stream on every
33
/// iteration, so no exception is thrown, nothing is logged above FINE and
34
/// `SyncStatus.anyError` stays null. The UI shows "Connecting" forever
35
/// without a trace.
36
///
37
/// The watchdog arms a timer while the client wants to sync but has not
38
/// received a checkpoint in this connection epoch. Checkpoint progress
39
/// disarms it until [reset]; an active download or a surfaced error pauses
40
/// it (those cases are visible through other channels). If the timer
41
/// survives [timeout], [stalled] flips to true (the sync status dialog
42
/// shows a hint) and a WARNING lands in the app logs.
43
class SyncStreamWatchdog {
44
  SyncStreamWatchdog({this.timeout = const Duration(minutes: 2)});
2✔
45

46
  final Duration timeout;
47

48
  /// True while the stream looks blocked. Consumed by the sync status dialog.
49
  final ValueNotifier<bool> stalled = ValueNotifier(false);
50

51
  Timer? _timer;
52
  DateTime? _lastCheckpoint;
53
  bool _sawStatus = false;
54

55
  /// Set once a checkpoint arrived; the watchdog then stays quiet so a later
56
  /// idle connection (status events stop while nothing changes) cannot
57
  /// re-trigger it. [reset] re-arms for the next connection epoch.
58
  bool _disarmed = false;
59

60
  void onStatus(SyncStatus status) {
1✔
61
    final checkpoint = status.lastSyncedAt;
1✔
62
    // Progress is a NEW checkpoint: the first status event replays the
63
    // timestamp persisted in the local DB (which may be weeks old on a
64
    // blocked device), so only a change to a non-null value counts.
65
    final madeProgress = _sawStatus && checkpoint != null && checkpoint != _lastCheckpoint;
1✔
66
    _sawStatus = true;
1✔
67
    _lastCheckpoint = checkpoint;
1✔
68

69
    if (madeProgress) {
70
      _disarmed = true;
×
71
      _cancelTimer();
×
72
      if (stalled.value) {
×
73
        _logger.info('Sync stream recovered, checkpoint received');
×
74
        stalled.value = false;
×
75
      }
76
      return;
77
    }
78

79
    // Downloading means data is flowing (a first checkpoint on a large
80
    // account can take a while); an error is already surfaced by the sync
81
    // status dialog. Neither is the silent failure we are watching for.
82
    if (status.downloading || status.anyError != null) {
2✔
83
      _cancelTimer();
×
84
      return;
85
    }
86

87
    // A `connecting` flag briefly dropping between two retry iterations must
88
    // NOT clear the timer: the silent connect/EOF loop would reset it on
89
    // every cycle. Only [reset] (deliberate disconnect) clears it. While
90
    // already stalled there is nothing new to detect (and no reason to log
91
    // the warning again), so the timer stays off until recovery.
92
    if (!_disarmed && !stalled.value && (status.connecting || status.connected)) {
5✔
93
      _timer ??= Timer(timeout, _onTimeout);
4✔
94
    }
95
  }
96

97
  /// Call when the app disconnects on purpose (offline gate, logout), so a
98
  /// device that is simply offline is never reported as blocked.
99
  void reset() {
1✔
100
    _cancelTimer();
1✔
101
    _disarmed = false;
1✔
102
    stalled.value = false;
2✔
103
  }
104

105
  void dispose() {
1✔
106
    _cancelTimer();
1✔
107
    stalled.dispose();
2✔
108
  }
109

110
  void _onTimeout() {
1✔
111
    _timer = null;
1✔
112
    stalled.value = true;
2✔
113
    _logger.warning(
2✔
114
      'Sync stream keeps terminating without receiving any data. The '
115
      'connection to the sync service may be blocked by a VPN, ad blocker, '
116
      'antivirus or firewall on this network.',
117
    );
118
  }
119

120
  void _cancelTimer() {
1✔
121
    _timer?.cancel();
2✔
122
    _timer = null;
1✔
123
  }
124
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc