Skip to main content

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}