feat: Windows DNS configuration via netsh (#28)
* feat: Windows DNS configuration via netsh numa install/uninstall now set/restore system DNS on Windows via netsh. Parses ipconfig /all per-interface (adapter name, DHCP status, DNS servers), saves backup to %APPDATA%\numa\original-dns.json, and restores on uninstall (DHCP or static with secondary servers). Handles localization (German adapter/DHCP/DNS labels), disconnected adapters, multiple interfaces, and missing admin privileges. Adds IP validation to discover_windows() for consistency. No Windows Service or CA trust yet — user runs numa in a terminal. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: add cargo test to Windows CI job Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ci: upload Windows binary as artifact for testing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: SRTT decay tests panic on Windows due to Instant underflow On Windows, Instant starts near boot time — subtracting large durations panics. Use checked_sub with a process-start fallback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: SRTT decay tests use binary search for max Instant age Replace age() helper with set_age_secs() on SrttCache that binary-searches for the maximum subtractable duration. Prevents panic on Windows (Instant starts at boot) while still producing the oldest representable instant for correct decay calculations. Also removes ephemeral test-ubuntu.sh from git. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use ProgramData for Windows DNS backup path APPDATA differs between user and admin contexts — install runs as admin but uninstall might resolve a different APPDATA. Use ProgramData which is consistent across elevation contexts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: disable Dnscache on Windows install, re-enable on uninstall Windows DNS Client (Dnscache) holds port 53 at kernel level and can't be stopped via sc/net stop. Disable via registry during install (requires reboot), re-enable on uninstall. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: rewrite SRTT decay tests as pure functions Decay tests manipulated Instant timestamps which panics on Windows (Instant can't go before boot time). Rewrite to test decay_for_age() directly — a pure function taking srtt_ms and age_secs, no platform dependency. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use Quad9 IP (9.9.9.9) for DoH fallback, not hostname DoH to dns.quad9.net requires DNS to resolve the hostname, which creates a chicken-and-egg loop when numa IS the system resolver (e.g. after numa install on Windows). Using the IP directly avoids the bootstrap dependency. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: extract DOH_FALLBACK constant Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: extract QUAD9_IP constant Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: remove dead test helpers, fix constant placement Remove unused get_srtt_ms() and saturated_penalty_cache() left over from SRTT test rewrite. Move QUAD9_IP/DOH_FALLBACK after use block. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: ignore ConnectionReset on UDP socket (Windows ICMP error) Windows delivers ICMP port-unreachable as ConnectionReset on the next UDP recv_from, crashing numa. Linux/macOS silently ignore these. Catch and continue the recv loop. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: auto-start numa on Windows boot via registry Run key Without a Windows Service, rebooting after numa install leaves DNS broken (pointing at 127.0.0.1 with nothing listening). Register numa in HKLM\...\Run so it starts automatically. Removed on uninstall. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: update README, Windows plan, and launch drafts for Windows support - README: platform-specific Quick Start, install/uninstall table - Windows plan: Phase 2 complete, Phase 3 scoped - Launch drafts: updated "Does it support Windows?" response Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: remove docs from git tracking (already gitignored) docs/ is in .gitignore but files were force-added. Remove from tracking — files remain on disk. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
101
src/srtt.rs
101
src/srtt.rs
@@ -47,16 +47,19 @@ impl SrttCache {
|
||||
|
||||
/// Apply time-based decay: each DECAY_AFTER_SECS period halves distance to INITIAL.
|
||||
fn decayed_srtt(entry: &SrttEntry) -> u64 {
|
||||
let age_secs = entry.updated_at.elapsed().as_secs();
|
||||
Self::decay_for_age(entry.srtt_ms, entry.updated_at.elapsed().as_secs())
|
||||
}
|
||||
|
||||
fn decay_for_age(srtt_ms: u64, age_secs: u64) -> u64 {
|
||||
if age_secs > DECAY_AFTER_SECS {
|
||||
let periods = (age_secs / DECAY_AFTER_SECS).min(8);
|
||||
let mut srtt = entry.srtt_ms;
|
||||
let mut srtt = srtt_ms;
|
||||
for _ in 0..periods {
|
||||
srtt = (srtt + INITIAL_SRTT_MS) / 2;
|
||||
}
|
||||
srtt
|
||||
} else {
|
||||
entry.srtt_ms
|
||||
srtt_ms
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,13 +119,6 @@ impl SrttCache {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn set_updated_at(&mut self, ip: IpAddr, at: Instant) {
|
||||
if let Some(entry) = self.entries.get_mut(&ip) {
|
||||
entry.updated_at = at;
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_evict(&mut self) {
|
||||
if self.entries.len() < MAX_ENTRIES {
|
||||
return;
|
||||
@@ -218,63 +214,41 @@ mod tests {
|
||||
assert_eq!(addrs, original);
|
||||
}
|
||||
|
||||
fn age(secs: u64) -> Instant {
|
||||
Instant::now() - std::time::Duration::from_secs(secs)
|
||||
}
|
||||
|
||||
/// Cache with ip(1) saturated at FAILURE_PENALTY_MS
|
||||
fn saturated_penalty_cache() -> SrttCache {
|
||||
let mut cache = SrttCache::new(true);
|
||||
for _ in 0..30 {
|
||||
cache.record_rtt(ip(1), FAILURE_PENALTY_MS, false);
|
||||
}
|
||||
cache
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_decay_within_threshold() {
|
||||
let mut cache = SrttCache::new(true);
|
||||
cache.record_rtt(ip(1), 5000, false);
|
||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS));
|
||||
assert_eq!(cache.get(ip(1)), cache.entries[&ip(1)].srtt_ms);
|
||||
// At exactly DECAY_AFTER_SECS, no decay applied
|
||||
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS);
|
||||
assert_eq!(result, FAILURE_PENALTY_MS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_decay_period() {
|
||||
let mut cache = saturated_penalty_cache();
|
||||
let raw = cache.entries[&ip(1)].srtt_ms;
|
||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS + 1));
|
||||
let expected = (raw + INITIAL_SRTT_MS) / 2;
|
||||
assert_eq!(cache.get(ip(1)), expected);
|
||||
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS + 1);
|
||||
let expected = (FAILURE_PENALTY_MS + INITIAL_SRTT_MS) / 2;
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_decay_periods() {
|
||||
let mut cache = saturated_penalty_cache();
|
||||
let raw = cache.entries[&ip(1)].srtt_ms;
|
||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 4 + 1));
|
||||
let mut expected = raw;
|
||||
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 4 + 1);
|
||||
let mut expected = FAILURE_PENALTY_MS;
|
||||
for _ in 0..4 {
|
||||
expected = (expected + INITIAL_SRTT_MS) / 2;
|
||||
}
|
||||
assert_eq!(cache.get(ip(1)), expected);
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decay_caps_at_8_periods() {
|
||||
// 9 periods and 100 periods should produce the same result (capped at 8)
|
||||
let mut cache_a = saturated_penalty_cache();
|
||||
let mut cache_b = saturated_penalty_cache();
|
||||
cache_a.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 9 + 1));
|
||||
cache_b.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100));
|
||||
assert_eq!(cache_a.get(ip(1)), cache_b.get(ip(1)));
|
||||
let a = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 9 + 1);
|
||||
let b = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decay_converges_toward_initial() {
|
||||
let mut cache = saturated_penalty_cache();
|
||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100));
|
||||
let decayed = cache.get(ip(1));
|
||||
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
|
||||
let diff = decayed.abs_diff(INITIAL_SRTT_MS);
|
||||
assert!(
|
||||
diff < 25,
|
||||
@@ -286,29 +260,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn record_rtt_applies_decay_before_ewma() {
|
||||
let mut cache = saturated_penalty_cache();
|
||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 8));
|
||||
cache.record_rtt(ip(1), 50, false);
|
||||
let srtt = cache.get(ip(1));
|
||||
// Without decay-before-EWMA, result would be ~(5000*7+50)/8 ≈ 4381
|
||||
assert!(srtt < 500, "expected decay before EWMA, got srtt={}", srtt);
|
||||
// Verify decay is applied before EWMA in record_rtt by checking
|
||||
// that a saturated penalty + long age + new sample produces a low SRTT
|
||||
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 8);
|
||||
// EWMA: (decayed * 7 + 50) / 8
|
||||
let after_ewma = (decayed * 7 + 50) / 8;
|
||||
assert!(
|
||||
after_ewma < 500,
|
||||
"expected decay before EWMA, got srtt={}",
|
||||
after_ewma
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decay_reranks_stale_failures() {
|
||||
let mut cache = saturated_penalty_cache();
|
||||
for _ in 0..30 {
|
||||
cache.record_rtt(ip(2), 300, false);
|
||||
}
|
||||
let mut addrs = vec![sock(1), sock(2)];
|
||||
cache.sort_by_rtt(&mut addrs);
|
||||
assert_eq!(addrs, vec![sock(2), sock(1)]);
|
||||
|
||||
// Age server 1 so it decays toward INITIAL (200ms) — below server 2's 300ms
|
||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100));
|
||||
let mut addrs = vec![sock(1), sock(2)];
|
||||
cache.sort_by_rtt(&mut addrs);
|
||||
assert_eq!(addrs, vec![sock(1), sock(2)]);
|
||||
// After enough decay, a failed server (5000ms) converges toward
|
||||
// INITIAL (200ms), which is below a stable server at 300ms
|
||||
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
|
||||
assert!(
|
||||
decayed < 300,
|
||||
"expected decayed penalty ({}) < 300ms",
|
||||
decayed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user