fix(client): 自动连接竞态真因 — 等设置加载完再判(1.0.22)

日志实证:节点就绪(~450ms)与设置加载几乎同时完成,节点回调早 1ms 读到还没加载完的
autoConnect=false → 不连。AppSettings 加 loaded 标志(_load 完成置 true,失败也置);
_maybeAutoConnect 同时监听 nodes + settings,二者都就绪(settings.loaded && 节点 uuid 非空)
才决策一次。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-29 19:08:49 +08:00
parent ff2f2f3c07
commit 28f8e5ebcd
4 changed files with 40 additions and 28 deletions
+25 -20
View File
@@ -121,34 +121,39 @@ class _RootFlowState extends ConsumerState<RootFlow> {
bool _autoConnectArmed = false; // 自动连接每进程只尝试一次 bool _autoConnectArmed = false; // 自动连接每进程只尝试一次
bool _homeLogged = false; // 仅日志:HomeShell 首次显示打一行 bool _homeLogged = false; // 仅日志:HomeShell 首次显示打一行
// 进入登录态主界面后:开了「自动连接」且当前未连接,等节点就绪后自动连一次。 // 进入登录态主界面后:开了「自动连接」且当前未连接,自动连一次。
// 关键:必须同时等「设置已从持久化加载完(settings.loaded)」与「节点已就绪」——
// 二者都是异步且完成时刻接近,早读 autoConnect 会撞上还没加载完的默认 false(实测差 1ms)。
// 同时监听两者,任一变化就重判,直到都就绪再决策(只决策一次)。
void _maybeAutoConnect() { void _maybeAutoConnect() {
if (_autoConnectArmed) return; if (_autoConnectArmed) return;
_autoConnectArmed = true; _autoConnectArmed = true;
logLine('AutoConnect', 'armed; waiting for nodes ready'); logLine('AutoConnect', 'armed; waiting for settings loaded + nodes ready');
// 注意:不在此处早读 settingsProvider.autoConnect —— 其 _load 是异步(SharedPreferences), ProviderSubscription<AsyncValue<List<Node>>>? nodeSub;
// 启动这一刻多半还没加载完,会读到默认 false 而永久跳过。改到「节点就绪」回调里再判: ProviderSubscription<AppSettings>? settingsSub;
// 节点要走网络拉取,远慢于本地设置读取,那时 autoConnect 必已加载到位。 var decided = false;
// 不用回调的 next(裸 ProviderSubscription 会让它退化成 dynamic,valueOrNull 这种 void tryGo() {
// 扩展 getter 在 dynamic 上不解析 → 运行期 NoSuchMethod)。直接 ref.read 拿强类型值。 if (decided) return;
late final ProviderSubscription<AsyncValue<List<Node>>> sub; final settings = ref.read(settingsProvider);
sub = ref.listenManual(nodesProvider, (prev, next) { final nodes = ref.read(nodesProvider).valueOrNull ?? const <Node>[];
final av = ref.read(nodesProvider); final nodesReady = nodes.any((n) => n.uuid.isNotEmpty);
final nodes = av.valueOrNull ?? const <Node>[]; if (!settings.loaded || !nodesReady) return; // 等两者都就绪
final ready = nodes.any((n) => n.uuid.isNotEmpty); decided = true;
logLine('AutoConnect', 'nodes update: total=${nodes.length} ready=$ready loading=${av.isLoading} err=${av.hasError}'); nodeSub?.close();
if (!ready) return; settingsSub?.close();
sub.close();
final ac = ref.read(settingsProvider).autoConnect;
final phase = ref.read(connectionProvider).phase; final phase = ref.read(connectionProvider).phase;
logLine('AutoConnect', 'nodes ready autoConnect=$ac phase=$phase'); logLine('AutoConnect', 'ready: autoConnect=${settings.autoConnect} phase=$phase nodes=${nodes.length}');
if (ac && phase == VpnPhase.off) { if (settings.autoConnect && phase == VpnPhase.off) {
logLine('AutoConnect', 'triggering connect'); logLine('AutoConnect', 'triggering connect');
ref.read(connectionProvider.notifier).toggle(); ref.read(connectionProvider.notifier).toggle();
} else { } else {
logLine('AutoConnect', 'NOT connecting (autoConnect=$ac phase=$phase)'); logLine('AutoConnect', 'NOT connecting (autoConnect=${settings.autoConnect} phase=$phase)');
} }
}, fireImmediately: true); }
nodeSub = ref.listenManual(nodesProvider, (_, __) => tryGo());
settingsSub = ref.listenManual(settingsProvider, (_, __) => tryGo());
tryGo();
} }
// 登录/注册成功后:未看过引导才进引导,否则直接主界面。 // 登录/注册成功后:未看过引导才进引导,否则直接主界面。
+13 -6
View File
@@ -20,17 +20,21 @@ class AppSettings {
this.autoConnect = false, this.autoConnect = false,
this.smartRoute = true, this.smartRoute = true,
this.killSwitch = true, this.killSwitch = true,
this.loaded = false,
}); });
final bool autostart; final bool autostart;
final bool autoConnect; // 启动 app 时自动连接(opt-in,默认关) final bool autoConnect; // 启动 app 时自动连接(opt-in,默认关)
final bool smartRoute; final bool smartRoute;
final bool killSwitch; final bool killSwitch;
final bool loaded; // 是否已从持久化加载完(自动连接要等它 true 再读 autoConnect,避免竞态)
AppSettings copyWith({bool? autostart, bool? autoConnect, bool? smartRoute, bool? killSwitch}) => AppSettings( AppSettings copyWith({bool? autostart, bool? autoConnect, bool? smartRoute, bool? killSwitch, bool? loaded}) =>
AppSettings(
autostart: autostart ?? this.autostart, autostart: autostart ?? this.autostart,
autoConnect: autoConnect ?? this.autoConnect, autoConnect: autoConnect ?? this.autoConnect,
smartRoute: smartRoute ?? this.smartRoute, smartRoute: smartRoute ?? this.smartRoute,
killSwitch: killSwitch ?? this.killSwitch, killSwitch: killSwitch ?? this.killSwitch,
loaded: loaded ?? this.loaded,
); );
} }
@@ -51,22 +55,25 @@ class SettingsController extends StateNotifier<AppSettings> {
final bool _desktop = Platform.isMacOS || Platform.isWindows || Platform.isLinux; final bool _desktop = Platform.isMacOS || Platform.isWindows || Platform.isLinux;
Future<void> _load() async { Future<void> _load() async {
var s = const AppSettings(loaded: true); // 失败也标 loaded:true,自动连接才不会永久等
try { try {
final p = await SharedPreferences.getInstance(); final p = await SharedPreferences.getInstance();
if (!mounted) return; // 异步初始化期间已 dispose:别再写 state。 if (!mounted) return; // 异步初始化期间已 dispose:别再写 state。
state = AppSettings( s = AppSettings(
autostart: p.getBool(_kAutostart) ?? true, autostart: p.getBool(_kAutostart) ?? true,
autoConnect: p.getBool(_kAutoConnect) ?? false, autoConnect: p.getBool(_kAutoConnect) ?? false,
smartRoute: p.getBool(_kSmartRoute) ?? true, smartRoute: p.getBool(_kSmartRoute) ?? true,
killSwitch: p.getBool(_kKillSwitch) ?? true, killSwitch: p.getBool(_kKillSwitch) ?? true,
loaded: true,
); );
logLine('Settings', 'loaded autoConnect=${state.autoConnect} autostart=${state.autostart}');
} catch (_) { } catch (_) {
// 测试/无平台:保留默认值。 // 测试/无平台:默认值(已标 loaded)
} }
if (!mounted) return; if (!mounted) return;
_applyKillSwitch(state.killSwitch); state = s;
if (_desktop) await _applyAutostart(state.autostart); logLine('Settings', 'loaded autoConnect=${s.autoConnect} autostart=${s.autostart}');
_applyKillSwitch(s.killSwitch);
if (_desktop) await _applyAutostart(s.autostart);
} }
Future<void> setAutostart(bool v) async { Future<void> setAutostart(bool v) async {
+1 -1
View File
@@ -1,7 +1,7 @@
name: pangolin_vpn name: pangolin_vpn
description: 穿山甲 · Pangolin — 极简、稳定、跨平台网络加速客户端。 description: 穿山甲 · Pangolin — 极简、稳定、跨平台网络加速客户端。
publish_to: "none" publish_to: "none"
version: 1.0.21+22 version: 1.0.22+23
environment: environment:
sdk: ^3.5.0 sdk: ^3.5.0
+1 -1
View File
@@ -3,7 +3,7 @@
; 前置: 先在 client/ 跑 `flutter build windows`(Release),产物在 ; 前置: 先在 client/ 跑 `flutter build windows`(Release),产物在
; ..\..\build\windows\x64\runner\Release ; ..\..\build\windows\x64\runner\Release
#define MyAppName "穿山甲 Pangolin" #define MyAppName "穿山甲 Pangolin"
#define MyAppVersion "1.0.21" #define MyAppVersion "1.0.22"
#define MyAppPublisher "Pangolin" #define MyAppPublisher "Pangolin"
#define MyAppExeName "pangolin_vpn.exe" #define MyAppExeName "pangolin_vpn.exe"
#define BuildDir "..\..\build\windows\x64\runner\Release" #define BuildDir "..\..\build\windows\x64\runner\Release"