Skip to main content

bge_m3_embedding_server/
lib.rs

1// Copyright (c) 2026 J. Patrick Fulton
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Library crate for the bge-m3 embedding server.
16//!
17//! `main.rs` is a 20–30 line entry point that calls [`run`]; all real
18//! orchestration logic lives here so it can be unit-tested and reused from
19//! integration tests without spawning the binary.
20
21// Rustdoc lints — enforce documentation quality
22#![warn(missing_docs)]
23#![warn(rustdoc::missing_crate_level_docs)]
24#![warn(rustdoc::unescaped_backticks)]
25#![deny(rustdoc::broken_intra_doc_links)]
26#![deny(rustdoc::invalid_html_tags)]
27#![deny(rustdoc::bare_urls)]
28#![warn(rustdoc::redundant_explicit_links)]
29#![warn(rustdoc::private_doc_tests)]
30
31pub mod binpack;
32pub mod bootstrap;
33pub mod config;
34pub mod embedder;
35pub mod error;
36pub mod gpu_stats;
37pub mod handler;
38pub mod logging;
39pub mod models;
40pub mod probe;
41pub mod state;
42pub mod sysinfo;
43pub mod weights;
44
45use std::path::PathBuf;
46use std::sync::Arc;
47use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering};
48use std::time::Duration;
49
50use arc_swap::ArcSwap;
51use tokio::sync::Semaphore;
52use tracing::info;
53
54use crate::binpack::CostModel;
55use crate::bootstrap::{build_router, run_readiness_probe};
56use crate::config::{Config, EpSelection};
57use crate::embedder::adaptive_warmup::AdaptiveWarmupConfig;
58use crate::embedder::{EmbedPool, JitSuspectSender, WorkerConfig};
59use crate::gpu_stats::GpuStatsCollector;
60use crate::state::{AppState, ProbeStatus};
61
62/// Process-exit code emitted by the warmup-only path when the postcondition
63/// fails (compile-success events present but no `.engine` files on disk).
64///
65/// Distinct from `1` (general failure) so operators can tell the warmup
66/// container "compiled successfully but did not persist anything" apart
67/// from "compile errored mid-run". Surfaced in the wrapping ECS task /
68/// EC2 userdata log group as the container's `exitCode`.
69pub const WARMUP_POSTCONDITION_FAILED_EXIT_CODE: i32 = 2;
70
71/// Decides whether the warmup-only postcondition is violated.
72///
73/// The postcondition is: when `BGE_M3_EP=tensorrt` is set and the warmup-only
74/// path has run to completion, at least one `.engine` file must exist in the
75/// engine cache directory. A `true` return value should produce an `ERROR`
76/// log and a non-zero exit so deployments fail loudly instead of silently
77/// looking healthy with a perpetually-cold cache.
78///
79/// Non-TensorRT EPs are exempt — they do not produce engine plan files at
80/// all, so an empty engine cache directory is the expected steady state.
81#[must_use]
82pub fn warmup_postcondition_failed(ep: EpSelection, engine_count: usize) -> bool {
83    ep == EpSelection::TensorRt && engine_count == 0
84}
85
86/// Polls `live_workers` until it reaches zero or `timeout` elapses.
87///
88/// Used by the warmup-only path after the [`EmbedPool`] handle has been
89/// dropped: closing the request channel asks each worker to break out of
90/// its receive loop, drop its `ort::session::Session`, and return — but
91/// dropping the pool only signals intent. The actual worker exit happens
92/// asynchronously on a `spawn_blocking` thread, and we want the ORT/TRT
93/// destructor to run BEFORE the process exits so any session-shutdown
94/// flushes (e.g. the TRT timing cache) make it to disk.
95///
96/// Returns silently on timeout — callers proceed to fsync + exit regardless.
97/// Engine plan files are durable independent of this wait (each shape
98/// fsyncs before returning from `run_warmup_shape`), so a slow worker
99/// shutdown only loses ancillary state.
100async fn wait_for_workers_to_exit(live_workers: &Arc<AtomicUsize>, timeout: Duration) {
101    let deadline = std::time::Instant::now() + timeout;
102    while live_workers.load(Ordering::Acquire) > 0 {
103        if std::time::Instant::now() >= deadline {
104            tracing::warn!(
105                live_workers = live_workers.load(Ordering::Acquire),
106                timeout_secs = timeout.as_secs(),
107                "warmup-only mode: timed out waiting for workers to exit; \
108                 proceeding to fsync and process exit"
109            );
110            return;
111        }
112        tokio::time::sleep(Duration::from_millis(100)).await;
113    }
114}
115
116/// Runs the embedding server end-to-end: load config, spawn the worker pool,
117/// install the readiness probe, start the heartbeat, and serve HTTP traffic.
118///
119/// Background tasks log and call `process::exit(1)` on their own unrecoverable
120/// failures so the container is restarted by the orchestrator.
121///
122/// # Errors
123///
124/// Returns `Err` if the TCP listener cannot bind to the configured address.
125#[allow(clippy::too_many_lines)]
126pub async fn run() -> anyhow::Result<()> {
127    info!(
128        version = env!("CARGO_PKG_VERSION"),
129        git_sha = env!("BGE_M3_GIT_SHA"),
130        target_arch = std::env::consts::ARCH,
131        target_os = std::env::consts::OS,
132        profile = if cfg!(debug_assertions) {
133            "debug"
134        } else {
135            "release"
136        },
137        "bge-m3-embedding-server build info"
138    );
139
140    // Install a rustls crypto provider before any rustls user (hf-hub's
141    // reqwest::Client, axum-server's RustlsConfig, etc.) is constructed.
142    //
143    // rustls 0.23+ refuses to auto-select when more than one provider is
144    // visible in the dep graph; bge-m3-embedding-server pulls rustls through
145    // two paths:
146    //   - `hf-hub` → reqwest (rustls-tls)        → defaults to aws-lc-rs
147    //   - `axum-server` (tls-rustls feature)     → defaults to ring (via
148    //                                                 tokio-rustls)
149    // Both are present in the compiled binary, so rustls cannot pick. Without
150    // an explicit install, the process panics during worker model load (first
151    // hf-hub fetch) with: "Could not automatically determine the process-level
152    // CryptoProvider".
153    //
154    // `.ok()` lets us re-enter from tests where a provider may already be set.
155    #[cfg(feature = "tls")]
156    {
157        rustls::crypto::aws_lc_rs::default_provider()
158            .install_default()
159            .ok();
160    }
161
162    let cfg = Config::from_env()?;
163
164    let disable_probe_cache = std::env::var("BGE_M3_DISABLE_PROBE_CACHE")
165        .is_ok_and(|v| matches!(v.as_str(), "1" | "true" | "yes"));
166
167    info!(
168        bind = %cfg.bind_addr,
169        workers = cfg.workers,
170        max_batch = cfg.max_batch,
171        max_seq_length = cfg.max_seq_length,
172        cache_dir = %cfg.cache_dir,
173        idle_timeout_secs = cfg.idle_timeout.map(|d| d.as_secs()),
174        model_variant = ?cfg.model_variant,
175        memory_safety_factor = cfg.memory_safety_factor,
176        auto_budget = cfg.cost_model_override.is_none(),
177        disable_probe_cache,
178        "Starting bge-m3-embedding-server"
179    );
180
181    // Allocate one shared cost-model handle.  Conservative defaults are used
182    // until the background probe (or cache hit) updates the handle via ArcSwap.
183    // All workers share the same Arc<ArcSwap<CostModel>> so a single store()
184    // call in the probe task is immediately visible to every worker.
185    let initial_cost_model = cfg
186        .cost_model_override
187        .unwrap_or_else(|| CostModel::conservative(CostModel::DEFAULT_MAX_WORKSPACE));
188    let cost_model_handle = Arc::new(ArcSwap::from_pointee(initial_cost_model));
189
190    // Request concurrency limiter.  Start with cfg_workers - 1 permits so the
191    // background probe always has a worker slot free.  The probe (or any terminal
192    // probe bypass) calls add_permits(1) to raise to cfg_workers once the probe
193    // lifecycle ends.  Minimum is 1 so a single-worker deployment always accepts
194    // at least one concurrent request (at the cost of a shared probe slot).
195    let initial_permits = cfg.workers.saturating_sub(1).max(1);
196    let request_permits = Arc::new(Semaphore::new(initial_permits));
197
198    // Pre-create the JIT-suspect channel so the sender half can be placed in
199    // WorkerConfig before the pool is spawned.  The receiver half is passed to
200    // `spawn_adaptive_warmup` after the pool is created.  When adaptive warmup
201    // is disabled the sender is dropped immediately (workers hold `None`).
202    let (jit_suspect_tx, jit_suspect_rx): (
203        Option<JitSuspectSender>,
204        Option<tokio::sync::mpsc::Receiver<(usize, usize)>>,
205    ) = if cfg.adaptive_warmup_enabled && cfg.ep == EpSelection::TensorRt {
206        let (tx, rx) = tokio::sync::mpsc::channel::<(usize, usize)>(64);
207        (Some(tx), Some(rx))
208    } else {
209        if cfg.adaptive_warmup_enabled {
210            tracing::warn!(
211                ep = %cfg.ep,
212                "BGE_M3_ADAPTIVE_WARMUP_ENABLED=1 has no effect when the execution \
213                 provider is not TensorRT — adaptive warmup only compiles TRT engine \
214                 files. Set BGE_M3_EP=tensorrt or unset BGE_M3_ADAPTIVE_WARMUP_ENABLED."
215            );
216        }
217        (None, None)
218    };
219
220    // Create the engine propagation broadcast channel when enabled.
221    // Using Some(tx) vs None lets EmbedPool::spawn determine enabled status
222    // from the WorkerConfig without an additional bool field (ARC-5).
223    // The initial receiver from channel() is dropped immediately; each worker
224    // subscribes its own via tx.subscribe() inside run_worker.
225    let engine_propagation_tx = if cfg.engine_propagation_enabled {
226        let (tx, _initial_rx) = tokio::sync::broadcast::channel::<(usize, usize)>(32);
227        Some(tx)
228    } else {
229        None
230    };
231
232    let (pool, init_handle) = EmbedPool::spawn(
233        cfg.workers,
234        PathBuf::from(&cfg.cache_dir),
235        WorkerConfig {
236            cost_model: Arc::clone(&cost_model_handle),
237            idle_timeout: cfg.idle_timeout,
238            model_variant: cfg.model_variant,
239            max_seq_length: cfg.max_seq_length,
240            intra_threads: cfg.intra_threads,
241            ep: cfg.ep,
242            trt_warmup_shapes: cfg.trt_warmup_shapes,
243            // device_id is overridden per-worker by EmbedPool::spawn;
244            // the initial value here is a harmless placeholder.
245            device_id: 0,
246            gpu_count: cfg.gpu_count,
247            trt_max_workspace_bytes: cfg.trt_max_workspace_bytes,
248            gpu_mem_limit_bytes: cfg.gpu_mem_limit_bytes,
249            jit_suspect_tx,
250            engine_propagation_tx,
251            prewarm_strict: cfg.prewarm_strict,
252            circuit_breaker_threshold: cfg.circuit_breaker_threshold,
253            trt_inband_jit_guard_enabled: cfg.trt_inband_jit_guard_enabled,
254            trt_inband_jit_guard_seq: cfg.trt_inband_jit_guard_seq,
255            // Shared across all workers: the max sequence tier any worker has
256            // successfully warmed. Starts at 0 (nothing warmed) and is raised
257            // via fetch_max as workers complete prewarm / propagation /
258            // adaptive compiles. Read by the per-request in-band JIT guard.
259            warmed_seq_ceiling: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
260            #[cfg(feature = "cache-gc")]
261            trt_cache_gc_enabled: cfg.trt_cache_gc_enabled,
262        },
263    );
264
265    // Warmup-only path: wait for all workers (and TRT engine compilation) to
266    // finish, log the engine count, verify the on-disk postcondition, and
267    // exit cleanly through `main.rs`.  No HTTP listener is bound — intended
268    // for use as an ECS init container that pre-populates the shared EFS
269    // engine cache before the main container starts.
270    if cfg.warmup_only {
271        // Emit GPU heartbeats while engines are compiling so operators have
272        // VRAM and temperature visibility in CloudWatch during the warmup
273        // window.  Uses the same GpuStatsCollector and interval as the
274        // normal-mode heartbeat; no-op on CPU builds.
275        let warmup_hb_handle = if cfg.heartbeat_secs > 0 {
276            let gpu_stats = GpuStatsCollector::init(cfg.gpu_count);
277            let heartbeat_secs = cfg.heartbeat_secs;
278            Some(tokio::spawn(async move {
279                let mut tick = tokio::time::interval(Duration::from_secs(heartbeat_secs));
280                tick.tick().await; // skip the immediate t=0 tick
281                loop {
282                    tick.tick().await;
283                    gpu_stats.emit_heartbeat();
284                }
285            }))
286        } else {
287            None
288        };
289
290        init_handle
291            .await
292            .map_err(|e| anyhow::anyhow!("Worker pool task panicked: {e}"))?
293            .map_err(|e| anyhow::anyhow!("Worker pool initialization failed: {e}"))?;
294
295        if let Some(h) = warmup_hb_handle {
296            h.abort();
297        }
298
299        // Explicit teardown BEFORE the postcondition check and exit.
300        //
301        // Dropping `pool` releases the only `mpsc::Sender<EmbedRequest>`
302        // owned outside the worker threads, which closes the channel.
303        // Each worker's recv() call returns `Ok(None)`, the worker breaks
304        // out of its loop, drops its `ort::session::Session`, and exits.
305        //
306        // ORT TRT EP writes engine plan files synchronously inside
307        // `session.run()`, but it can buffer auxiliary state (e.g. the
308        // timing cache) that is only flushed on session destruction.
309        // Calling `process::exit(0)` while sessions are still alive would
310        // skip those destructors, which is the failure mode we are
311        // engineering away from.  After dropping the pool we wait briefly
312        // for `live_workers` to drain so worker drop paths can run.
313        let cache_dir_path = PathBuf::from(&cfg.cache_dir);
314        let live_workers = pool.live_worker_count();
315        let live_workers_arc = pool.live_workers_for_shutdown();
316        drop(pool);
317
318        wait_for_workers_to_exit(&live_workers_arc, Duration::from_secs(10)).await;
319        let live_workers_after = live_workers_arc.load(Ordering::Acquire);
320        info!(
321            live_workers_before = live_workers,
322            live_workers_after, "warmup-only mode: pool dropped, waited for worker teardown"
323        );
324
325        // Final fsync sweep covers any sidecar files (timing cache,
326        // `.profile`) that may have been written during the session-drop
327        // path. Engine plan files were already fsynced inside
328        // `run_warmup_shape`, so this second pass is belt-and-braces.
329        let engine_cache_dir = crate::embedder::trt_cache::engine_cache_path(&cache_dir_path);
330        crate::embedder::trt_cache::fsync_cache_dir(&engine_cache_dir);
331
332        let trt_info = crate::embedder::trt_cache::ensure_and_inspect(&cache_dir_path);
333
334        // Detect SM for device 0 so the postcondition check counts only
335        // plans usable by this host's GPUs, not stale `_smXX.engine` plans
336        // left over on EFS from a previous instance family. Without this,
337        // a fresh sm120 (Blackwell) deploy with a leftover sm89 (L40S)
338        // plan would exit 0 even when zero usable plans were produced —
339        // hiding the persistence failure behind the stale total count.
340        //
341        // `gpu_count` is assumed homogeneous (per `CLAUDE.md`: ASGs must
342        // share an instance family when reusing an EFS cache), so device 0
343        // is a sound representative for the whole pool.
344        let detected_sm: Option<String> = if cfg.ep == EpSelection::TensorRt {
345            crate::embedder::sm_detect::detect_sm_for_device(0)
346        } else {
347            None
348        };
349        let matching_engine_count = crate::embedder::trt_cache::count_engine_files_for_sm(
350            &engine_cache_dir,
351            detected_sm.as_deref(),
352        );
353        info!(
354            target: "bge_m3_embedding_server::trt_cache",
355            ep = %cfg.ep,
356            device_id = 0,
357            detected_sm = detected_sm.as_deref().unwrap_or("unfiltered"),
358            cache_path = %engine_cache_dir.display(),
359            matching_engine_count,
360            total_engine_count = trt_info.engine_count,
361            "trt cache: SM-filtered engine plan enumeration (warmup-only mode)"
362        );
363
364        if warmup_postcondition_failed(cfg.ep, matching_engine_count) {
365            tracing::error!(
366                matching_engine_count,
367                total_engine_count = trt_info.engine_count,
368                detected_sm = detected_sm.as_deref().unwrap_or("unfiltered"),
369                cache_path = %trt_info.path.display(),
370                ep = %cfg.ep,
371                "warmup-only postcondition failed: compile-success events \
372                 present but no .engine files matching this host's SM on disk; \
373                 check TRT EP construction, EFS mount, engine cache path resolution, \
374                 and that the warmup container ran on the same GPU family as the \
375                 serving fleet"
376            );
377            std::process::exit(WARMUP_POSTCONDITION_FAILED_EXIT_CODE);
378        }
379
380        info!(
381            matching_engine_count,
382            total_engine_count = trt_info.engine_count,
383            profile_count = trt_info.profile_count,
384            detected_sm = detected_sm.as_deref().unwrap_or("unfiltered"),
385            cache_path = %trt_info.path.display(),
386            ep = %cfg.ep,
387            "warmup-only mode: all TRT engines compiled and cached, exiting"
388        );
389        return Ok(());
390    }
391
392    // Spawn the adaptive warmup background task if enabled.  A clone of the
393    // pool is sufficient — `EmbedPool` is `Clone` (all fields are
394    // reference-counted).  The warmup-only mode does not use adaptive warmup
395    // (TRT engines are compiled synchronously during startup).
396    if let Some(rx) = jit_suspect_rx {
397        let adaptive_cfg = AdaptiveWarmupConfig {
398            enabled: cfg.adaptive_warmup_enabled,
399            quiet_secs: cfg.adaptive_warmup_quiet_secs,
400            max_shapes_per_hour: cfg.adaptive_warmup_max_shapes_per_hour,
401        };
402        crate::embedder::adaptive_warmup::spawn_adaptive_warmup(adaptive_cfg, pool.clone(), rx);
403        info!(
404            quiet_secs = cfg.adaptive_warmup_quiet_secs,
405            max_shapes_per_hour = cfg.adaptive_warmup_max_shapes_per_hour,
406            "adaptive warmup task spawned"
407        );
408    }
409
410    let state = Arc::new(AppState {
411        pool,
412        ready: AtomicBool::new(false),
413        max_batch: cfg.max_batch,
414        total_workers: cfg.workers,
415        max_seq_length: cfg.max_seq_length,
416        tuning: std::sync::OnceLock::new(),
417        cost_model: cost_model_handle,
418        probe_status: AtomicU8::new(ProbeStatus::Running as u8),
419        request_permits,
420    });
421
422    let app = build_router(Arc::clone(&state), cfg.max_body_bytes);
423
424    let state_for_readiness = Arc::clone(&state);
425    let cfg_max_seq = cfg.max_seq_length;
426    let cfg_workers = cfg.workers;
427    let cfg_safety = cfg.memory_safety_factor;
428    let cost_model_override = cfg.cost_model_override;
429    let cache_dir = PathBuf::from(&cfg.cache_dir);
430    let model_variant_str = cfg.model_variant.to_string();
431
432    tokio::spawn(async move {
433        if let Err(e) = run_readiness_probe(
434            init_handle,
435            state_for_readiness,
436            cfg_max_seq,
437            cfg_workers,
438            cfg_safety,
439            cost_model_override,
440            cache_dir,
441            model_variant_str,
442            disable_probe_cache,
443        )
444        .await
445        {
446            tracing::error!("{e}");
447            std::process::exit(1);
448        }
449    });
450
451    // Periodic heartbeat — logs RSS, worker counts, queue depth, and permits
452    // at a fixed interval so dashboards can detect slow leaks or saturation.
453    // On GPU builds, also emits per-device VRAM and utilization stats.
454    let heartbeat_secs = cfg.heartbeat_secs;
455    if heartbeat_secs > 0 {
456        let gpu_stats = GpuStatsCollector::init(cfg.gpu_count);
457        let state_hb = Arc::clone(&state);
458        tokio::spawn(async move {
459            let mut tick = tokio::time::interval(Duration::from_secs(heartbeat_secs));
460            // Skip the first (immediate) tick so we don't log at t=0 before
461            // the server has finished starting up.
462            tick.tick().await;
463            loop {
464                tick.tick().await;
465                let rss_mb = sysinfo::read_process_rss_bytes().unwrap_or(0) / (1024 * 1024);
466                info!(
467                    rss_mb,
468                    live_workers = state_hb.pool.live_worker_count(),
469                    loaded_workers = state_hb.pool.loaded_worker_count(),
470                    queue_depth = state_hb.pool.queue_depth(),
471                    available_permits = state_hb.request_permits.available_permits(),
472                    probe_status =
473                        ProbeStatus::from_u8(state_hb.probe_status.load(Ordering::Acquire))
474                            .as_str(),
475                    "heartbeat"
476                );
477                gpu_stats.emit_heartbeat();
478            }
479        });
480    }
481
482    #[cfg(feature = "tls")]
483    if let (Some(cert), Some(key)) = (cfg.tls_cert_path.as_ref(), cfg.tls_key_path.as_ref()) {
484        use axum_server::Handle;
485        use axum_server::tls_rustls::RustlsConfig;
486        let tls_config = RustlsConfig::from_pem_file(cert, key)
487            .await
488            .map_err(|e| anyhow::anyhow!("TLS config error: {e}"))?;
489        let addr: std::net::SocketAddr = cfg
490            .bind_addr
491            .parse()
492            .map_err(|e| anyhow::anyhow!("invalid bind addr: {e}"))?;
493        info!(bind = %cfg.bind_addr, mode = "tls", "Listening");
494        let handle = Handle::new();
495        let h = handle.clone();
496        tokio::spawn(async move {
497            tokio::signal::ctrl_c().await.ok();
498            tracing::info!("TLS shutdown signal received, draining connections");
499            h.graceful_shutdown(Some(std::time::Duration::from_secs(30)));
500        });
501        axum_server::bind_rustls(addr, tls_config)
502            .handle(handle)
503            .serve(app.into_make_service())
504            .await?;
505        return Ok(());
506    }
507
508    let listener = tokio::net::TcpListener::bind(&cfg.bind_addr).await?;
509    info!(bind = %cfg.bind_addr, mode = "plain", "Listening");
510    axum::serve(listener, app).await?;
511    Ok(())
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    // ─── warmup_postcondition_failed ──────────────────────────────────────
519
520    #[test]
521    fn postcondition_passes_for_tensorrt_with_engines_present() {
522        assert!(!warmup_postcondition_failed(EpSelection::TensorRt, 1));
523        assert!(!warmup_postcondition_failed(EpSelection::TensorRt, 16));
524    }
525
526    #[test]
527    fn postcondition_fails_for_tensorrt_with_zero_engines() {
528        assert!(
529            warmup_postcondition_failed(EpSelection::TensorRt, 0),
530            "TRT EP + 0 engines must be flagged as a postcondition failure"
531        );
532    }
533
534    #[test]
535    fn postcondition_passes_for_cpu_ep_regardless_of_engine_count() {
536        assert!(!warmup_postcondition_failed(EpSelection::Cpu, 0));
537        assert!(!warmup_postcondition_failed(EpSelection::Cpu, 16));
538    }
539
540    #[test]
541    fn postcondition_passes_for_cuda_ep_regardless_of_engine_count() {
542        assert!(!warmup_postcondition_failed(EpSelection::Cuda, 0));
543        assert!(!warmup_postcondition_failed(EpSelection::Cuda, 16));
544    }
545
546    #[test]
547    fn warmup_postcondition_exit_code_is_distinct_from_general_failure() {
548        assert_eq!(
549            WARMUP_POSTCONDITION_FAILED_EXIT_CODE, 2,
550            "operators rely on exit_code=2 being specific to the warmup-only \
551             postcondition; do not collapse it back into 1"
552        );
553    }
554
555    // ─── wait_for_workers_to_exit ─────────────────────────────────────────
556    //
557    // These tests use real time deliberately: the helper polls a
558    // `live_workers` atomic on a 100 ms tokio::time::sleep cadence, which
559    // we want to exercise end-to-end. Adding `tokio/test-util` purely to
560    // virtualise time would buy little here.
561
562    /// When `live_workers` is already 0, the helper returns immediately
563    /// without sleeping for the entire timeout window.
564    #[tokio::test(flavor = "current_thread")]
565    async fn wait_for_workers_returns_immediately_when_already_zero() {
566        let live = Arc::new(AtomicUsize::new(0));
567        let start = std::time::Instant::now();
568        wait_for_workers_to_exit(&live, Duration::from_mins(1)).await;
569        assert!(
570            start.elapsed() < Duration::from_millis(200),
571            "wait should return immediately when live_workers is already zero; \
572             elapsed={:?}",
573            start.elapsed()
574        );
575    }
576
577    /// Workers transitioning to zero mid-wait causes the helper to return
578    /// well before the timeout.
579    #[tokio::test(flavor = "current_thread")]
580    async fn wait_for_workers_returns_when_count_reaches_zero() {
581        let live = Arc::new(AtomicUsize::new(2));
582        let live_for_task = Arc::clone(&live);
583        let drainer = tokio::spawn(async move {
584            tokio::time::sleep(Duration::from_millis(250)).await;
585            live_for_task.store(0, Ordering::Release);
586        });
587
588        let start = std::time::Instant::now();
589        wait_for_workers_to_exit(&live, Duration::from_mins(1)).await;
590        let elapsed = start.elapsed();
591        drainer.await.unwrap();
592
593        assert_eq!(live.load(Ordering::Acquire), 0);
594        assert!(
595            elapsed < Duration::from_secs(5),
596            "wait should have returned soon after counter reached zero; \
597             elapsed={elapsed:?}"
598        );
599    }
600
601    /// When `live_workers` never reaches zero, the helper returns at the
602    /// timeout without blocking forever.
603    #[tokio::test(flavor = "current_thread")]
604    async fn wait_for_workers_times_out_when_count_stays_positive() {
605        let live = Arc::new(AtomicUsize::new(3));
606        let start = std::time::Instant::now();
607        wait_for_workers_to_exit(&live, Duration::from_millis(300)).await;
608        let elapsed = start.elapsed();
609        assert!(
610            elapsed >= Duration::from_millis(280),
611            "wait should have honored the timeout; elapsed={elapsed:?}"
612        );
613        assert!(
614            elapsed < Duration::from_secs(2),
615            "wait should not block past the timeout by more than one poll; \
616             elapsed={elapsed:?}"
617        );
618        assert_eq!(
619            live.load(Ordering::Acquire),
620            3,
621            "live counter must be untouched after timeout"
622        );
623    }
624
625    // ─── TLS cert-loading path ────────────────────────────────────────────
626    //
627    // The axum_server::bind_rustls(...).serve(...) call requires an actual
628    // listening socket and cannot be integration-tested here. These tests
629    // exercise the RustlsConfig::from_pem_file path that precedes it,
630    // covering the cert-loading and error-mapping logic in `run()`.
631
632    /// `RustlsConfig::from_pem_file` succeeds when given valid PEM files
633    /// produced by rcgen.  This covers the happy-path cert-loading lines in
634    /// the `#[cfg(feature = "tls")]` bind block inside `run()`.
635    #[cfg(feature = "tls")]
636    #[tokio::test(flavor = "current_thread")]
637    async fn tls_rustls_config_loads_valid_pem_files() {
638        use axum_server::tls_rustls::RustlsConfig;
639        use rcgen::{CertifiedKey, generate_simple_self_signed};
640
641        // Both aws-lc-rs and ring are in the dep tree; install aws-lc-rs
642        // explicitly so rustls 0.23 does not panic with an "ambiguous
643        // provider" error before we even read the PEM files.
644        rustls::crypto::aws_lc_rs::default_provider()
645            .install_default()
646            .ok();
647
648        let CertifiedKey { cert, signing_key } =
649            generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
650        let cert_pem = cert.pem();
651        let key_pem = signing_key.serialize_pem();
652
653        let cert_file = tempfile::NamedTempFile::new().unwrap();
654        let key_file = tempfile::NamedTempFile::new().unwrap();
655        std::fs::write(cert_file.path(), &cert_pem).unwrap();
656        std::fs::write(key_file.path(), &key_pem).unwrap();
657
658        let result = RustlsConfig::from_pem_file(cert_file.path(), key_file.path()).await;
659        assert!(
660            result.is_ok(),
661            "RustlsConfig::from_pem_file must succeed with valid PEM files"
662        );
663    }
664
665    /// `RustlsConfig::from_pem_file` returns an error when the cert file does
666    /// not exist.  This covers the `.map_err(|e| anyhow::anyhow!(...))` arm
667    /// in the TLS bind block.
668    #[cfg(feature = "tls")]
669    #[tokio::test(flavor = "current_thread")]
670    async fn tls_rustls_config_errors_on_missing_cert_file() {
671        use axum_server::tls_rustls::RustlsConfig;
672
673        rustls::crypto::aws_lc_rs::default_provider()
674            .install_default()
675            .ok();
676
677        let result = RustlsConfig::from_pem_file(
678            "/nonexistent/path/server.crt",
679            "/nonexistent/path/server.key",
680        )
681        .await;
682        assert!(
683            result.is_err(),
684            "RustlsConfig::from_pem_file must return Err for missing files"
685        );
686    }
687}