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//! ## 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}