隧道死掉时,你的应用仍在工作。Wi-Fi 还连着,互联网还有回应,而应用依赖的私有 API 已经不可达。人们在这里常用的 .NET 事件, NetworkAvailabilityChanged,根本没有描述这种情况。它报告的是机器总体可用状态的改变,而这一状态在 VPN 断开并重连的全过程中可以一直为真。观察者必须盯住拥有隧道的客户端,而不是网络栈对它的汇总。

可用性是聚合值,不是你的隧道

NetworkAvailabilityChanged 在机器总体可用性的答案改变时触发,而这个答案把除 loopback 或隧道之外的任何活动接口都算在内。

Wi-Fi 还连着时 VPN 掉线,并不会改变它测量的任何东西,所以你真正关心的那次故障期间,这个事件可能一直沉默。

状态流属于拥有隧道的客户端

确定是哪个组件拥有这条连接:OpenVPN 的 GUI 或服务、OpenVPN Connect,还是你的应用启动并控制的进程。它们是不同的拥有者,支持的接口也不同,观察者必须用与部署相匹配的那一个。对于你获准集成的 OpenVPN 进程,管理接口会报告当前状态、历史和实时通知。请解析有文档的结构化消息,而不是在本地化的 GUI 标题里搜索 connected 这个词。

管理通道是有特权的控制面,所以不要新开监听器,也不要和 GUI 争抢它已经持有的通道。

代际护栏拦住过期结果

每一次观察都属于某次特定的连接尝试。新的尝试开始时、通道丢失时、客户端报告退出时,都要递增代际计数器。

设想一个在连接 A 期间启动的探测。隧道掉了,连接 B 开始,随后 A 的延迟 HTTP 成功才到达。没有护栏的话,这个旧结果会在 B 还在认证时让界面变绿。取消能减少无用功,而代际检查才是阻止过期的完成状态变成当前状态的那一步:

var generation = _generation;
var reachable = await ProbeAsync(token);
if (generation != _generation) return;
Publish(reachable);

在 UI 派发周围也套用同一条规则,因为编组后的更新可能在代际已经前进之后才到达。

初始化与重连是一个生命周期

把首次连接、重连和通道丢失当作一个生命周期,用同一套规则处理:

  • 在接口允许的地方,用一步读取当前状态并订阅通知。
  • 给每一次观察打上连接标识和代际。
  • 在重连、退出或观察通道丢失时作废就绪状态。
  • 只在拥有者报告初始化完成之后,才去探测私有资源。
  • 只发布仍然属于当前代际的结果。

通道丢失意味着未知,而不是已断开。报告未知比报告你无法证明的断开更诚实,也能阻止重试逻辑凭猜测行动。

报告证据,而不是布尔值

把具名的会话状态和资源就绪状态作为两个独立的值返回,各自带上时效。需要做决定的消费者于是能看到最后一次成功探测已经是四十秒前,这与新鲜信息是不同的信息。

公网 IP 查询并不能证明私有 API 走的是所需的路由,所以不要把它当成那样的证据来呈现。

把强制执行留在观察器之外

回调是在事后运行的。它无法保证在事件与处理器之间没有数据包离开机器。如果设计要求阻断,那必须由网络策略来提供,观察者的职责是向调用方解释证据。把两者混在一起,会产出因为持有一个布尔值就自以为是的防火墙代码。让强制执行层保持乏味且可测试,让观察者继续做一个读者。

于是 UI 的问题就简单了:把已知的东西连同它的时效展示出来,未知时就说未知。

相关文章