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