Skip to main content

sova_core/app/
mod.rs

1mod bind;
2mod hooks;
3
4pub(crate) use hooks::{AppInner, ListenParts, ShutdownHook, StartupHook};
5pub use bind::{Bind, BoundApp, Http};
6pub use hooks::Server;
7
8use crate::error::{Error, Result};
9use crate::events::EventBus;
10use crate::handler::BoxFuture;
11use crate::plugin::{
12    check_plugin_sdk, InstalledPlugin, Plugin, SdkCompat, PLUGIN_SDK_VERSION,
13};
14use crate::request::Request;
15use crate::response::Response;
16use crate::router::Router;
17use crate::service::{BackgroundService, BoxedService};
18use crate::state::StateMap;
19use bytes::Bytes;
20use http::Method;
21use std::collections::{HashMap, HashSet};
22use std::fs;
23use std::net::{IpAddr, SocketAddr};
24use std::ops::{Deref, DerefMut};
25use std::sync::Arc;
26use std::time::Duration;
27
28pub(crate) type CliCommandFn =
29    Arc<dyn Fn(Arc<StateMap>, Vec<String>) -> BoxFuture<Result<()>> + Send + Sync>;
30
31pub(crate) type CheckFn =
32    Arc<dyn Fn(Arc<StateMap>) -> BoxFuture<Result<()>> + Send + Sync>;
33
34/// Kind of app check — readiness probes vs deploy-time audits.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum CheckKind {
37    /// Runtime dependencies (db, redis, storage, …) — used by `GET /ready`.
38    Ready,
39    /// Deploy-time / config audits (openapi, vld, templates, …) — CLI `check` only.
40    Audit,
41}
42
43/// Outcome of one named check from [`App::run_checks`].
44#[derive(Debug, Clone)]
45pub struct CheckResult {
46    pub name: &'static str,
47    pub ok: bool,
48    pub error: Option<String>,
49}
50
51type CheckEntry = (&'static str, CheckKind, CheckFn);
52type CheckList = Arc<std::sync::Mutex<Vec<CheckEntry>>>;
53
54const DEFAULT_MAX_BODY: usize = 2 * 1024 * 1024;
55const DEFAULT_MAX_CONNECTIONS: usize = 1024;
56const DEFAULT_MAX_UPGRADED: usize = 1024;
57const DEFAULT_MAX_CONCURRENT_STREAMS: usize = 200;
58const DEFAULT_MAX_HEADERS: usize = 100;
59/// Default drain budget — leave headroom under k8s' 30s `terminationGracePeriodSeconds`.
60const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(20);
61const DEFAULT_HEADER_READ_TIMEOUT: Duration = Duration::from_secs(10);
62/// Keep-alive idle wait between requests (also Slowloris / first-header wait via hyper).
63const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
64
65/// Thin wrapper over [`Router`] plus server settings and lifecycle hooks.
66pub struct App {
67    pub(crate) router: Router,
68    pub(crate) max_body_size: usize,
69    pub(crate) max_connections: usize,
70    pub(crate) max_upgraded_connections: usize,
71    pub(crate) max_concurrent_streams: usize,
72    pub(crate) max_headers: usize,
73    pub(crate) max_buf_size: Option<usize>,
74    pub(crate) request_timeout: Option<Duration>,
75    pub(crate) header_read_timeout: Duration,
76    pub(crate) idle_timeout: Duration,
77    pub(crate) drain_timeout: Duration,
78    pub(crate) keep_alive: bool,
79    pub(crate) trust_proxy: bool,
80    pub(crate) reuseport: bool,
81    /// Set by CLI listen helpers — BackgroundServices skipped unless `service_in_cli`.
82    pub(crate) cli_mode: bool,
83    pub(crate) service_in_cli: bool,
84    pub(crate) hsts: bool,
85    pub(crate) alt_svc: Option<String>,
86    pub(crate) installed_plugins: HashSet<&'static str>,
87    pub(crate) installed_plugin_meta: Vec<InstalledPlugin>,
88    pub(crate) missing_plugin_requires: Vec<(&'static str, &'static str)>,
89    pub(crate) duplicate_plugin_ids: Vec<&'static str>,
90    pub(crate) plugin_sdk_errors: Vec<String>,
91    pub(crate) on_startup: Vec<StartupHook>,
92    pub(crate) on_shutdown: Vec<ShutdownHook>,
93    pub(crate) services: Vec<BoxedService>,
94    pub(crate) cli_commands: HashMap<&'static str, CliCommandFn>,
95    pub(crate) checks: CheckList,
96    pub(crate) probes: bool,
97}
98
99impl App {
100    pub fn new() -> Self {
101        Self {
102            router: Router::new(),
103            max_body_size: DEFAULT_MAX_BODY,
104            max_connections: DEFAULT_MAX_CONNECTIONS,
105            max_upgraded_connections: DEFAULT_MAX_UPGRADED,
106            max_concurrent_streams: DEFAULT_MAX_CONCURRENT_STREAMS,
107            max_headers: DEFAULT_MAX_HEADERS,
108            max_buf_size: None,
109            request_timeout: Some(Duration::from_secs(30)),
110            header_read_timeout: DEFAULT_HEADER_READ_TIMEOUT,
111            idle_timeout: DEFAULT_IDLE_TIMEOUT,
112            drain_timeout: DEFAULT_DRAIN_TIMEOUT,
113            keep_alive: true,
114            trust_proxy: false,
115            reuseport: false,
116            cli_mode: false,
117            service_in_cli: false,
118            hsts: false,
119            alt_svc: None,
120            installed_plugins: HashSet::new(),
121            installed_plugin_meta: Vec::new(),
122            missing_plugin_requires: Vec::new(),
123            duplicate_plugin_ids: Vec::new(),
124            plugin_sdk_errors: Vec::new(),
125            on_startup: Vec::new(),
126            on_shutdown: Vec::new(),
127            services: Vec::new(),
128            cli_commands: HashMap::new(),
129            checks: Arc::new(std::sync::Mutex::new(Vec::new())),
130            probes: false,
131        }
132    }
133
134    /// Register a plugin CLI command handled by [`Self::run`] (e.g. `"migrate"`).
135    pub fn register_cli<F, Fut>(&mut self, name: &'static str, f: F) -> &mut Self
136    where
137        F: Fn(Arc<StateMap>, Vec<String>) -> Fut + Send + Sync + 'static,
138        Fut: std::future::Future<Output = Result<()>> + Send + 'static,
139    {
140        self.cli_commands
141            .insert(name, Arc::new(move |state, args| Box::pin(f(state, args))));
142        self
143    }
144
145    /// Register a readiness check (`CheckKind::Ready`) for `GET /ready` and CLI `check`.
146    pub fn register_check<F, Fut>(&mut self, name: &'static str, f: F) -> &mut Self
147    where
148        F: Fn(Arc<StateMap>) -> Fut + Send + Sync + 'static,
149        Fut: std::future::Future<Output = Result<()>> + Send + 'static,
150    {
151        self.push_check(name, CheckKind::Ready, f)
152    }
153
154    /// Register a deploy-time audit (`CheckKind::Audit`) — CLI `check` only, not `/ready`.
155    pub fn register_audit<F, Fut>(&mut self, name: &'static str, f: F) -> &mut Self
156    where
157        F: Fn(Arc<StateMap>) -> Fut + Send + Sync + 'static,
158        Fut: std::future::Future<Output = Result<()>> + Send + 'static,
159    {
160        self.push_check(name, CheckKind::Audit, f)
161    }
162
163    fn push_check<F, Fut>(&mut self, name: &'static str, kind: CheckKind, f: F) -> &mut Self
164    where
165        F: Fn(Arc<StateMap>) -> Fut + Send + Sync + 'static,
166        Fut: std::future::Future<Output = Result<()>> + Send + 'static,
167    {
168        self.checks
169            .lock()
170            .expect("checks lock")
171            .push((name, kind, Arc::new(move |state| Box::pin(f(state)))));
172        self
173    }
174
175    /// Run registered checks filtered by [`CheckKind`].
176    pub async fn run_checks(
177        &self,
178        state: Arc<StateMap>,
179        kinds: &[CheckKind],
180    ) -> Vec<CheckResult> {
181        run_check_list(&self.checks, state, kinds).await
182    }
183
184    /// Install k8s-style probes: `GET /healthz` (liveness) and `GET /ready` (Ready checks).
185    ///
186    /// Idempotent. Presets (`App::web` / `App::api`) call this automatically.
187    pub fn with_probes(&mut self) -> &mut Self {
188        if self.probes {
189            return self;
190        }
191        self.probes = true;
192        let checks = Arc::clone(&self.checks);
193
194        self.get("/healthz", || async {
195            Response::json(&serde_json::json!({ "status": "ok" }))
196        });
197
198        self.get("/ready", move |req: Request| {
199            let checks = Arc::clone(&checks);
200            async move {
201                let results =
202                    run_check_list(&checks, req.states(), &[CheckKind::Ready]).await;
203                ready_response(&results)
204            }
205        });
206
207        self
208    }
209
210    pub fn max_body_size(&mut self, bytes: usize) -> &mut Self {
211        self.max_body_size = bytes;
212        self.router.defaults.insert(crate::limits::MaxBody::bytes(bytes));
213        self
214    }
215
216    /// Cap concurrent TCP/UDS connections (default 1024).
217    pub fn max_connections(&mut self, n: usize) -> &mut Self {
218        self.max_connections = n.max(1);
219        self
220    }
221
222    /// Cap concurrent HTTP upgrades (WebSocket, …). Excess → **503** + `Retry-After`.
223    pub fn max_upgraded_connections(&mut self, n: usize) -> &mut Self {
224        self.max_upgraded_connections = n.max(1);
225        self
226    }
227
228    /// Cap concurrent HTTP/2 streams per connection (default 200).
229    /// Excess streams → `GOAWAY`/stream-level rejection handled by hyper.
230    pub fn max_concurrent_streams(&mut self, n: usize) -> &mut Self {
231        self.max_concurrent_streams = n.max(1);
232        self
233    }
234
235    /// Max HTTP/1 header count (default 100). Excess → 431 from hyper.
236    pub fn max_headers(&mut self, n: usize) -> &mut Self {
237        self.max_headers = n.max(1);
238        self
239    }
240
241    /// Cap hyper's connection buffer (headers + body framing). Minimum 8192.
242    /// Default ~400 KiB. Use this to bound oversized header blocks.
243    pub fn max_buf_size(&mut self, bytes: usize) -> &mut Self {
244        self.max_buf_size = Some(bytes.max(8192));
245        self
246    }
247
248    /// Per-request timeout around the handler (default 30s). `None` disables.
249    ///
250    /// Measured: timeout ends when the handler returns a [`Response`]. Streaming
251    /// response bodies (SSE) continue afterward and are **not** cut by this timer.
252    /// Idle between stream chunks is governed by TCP/keep-alive, not this setting.
253    pub fn request_timeout(&mut self, timeout: Option<Duration>) -> &mut Self {
254        self.request_timeout = timeout;
255        if let Some(d) = timeout {
256            self.router
257                .defaults
258                .insert(crate::limits::RequestTimeout(d));
259        }
260        self
261    }
262
263    /// Timeout for reading request headers (Slowloris). Also applied while waiting
264    /// for the next keep-alive request (see [`Self::idle_timeout`]). Default 10s.
265    pub fn header_read_timeout(&mut self, timeout: Duration) -> &mut Self {
266        self.header_read_timeout = timeout;
267        self
268    }
269
270    /// Keep-alive idle: how long a quiet connection may wait for the next request.
271    /// Hyper uses one timer for header reads; the effective wait is
272    /// `min(header_read_timeout, idle_timeout)`. Default 60s.
273    pub fn idle_timeout(&mut self, timeout: Duration) -> &mut Self {
274        self.idle_timeout = timeout;
275        self
276    }
277
278    /// How long to wait for in-flight connections after accept stops (default 20s).
279    pub fn drain_timeout(&mut self, timeout: Duration) -> &mut Self {
280        self.drain_timeout = timeout;
281        self
282    }
283
284    /// HTTP/1 keep-alive (default `true`).
285    pub fn keep_alive(&mut self, enabled: bool) -> &mut Self {
286        self.keep_alive = enabled;
287        self
288    }
289
290    /// When true, `ClientAddr` may use `X-Forwarded-For` / `Forwarded` (only behind a trusted proxy).
291    pub fn trust_proxy(&mut self, trust: bool) -> &mut Self {
292        self.trust_proxy = trust;
293        self
294    }
295
296    /// Mark this app as running under the CLI helper (skips BackgroundServices by default).
297    pub fn cli_mode(&mut self, enabled: bool) -> &mut Self {
298        self.cli_mode = enabled;
299        self
300    }
301
302    /// Start BackgroundServices even when [`Self::cli_mode`] is set (default `false`).
303    pub fn service_in_cli(&mut self, enabled: bool) -> &mut Self {
304        self.service_in_cli = enabled;
305        self
306    }
307
308    pub fn install<P: Plugin>(&mut self, plugin: P) -> &mut Self {
309        let plugin_id = plugin.id();
310        if self.installed_plugins.contains(plugin_id) {
311            self.duplicate_plugin_ids.push(plugin_id);
312            return self;
313        }
314        let meta = plugin.meta();
315        for dep in plugin.requires() {
316            if !self.installed_plugins.contains(dep) {
317                self.missing_plugin_requires.push((plugin_id, dep));
318            }
319        }
320        match check_plugin_sdk(meta.sdk, PLUGIN_SDK_VERSION) {
321            SdkCompat::Ok => {}
322            SdkCompat::Warn { core, plugin: declared } => {
323                tracing::warn!(
324                    plugin = plugin_id,
325                    plugin_sdk = %declared,
326                    core_sdk = %core,
327                    "plugin SDK is older than core; consider rebuilding against the current Plugin SDK"
328                );
329            }
330            SdkCompat::Error(msg) => {
331                self.plugin_sdk_errors
332                    .push(format!("plugin `{plugin_id}`: {msg}"));
333            }
334        }
335        self.installed_plugin_meta.push(InstalledPlugin {
336            id: plugin_id,
337            meta,
338        });
339        plugin.install(self);
340        self.installed_plugins.insert(plugin_id);
341        self
342    }
343
344    /// Whether a plugin with this [`Plugin::id`] was already installed.
345    pub fn has_plugin(&self, id: &str) -> bool {
346        self.installed_plugins.contains(id)
347    }
348
349    /// Metadata for every plugin passed to [`Self::install`] (order preserved).
350    pub fn installed_plugin_meta(&self) -> &[InstalledPlugin] {
351        &self.installed_plugin_meta
352    }
353
354    /// Primary app entrypoint: run as a server process.
355    ///
356    /// CLI mode:
357    /// - `check`
358    /// - `routes`
359    /// - `plugins`
360    /// - `openapi --out <path>`
361    /// - `tasks`
362    /// - `i18n missing`
363    ///
364    /// Non-CLI mode binds via [`Bind::Env`] (`HOST`/`PORT`, default port `3000`).
365    /// Prefer [`App::bind`] + [`BoundApp::run`] when the address is fixed in code.
366    pub async fn run(self) -> Result<()> {
367        self.bind(Bind::Env { default_port: 3000 }).run().await
368    }
369
370    pub(crate) async fn run_cli_command(&self, args: &[String]) -> Result<bool> {
371        let Some(cmd) = args.first().map(String::as_str) else {
372            return Ok(false);
373        };
374
375        let server = self.build()?;
376        let state = server.state();
377        for hook in &server.startups {
378            hook(Arc::clone(&state)).await?;
379        }
380
381        let rest: Vec<String> = args.iter().skip(1).cloned().collect();
382        let handled = if let Some(handler) = self.cli_commands.get(cmd) {
383            handler(Arc::clone(&state), rest).await?;
384            true
385        } else {
386            match cmd {
387                "check" => {
388                    // `build()` already ran — plugin `requires()` are satisfied.
389                    println!("ok plugins");
390                    let results = self
391                        .run_checks(
392                            Arc::clone(&state),
393                            &[CheckKind::Ready, CheckKind::Audit],
394                        )
395                        .await;
396                    let mut failed = false;
397                    for r in &results {
398                        if r.ok {
399                            println!("ok {}", r.name);
400                        } else {
401                            println!(
402                                "fail {} — {}",
403                                r.name,
404                                r.error.as_deref().unwrap_or("")
405                            );
406                            failed = true;
407                        }
408                    }
409                    if failed {
410                        return Err(Error::Internal(
411                            "one or more checks failed".into(),
412                        ));
413                    }
414                    println!("ok");
415                    true
416                }
417                "routes" => {
418                    println!("{}", self.explain());
419                    true
420                }
421                "plugins" => {
422                    for p in &self.installed_plugin_meta {
423                        let desc = if p.meta.description.is_empty() {
424                            "-"
425                        } else {
426                            p.meta.description
427                        };
428                        println!(
429                            "{:<24} {:<20} sdk={}  {}",
430                            p.id, p.meta.name, p.meta.sdk, desc
431                        );
432                    }
433                    if self.installed_plugin_meta.is_empty() {
434                        println!("(no plugins installed)");
435                    }
436                    true
437                }
438                "openapi" => {
439                    let out_idx = args.iter().position(|a| a == "--out");
440                    let out_path = out_idx
441                        .and_then(|idx| args.get(idx + 1))
442                        .ok_or_else(|| Error::Internal("openapi requires --out <path>".into()))?;
443                    let res = server
444                        .handle_request(Method::GET, "/docs/openapi.json", "")
445                        .await;
446                    if !res.status_code().is_success() {
447                        return Err(Error::Internal(format!(
448                            "openapi endpoint failed with status {}",
449                            res.status_code()
450                        )));
451                    }
452                    let bytes = res
453                        .body_bytes()
454                        .ok_or_else(|| Error::Internal("openapi body is streaming".into()))?;
455                    fs::write(out_path, bytes).map_err(|e| {
456                        Error::Internal(format!("failed writing openapi to {out_path}: {e}"))
457                    })?;
458                    println!("wrote {}", out_path);
459                    true
460                }
461                "tasks" => {
462                    println!(
463                        "tasks CLI requires the Tasks plugin (`app.install(Tasks::…)`).\n\
464                         Then: tasks list | tasks schedule | tasks run NAME"
465                    );
466                    true
467                }
468                "i18n" if args.get(1).map(String::as_str) == Some("missing") => {
469                    let res = server
470                        .handle_request(Method::GET, "/_i18n/_missing.json", "")
471                        .await;
472                    if let Some(body) = res.body_bytes() {
473                        println!("{}", String::from_utf8_lossy(body));
474                    } else {
475                        println!("i18n missing endpoint returned streaming body");
476                    }
477                    true
478                }
479                "i18n" => false,
480                _ => false,
481            }
482        };
483
484        if handled {
485            for hook in &server.shutdowns {
486                hook().await;
487            }
488        }
489        Ok(handled)
490    }
491
492    /// Register a process-local [`BackgroundService`].
493    ///
494    /// Lifecycle: `compile → on_startup → services → accept`;
495    /// stop: `stop accept → drain → stop services → on_shutdown`.
496    pub fn service<S: BackgroundService + 'static>(&mut self, service: S) -> &mut Self {
497        self.services.push(Box::new(service));
498        self
499    }
500
501    /// Run before accepting connections. `Err` prevents the server from starting.
502    pub fn on_startup<F, Fut>(&mut self, f: F) -> &mut Self
503    where
504        F: Fn(Arc<StateMap>) -> Fut + Send + Sync + 'static,
505        Fut: std::future::Future<Output = Result<()>> + Send + 'static,
506    {
507        self.on_startup
508            .push(Arc::new(move |state| Box::pin(f(state))));
509        self
510    }
511
512    /// Run after the accept loop stops, connections drain, and services stop.
513    pub fn on_shutdown<F, Fut>(&mut self, f: F) -> &mut Self
514    where
515        F: Fn() -> Fut + Send + Sync + 'static,
516        Fut: std::future::Future<Output = ()> + Send + 'static,
517    {
518        self.on_shutdown
519            .push(Arc::new(move || Box::pin(f())));
520        self
521    }
522
523    /// Shared [`EventBus`] — inserts a default bus into app state on first use.
524    pub fn events(&mut self) -> EventBus {
525        if let Some(bus) = self.try_state::<EventBus>() {
526            return (*bus).clone();
527        }
528        let bus = EventBus::new();
529        self.state(bus.clone());
530        bus
531    }
532
533    /// Route map for debugging / startup banner.
534    pub fn explain(&self) -> String {
535        self.router.explain()
536    }
537
538    /// Handle one request (compiles the router each call). Prefer [`Self::build`].
539    pub async fn handle(&self, req: Request) -> Response {
540        match self.build() {
541            Ok(server) => server.handle(req).await,
542            Err(err) => err.into_response(),
543        }
544    }
545
546    /// Sugar over [`Request::builder`] + [`Self::handle`] (no custom headers).
547    /// For headers use `Request::builder().header(...).build()` + [`Self::handle`].
548    /// Prefer [`Server::handle_request`] after [`Self::build`].
549    pub async fn handle_request(&self, method: Method, path: &str, body: &str) -> Response {
550        let req = Request::builder()
551            .method(method)
552            .path(path)
553            .body(Bytes::from(body.to_string()))
554            .build();
555        self.handle(req).await
556    }
557
558    /// Run startup hooks via [`Server::run_startup`] (non-destructive).
559    #[cfg(any(test, feature = "testing"))]
560    pub async fn run_startup(&self) -> Result<Arc<StateMap>> {
561        self.build()?.run_startup().await
562    }
563
564    /// Run shutdown hooks via [`Server::run_shutdown`] (non-destructive).
565    #[cfg(any(test, feature = "testing"))]
566    pub async fn run_shutdown(&self) {
567        if let Ok(server) = self.build() {
568            server.run_shutdown().await;
569        }
570    }
571}
572
573pub(crate) fn addr_from_env(default_port: u16) -> Result<SocketAddr> {
574    let port = std::env::var("PORT")
575        .ok()
576        .and_then(|p| p.parse::<u16>().ok())
577        .unwrap_or(default_port);
578
579    match std::env::var("HOST") {
580        Ok(host) if !host.is_empty() => {
581            if let Ok(ip) = host.parse::<IpAddr>() {
582                return Ok(SocketAddr::new(ip, port));
583            }
584            // Allow HOST="127.0.0.1:3000" to override port entirely.
585            if let Ok(addr) = host.parse::<SocketAddr>() {
586                return Ok(addr);
587            }
588            format!("{host}:{port}")
589                .parse()
590                .map_err(|e| Error::Internal(format!("HOST={host:?} invalid: {e}")))
591        }
592        _ => Ok(SocketAddr::from(([0, 0, 0, 0], port))),
593    }
594}
595
596impl Default for App {
597    fn default() -> Self {
598        Self::new()
599    }
600}
601
602impl Deref for App {
603    type Target = Router;
604
605    fn deref(&self) -> &Router {
606        &self.router
607    }
608}
609
610impl DerefMut for App {
611    fn deref_mut(&mut self) -> &mut Router {
612        &mut self.router
613    }
614}
615
616async fn run_check_list(
617    checks: &CheckList,
618    state: Arc<StateMap>,
619    kinds: &[CheckKind],
620) -> Vec<CheckResult> {
621    let entries: Vec<CheckEntry> = checks.lock().expect("checks lock").clone();
622    let mut out = Vec::with_capacity(entries.len());
623    for (name, kind, check) in entries {
624        if !kinds.contains(&kind) {
625            continue;
626        }
627        match check(Arc::clone(&state)).await {
628            Ok(()) => out.push(CheckResult {
629                name,
630                ok: true,
631                error: None,
632            }),
633            Err(e) => out.push(CheckResult {
634                name,
635                ok: false,
636                error: Some(e.to_string()),
637            }),
638        }
639    }
640    out
641}
642
643fn ready_response(results: &[CheckResult]) -> Response {
644    let mut checks = serde_json::Map::new();
645    let mut failed = Vec::new();
646    for r in results {
647        if r.ok {
648            checks.insert(r.name.to_string(), serde_json::json!("ok"));
649        } else {
650            let msg = r.error.clone().unwrap_or_else(|| "failed".into());
651            checks.insert(r.name.to_string(), serde_json::json!(msg));
652            failed.push(r.name);
653        }
654    }
655    let mut res = if failed.is_empty() {
656        Response::json(&serde_json::json!({
657            "status": "ok",
658            "checks": checks,
659        }))
660    } else {
661        Response::json(&serde_json::json!({
662            "status": "not_ready",
663            "failed": failed,
664            "checks": checks,
665        }))
666        .status(503)
667    };
668    // Set by `cargo sovax dev --graceful` so the orchestrator can detect the new process
669    // while the old one still answers on the same REUSEPORT socket.
670    if let Ok(id) = std::env::var("SOVA_INSTANCE_ID") {
671        if !id.is_empty() {
672            res = res.header("x-sova-instance", id);
673        }
674    }
675    res
676}
677
678#[cfg(test)]
679mod env_addr_tests {
680    use super::addr_from_env;
681    use std::sync::Mutex;
682
683    static ENV_LOCK: Mutex<()> = Mutex::new(());
684
685    #[test]
686    fn port_from_env() {
687        let _g = ENV_LOCK.lock().unwrap();
688        std::env::set_var("PORT", "9876");
689        std::env::remove_var("HOST");
690        let addr = addr_from_env(3000).unwrap();
691        assert_eq!(addr.port(), 9876);
692        std::env::remove_var("PORT");
693    }
694
695    #[test]
696    fn host_ip_from_env() {
697        let _g = ENV_LOCK.lock().unwrap();
698        std::env::remove_var("PORT");
699        std::env::set_var("HOST", "127.0.0.1");
700        let addr = addr_from_env(3000).unwrap();
701        assert_eq!(addr, "127.0.0.1:3000".parse().unwrap());
702        std::env::remove_var("HOST");
703    }
704}