seq_runtime/dns.rs
1//! DNS resolution for Seq.
2//!
3//! Resolves hostnames to IP-address strings via libc's `getaddrinfo`,
4//! offloaded onto a dedicated OS-thread pool so may-carrier threads
5//! never park on the syscall. Inherits all platform-correct resolution
6//! behaviour (`/etc/hosts`, systemd-resolved, VPN/corp DNS, mDNS) for
7//! free.
8//!
9//! A small TTL cache collapses fanout to the same host. Cache and
10//! worker pool are process-global and lazy-initialised on first call.
11//!
12//! ## Surface
13//!
14//! `net.dns.resolve ( String -- List Bool )` returns a list of IP-string
15//! representations. On unresolvable name or empty result, pushes
16//! `(empty-list, false)`.
17//!
18//! ## Known limitation: no single-flight
19//!
20//! The cache collapses *sequential* fanout — repeated resolves of the
21//! same host after one has filled the cache hit the fast path. It does
22//! *not* deduplicate *concurrent* first-resolves: N strands racing to
23//! resolve the same uncached host enqueue N worker jobs, each running
24//! its own `getaddrinfo` and writing the cache on return. Wasted work
25//! under bursty load (e.g., connection-pool warm-up), but not a
26//! correctness issue. Single-flight via an in-flight map keyed by
27//! hostname is a planned follow-up.
28
29use crate::seqstring::global_string;
30use crate::stack::{Stack, pop, push};
31use crate::value::{Value, VariantData};
32
33use std::collections::HashMap;
34use std::net::ToSocketAddrs;
35use std::sync::mpsc as std_mpsc;
36use std::sync::{Arc, LazyLock, Mutex};
37use std::thread;
38use std::time::{Duration, Instant};
39
40const DEFAULT_WORKERS: usize = 8;
41const MAX_WORKERS: usize = 64;
42const CACHE_TTL: Duration = Duration::from_secs(60);
43const CACHE_MAX: usize = 256;
44
45struct CacheEntry {
46 addrs: Vec<String>,
47 expires: Instant,
48}
49
50struct DnsCache {
51 entries: HashMap<String, CacheEntry>,
52}
53
54impl DnsCache {
55 fn new() -> Self {
56 Self {
57 entries: HashMap::new(),
58 }
59 }
60
61 fn get(&mut self, host: &str) -> Option<Vec<String>> {
62 if let Some(entry) = self.entries.get(host)
63 && Instant::now() < entry.expires
64 {
65 return Some(entry.addrs.clone());
66 }
67 self.entries.remove(host);
68 None
69 }
70
71 fn put(&mut self, host: String, addrs: Vec<String>) {
72 if self.entries.len() >= CACHE_MAX {
73 // Bounded eviction: drop one arbitrary entry. True LRU is
74 // overkill for v1 — DNS lookups are O(50ms) on miss, and the
75 // 60s TTL means a churned-out entry costs at most one extra
76 // worker round-trip.
77 if let Some(k) = self.entries.keys().next().cloned() {
78 self.entries.remove(&k);
79 }
80 }
81 self.entries.insert(
82 host,
83 CacheEntry {
84 addrs,
85 expires: Instant::now() + CACHE_TTL,
86 },
87 );
88 }
89}
90
91static CACHE: LazyLock<Mutex<DnsCache>> = LazyLock::new(|| Mutex::new(DnsCache::new()));
92
93struct Job {
94 hostname: String,
95 reply: may::sync::mpsc::Sender<Vec<String>>,
96}
97
98/// Lazy job queue. First send spawns the worker pool. Workers live for
99/// the lifetime of the process; the static sender drops only at shutdown.
100///
101/// `None` means every requested worker failed to spawn (resource
102/// starvation, ulimit, etc.). In that case the FFI fast-fails to
103/// `(empty-list, false)` instead of panicking forever.
104///
105/// Note: `SEQ_DNS_WORKERS=0` silently falls back to `DEFAULT_WORKERS`
106/// — disabling the pool makes no architectural sense (the syscall has
107/// to run *somewhere*), so we treat 0 as "unset" rather than rejecting.
108static JOB_QUEUE: LazyLock<Option<std_mpsc::Sender<Job>>> = LazyLock::new(|| {
109 let (tx, rx) = std_mpsc::channel::<Job>();
110 let rx = Arc::new(Mutex::new(rx));
111
112 let workers = std::env::var("SEQ_DNS_WORKERS")
113 .ok()
114 .and_then(|v| v.parse::<usize>().ok())
115 .filter(|n| *n > 0)
116 .unwrap_or(DEFAULT_WORKERS)
117 .min(MAX_WORKERS);
118
119 let mut spawned = 0usize;
120 for i in 0..workers {
121 let rx = rx.clone();
122 if thread::Builder::new()
123 .name(format!("seq-dns-{i}"))
124 .spawn(move || worker_loop(rx))
125 .is_ok()
126 {
127 spawned += 1;
128 }
129 // Spawn failures degrade the pool gracefully — we keep
130 // whatever workers the OS allowed. A poisoned LazyLock would
131 // turn a soft starvation event into permanent FFI panics.
132 }
133 if spawned == 0 { None } else { Some(tx) }
134});
135
136fn worker_loop(rx: Arc<Mutex<std_mpsc::Receiver<Job>>>) {
137 loop {
138 // Only one worker holds the lock while inside recv(); others
139 // queue on the Mutex. As soon as a job arrives the receiver
140 // releases the lock, another worker enters recv(), and the
141 // current worker spends the bulk of its time in getaddrinfo.
142 // Parallelism is genuine; the lock contention is sub-microsecond.
143 let job = match rx.lock().unwrap().recv() {
144 Ok(j) => j,
145 Err(_) => return, // sender dropped — process shutting down
146 };
147 let addrs = resolve_blocking(&job.hostname);
148 if !addrs.is_empty() {
149 CACHE
150 .lock()
151 .unwrap()
152 .put(job.hostname.clone(), addrs.clone());
153 }
154 let _ = job.reply.send(addrs); // requester may have died — drop
155 }
156}
157
158fn resolve_blocking(hostname: &str) -> Vec<String> {
159 // Port 0 is a dummy — getaddrinfo wants host:port but we only need
160 // the address list. Deduplicate because A and AAAA records can
161 // produce the same address representation in some configurations.
162 match (hostname, 0u16).to_socket_addrs() {
163 Ok(iter) => {
164 let mut seen = Vec::new();
165 for sa in iter {
166 let ip = sa.ip().to_string();
167 if !seen.contains(&ip) {
168 seen.push(ip);
169 }
170 }
171 seen
172 }
173 Err(_) => Vec::new(),
174 }
175}
176
177/// Resolve a hostname to a list of IP-address strings.
178///
179/// Cooperative: yields the strand via the may-channel recv() while a
180/// worker thread runs `getaddrinfo`. Returns an empty Vec on any
181/// failure (empty hostname, worker pool failed to start, send/recv
182/// error, or unresolvable name). Other runtime modules call this
183/// directly so they share the cache and worker pool with the FFI
184/// surface instead of opening a parallel path to `getaddrinfo`.
185pub fn resolve(hostname: &str) -> Vec<String> {
186 if hostname.is_empty() {
187 return Vec::new();
188 }
189 if let Some(addrs) = CACHE.lock().unwrap().get(hostname) {
190 return addrs;
191 }
192 let sender = match JOB_QUEUE.as_ref() {
193 Some(s) => s,
194 None => return Vec::new(),
195 };
196 let (reply_tx, reply_rx) = may::sync::mpsc::channel::<Vec<String>>();
197 let job = Job {
198 hostname: hostname.to_string(),
199 reply: reply_tx,
200 };
201 if sender.send(job).is_err() {
202 return Vec::new();
203 }
204 reply_rx.recv().unwrap_or_default()
205}
206
207/// Resolve a hostname to a list of IP-address strings.
208///
209/// Stack effect: `( String -- Variant Bool )`
210///
211/// The Variant is a `List` (tag `"List"`) of IP-string `Value::String`s.
212/// On unresolvable hostname, empty result, or type mismatch, pushes
213/// `(empty-list, false)`. Yields the strand cooperatively while the
214/// lookup runs on the worker pool.
215///
216/// # Safety
217/// Stack must have a String (hostname) on top.
218#[unsafe(no_mangle)]
219pub unsafe extern "C" fn patch_seq_dns_resolve(stack: Stack) -> Stack {
220 unsafe {
221 let (stack, host_val) = pop(stack);
222 let host = match host_val {
223 Value::String(s) => s,
224 _ => return push_failure(stack),
225 };
226 let hostname = host.as_str_or_empty().to_string();
227 let addrs = resolve(&hostname);
228 push_result(stack, addrs)
229 }
230}
231
232unsafe fn push_result(stack: Stack, addrs: Vec<String>) -> Stack {
233 unsafe {
234 if addrs.is_empty() {
235 return push_failure(stack);
236 }
237 let fields = addrs
238 .into_iter()
239 .map(|s| Value::String(global_string(s)))
240 .collect();
241 let list = Value::Variant(Arc::new(VariantData::new(
242 global_string("List".to_string()),
243 fields,
244 )));
245 let stack = push(stack, list);
246 push(stack, Value::Bool(true))
247 }
248}
249
250unsafe fn push_failure(stack: Stack) -> Stack {
251 unsafe {
252 let empty = Value::Variant(Arc::new(VariantData::new(
253 global_string("List".to_string()),
254 vec![],
255 )));
256 let stack = push(stack, empty);
257 push(stack, Value::Bool(false))
258 }
259}