Fix reachability-gate races flagged by Codex on #1389 (#1394)

P1: TorManager.shutdownCompletely() resets didStart asynchronously
(after Arti has actually stopped, up to ~5s later). A brief
offline->online flap could call startIfNeeded() inside that window;
the guard on didStart dropped it, and nothing reevaluated afterwards,
so Tor stayed down while activationAllowed was true. Track shutdowns
in flight and record a deferred start, honored when the last shutdown
finishes (still gated on allowAutoStart/foreground at that point).

P2: NWPathReachabilityMonitor.ingest() cancelled and rescheduled the
flush a full debounce interval from "now" on every observation, even
duplicates (e.g. interface detail changes while still unsatisfied).
ReachabilityDebounce already preserves the original pending.since, so
schedule the flush for the remaining time to the true deadline instead
of restarting the window.

Tests: debounce deadline preservation (pure) + a monitor-level timing
test that a mid-window duplicate does not postpone the offline commit.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-07 18:09:30 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent 28ffc53f3f
commit bbe5e1ef4e
3 changed files with 64 additions and 1 deletions
@@ -48,6 +48,15 @@ struct ReachabilityDebounce {
/// Whether a change is currently waiting out the debounce window.
var hasPendingChange: Bool { pending != nil }
/// Time left before the pending change may commit, or `nil` when nothing
/// is pending. Lets callers schedule a flush at the true deadline instead
/// of a full interval from "now" (duplicate observations must not push
/// the deadline out).
func pendingRemaining(at now: Date) -> TimeInterval? {
guard let pending else { return nil }
return max(0, interval - now.timeIntervalSince(pending.since))
}
/// Feed a raw observation. Returns the new committed value if it changed,
/// otherwise `nil`.
mutating func observe(reachable: Bool, at now: Date) -> Bool? {
@@ -157,7 +166,10 @@ final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring {
}
}
flushWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + debounce.interval, execute: work)
// Fire at the pending change's real deadline (pending.since + interval):
// duplicate path updates re-enter here and must not restart the window.
let delay = debounce.pendingRemaining(at: now()) ?? debounce.interval
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
}
private func publish(_ reachable: Bool) {