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