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//! ## Fanout collapsing
19//!
20//! Both *sequential* and *concurrent* fanout collapse to a single
21//! `getaddrinfo`. The TTL cache (see `CACHE`) catches the sequential
22//! case. The in-flight map (see `IN_FLIGHT`) catches the concurrent
23//! case: when N strands race to resolve the same uncached host, the
24//! first to arrive enqueues exactly one worker job and the others
25//! attach their reply channels to the in-flight entry. When the
26//! worker returns, it writes the cache and fans the result out to
27//! every attached channel. Late arrivers that come in after the
28//! fanout pop see the freshly-written cache and short-circuit
29//! without ever touching the in-flight map.
30
31use crate::seqstring::global_string;
32use crate::stack::{Stack, pop, push};
33use crate::value::{Value, VariantData};
34
35use std::collections::HashMap;
36use std::net::ToSocketAddrs;
37#[cfg(test)]
38use std::sync::atomic::{AtomicUsize, Ordering};
39use std::sync::mpsc as std_mpsc;
40use std::sync::{Arc, LazyLock, Mutex};
41use std::thread;
42use std::time::{Duration, Instant};
43
44#[cfg(test)]
45use std::collections::VecDeque;
46
47const DEFAULT_WORKERS: usize = 8;
48const MAX_WORKERS: usize = 64;
49const CACHE_TTL: Duration = Duration::from_secs(60);
50const CACHE_MAX: usize = 256;
51
52struct CacheEntry {
53 addrs: Vec<String>,
54 expires: Instant,
55}
56
57struct DnsCache {
58 entries: HashMap<String, CacheEntry>,
59}
60
61impl DnsCache {
62 fn new() -> Self {
63 Self {
64 entries: HashMap::new(),
65 }
66 }
67
68 fn get(&mut self, host: &str) -> Option<Vec<String>> {
69 if let Some(entry) = self.entries.get(host)
70 && Instant::now() < entry.expires
71 {
72 return Some(entry.addrs.clone());
73 }
74 self.entries.remove(host);
75 None
76 }
77
78 fn put(&mut self, host: String, addrs: Vec<String>) {
79 if self.entries.len() >= CACHE_MAX {
80 // Bounded eviction: drop one arbitrary entry. True LRU is
81 // overkill for v1 — DNS lookups are O(50ms) on miss, and the
82 // 60s TTL means a churned-out entry costs at most one extra
83 // worker round-trip.
84 if let Some(k) = self.entries.keys().next().cloned() {
85 self.entries.remove(&k);
86 }
87 }
88 self.entries.insert(
89 host,
90 CacheEntry {
91 addrs,
92 expires: Instant::now() + CACHE_TTL,
93 },
94 );
95 }
96}
97
98static CACHE: LazyLock<Mutex<DnsCache>> = LazyLock::new(|| Mutex::new(DnsCache::new()));
99
100type InFlightSenders = Vec<may::sync::mpsc::Sender<Vec<String>>>;
101
102/// In-flight resolutions, keyed by hostname. The first strand that
103/// wants a hostname inserts an entry containing its reply channel,
104/// then enqueues a worker job; subsequent strands wanting the same
105/// hostname find the entry and *append* their reply channels instead
106/// of enqueuing duplicate work. When the worker returns, it removes
107/// the entry and fans the result out to every channel. Closes the
108/// "N concurrent first-resolves of the same uncached host enqueue N
109/// worker jobs" gap from PR1.
110static IN_FLIGHT: LazyLock<Mutex<HashMap<String, InFlightSenders>>> =
111 LazyLock::new(|| Mutex::new(HashMap::new()));
112
113struct Job {
114 hostname: String,
115}
116
117// -----------------------------------------------------------------------------
118// Test-only instrumentation
119// -----------------------------------------------------------------------------
120//
121// Two hooks let tests pin architectural properties without touching
122// the network:
123//
124// - `RESOLVE_CALL_COUNT` is incremented on every `resolve()`. Tests
125// can snapshot it before/after a request to assert at-most-N DNS
126// lookups happened (the SSRF DNS-rebinding closure test uses
127// this to lock in "at most one resolve per HTTP request").
128//
129// - `SCRIPTED_RESPONSES` lets tests push canned answers that
130// `resolve()` returns instead of going through the cache or the
131// worker pool. Useful for deterministic tests where the answer
132// to a hostname needs to be controlled.
133//
134// Both are gated behind `#[cfg(test)]` so they vanish from release
135// builds — no production overhead, no shipping surface.
136
137#[cfg(test)]
138static RESOLVE_CALL_COUNT: AtomicUsize = AtomicUsize::new(0);
139
140#[cfg(test)]
141static SCRIPTED_RESPONSES: LazyLock<Mutex<VecDeque<Vec<String>>>> =
142 LazyLock::new(|| Mutex::new(VecDeque::new()));
143
144#[cfg(test)]
145pub(crate) fn reset_resolve_call_count() {
146 RESOLVE_CALL_COUNT.store(0, Ordering::SeqCst);
147}
148
149#[cfg(test)]
150pub(crate) fn resolve_call_count() -> usize {
151 RESOLVE_CALL_COUNT.load(Ordering::SeqCst)
152}
153
154#[cfg(test)]
155pub(crate) fn push_scripted_response(addrs: Vec<String>) {
156 SCRIPTED_RESPONSES.lock().unwrap().push_back(addrs);
157}
158
159#[cfg(test)]
160pub(crate) fn clear_scripted_responses() {
161 SCRIPTED_RESPONSES.lock().unwrap().clear();
162}
163
164/// Lazy job queue. First send spawns the worker pool. Workers live for
165/// the lifetime of the process; the static sender drops only at shutdown.
166///
167/// `None` means every requested worker failed to spawn (resource
168/// starvation, ulimit, etc.). In that case the FFI fast-fails to
169/// `(empty-list, false)` instead of panicking forever.
170///
171/// Note: `SEQ_DNS_WORKERS=0` silently falls back to `DEFAULT_WORKERS`
172/// — disabling the pool makes no architectural sense (the syscall has
173/// to run *somewhere*), so we treat 0 as "unset" rather than rejecting.
174static JOB_QUEUE: LazyLock<Option<std_mpsc::Sender<Job>>> = LazyLock::new(|| {
175 let (tx, rx) = std_mpsc::channel::<Job>();
176 let rx = Arc::new(Mutex::new(rx));
177
178 let workers = std::env::var("SEQ_DNS_WORKERS")
179 .ok()
180 .and_then(|v| v.parse::<usize>().ok())
181 .filter(|n| *n > 0)
182 .unwrap_or(DEFAULT_WORKERS)
183 .min(MAX_WORKERS);
184
185 let mut spawned = 0usize;
186 for i in 0..workers {
187 let rx = rx.clone();
188 if thread::Builder::new()
189 .name(format!("seq-dns-{i}"))
190 .spawn(move || worker_loop(rx))
191 .is_ok()
192 {
193 spawned += 1;
194 }
195 // Spawn failures degrade the pool gracefully — we keep
196 // whatever workers the OS allowed. A poisoned LazyLock would
197 // turn a soft starvation event into permanent FFI panics.
198 }
199 if spawned == 0 { None } else { Some(tx) }
200});
201
202fn worker_loop(rx: Arc<Mutex<std_mpsc::Receiver<Job>>>) {
203 loop {
204 // Only one worker holds the lock while inside recv(); others
205 // queue on the Mutex. As soon as a job arrives the receiver
206 // releases the lock, another worker enters recv(), and the
207 // current worker spends the bulk of its time in getaddrinfo.
208 // Parallelism is genuine; the lock contention is sub-microsecond.
209 let job = match rx.lock().unwrap().recv() {
210 Ok(j) => j,
211 Err(_) => return, // sender dropped — process shutting down
212 };
213 let addrs = resolve_blocking(&job.hostname);
214 if !addrs.is_empty() {
215 CACHE
216 .lock()
217 .unwrap()
218 .put(job.hostname.clone(), addrs.clone());
219 }
220 // Cache is written before the IN_FLIGHT fanout so a late
221 // arriver that races the fanout (between this worker popping
222 // IN_FLIGHT and the senders firing) will see the cache hit and
223 // never need to attach.
224 let senders = IN_FLIGHT
225 .lock()
226 .unwrap()
227 .remove(&job.hostname)
228 .unwrap_or_default();
229 for s in senders {
230 let _ = s.send(addrs.clone()); // requester may have died — drop
231 }
232 }
233}
234
235fn resolve_blocking(hostname: &str) -> Vec<String> {
236 // Port 0 is a dummy — getaddrinfo wants host:port but we only need
237 // the address list. Deduplicate because A and AAAA records can
238 // produce the same address representation in some configurations.
239 match (hostname, 0u16).to_socket_addrs() {
240 Ok(iter) => {
241 let mut seen = Vec::new();
242 for sa in iter {
243 let ip = sa.ip().to_string();
244 if !seen.contains(&ip) {
245 seen.push(ip);
246 }
247 }
248 seen
249 }
250 Err(_) => Vec::new(),
251 }
252}
253
254/// Resolve a hostname to a `Vec<IpAddr>`, with an IP-literal fast path.
255///
256/// If `hostname` parses as an IP literal, returns `vec![that_ip]`
257/// without touching the worker pool or the cache. Otherwise falls
258/// back to `resolve(hostname)` and parses each returned string.
259///
260/// This is the preferred entry point for any caller that's going to
261/// build `SocketAddr`s from the result (TCP connect, UDP send-to,
262/// HTTP SSRF validation). Returns an empty Vec on resolution failure.
263pub fn resolve_to_ips(hostname: &str) -> Vec<std::net::IpAddr> {
264 if let Ok(ip) = hostname.parse::<std::net::IpAddr>() {
265 return vec![ip];
266 }
267 resolve(hostname)
268 .iter()
269 .filter_map(|s| s.parse::<std::net::IpAddr>().ok())
270 .collect()
271}
272
273/// Resolve a hostname to a list of IP-address strings.
274///
275/// Cooperative: yields the strand via the may-channel recv() while a
276/// worker thread runs `getaddrinfo`. Returns an empty Vec on any
277/// failure (empty hostname, worker pool failed to start, send/recv
278/// error, or unresolvable name). Other runtime modules call this
279/// directly so they share the cache and worker pool with the FFI
280/// surface instead of opening a parallel path to `getaddrinfo`.
281///
282/// For callers that produce `IpAddr` values, prefer
283/// [`resolve_to_ips`] — it short-circuits IP-literal input to skip
284/// the worker round-trip.
285pub fn resolve(hostname: &str) -> Vec<String> {
286 #[cfg(test)]
287 RESOLVE_CALL_COUNT.fetch_add(1, Ordering::SeqCst);
288
289 if hostname.is_empty() {
290 return Vec::new();
291 }
292
293 // Test-only short-circuit: if a scripted response is pending, pop
294 // and return it directly, skipping cache, in-flight map, and the
295 // worker pool. Lets unit tests drive deterministic resolution
296 // behaviour without touching the network or the static state.
297 #[cfg(test)]
298 if let Some(scripted) = SCRIPTED_RESPONSES.lock().unwrap().pop_front() {
299 return scripted;
300 }
301
302 if let Some(addrs) = CACHE.lock().unwrap().get(hostname) {
303 return addrs;
304 }
305
306 let (reply_tx, reply_rx) = may::sync::mpsc::channel::<Vec<String>>();
307
308 // Attach our reply channel to the in-flight map. If we're the
309 // first arriver for this hostname we become the *leader* and
310 // enqueue a worker job; otherwise we attach to the existing entry
311 // and wait for the leader's worker to fan the result out.
312 let became_leader = {
313 let mut in_flight = IN_FLIGHT.lock().unwrap();
314 match in_flight.get_mut(hostname) {
315 Some(senders) => {
316 senders.push(reply_tx);
317 false
318 }
319 None => {
320 in_flight.insert(hostname.to_string(), vec![reply_tx]);
321 true
322 }
323 }
324 };
325
326 if became_leader {
327 let enqueue_err = match JOB_QUEUE.as_ref() {
328 Some(s) => s
329 .send(Job {
330 hostname: hostname.to_string(),
331 })
332 .is_err(),
333 None => true, // worker pool failed to start
334 };
335 if enqueue_err {
336 // No worker will pop this hostname; nothing will fan
337 // results out. Drain ourselves and any followers that
338 // raced us, signalling empty-result failure.
339 let senders = IN_FLIGHT
340 .lock()
341 .unwrap()
342 .remove(hostname)
343 .unwrap_or_default();
344 for s in senders {
345 let _ = s.send(Vec::new());
346 }
347 return Vec::new();
348 }
349 }
350
351 reply_rx.recv().unwrap_or_default()
352}
353
354/// Resolve a hostname to a list of IP-address strings.
355///
356/// Stack effect: `( String -- Variant Bool )`
357///
358/// The Variant is a `List` (tag `"List"`) of IP-string `Value::String`s.
359/// On unresolvable hostname, empty result, or type mismatch, pushes
360/// `(empty-list, false)`. Yields the strand cooperatively while the
361/// lookup runs on the worker pool.
362///
363/// # Safety
364/// Stack must have a String (hostname) on top.
365#[unsafe(no_mangle)]
366pub unsafe extern "C" fn patch_seq_dns_resolve(stack: Stack) -> Stack {
367 unsafe {
368 let (stack, host_val) = pop(stack);
369 let host = match host_val {
370 Value::String(s) => s,
371 _ => return push_failure(stack),
372 };
373 let hostname = host.as_str_or_empty().to_string();
374 let addrs = resolve(&hostname);
375 push_result(stack, addrs)
376 }
377}
378
379unsafe fn push_result(stack: Stack, addrs: Vec<String>) -> Stack {
380 unsafe {
381 if addrs.is_empty() {
382 return push_failure(stack);
383 }
384 let fields = addrs
385 .into_iter()
386 .map(|s| Value::String(global_string(s)))
387 .collect();
388 let list = Value::Variant(Arc::new(VariantData::new(
389 global_string("List".to_string()),
390 fields,
391 )));
392 let stack = push(stack, list);
393 push(stack, Value::Bool(true))
394 }
395}
396
397unsafe fn push_failure(stack: Stack) -> Stack {
398 unsafe {
399 let empty = Value::Variant(Arc::new(VariantData::new(
400 global_string("List".to_string()),
401 vec![],
402 )));
403 let stack = push(stack, empty);
404 push(stack, Value::Bool(false))
405 }
406}