Merge nucleic/lucid-glass-bison-viwq into dev

This commit is contained in:
2026-07-18 22:07:15 -07:00
parent 33518eb800
commit 7e46002b20
2 changed files with 29 additions and 5 deletions
@@ -1097,10 +1097,18 @@ final class HostConnection {
reconnect(to: host) reconnect(to: host)
return return
} }
// Live over a fallback transport (tailnet/relay) and a LAN path just reappeared (rejoined the // Live over a fallback transport (tailnet/relay) and the path just changed. On cellular this
// Mac's Wi-Fi) → the return leg of the switch above. The tunnel survives the interface change // is a tower roam that may have rebound our IP and silently killed the WebSocket — the tunnel
// on its own, so nothing else would ever re-plan back down to the direct path — probe LAN and // does NOT reliably survive an IP rebind, contrary to the interface-swap case. Don't trust the
// swap to it if the Mac actually answers here. // stale `.connected`: fast-probe it and re-dial the instant it fails to answer (sub-second),
// rather than waiting out the 25s relay ping / 55s keepalive / URLSession's own detection.
// `revalidate` also runs `maybeUpgradeToLAN`, so the LAN return leg (rejoined the Mac's Wi-Fi)
// is still covered here.
if connectivity.isLive, activeTransport != .lan {
revalidate()
return
}
// Live over LAN (or a path change with nothing to re-probe) → just try the LAN return leg.
maybeUpgradeToLAN() maybeUpgradeToLAN()
} }
@@ -26,6 +26,13 @@ final class NetworkPathMonitor: ObservableObject {
private let queue = DispatchQueue(label: "nucleic.remote.path") private let queue = DispatchQueue(label: "nucleic.remote.path")
private var started = false private var started = false
/// A coarse fingerprint of the last path, so a *same-class* transition (a cellular tower roam
/// that reprovisions the pdp interface, a Wi‑Fi interface swap) fires `onChange` too — not just
/// the LAN/satisfied flips. Without this, a roam that keeps the path `satisfied` on `.cellular`
/// leaves both booleans unchanged and the proactive re‑plan never fires, so a silently‑rebound
/// socket is only caught by the slow reactive keepalive.
private var lastSignature = ""
func start() { func start() {
guard !started else { return } guard !started else { return }
started = true started = true
@@ -43,9 +50,18 @@ final class NetworkPathMonitor: ObservableObject {
let lan = path.status == .satisfied let lan = path.status == .satisfied
&& (path.usesInterfaceType(.wifi) || path.usesInterfaceType(.wiredEthernet)) && (path.usesInterfaceType(.wifi) || path.usesInterfaceType(.wiredEthernet))
let satisfied = path.status == .satisfied let satisfied = path.status == .satisfied
let changed = lan != canUseLAN || satisfied != isSatisfied // Also move on a same‑class transition (cellular tower roam, Wi‑Fi interface swap) so a live
// relay/tailnet tunnel gets re‑validated instead of waiting out socket‑death detection. The
// interface identity + expensiveness/constrained flags are what `NWPath` surfaces on a
// handoff; the IP itself isn't exposed, so this is best‑effort — the follow‑on probe is the
// gate, and it's cheap on a still‑healthy link.
let signature = "\(path.status)|"
+ path.availableInterfaces.map { "\($0.name)#\($0.index)" }.joined(separator: ",")
+ "|\(path.isExpensive)|\(path.isConstrained)"
let changed = lan != canUseLAN || satisfied != isSatisfied || signature != lastSignature
canUseLAN = lan canUseLAN = lan
isSatisfied = satisfied isSatisfied = satisfied
lastSignature = signature
if changed { onChange?() } if changed { onChange?() }
} }
} }