fix(client): 看门狗后台唤醒不再误判节点死→假重连 (#18)
现象:app/Mac 后台或睡眠一段时间再打开,出现"重连过程"但 VPN 没掉。 根因:连通看门狗路径A(urltest stale)用墙上时钟算陈旧度,挂起期 stats 停更、 _lastUrltestOk 冻住;唤醒时一算 >45s 即判上游死 → _onNodeUnhealthy 触发 disconnect+connect(智能模式还自动切节点),但隧道一直好好的。 修(connection_provider.dart 一处): - _onStats 记 _lastStatsAt(任意 stats 帧时刻);stats 出现 >10s 长间断(挂起/唤醒) 时把 _lastUrltestOk 前移到现在,给节点新的 45s 窗口自证 - 看门狗路径A 增加 statsLive 闸:仅在 stats 确实在流(距今 <10s)且 urltest 停 ≥45s 时才判节点死;stats 整体停(挂起未恢复)则跳过本轮 - 加可注入时钟 _now(默认 DateTime.now),使时间驱动的 path A 可单测 测试:connection_watchdog_test 新增两例 —— ① stats 在流但 urltest 停≥45s 仍正常判死 切节点(path A 未被削弱);② 挂起 60s 唤醒不假重连(无 statsLive 闸则会误切,真回归)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ class _FakeBridge implements VpnBridge {
|
||||
final _status = StreamController<VpnStatus>.broadcast();
|
||||
final _stats = StreamController<VpnStatsEvent>.broadcast();
|
||||
void emit(VpnStatus s) => _status.add(s);
|
||||
void emitStats(VpnStatsEvent e) => _stats.add(e);
|
||||
@override
|
||||
Stream<VpnStatus> get statusStream => _status.stream;
|
||||
@override
|
||||
@@ -78,13 +79,14 @@ class _StubNodesHKDown extends NodesNotifier {
|
||||
void main() {
|
||||
const t = StringsZh();
|
||||
|
||||
ProviderContainer makeContainer(_FakeBridge bridge, {NodesNotifier Function()? nodes}) =>
|
||||
ProviderContainer makeContainer(_FakeBridge bridge,
|
||||
{NodesNotifier Function()? nodes, DateTime Function()? now}) =>
|
||||
ProviderContainer(overrides: [
|
||||
vpnBridgeProvider.overrideWithValue(bridge),
|
||||
nodesProvider.overrideWith(nodes ?? _StubNodes.new),
|
||||
connectApiFactoryProvider.overrideWithValue((_) => _FakeConnectApi()),
|
||||
connectionProvider.overrideWith(
|
||||
(ref) => ConnectionController(ref, ref.watch(vpnBridgeProvider))),
|
||||
(ref) => ConnectionController(ref, ref.watch(vpnBridgeProvider), now: now)),
|
||||
]);
|
||||
|
||||
// 加载节点 + 建 controller + 推 on,并让「即时首查」跑完。
|
||||
@@ -138,6 +140,69 @@ void main() {
|
||||
expect(c.read(selectedNodeCodeProvider), 'HK', reason: '手动模式不应自动换节点');
|
||||
});
|
||||
|
||||
// urltest 帧(经 proxy 真实可达):置/刷新判活基准。
|
||||
VpnStatsEvent urltestFrame(int delayMs) => VpnStatsEvent(
|
||||
uploadBytes: 0, downloadBytes: 0, uploadSpeed: 0, downloadSpeed: 0,
|
||||
urltestResults: [UrltestResult(tag: 'hk', delayMs: delayMs)],
|
||||
);
|
||||
// 仅速率帧(无 urltest):证明 stats 仍在流,但不刷新 urltest 判活基准。
|
||||
const speedOnlyFrame = VpnStatsEvent(
|
||||
uploadBytes: 1, downloadBytes: 1, uploadSpeed: 1, downloadSpeed: 1, urltestResults: []);
|
||||
|
||||
// 注:看门狗 tick 由假定时器(tester.pump 推进)驱动,但陈旧度判定用注入时钟 `fake`;
|
||||
// 故下面同时推进 fake(墙上时钟)与 pump(触发 tick),复现真机时间流逝。
|
||||
testWidgets('路径A:stats 在流但 urltest 停 ≥45s → 智能切到其他节点', (tester) async {
|
||||
final bridge = _FakeBridge();
|
||||
var fake = DateTime(2026, 1, 1);
|
||||
final c = makeContainer(bridge, now: () => fake); // 都 up,排除路径B;默认智能,最优 HK
|
||||
addTearDown(bridge.dispose);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox()));
|
||||
await driveOn(tester, c, bridge);
|
||||
bridge.emitStats(urltestFrame(20)); // 基准:一次正常 urltest(lastOk=t0)
|
||||
await tester.pump();
|
||||
// 节点死但本地 TUN 还在:stats 持续在流(每 8s 一帧、gap<10s 不触发重置),urltest 停。
|
||||
// 墙上时钟累计走过 45s → lastOk 仍停在 t0、statsAt 一路推进(在流)。
|
||||
for (var i = 0; i < 7; i++) {
|
||||
fake = fake.add(const Duration(seconds: 8));
|
||||
bridge.emitStats(speedOnlyFrame);
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.pump(const Duration(seconds: 15)); // 驱动一次看门狗 tick
|
||||
await tester.pump();
|
||||
expect(c.read(selectedNodeCodeProvider), 'JP',
|
||||
reason: 'stats 在流而 urltest 停 ≥45s → 路径A 判节点死,智能应切到 JP');
|
||||
});
|
||||
|
||||
testWidgets('app 挂起唤醒:stats 整体停后恢复 → 不假重连', (tester) async {
|
||||
final bridge = _FakeBridge();
|
||||
var fake = DateTime(2026, 1, 1);
|
||||
final c = makeContainer(bridge, now: () => fake); // 都 up,排除路径B
|
||||
addTearDown(bridge.dispose);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox()));
|
||||
c.read(selectedNodeCodeProvider.notifier).select('HK');
|
||||
await driveOn(tester, c, bridge);
|
||||
bridge.emitStats(urltestFrame(20)); // 连上后一次正常 urltest:lastOk=t0, statsAt=t0
|
||||
await tester.pump();
|
||||
// 模拟 app 挂起/睡眠 60s:墙上时钟跳进,但期间无任何 stats 帧(stats 全停)。
|
||||
fake = fake.add(const Duration(seconds: 60));
|
||||
await tester.pump(const Duration(seconds: 15)); // 驱动看门狗 tick(此刻 statsAt 仍停在 t0)
|
||||
await tester.pump();
|
||||
// 修复前:lastOk 看似陈旧(60s)→ 误判节点死 → 智能切 JP。
|
||||
// 修复后:statsLive=false(statsAt 距今 60s)→ 路径A 跳过 → 保持 HK。
|
||||
expect(c.read(connectionProvider).phase, VpnPhase.on, reason: '隧道没掉,应保持连接');
|
||||
expect(c.read(selectedNodeCodeProvider), 'HK', reason: '挂起空档不应被误判为节点死而切节点');
|
||||
// 唤醒:恢复 stats(gap>10s → _onStats 把 lastOk 前移到现在),后续仍不应切。
|
||||
bridge.emitStats(urltestFrame(22));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 15));
|
||||
await tester.pump();
|
||||
expect(c.read(selectedNodeCodeProvider), 'HK', reason: '唤醒后基准刷新,保持 HK');
|
||||
bridge.emit(VpnStatus.off); // 收尾
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
testWidgets('节点健康 → 保持连接,不触发任何动作', (tester) async {
|
||||
final bridge = _FakeBridge();
|
||||
final c = makeContainer(bridge); // 都 up
|
||||
|
||||
Reference in New Issue
Block a user