From e6489d4a1fa42ecd852f03ccee19e9430b854f35 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 30 Mar 2026 02:05:08 +0300 Subject: [PATCH 1/3] feat: add memory footprint to /stats and dashboard Per-structure heap estimation (cache, blocklist, query log, SRTT, overrides) with process RSS via mach_task_basic_info / sysconf. Dashboard gets a 6th stat card and a sidebar breakdown panel with stacked bar visualization. Co-Authored-By: Claude Opus 4.6 (1M context) --- site/dashboard.html | 149 +++++++++++++++++++++++++++++++++++++++++- src/api.rs | 52 +++++++++++++-- src/blocklist.rs | 14 ++++ src/cache.rs | 12 ++++ src/override_store.rs | 13 ++++ src/packet.rs | 19 ++++++ src/query_log.rs | 15 +++++ src/record.rs | 40 ++++++++++++ src/srtt.rs | 4 ++ src/stats.rs | 79 ++++++++++++++++++++++ 10 files changed, 391 insertions(+), 6 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index c54a331..a5780b8 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -101,7 +101,7 @@ body { /* Stat cards row */ .stats-row { display: grid; - grid-template-columns: repeat(5, 1fr); + grid-template-columns: repeat(6, 1fr); gap: 1rem; } .stat-card { @@ -125,6 +125,8 @@ body { .stat-card.blocked::before { background: var(--rose); } .stat-card.overrides::before { background: var(--violet); } .stat-card.uptime::before { background: var(--cyan); } +.stat-card.memory::before { background: var(--text-dim); } +.stat-card.memory .stat-value { color: var(--text-secondary); } .stat-label { font-size: 0.7rem; @@ -468,10 +470,74 @@ body { display: none; } +/* Memory sidebar panel */ +.memory-bar { + display: flex; + height: 18px; + border-radius: 4px; + overflow: hidden; + background: var(--bg-surface); + margin-bottom: 0.8rem; +} +.memory-bar-seg { + height: 100%; + min-width: 2px; + transition: width 0.6s ease; +} +.memory-bar-seg.cache { background: var(--teal); } +.memory-bar-seg.blocklist { background: var(--rose); } +.memory-bar-seg.querylog { background: var(--amber); } +.memory-bar-seg.srtt { background: var(--cyan); } +.memory-bar-seg.overrides { background: var(--violet); } +.memory-row { + display: flex; + align-items: center; + padding: 0.3rem 0; + border-bottom: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.72rem; +} +.memory-row:last-child { border-bottom: none; } +.memory-row-dot { + width: 8px; + height: 8px; + border-radius: 2px; + flex-shrink: 0; + margin-right: 0.5rem; +} +.memory-row-label { + flex: 1; + color: var(--text-secondary); +} +.memory-row-size { + width: 65px; + text-align: right; + color: var(--text-primary); + font-weight: 500; +} +.memory-row-entries { + width: 90px; + text-align: right; + color: var(--text-dim); +} +.memory-rss { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--border); + display: flex; + justify-content: space-between; + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--text-dim); +} + /* Responsive */ @media (max-width: 1100px) { .main-grid { grid-template-columns: 1fr; } } +@media (max-width: 900px) { + .stats-row { grid-template-columns: repeat(3, 1fr); } +} @media (max-width: 700px) { .stats-row { grid-template-columns: repeat(2, 1fr); } .dashboard { padding: 1rem; } @@ -524,6 +590,11 @@ body {
 
+
+
Memory
+
+
 
+
@@ -648,6 +719,17 @@ body { + +
+
+ Memory + +
+
+
No memory data
+
+
+
@@ -712,6 +794,70 @@ function formatRemaining(secs) { return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m left`; } +function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; + if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB'; + return (bytes / 1073741824).toFixed(1) + ' GB'; +} + +const MEMORY_COMPONENTS = [ + { key: 'cache', label: 'Cache', cls: 'cache', color: 'var(--teal)' }, + { key: 'blocklist', label: 'Blocklist', cls: 'blocklist', color: 'var(--rose)' }, + { key: 'query_log', label: 'Query Log', cls: 'querylog', color: 'var(--amber)' }, + { key: 'srtt', label: 'SRTT', cls: 'srtt', color: 'var(--cyan)' }, + { key: 'overrides', label: 'Overrides', cls: 'overrides', color: 'var(--violet)' }, +]; + +function renderMemory(mem, stats) { + if (!mem) return; + + // Stat card + document.getElementById('memoryRss').textContent = formatBytes(mem.process_rss_bytes); + document.getElementById('memorySub').textContent = 'est. ' + formatBytes(mem.total_estimated_bytes); + + // Entry counts from sibling stats objects (avoid duplication in memory payload) + const entryCounts = { + cache: stats.cache.entries, + blocklist: stats.blocking.domains_loaded, + query_log: mem.query_log_entries, + srtt: mem.srtt_entries, + overrides: stats.overrides.active, + }; + + // Sidebar panel + const total = mem.total_estimated_bytes || 1; + document.getElementById('memoryTotal').textContent = formatBytes(total); + + const barSegments = MEMORY_COMPONENTS.map(c => { + const bytes = mem[c.key + '_bytes'] || 0; + const pct = ((bytes / total) * 100).toFixed(1); + return `
`; + }).join(''); + + const rows = MEMORY_COMPONENTS.map(c => { + const bytes = mem[c.key + '_bytes'] || 0; + const entries = entryCounts[c.key] || 0; + return ` +
+
+ ${c.label} + ${formatBytes(bytes)} + ${formatNumber(entries)} entries +
`; + }).join(''); + + document.getElementById('memoryBody').innerHTML = ` +
${barSegments}
+ ${rows} +
+ Process RSS + ${formatBytes(mem.process_rss_bytes)} +
+ `; +} + const PATH_DEFS = [ { key: 'forwarded', label: 'Forward', cls: 'forward' }, { key: 'recursive', label: 'Recursive', cls: 'recursive' }, @@ -960,6 +1106,7 @@ async function refresh() { renderServices(services); renderBlockingInfo(blockingInfo); renderAllowlist(allowlist); + renderMemory(stats.memory, stats); } catch (err) { document.getElementById('statusDot').className = 'status-dot error'; diff --git a/src/api.rs b/src/api.rs index 9bf9bae..3476bc3 100644 --- a/src/api.rs +++ b/src/api.rs @@ -170,6 +170,7 @@ struct StatsResponse { overrides: OverrideStats, blocking: BlockingStatsResponse, lan: LanStatsResponse, + memory: MemoryStats, } #[derive(Serialize)] @@ -210,6 +211,19 @@ struct BlockingStatsResponse { allowlist_size: usize, } +#[derive(Serialize)] +struct MemoryStats { + cache_bytes: usize, + blocklist_bytes: usize, + query_log_bytes: usize, + query_log_entries: usize, + srtt_bytes: usize, + srtt_entries: usize, + overrides_bytes: usize, + total_estimated_bytes: usize, + process_rss_bytes: usize, +} + #[derive(Serialize)] struct DiagnoseResponse { domain: String, @@ -471,12 +485,29 @@ async fn query_log( async fn stats(State(ctx): State>) -> Json { let snap = ctx.stats.lock().unwrap().snapshot(); - let (cache_len, cache_max) = { + let (cache_len, cache_max, cache_bytes) = { let cache = ctx.cache.read().unwrap(); - (cache.len(), cache.max_entries()) + (cache.len(), cache.max_entries(), cache.heap_bytes()) }; - let override_count = ctx.overrides.read().unwrap().active_count(); - let bl_stats = ctx.blocklist.read().unwrap().stats(); + let (override_count, overrides_bytes) = { + let ov = ctx.overrides.read().unwrap(); + (ov.active_count(), ov.heap_bytes()) + }; + let (bl_stats, blocklist_bytes) = { + let bl = ctx.blocklist.read().unwrap(); + (bl.stats(), bl.heap_bytes()) + }; + let (query_log_bytes, query_log_entries) = { + let log = ctx.query_log.lock().unwrap(); + (log.heap_bytes(), log.len()) + }; + let (srtt_bytes, srtt_entries, srtt_enabled) = { + let s = ctx.srtt.read().unwrap(); + (s.heap_bytes(), s.len(), s.is_enabled()) + }; + + let total_estimated = + cache_bytes + blocklist_bytes + query_log_bytes + srtt_bytes + overrides_bytes; let upstream = if ctx.upstream_mode == crate::config::UpstreamMode::Recursive { "recursive (root hints)".to_string() @@ -491,7 +522,7 @@ async fn stats(State(ctx): State>) -> Json { config_path: ctx.config_path.clone(), data_dir: ctx.data_dir.to_string_lossy().to_string(), dnssec: ctx.dnssec_enabled, - srtt: ctx.srtt.read().unwrap().is_enabled(), + srtt: srtt_enabled, queries: QueriesStats { total: snap.total, forwarded: snap.forwarded, @@ -520,6 +551,17 @@ async fn stats(State(ctx): State>) -> Json { enabled: ctx.lan_enabled, peers: ctx.lan_peers.lock().unwrap().list().len(), }, + memory: MemoryStats { + cache_bytes, + blocklist_bytes, + query_log_bytes, + query_log_entries, + srtt_bytes, + srtt_entries, + overrides_bytes, + total_estimated_bytes: total_estimated, + process_rss_bytes: crate::stats::process_rss_bytes(), + }, }) } diff --git a/src/blocklist.rs b/src/blocklist.rs index 4319de9..8f1e14c 100644 --- a/src/blocklist.rs +++ b/src/blocklist.rs @@ -183,6 +183,20 @@ impl BlocklistStore { self.allowlist.iter().cloned().collect() } + pub fn heap_bytes(&self) -> usize { + let domains: usize = self + .domains + .iter() + .map(|d| std::mem::size_of::() + d.capacity()) + .sum(); + let allow: usize = self + .allowlist + .iter() + .map(|d| std::mem::size_of::() + d.capacity()) + .sum(); + domains + allow + } + pub fn stats(&self) -> BlocklistStats { BlocklistStats { enabled: self.is_enabled(), diff --git a/src/cache.rs b/src/cache.rs index a41ea96..89b7e75 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -142,6 +142,18 @@ impl DnsCache { self.entry_count = 0; } + pub fn heap_bytes(&self) -> usize { + let mut total = 0; + for (domain, type_map) in &self.entries { + total += domain.capacity() + std::mem::size_of::(); + for entry in type_map.values() { + total += std::mem::size_of::(); + total += entry.packet.heap_bytes(); + } + } + total + } + pub fn remove(&mut self, domain: &str) { let domain_lower = domain.to_lowercase(); if let Some(type_map) = self.entries.remove(&domain_lower) { diff --git a/src/override_store.rs b/src/override_store.rs index 2ae671c..1545579 100644 --- a/src/override_store.rs +++ b/src/override_store.rs @@ -117,6 +117,19 @@ impl OverrideStore { self.entries.clear(); } + pub fn heap_bytes(&self) -> usize { + self.entries + .iter() + .map(|(k, v)| { + k.capacity() + + std::mem::size_of::() + + v.domain.capacity() + + v.target.capacity() + + v.record.heap_bytes() + }) + .sum() + } + pub fn active_count(&self) -> usize { self.entries.values().filter(|e| !e.is_expired()).count() } diff --git a/src/packet.rs b/src/packet.rs index bf89ea3..9dded42 100644 --- a/src/packet.rs +++ b/src/packet.rs @@ -66,6 +66,25 @@ impl DnsPacket { pkt } + pub fn heap_bytes(&self) -> usize { + fn records_heap(records: &[DnsRecord]) -> usize { + records + .iter() + .map(|r| std::mem::size_of::() + r.heap_bytes()) + .sum::() + } + let questions: usize = self + .questions + .iter() + .map(|q| std::mem::size_of::() + q.name.capacity()) + .sum(); + questions + + records_heap(&self.answers) + + records_heap(&self.authorities) + + records_heap(&self.resources) + + self.edns.as_ref().map_or(0, |e| e.options.capacity()) + } + pub fn response_from(query: &DnsPacket, rescode: crate::header::ResultCode) -> DnsPacket { let mut resp = DnsPacket::new(); resp.header.id = query.header.id; diff --git a/src/query_log.rs b/src/query_log.rs index dff606e..23253bd 100644 --- a/src/query_log.rs +++ b/src/query_log.rs @@ -38,6 +38,21 @@ impl QueryLog { self.entries.push_back(entry); } + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn heap_bytes(&self) -> usize { + self.entries + .iter() + .map(|e| std::mem::size_of::() + e.domain.capacity()) + .sum() + } + pub fn query(&self, filter: &QueryLogFilter) -> Vec<&QueryLogEntry> { self.entries .iter() diff --git a/src/record.rs b/src/record.rs index 21fbdf0..1c5f2db 100644 --- a/src/record.rs +++ b/src/record.rs @@ -136,6 +136,46 @@ impl DnsRecord { } } + pub fn heap_bytes(&self) -> usize { + match self { + DnsRecord::A { domain, .. } => domain.capacity(), + DnsRecord::NS { domain, host, .. } | DnsRecord::CNAME { domain, host, .. } => { + domain.capacity() + host.capacity() + } + DnsRecord::MX { domain, host, .. } => domain.capacity() + host.capacity(), + DnsRecord::AAAA { domain, .. } => domain.capacity(), + DnsRecord::DNSKEY { + domain, public_key, .. + } => domain.capacity() + public_key.capacity(), + DnsRecord::DS { domain, digest, .. } => domain.capacity() + digest.capacity(), + DnsRecord::RRSIG { + domain, + signer_name, + signature, + .. + } => domain.capacity() + signer_name.capacity() + signature.capacity(), + DnsRecord::NSEC { + domain, + next_domain, + type_bitmap, + .. + } => domain.capacity() + next_domain.capacity() + type_bitmap.capacity(), + DnsRecord::NSEC3 { + domain, + salt, + next_hashed_owner, + type_bitmap, + .. + } => { + domain.capacity() + + salt.capacity() + + next_hashed_owner.capacity() + + type_bitmap.capacity() + } + DnsRecord::UNKNOWN { domain, data, .. } => domain.capacity() + data.capacity(), + } + } + pub fn set_ttl(&mut self, new_ttl: u32) { match self { DnsRecord::A { ttl, .. } diff --git a/src/srtt.rs b/src/srtt.rs index e44efbb..8eeb5c0 100644 --- a/src/srtt.rs +++ b/src/srtt.rs @@ -100,6 +100,10 @@ impl SrttCache { addrs.sort_by_key(|a| self.get(a.ip())); } + pub fn heap_bytes(&self) -> usize { + self.entries.capacity() * (std::mem::size_of::() + std::mem::size_of::()) + } + pub fn len(&self) -> usize { self.entries.len() } diff --git a/src/stats.rs b/src/stats.rs index 67ac56d..4df4f39 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -1,5 +1,84 @@ use std::time::Instant; +/// Returns the process resident set size in bytes, or 0 if unavailable. +pub fn process_rss_bytes() -> usize { + #[cfg(target_os = "macos")] + { + macos_rss() + } + #[cfg(target_os = "linux")] + { + linux_rss() + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + 0 + } +} + +#[cfg(target_os = "macos")] +fn macos_rss() -> usize { + use std::mem; + extern "C" { + fn mach_task_self() -> u32; + fn task_info( + target_task: u32, + flavor: u32, + task_info_out: *mut libc_task_basic_info, + task_info_count: *mut u32, + ) -> i32; + } + #[repr(C)] + struct libc_task_basic_info { + virtual_size: u64, + resident_size: u64, + resident_size_max: u64, + user_time: [u32; 2], + system_time: [u32; 2], + policy: i32, + suspend_count: i32, + } + const MACH_TASK_BASIC_INFO: u32 = 20; + let mut info: libc_task_basic_info = unsafe { mem::zeroed() }; + let mut count = (mem::size_of::() / mem::size_of::()) as u32; + let kr = unsafe { + task_info( + mach_task_self(), + MACH_TASK_BASIC_INFO, + &mut info, + &mut count, + ) + }; + if kr == 0 { + info.resident_size as usize + } else { + 0 + } +} + +#[cfg(target_os = "linux")] +fn linux_rss() -> usize { + extern "C" { + fn sysconf(name: i32) -> i64; + } + const SC_PAGESIZE: i32 = 30; + let page_size = unsafe { sysconf(SC_PAGESIZE) }; + let page_size = if page_size > 0 { + page_size as usize + } else { + 4096 + }; + + if let Ok(statm) = std::fs::read_to_string("/proc/self/statm") { + if let Some(rss_pages) = statm.split_whitespace().nth(1) { + if let Ok(pages) = rss_pages.parse::() { + return pages * page_size; + } + } + } + 0 +} + pub struct ServerStats { queries_total: u64, queries_forwarded: u64, -- 2.34.1 From a5a3a0552da33465b28deb83be4c91614094aa0d Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 30 Mar 2026 12:06:23 +0300 Subject: [PATCH 2/3] fix: use phys_footprint on macOS to match Activity Monitor Switch from MACH_TASK_BASIC_INFO (resident_size) to TASK_VM_INFO (phys_footprint) which matches Activity Monitor's Memory column. Also: capacity-aware heap estimation, entry counts in memory payload, heap_bytes tests for all stores. Co-Authored-By: Claude Opus 4.6 (1M context) --- site/dashboard.html | 9 ++++----- src/api.rs | 6 ++++++ src/blocklist.rs | 35 ++++++++++++++++++++++---------- src/cache.rs | 36 ++++++++++++++++++++++++++++++--- src/override_store.rs | 31 +++++++++++++++++++++------- src/packet.rs | 12 +++++++++++ src/query_log.rs | 22 ++++++++++++++++++++ src/record.rs | 10 +++++++++ src/srtt.rs | 17 +++++++++++++++- src/stats.rs | 47 +++++++++++++++++++++++++------------------ 10 files changed, 178 insertions(+), 47 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index a5780b8..e75b7f6 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -817,13 +817,12 @@ function renderMemory(mem, stats) { document.getElementById('memoryRss').textContent = formatBytes(mem.process_rss_bytes); document.getElementById('memorySub').textContent = 'est. ' + formatBytes(mem.total_estimated_bytes); - // Entry counts from sibling stats objects (avoid duplication in memory payload) const entryCounts = { - cache: stats.cache.entries, - blocklist: stats.blocking.domains_loaded, + cache: mem.cache_entries, + blocklist: mem.blocklist_entries, query_log: mem.query_log_entries, srtt: mem.srtt_entries, - overrides: stats.overrides.active, + overrides: mem.overrides_entries, }; // Sidebar panel @@ -852,7 +851,7 @@ function renderMemory(mem, stats) {
${barSegments}
${rows}
- Process RSS + Process Footprint ${formatBytes(mem.process_rss_bytes)}
`; diff --git a/src/api.rs b/src/api.rs index 3476bc3..f3f7b37 100644 --- a/src/api.rs +++ b/src/api.rs @@ -214,12 +214,15 @@ struct BlockingStatsResponse { #[derive(Serialize)] struct MemoryStats { cache_bytes: usize, + cache_entries: usize, blocklist_bytes: usize, + blocklist_entries: usize, query_log_bytes: usize, query_log_entries: usize, srtt_bytes: usize, srtt_entries: usize, overrides_bytes: usize, + overrides_entries: usize, total_estimated_bytes: usize, process_rss_bytes: usize, } @@ -553,12 +556,15 @@ async fn stats(State(ctx): State>) -> Json { }, memory: MemoryStats { cache_bytes, + cache_entries: cache_len, blocklist_bytes, + blocklist_entries: bl_stats.domains_loaded, query_log_bytes, query_log_entries, srtt_bytes, srtt_entries, overrides_bytes, + overrides_entries: override_count, total_estimated_bytes: total_estimated, process_rss_bytes: crate::stats::process_rss_bytes(), }, diff --git a/src/blocklist.rs b/src/blocklist.rs index 8f1e14c..0a7db4d 100644 --- a/src/blocklist.rs +++ b/src/blocklist.rs @@ -184,17 +184,13 @@ impl BlocklistStore { } pub fn heap_bytes(&self) -> usize { - let domains: usize = self - .domains - .iter() - .map(|d| std::mem::size_of::() + d.capacity()) - .sum(); - let allow: usize = self - .allowlist - .iter() - .map(|d| std::mem::size_of::() + d.capacity()) - .sum(); - domains + allow + // HashSet stores (hash, String) per slot + 1 control byte + let per_slot_overhead = std::mem::size_of::() + std::mem::size_of::() + 1; + let domains_table = self.domains.capacity() * per_slot_overhead; + let domains_heap: usize = self.domains.iter().map(|d| d.capacity()).sum(); + let allow_table = self.allowlist.capacity() * per_slot_overhead; + let allow_heap: usize = self.allowlist.iter().map(|d| d.capacity()).sum(); + domains_table + domains_heap + allow_table + allow_heap } pub fn stats(&self) -> BlocklistStats { @@ -248,6 +244,23 @@ pub fn parse_blocklist(text: &str) -> HashSet { domains } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn heap_bytes_grows_with_domains() { + let mut store = BlocklistStore::new(); + let empty = store.heap_bytes(); + let domains: HashSet = ["example.com", "example.org", "test.net"] + .iter() + .map(|s| s.to_string()) + .collect(); + store.swap_domains(domains, vec![]); + assert!(store.heap_bytes() > empty); + } +} + pub async fn download_blocklists(lists: &[String]) -> Vec<(String, String)> { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) diff --git a/src/cache.rs b/src/cache.rs index 89b7e75..a329d53 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -143,11 +143,21 @@ impl DnsCache { } pub fn heap_bytes(&self) -> usize { - let mut total = 0; + // Outer HashMap: (hash, String, HashMap) per slot + control byte + let outer_slot = std::mem::size_of::() + + std::mem::size_of::() + + std::mem::size_of::>() + + 1; + let mut total = self.entries.capacity() * outer_slot; for (domain, type_map) in &self.entries { - total += domain.capacity() + std::mem::size_of::(); + total += domain.capacity(); + // Inner HashMap: (hash, QueryType, CacheEntry) per slot + control byte + let inner_slot = std::mem::size_of::() + + std::mem::size_of::() + + std::mem::size_of::() + + 1; + total += type_map.capacity() * inner_slot; for entry in type_map.values() { - total += std::mem::size_of::(); total += entry.packet.heap_bytes(); } } @@ -206,3 +216,23 @@ fn adjust_ttls(records: &mut [DnsRecord], new_ttl: u32) { record.set_ttl(new_ttl); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::packet::DnsPacket; + + #[test] + fn heap_bytes_grows_with_entries() { + let mut cache = DnsCache::new(100, 1, 3600); + let empty = cache.heap_bytes(); + let mut pkt = DnsPacket::new(); + pkt.answers.push(DnsRecord::A { + domain: "example.com".into(), + addr: "1.2.3.4".parse().unwrap(), + ttl: 300, + }); + cache.insert("example.com", QueryType::A, &pkt); + assert!(cache.heap_bytes() > empty); + } +} diff --git a/src/override_store.rs b/src/override_store.rs index 1545579..96e0179 100644 --- a/src/override_store.rs +++ b/src/override_store.rs @@ -118,16 +118,20 @@ impl OverrideStore { } pub fn heap_bytes(&self) -> usize { - self.entries + // HashMap: (hash, String, OverrideEntry) per slot + control byte + let per_slot = std::mem::size_of::() + + std::mem::size_of::() + + std::mem::size_of::() + + 1; + let table = self.entries.capacity() * per_slot; + let heap: usize = self + .entries .iter() .map(|(k, v)| { - k.capacity() - + std::mem::size_of::() - + v.domain.capacity() - + v.target.capacity() - + v.record.heap_bytes() + k.capacity() + v.domain.capacity() + v.target.capacity() + v.record.heap_bytes() }) - .sum() + .sum(); + table + heap } pub fn active_count(&self) -> usize { @@ -167,3 +171,16 @@ fn parse_target(domain: &str, target: &str, ttl: u32) -> Result<(QueryType, DnsR }, )) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn heap_bytes_grows_with_entries() { + let mut store = OverrideStore::new(); + let empty = store.heap_bytes(); + store.insert("example.com", "1.2.3.4", 300, None).unwrap(); + assert!(store.heap_bytes() > empty); + } +} diff --git a/src/packet.rs b/src/packet.rs index 9dded42..ba9e30a 100644 --- a/src/packet.rs +++ b/src/packet.rs @@ -610,4 +610,16 @@ mod tests { panic!("expected DNSKEY"); } } + + #[test] + fn heap_bytes_accounts_for_records() { + let mut pkt = DnsPacket::new(); + let empty = pkt.heap_bytes(); + pkt.answers.push(DnsRecord::A { + domain: "example.com".into(), + addr: "1.2.3.4".parse().unwrap(), + ttl: 300, + }); + assert!(pkt.heap_bytes() > empty); + } } diff --git a/src/query_log.rs b/src/query_log.rs index 23253bd..1dc2d17 100644 --- a/src/query_log.rs +++ b/src/query_log.rs @@ -92,3 +92,25 @@ pub struct QueryLogFilter { pub since: Option, pub limit: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn heap_bytes_grows_with_entries() { + let mut log = QueryLog::new(100); + let empty = log.heap_bytes(); + log.push(QueryLogEntry { + timestamp: SystemTime::now(), + src_addr: "127.0.0.1:1234".parse().unwrap(), + domain: "example.com".into(), + query_type: QueryType::A, + path: QueryPath::Forwarded, + rescode: ResultCode::NOERROR, + latency_us: 500, + dnssec: DnssecStatus::Indeterminate, + }); + assert!(log.heap_bytes() > empty); + } +} diff --git a/src/record.rs b/src/record.rs index 1c5f2db..7de9bb4 100644 --- a/src/record.rs +++ b/src/record.rs @@ -690,4 +690,14 @@ mod tests { let parsed = round_trip(&rec); assert_eq!(rec, parsed); } + + #[test] + fn heap_bytes_reflects_string_capacity() { + let rec = DnsRecord::CNAME { + domain: "a]".repeat(100), + host: "b".repeat(200), + ttl: 60, + }; + assert!(rec.heap_bytes() >= 300); + } } diff --git a/src/srtt.rs b/src/srtt.rs index 8eeb5c0..bf02055 100644 --- a/src/srtt.rs +++ b/src/srtt.rs @@ -101,7 +101,12 @@ impl SrttCache { } pub fn heap_bytes(&self) -> usize { - self.entries.capacity() * (std::mem::size_of::() + std::mem::size_of::()) + // HashMap stores (hash, key, value) per slot + 1 control byte + let per_slot = std::mem::size_of::() + + std::mem::size_of::() + + std::mem::size_of::() + + 1; + self.entries.capacity() * per_slot } pub fn len(&self) -> usize { @@ -307,6 +312,16 @@ mod tests { assert_eq!(addrs, vec![sock(1), sock(2)]); } + #[test] + fn heap_bytes_grows_with_entries() { + let mut cache = SrttCache::new(true); + let empty = cache.heap_bytes(); + for i in 1..=10u8 { + cache.record_rtt(ip(i), 100, false); + } + assert!(cache.heap_bytes() > empty); + } + #[test] fn eviction_removes_oldest() { let mut cache = SrttCache::new(true); diff --git a/src/stats.rs b/src/stats.rs index 4df4f39..32739cc 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -24,33 +24,40 @@ fn macos_rss() -> usize { fn task_info( target_task: u32, flavor: u32, - task_info_out: *mut libc_task_basic_info, + task_info_out: *mut TaskVmInfo, task_info_count: *mut u32, ) -> i32; } + // Partial task_vm_info_data_t — only fields up to phys_footprint. #[repr(C)] - struct libc_task_basic_info { + struct TaskVmInfo { virtual_size: u64, + region_count: i32, + page_size: i32, resident_size: u64, - resident_size_max: u64, - user_time: [u32; 2], - system_time: [u32; 2], - policy: i32, - suspend_count: i32, + resident_size_peak: u64, + device: u64, + device_peak: u64, + internal: u64, + internal_peak: u64, + external: u64, + external_peak: u64, + reusable: u64, + reusable_peak: u64, + purgeable_volatile_pmap: u64, + purgeable_volatile_resident: u64, + purgeable_volatile_virtual: u64, + compressed: u64, + compressed_peak: u64, + compressed_lifetime: u64, + phys_footprint: u64, } - const MACH_TASK_BASIC_INFO: u32 = 20; - let mut info: libc_task_basic_info = unsafe { mem::zeroed() }; - let mut count = (mem::size_of::() / mem::size_of::()) as u32; - let kr = unsafe { - task_info( - mach_task_self(), - MACH_TASK_BASIC_INFO, - &mut info, - &mut count, - ) - }; + const TASK_VM_INFO: u32 = 22; + let mut info: TaskVmInfo = unsafe { mem::zeroed() }; + let mut count = (mem::size_of::() / mem::size_of::()) as u32; + let kr = unsafe { task_info(mach_task_self(), TASK_VM_INFO, &mut info, &mut count) }; if kr == 0 { - info.resident_size as usize + info.phys_footprint as usize } else { 0 } @@ -61,7 +68,7 @@ fn linux_rss() -> usize { extern "C" { fn sysconf(name: i32) -> i64; } - const SC_PAGESIZE: i32 = 30; + const SC_PAGESIZE: i32 = 30; // x86_64 + aarch64; differs on mips (28), sparc (29) let page_size = unsafe { sysconf(SC_PAGESIZE) }; let page_size = if page_size > 0 { page_size as usize -- 2.34.1 From 987e3aeeae4ae9c75128db25c4b8759ff8418361 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 30 Mar 2026 13:11:45 +0300 Subject: [PATCH 3/3] refactor: remove redundant fields and fix naming in memory stats Remove duplicate entry counts from MemoryStats (already in parent StatsResponse), rename process_rss_bytes to process_memory_bytes to match macOS phys_footprint semantics, drop restating comments. Co-Authored-By: Claude Opus 4.6 --- site/dashboard.html | 10 +++++----- src/api.rs | 10 ++-------- src/blocklist.rs | 1 - src/cache.rs | 2 -- src/override_store.rs | 1 - src/srtt.rs | 1 - src/stats.rs | 5 +++-- 7 files changed, 10 insertions(+), 20 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index e75b7f6..ffc6e0d 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -814,15 +814,15 @@ function renderMemory(mem, stats) { if (!mem) return; // Stat card - document.getElementById('memoryRss').textContent = formatBytes(mem.process_rss_bytes); + document.getElementById('memoryRss').textContent = formatBytes(mem.process_memory_bytes); document.getElementById('memorySub').textContent = 'est. ' + formatBytes(mem.total_estimated_bytes); const entryCounts = { - cache: mem.cache_entries, - blocklist: mem.blocklist_entries, + cache: stats.cache.entries, + blocklist: stats.blocking.domains_loaded, query_log: mem.query_log_entries, srtt: mem.srtt_entries, - overrides: mem.overrides_entries, + overrides: stats.overrides.active, }; // Sidebar panel @@ -852,7 +852,7 @@ function renderMemory(mem, stats) { ${rows}
Process Footprint - ${formatBytes(mem.process_rss_bytes)} + ${formatBytes(mem.process_memory_bytes)}
`; } diff --git a/src/api.rs b/src/api.rs index f3f7b37..1a6b7ef 100644 --- a/src/api.rs +++ b/src/api.rs @@ -214,17 +214,14 @@ struct BlockingStatsResponse { #[derive(Serialize)] struct MemoryStats { cache_bytes: usize, - cache_entries: usize, blocklist_bytes: usize, - blocklist_entries: usize, query_log_bytes: usize, query_log_entries: usize, srtt_bytes: usize, srtt_entries: usize, overrides_bytes: usize, - overrides_entries: usize, total_estimated_bytes: usize, - process_rss_bytes: usize, + process_memory_bytes: usize, } #[derive(Serialize)] @@ -556,17 +553,14 @@ async fn stats(State(ctx): State>) -> Json { }, memory: MemoryStats { cache_bytes, - cache_entries: cache_len, blocklist_bytes, - blocklist_entries: bl_stats.domains_loaded, query_log_bytes, query_log_entries, srtt_bytes, srtt_entries, overrides_bytes, - overrides_entries: override_count, total_estimated_bytes: total_estimated, - process_rss_bytes: crate::stats::process_rss_bytes(), + process_memory_bytes: crate::stats::process_memory_bytes(), }, }) } diff --git a/src/blocklist.rs b/src/blocklist.rs index 0a7db4d..e5caa99 100644 --- a/src/blocklist.rs +++ b/src/blocklist.rs @@ -184,7 +184,6 @@ impl BlocklistStore { } pub fn heap_bytes(&self) -> usize { - // HashSet stores (hash, String) per slot + 1 control byte let per_slot_overhead = std::mem::size_of::() + std::mem::size_of::() + 1; let domains_table = self.domains.capacity() * per_slot_overhead; let domains_heap: usize = self.domains.iter().map(|d| d.capacity()).sum(); diff --git a/src/cache.rs b/src/cache.rs index a329d53..d9a2a76 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -143,7 +143,6 @@ impl DnsCache { } pub fn heap_bytes(&self) -> usize { - // Outer HashMap: (hash, String, HashMap) per slot + control byte let outer_slot = std::mem::size_of::() + std::mem::size_of::() + std::mem::size_of::>() @@ -151,7 +150,6 @@ impl DnsCache { let mut total = self.entries.capacity() * outer_slot; for (domain, type_map) in &self.entries { total += domain.capacity(); - // Inner HashMap: (hash, QueryType, CacheEntry) per slot + control byte let inner_slot = std::mem::size_of::() + std::mem::size_of::() + std::mem::size_of::() diff --git a/src/override_store.rs b/src/override_store.rs index 96e0179..9b8a3f4 100644 --- a/src/override_store.rs +++ b/src/override_store.rs @@ -118,7 +118,6 @@ impl OverrideStore { } pub fn heap_bytes(&self) -> usize { - // HashMap: (hash, String, OverrideEntry) per slot + control byte let per_slot = std::mem::size_of::() + std::mem::size_of::() + std::mem::size_of::() diff --git a/src/srtt.rs b/src/srtt.rs index bf02055..bfad115 100644 --- a/src/srtt.rs +++ b/src/srtt.rs @@ -101,7 +101,6 @@ impl SrttCache { } pub fn heap_bytes(&self) -> usize { - // HashMap stores (hash, key, value) per slot + 1 control byte let per_slot = std::mem::size_of::() + std::mem::size_of::() + std::mem::size_of::() diff --git a/src/stats.rs b/src/stats.rs index 32739cc..c1a176f 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -1,7 +1,8 @@ use std::time::Instant; -/// Returns the process resident set size in bytes, or 0 if unavailable. -pub fn process_rss_bytes() -> usize { +/// Returns the process memory footprint in bytes, or 0 if unavailable. +/// macOS: phys_footprint (matches Activity Monitor). Linux: RSS from /proc/self/statm. +pub fn process_memory_bytes() -> usize { #[cfg(target_os = "macos")] { macos_rss() -- 2.34.1