diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1023f944..9e45dc13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -218,9 +218,25 @@ jobs: retention-days: 1 e2e: - name: e2e (vitest) + coverage + name: e2e (vitest, ${{ matrix.serving }}) + coverage needs: [build-bin] runs-on: ubuntu-latest + strategy: + # The two serving modes are separate products of the same binary: + # thread-per-core answers a request on the thread that accepted it + # and dispatches on that thread's own upstream pool, work-stealing + # shares one runtime and one pool. A failure in one says nothing + # about the other, so neither leg cancels the other. + fail-fast: false + matrix: + include: + # The Linux default, and so the one production runs. + - serving: thread-per-core + thread_per_core: "true" + # The documented fallback. Kept green so it stays a real + # option when a deployment needs it. + - serving: work-stealing + thread_per_core: "false" services: etcd: image: quay.io/coreos/etcd:v3.5.15 @@ -270,12 +286,29 @@ jobs: - name: run e2e if: steps.harness.outputs.present == 'true' working-directory: tests/e2e + # Read by the harness, which writes it into every spawned + # gateway's `proxy.thread_per_core`. The binary's own default is + # platform-derived, so without this both legs would run the same + # mode and the second would prove nothing. + env: + E2E_THREAD_PER_CORE: ${{ matrix.thread_per_core }} run: pnpm test + - name: tag coverage by leg + # The gate downloads every leg with `merge-multiple: true`, which + # flattens same-named files to one — the legs must upload distinct + # filenames or only one leg's coverage survives the merge. + if: always() + run: | + if [ -f tests/e2e/coverage/lcov.info ]; then + mv tests/e2e/coverage/lcov.info "tests/e2e/coverage/lcov-${{ matrix.serving }}.info" + fi - uses: actions/upload-artifact@v7 if: always() with: - name: coverage-e2e - path: tests/e2e/coverage/lcov.info + # Per leg: two uploads under one name is a hard conflict the + # moment the suite starts emitting coverage. + name: coverage-e2e-${{ matrix.serving }} + path: tests/e2e/coverage/lcov-${{ matrix.serving }}.info if-no-files-found: ignore retention-days: 7 @@ -296,7 +329,7 @@ jobs: # follow-up), this download must remain soft — otherwise the # coverage-gate hard-fails every run on `Artifact not found`. continue-on-error: true - with: { name: coverage-e2e, path: cov/e2e } + with: { pattern: coverage-e2e-*, path: cov/e2e, merge-multiple: true } - name: merge + threshold run: | npm i -g lcov-result-merger @lcov-viewer/cli diff --git a/Cargo.lock b/Cargo.lock index dc42a003..c99f8552 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -449,6 +449,7 @@ dependencies = [ "rustls 0.23.38", "serde", "serde_json", + "socket2 0.5.10", "tempfile", "tokio", "tracing", diff --git a/config.example.yaml b/config.example.yaml index e4a69da3..47aeeb20 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -51,6 +51,16 @@ proxy: # trusted_proxies: ["10.0.0.0/8", "127.0.0.1/32"] # recursive: true # header: x-forwarded-for + # Serve from independent worker threads, each with its own runtime, + # its own SO_REUSEPORT listener on addr, and its own upstream + # connection pool, so a request is handled end to end on the thread + # that accepted it. Omitted, it is on for Linux and off elsewhere. + # Set false to serve from one shared runtime. Applied at startup. + # thread_per_core: true + # Number of proxy worker threads. Omitted, it follows the parallelism + # available to the process, so a container CPU limit or a taskset + # affinity mask sizes it. Applied at startup. + # workers: 4 # Entry-level URL rewriting: map legacy URL shapes onto AISIX endpoints # before routing. The first rule whose `match` regex matches the request # path rewrites it (once, no cascading); the request then flows through diff --git a/config.managed.yaml b/config.managed.yaml index 6d49ae40..d668c3b4 100644 --- a/config.managed.yaml +++ b/config.managed.yaml @@ -45,6 +45,11 @@ proxy: # 0 = no request-body cap (the default); set a value to bound # per-request memory. # request_body_limit_bytes: 0 + # Serving topology; on for Linux by default. A managed deployment + # reaches these through the environment rather than by editing this + # file: AISIX_PROXY__THREAD_PER_CORE, AISIX_PROXY__WORKERS. + # thread_per_core: true + # workers: 4 # Entry-level URL rewriting (first matching rule wins; `match` runs on # the raw, percent-encoded path; `rewrite` replaces the matched portion, # $1 = capture group; the query string is preserved). Lets clients keep diff --git a/crates/aisix-admin/src/playground_handler.rs b/crates/aisix-admin/src/playground_handler.rs index 3477f6a8..f421dda3 100644 --- a/crates/aisix-admin/src/playground_handler.rs +++ b/crates/aisix-admin/src/playground_handler.rs @@ -81,6 +81,8 @@ mod tests { real_ip: Default::default(), url_rewrites: Vec::new(), tls: None, + thread_per_core: None, + workers: None, } } diff --git a/crates/aisix-core/src/config.rs b/crates/aisix-core/src/config.rs index 4eecc99c..2e20af5d 100644 --- a/crates/aisix-core/src/config.rs +++ b/crates/aisix-core/src/config.rs @@ -412,6 +412,35 @@ pub struct ProxyConfig { /// an L7 LB / ingress that sets `x-forwarded-for`. #[serde(default)] pub real_ip: RealIpConfig, + /// Serve the proxy from independent worker threads — each with its + /// own runtime, its own `SO_REUSEPORT` listener on `addr`, and its + /// own upstream connection pool — instead of one shared runtime + /// whose threads hand work to each other. + /// + /// Omitted, the default, enables it on Linux and disables it + /// elsewhere: the kernel spreads incoming connections across + /// same-port listeners on Linux, and other platforms do not. + /// Set `false` to serve from one shared runtime on any platform. + /// + /// A request is handled end to end on the thread that accepted it, + /// which removes a cross-thread handoff per request. On a small + /// number of client connections (fewer than about four per worker) + /// the kernel's per-connection spreading can leave workers unevenly + /// loaded; throughput at that size may be lower than with a shared + /// runtime. + /// + /// Applied at startup. Changing it requires a restart. + #[serde(default)] + pub thread_per_core: Option, + /// Number of proxy worker threads. + /// + /// Omitted, the default, uses the parallelism available to the + /// process, which follows the CPU limits applied by a container + /// runtime, cgroup, or `taskset`. Must be at least 1. + /// + /// Applied at startup. Changing it requires a restart. + #[serde(default)] + pub workers: Option, /// Entry-level URL rewrite rules, applied to every proxy-listener /// request **before** routing (the admin and metrics listeners are /// unaffected). The first rule whose `match` regex matches the request @@ -433,6 +462,23 @@ impl ProxyConfig { const fn default_body_limit() -> usize { 0 } + + /// Whether the proxy serves from thread-per-core workers, resolving + /// the platform default when unset. + pub fn thread_per_core_enabled(&self) -> bool { + self.thread_per_core.unwrap_or(cfg!(target_os = "linux")) + } + + /// Proxy worker-thread count, resolving the default when unset. + /// + /// `available_parallelism` reports the CPUs this process may actually + /// run on, so a cgroup CPU limit or a `taskset` affinity mask sizes + /// the pool correctly without the operator restating it here. Falls + /// back to 1 on the platforms that cannot report it. + pub fn worker_threads(&self) -> usize { + self.workers + .unwrap_or_else(|| std::thread::available_parallelism().map_or(1, |n| n.get())) + } } /// One entry-level URL rewrite rule (see [`ProxyConfig::url_rewrites`]). @@ -1349,6 +1395,15 @@ impl Config { "proxy.real_ip.trusted_proxies invalid CIDR/IP: {bad}" ))); } + // Zero workers would bind no listener at all: the proxy would + // boot, report healthy, and refuse every connection. + if self.proxy.workers == Some(0) { + return Err(BootstrapError::Config( + "proxy.workers must be at least 1 (omit it to use the \ + parallelism available to the process)" + .into(), + )); + } // The dedicated metrics listener address must be a bindable // socket address — it is always bound when prometheus is enabled. let metrics_addr = &self.observability.metrics.prometheus.addr; @@ -2651,4 +2706,81 @@ admin: assert_eq!(tls.client_key_file, "/c.key"); assert_eq!(tls.domain_name.as_deref(), Some("etcd.aisix.cloud")); } + + /// Serving topology is a startup decision, so every existing config + /// — none of which names it — has to keep loading and resolve to the + /// platform's answer. + #[test] + fn serving_topology_defaults_to_the_platform_answer() { + let f = write_yaml( + r#" +etcd: + endpoints: ["http://localhost:2379"] +proxy: + addr: "0.0.0.0:3000" +admin: + addr: "127.0.0.1:3001" + admin_keys: ["k1"] +"#, + ); + let cfg = Config::load_from_path(Some(f.path())).unwrap(); + assert_eq!(cfg.proxy.thread_per_core, None); + assert_eq!(cfg.proxy.workers, None); + assert_eq!( + cfg.proxy.thread_per_core_enabled(), + cfg!(target_os = "linux"), + "thread-per-core is the default where the kernel spreads \ + connections across same-port listeners, and only there" + ); + assert_eq!( + cfg.proxy.worker_threads(), + std::thread::available_parallelism().map_or(1, |n| n.get()), + ); + } + + /// The fallback an operator reaches for when thread-per-core is the + /// wrong shape for their traffic. It has to win on every platform, + /// including the one where it is also the default. + #[test] + fn explicit_serving_topology_overrides_the_platform_default() { + let f = write_yaml( + r#" +etcd: + endpoints: ["http://localhost:2379"] +proxy: + addr: "0.0.0.0:3000" + thread_per_core: false + workers: 3 +admin: + addr: "127.0.0.1:3001" + admin_keys: ["k1"] +"#, + ); + let cfg = Config::load_from_path(Some(f.path())).unwrap(); + assert_eq!(cfg.proxy.thread_per_core, Some(false)); + assert!(!cfg.proxy.thread_per_core_enabled()); + assert_eq!(cfg.proxy.worker_threads(), 3); + } + + /// Zero workers would bind no listener and still report a healthy + /// boot, so it has to fail at load naming the field to fix. + #[test] + fn rejects_zero_proxy_workers() { + let f = write_yaml( + r#" +etcd: + endpoints: ["http://localhost:2379"] +proxy: + addr: "0.0.0.0:3000" + workers: 0 +admin: + addr: "127.0.0.1:3001" + admin_keys: ["k1"] +"#, + ); + let err = Config::load_from_path(Some(f.path())) + .unwrap_err() + .to_string(); + assert!(err.contains("proxy.workers"), "unexpected error: {err}"); + } } diff --git a/crates/aisix-gateway/src/upstream_http.rs b/crates/aisix-gateway/src/upstream_http.rs index df6e45b9..3fcc1286 100644 --- a/crates/aisix-gateway/src/upstream_http.rs +++ b/crates/aisix-gateway/src/upstream_http.rs @@ -418,6 +418,48 @@ mod tests { assert!(!production.contains("fn t()"), "{production}"); } + /// On a thread-per-core worker every dispatch runs on that worker's + /// own pool, and that one pool stands in for all of the clients + /// below — so it is built with a single user agent + /// (`upstream_tls::DISPATCH_USER_AGENT`). + /// + /// That substitution is only invisible while the clients it replaces + /// agree on the user agent. Give one bridge its own and the header + /// it sends changes depending on which serving mode the deployment + /// runs, which is not something an upstream-facing identity is + /// allowed to do. Whoever wants a distinct user agent has to give + /// that client a distinct pool as well. + #[test] + fn every_dispatch_client_presents_the_same_user_agent() { + let crates_dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/..")); + // Only literals: the telemetry, heartbeat, and OTLP clients build + // theirs from a version string or a named constant, and none of + // them talks to a model provider or reaches the per-worker pool. + const NEEDLE: &str = ".user_agent(\""; + let mut offenders = Vec::new(); + for file in rust_sources(crates_dir) { + let src = std::fs::read_to_string(&file).expect("read source"); + for (n, line) in production_half(&src).lines().enumerate() { + let Some(rest) = line.split_once(NEEDLE).map(|(_, r)| r) else { + continue; + }; + let Some((agent, _)) = rest.split_once('"') else { + continue; + }; + if agent != crate::upstream_tls::DISPATCH_USER_AGENT { + offenders.push(format!("{}:{}: {agent}", file.display(), n + 1)); + } + } + } + assert!( + offenders.is_empty(), + "these dispatch clients present a user agent the per-worker \ + pool would replace with `{}`:\n{}", + crate::upstream_tls::DISPATCH_USER_AGENT, + offenders.join("\n"), + ); + } + fn rust_sources(dir: &std::path::Path) -> Vec { let mut out = Vec::new(); let Ok(entries) = std::fs::read_dir(dir) else { diff --git a/crates/aisix-gateway/src/upstream_tls.rs b/crates/aisix-gateway/src/upstream_tls.rs index 494bd977..ed477a98 100644 --- a/crates/aisix-gateway/src/upstream_tls.rs +++ b/crates/aisix-gateway/src/upstream_tls.rs @@ -210,6 +210,72 @@ pub fn reqwest_material() -> &'static ReqwestTlsMaterial { /// idle connection pool. static PK_CLIENTS: OnceLock> = OnceLock::new(); +// ─── per-worker pools ──────────────────────────────────────────────── + +/// The user agent every dispatch-path client is built with. +/// +/// A worker's pool stands in for those clients, so it has to present the +/// same identity upstream. `every_dispatch_client_presents_the_same_user_agent` +/// holds them in step. +pub(crate) const DISPATCH_USER_AGENT: &str = "aisix/0.1"; + +thread_local! { + /// Whether this thread serves proxy traffic on its own runtime. + static IS_WORKER_THREAD: std::cell::Cell = const { std::cell::Cell::new(false) }; + + /// This worker's upstream pool, built on first dispatch. `None` once + /// a build failure has been reported for this thread. + static WORKER_CLIENT: std::cell::OnceCell> = + const { std::cell::OnceCell::new() }; +} + +/// Declares the calling thread a proxy worker with its own runtime, so +/// its dispatches use a pool this thread alone polls. +/// +/// Called once per worker in thread-per-core serving. Left unset +/// everywhere else — the shared runtime's threads, the blocking pool, +/// background tasks — so those keep dispatching on the process-wide +/// pools exactly as before. +pub fn mark_worker_thread() { + IS_WORKER_THREAD.set(true); +} + +/// This worker's pool, or `None` when the thread is not a worker or the +/// pool could not be built. +/// +/// One pool per worker rather than per client: the dispatch clients are +/// all built from the same recipe, so merging them costs nothing but the +/// per-owner split of `pool_max_idle_per_host`, and keeping them split +/// would multiply idle connections by the worker count for no gain. +/// +/// A build failure falls back to the shared pool — which carries the +/// deployment's trust settings — and is cached so one broken +/// configuration cannot log per request. +fn worker_client() -> Option { + if !IS_WORKER_THREAD.get() { + return None; + } + WORKER_CLIENT.with(|cell| { + cell.get_or_init(|| { + match crate::upstream_http::client_builder() + .user_agent(DISPATCH_USER_AGENT) + .build() + { + Ok(client) => Some(client), + Err(e) => { + tracing::error!( + error = %e, + "per-worker upstream pool could not be built; this worker \ + dispatches on the shared pool" + ); + None + } + } + }) + .clone() + }) +} + /// The client to dispatch this Provider Key's request on. /// /// Returns `shared` unchanged whenever the key sets no override, which @@ -226,7 +292,12 @@ pub fn client_for_provider_key( tls: Option<&ProviderKeyTls>, ) -> reqwest::Client { let Some(tls) = tls.filter(|t| !t.is_noop()) else { - return shared.clone(); + // On a thread-per-core worker, dispatch on that worker's own + // pool: the upstream connection is then read by the same runtime + // that is waiting for the response, instead of waking a thread + // that has to hand it back. Everywhere else this is `None` and + // the shared pool is used, as it always was. + return worker_client().unwrap_or_else(|| shared.clone()); }; let cache = PK_CLIENTS.get_or_init(dashmap::DashMap::new); if let Some(existing) = cache.get(tls) { diff --git a/crates/aisix-proxy/src/a2a.rs b/crates/aisix-proxy/src/a2a.rs index 25133464..67eea0e1 100644 --- a/crates/aisix-proxy/src/a2a.rs +++ b/crates/aisix-proxy/src/a2a.rs @@ -386,6 +386,8 @@ mod tests { real_ip: Default::default(), url_rewrites: Vec::new(), tls: None, + thread_per_core: None, + workers: None, } } diff --git a/crates/aisix-proxy/src/audio.rs b/crates/aisix-proxy/src/audio.rs index 75dfed7f..0f8ceb48 100644 --- a/crates/aisix-proxy/src/audio.rs +++ b/crates/aisix-proxy/src/audio.rs @@ -1563,6 +1563,8 @@ mod tests { real_ip: Default::default(), url_rewrites: Vec::new(), tls: None, + thread_per_core: None, + workers: None, } } diff --git a/crates/aisix-proxy/src/completions.rs b/crates/aisix-proxy/src/completions.rs index 13c5412c..2e7da937 100644 --- a/crates/aisix-proxy/src/completions.rs +++ b/crates/aisix-proxy/src/completions.rs @@ -789,6 +789,8 @@ mod tests { real_ip: Default::default(), url_rewrites: Vec::new(), tls: None, + thread_per_core: None, + workers: None, } } diff --git a/crates/aisix-proxy/src/count_tokens.rs b/crates/aisix-proxy/src/count_tokens.rs index 65cdcc1e..46049f2c 100644 --- a/crates/aisix-proxy/src/count_tokens.rs +++ b/crates/aisix-proxy/src/count_tokens.rs @@ -583,6 +583,8 @@ mod tests { real_ip: Default::default(), url_rewrites: Vec::new(), tls: None, + thread_per_core: None, + workers: None, } } diff --git a/crates/aisix-proxy/src/embeddings.rs b/crates/aisix-proxy/src/embeddings.rs index 99a00a66..8c537dc7 100644 --- a/crates/aisix-proxy/src/embeddings.rs +++ b/crates/aisix-proxy/src/embeddings.rs @@ -718,6 +718,8 @@ mod tests { real_ip: Default::default(), url_rewrites: Vec::new(), tls: None, + thread_per_core: None, + workers: None, } } diff --git a/crates/aisix-proxy/src/images.rs b/crates/aisix-proxy/src/images.rs index 8552695d..861fa435 100644 --- a/crates/aisix-proxy/src/images.rs +++ b/crates/aisix-proxy/src/images.rs @@ -591,6 +591,8 @@ mod tests { real_ip: Default::default(), url_rewrites: Vec::new(), tls: None, + thread_per_core: None, + workers: None, } } diff --git a/crates/aisix-proxy/src/jobs.rs b/crates/aisix-proxy/src/jobs.rs index 21c7fc2d..c6dcc46f 100644 --- a/crates/aisix-proxy/src/jobs.rs +++ b/crates/aisix-proxy/src/jobs.rs @@ -1836,6 +1836,8 @@ mod tests { real_ip: Default::default(), url_rewrites: Vec::new(), tls: None, + thread_per_core: None, + workers: None, } } diff --git a/crates/aisix-proxy/src/lib.rs b/crates/aisix-proxy/src/lib.rs index 9071d6f3..add7caf0 100644 --- a/crates/aisix-proxy/src/lib.rs +++ b/crates/aisix-proxy/src/lib.rs @@ -844,6 +844,8 @@ mod tests { real_ip: Default::default(), url_rewrites: Vec::new(), tls: None, + thread_per_core: None, + workers: None, } } @@ -1818,6 +1820,8 @@ mod tests { real_ip: Default::default(), url_rewrites: Vec::new(), tls: None, + thread_per_core: None, + workers: None, }; ProxyState::new(handle, hub, &cfg).without_cache() } diff --git a/crates/aisix-proxy/src/mcp.rs b/crates/aisix-proxy/src/mcp.rs index c7f6488f..eeebd4e6 100644 --- a/crates/aisix-proxy/src/mcp.rs +++ b/crates/aisix-proxy/src/mcp.rs @@ -557,6 +557,8 @@ mod tests { real_ip: Default::default(), url_rewrites: Vec::new(), tls: None, + thread_per_core: None, + workers: None, } } diff --git a/crates/aisix-proxy/src/messages.rs b/crates/aisix-proxy/src/messages.rs index 4fd8339f..732b36af 100644 --- a/crates/aisix-proxy/src/messages.rs +++ b/crates/aisix-proxy/src/messages.rs @@ -3552,6 +3552,8 @@ mod tests { real_ip: Default::default(), url_rewrites: Vec::new(), tls: None, + thread_per_core: None, + workers: None, } } diff --git a/crates/aisix-proxy/src/models.rs b/crates/aisix-proxy/src/models.rs index 934c59fe..c7c76f81 100644 --- a/crates/aisix-proxy/src/models.rs +++ b/crates/aisix-proxy/src/models.rs @@ -126,6 +126,8 @@ mod tests { real_ip: Default::default(), url_rewrites: Vec::new(), tls: None, + thread_per_core: None, + workers: None, } } diff --git a/crates/aisix-proxy/src/passthrough.rs b/crates/aisix-proxy/src/passthrough.rs index bc209a57..0cce25d5 100644 --- a/crates/aisix-proxy/src/passthrough.rs +++ b/crates/aisix-proxy/src/passthrough.rs @@ -934,6 +934,8 @@ mod tests { real_ip: Default::default(), url_rewrites: Vec::new(), tls: None, + thread_per_core: None, + workers: None, } } diff --git a/crates/aisix-proxy/src/realtime.rs b/crates/aisix-proxy/src/realtime.rs index fc8d7b7d..17ecfed5 100644 --- a/crates/aisix-proxy/src/realtime.rs +++ b/crates/aisix-proxy/src/realtime.rs @@ -863,6 +863,8 @@ mod tests { real_ip: Default::default(), url_rewrites: Vec::new(), tls: None, + thread_per_core: None, + workers: None, } } diff --git a/crates/aisix-proxy/src/reject.rs b/crates/aisix-proxy/src/reject.rs index 18519a78..29af6cf4 100644 --- a/crates/aisix-proxy/src/reject.rs +++ b/crates/aisix-proxy/src/reject.rs @@ -205,6 +205,8 @@ mod tests { request_body_limit_bytes: 0, tls: None, real_ip: Default::default(), + thread_per_core: None, + workers: None, url_rewrites: Vec::new(), }; ProxyState::new( diff --git a/crates/aisix-proxy/src/rerank.rs b/crates/aisix-proxy/src/rerank.rs index 621554e5..1b123c72 100644 --- a/crates/aisix-proxy/src/rerank.rs +++ b/crates/aisix-proxy/src/rerank.rs @@ -778,6 +778,8 @@ mod tests { real_ip: Default::default(), url_rewrites: Vec::new(), tls: None, + thread_per_core: None, + workers: None, } } diff --git a/crates/aisix-proxy/src/responses.rs b/crates/aisix-proxy/src/responses.rs index 117836e2..688684fc 100644 --- a/crates/aisix-proxy/src/responses.rs +++ b/crates/aisix-proxy/src/responses.rs @@ -3132,6 +3132,8 @@ mod tests { real_ip: Default::default(), url_rewrites: Vec::new(), tls: None, + thread_per_core: None, + workers: None, } } diff --git a/crates/aisix-proxy/src/rewrite.rs b/crates/aisix-proxy/src/rewrite.rs index d9cfbb44..a36d1293 100644 --- a/crates/aisix-proxy/src/rewrite.rs +++ b/crates/aisix-proxy/src/rewrite.rs @@ -184,6 +184,8 @@ mod tests { request_body_limit_bytes: 0, tls: None, real_ip: Default::default(), + thread_per_core: None, + workers: None, url_rewrites: rules, }; let state = ProxyState::new( diff --git a/crates/aisix-proxy/src/state.rs b/crates/aisix-proxy/src/state.rs index 1f7b8f10..11ecc6f8 100644 --- a/crates/aisix-proxy/src/state.rs +++ b/crates/aisix-proxy/src/state.rs @@ -394,6 +394,8 @@ mod tests { request_body_limit_bytes: 1_048_576, tls: None, real_ip: Default::default(), + thread_per_core: None, + workers: None, url_rewrites: Vec::new(), }, ) diff --git a/crates/aisix-proxy/src/videos.rs b/crates/aisix-proxy/src/videos.rs index 566f28f9..2d47fddc 100644 --- a/crates/aisix-proxy/src/videos.rs +++ b/crates/aisix-proxy/src/videos.rs @@ -2345,6 +2345,8 @@ mod tests { real_ip: Default::default(), url_rewrites: Vec::new(), tls: None, + thread_per_core: None, + workers: None, } } diff --git a/crates/aisix-server/Cargo.toml b/crates/aisix-server/Cargo.toml index 3b3ad9ea..99472d9b 100644 --- a/crates/aisix-server/Cargo.toml +++ b/crates/aisix-server/Cargo.toml @@ -46,6 +46,11 @@ rcgen.workspace = true uuid.workspace = true hostname.workspace = true x509-parser = "0.16" +# `SO_REUSEPORT` for the thread-per-core proxy listeners. `all` is +# explicit because `set_reuse_port` lives behind it — it resolves +# without the feature today only because other dependencies happen to +# enable it on the same socket2, which a version bump would end. +socket2 = { version = "0.5", features = ["all"] } # Only for the hyper connection builder `axum_server::Server::http_builder()` # hands back — where `downstream.idle_timeout_secs` is applied. Same 0.1.x # axum-server itself depends on, so the builder types unify. diff --git a/crates/aisix-server/src/main.rs b/crates/aisix-server/src/main.rs index 34e1ab1d..f214ee30 100644 --- a/crates/aisix-server/src/main.rs +++ b/crates/aisix-server/src/main.rs @@ -101,8 +101,17 @@ enum CliCommand { }, } -#[tokio::main] -async fn main() -> anyhow::Result<()> { +/// Threads left to the control surfaces when the proxy serves from +/// thread-per-core workers. +/// +/// The proxy's own runtimes belong to its workers in that mode, so this +/// runtime is only running the etcd watch, the admin and metrics +/// listeners, signal handling, and the background exporters. Two threads +/// keep a config reload from stalling behind a telemetry flush without +/// taking a core back from the workers. +const CONTROL_RUNTIME_THREADS: usize = 2; + +fn main() -> anyhow::Result<()> { // Install the process-level rustls CryptoProvider before anything // else touches TLS. rustls 0.23 dropped implicit provider selection // and panics at first use when both `aws-lc-rs` and `ring` features @@ -127,13 +136,15 @@ async fn main() -> anyhow::Result<()> { reveal_secrets, output, }) => { - return export::run(export::ExportArgs { - endpoints: etcd, - prefix, - reveal_secrets, - output, - }) - .await; + return tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(export::run(export::ExportArgs { + endpoints: etcd, + prefix, + reveal_secrets, + output, + })); } None => {} } @@ -142,10 +153,24 @@ async fn main() -> anyhow::Result<()> { .config .expect("clap enforces --config unless a subcommand is given"); - // Steps 1-2: config. + // Steps 1-2: config. Read before the runtime exists because + // `proxy.thread_per_core` and `proxy.workers` decide how many threads + // this runtime gets — and, in thread-per-core mode, that it is not + // the runtime serving proxy traffic at all. let cfg = Config::load_from_path(Some(&config_path)) .map_err(|e| anyhow::anyhow!("config load failed: {e}"))?; + let mut builder = tokio::runtime::Builder::new_multi_thread(); + builder.enable_all(); + builder.worker_threads(if cfg.proxy.thread_per_core_enabled() { + CONTROL_RUNTIME_THREADS + } else { + cfg.proxy.worker_threads() + }); + builder.build()?.block_on(async_main(cfg)) +} + +async fn async_main(cfg: Config) -> anyhow::Result<()> { // Step 3: tracing + optional OTLP export. init_tracing(&cfg.observability).map_err(|e| anyhow::anyhow!("tracing init failed: {e}"))?; let _otlp = install_otlp_tracer(&cfg.observability) @@ -930,6 +955,7 @@ async fn run(mut cfg: Config) -> anyhow::Result<()> { downstream_idle_timeout, cancel_rx.clone(), "admin", + None, ))) } else { // Drop unused shared components so the compiler can see they @@ -975,6 +1001,7 @@ async fn run(mut cfg: Config) -> anyhow::Result<()> { downstream_idle_timeout, cancel_rx.clone(), "metrics", + None, ))) } else { None @@ -984,6 +1011,10 @@ async fn run(mut cfg: Config) -> anyhow::Result<()> { // Step 9: bind + serve the proxy (always). Admin is handled above. let proxy_addr: std::net::SocketAddr = cfg.proxy.addr.parse()?; let proxy_tls = cfg.proxy.tls.clone(); + let proxy_workers = cfg + .proxy + .thread_per_core_enabled() + .then(|| cfg.proxy.worker_threads()); let proxy_serve = serve_http( proxy_addr, proxy_router, @@ -991,6 +1022,7 @@ async fn run(mut cfg: Config) -> anyhow::Result<()> { downstream_idle_timeout, cancel_rx.clone(), "proxy", + proxy_workers, ); // Step 10: shutdown coordinator. Whichever of (signal, proxy, admin) @@ -1389,6 +1421,7 @@ async fn serve_http( idle_timeout: Option, cancel: watch::Receiver, label: &'static str, + workers: Option, ) -> anyhow::Result<()> { // Resolved before binding so a bad cert path still fails with the // same error it always did, before a port is taken. @@ -1407,6 +1440,21 @@ async fn serve_http( None => None, }; + // Thread-per-core serving is the proxy's; the admin and metrics + // surfaces are control traffic and keep their single listener. + if let Some(workers) = workers { + return serve_http_tpc( + addr, + router, + tls_config, + idle_timeout, + cancel, + label, + workers, + ) + .await; + } + let listener = std::net::TcpListener::bind(addr) .map_err(|e| anyhow::anyhow!("{label} listener bind {addr} failed: {e}"))?; listener.set_nonblocking(true)?; @@ -1446,6 +1494,221 @@ async fn serve_http( Ok(()) } +/// Serve from `workers` independent threads, each with its own runtime +/// and its own `SO_REUSEPORT` listener on the same address. +/// +/// A connection is accepted, read, dispatched, and answered on one +/// thread, and the upstream call it makes runs on that thread's own +/// connection pool (see `upstream_tls::mark_worker_thread`). That is the +/// point of the mode: the shared runtime hands a request between threads +/// roughly once per request — first when a worker steals the task, again +/// when the upstream response lands on whichever thread happens to own +/// that connection — and each handoff costs a wakeup and a context +/// switch. Here there are none. +/// +/// The kernel decides which listener gets each connection, hashing the +/// 4-tuple. That spreads evenly across many client connections and +/// unevenly across few, which is why the mode is documented as a +/// throughput setting rather than a latency one. +async fn serve_http_tpc( + addr: std::net::SocketAddr, + router: axum::Router, + tls_config: Option, + idle_timeout: Option, + cancel: watch::Receiver, + label: &'static str, + workers: usize, +) -> anyhow::Result<()> { + // An address someone else already holds has to stay a loud boot + // failure. Every socket on a `SO_REUSEPORT` address has to set the + // option, so a socket that does not set it fails to bind exactly + // when something else is there — which is the check the worker + // sockets below deliberately give up, since they must co-bind with + // each other. Without this a second gateway would start silently + // and split traffic with the first. + drop( + std::net::TcpListener::bind(addr) + .map_err(|e| anyhow::anyhow!("{label} listener bind {addr} failed: {e}"))?, + ); + + // Every listener is bound before any worker spawns, so a bind + // failure — fd exhaustion on the last socket included — aborts + // startup before a single connection is accepted, exactly as the + // single listener does. + let mut listeners = Vec::with_capacity(workers); + for _ in 0..workers { + let listener = bind_reuseport_listener(addr) + .map_err(|e| anyhow::anyhow!("{label} listener bind {addr} failed: {e}"))?; + listener.set_nonblocking(true)?; + listeners.push(listener); + } + + // One slot per worker so no worker blocks reporting its exit. + let (exit_tx, exit_rx) = std::sync::mpsc::sync_channel::>(workers); + for (worker, listener) in listeners.into_iter().enumerate() { + let router = router.clone(); + let cancel = cancel.clone(); + let tls_config = tls_config.clone(); + let exit_tx = exit_tx.clone(); + std::thread::Builder::new() + // Names the mode in `ps -T` / `top -H`: `tpc-N` here, + // tokio's own `tokio-rt-worker` on the shared runtime. + .name(format!("tpc-{worker}")) + .spawn(move || { + let mut exit = WorkerExit { + tx: exit_tx, + worker, + outcome: None, + }; + exit.outcome = Some(run_tpc_worker( + addr, + listener, + router, + tls_config, + idle_timeout, + cancel, + label, + worker, + )); + })?; + } + // Only the workers hold senders from here, so a `RecvError` means + // every worker is gone. + drop(exit_tx); + + // A graceful shutdown ends the workers together, but each drains its + // own connections on its own clock — an idle worker returns at once + // while a sibling may hold an in-flight stream for minutes. Wait for + // every worker, so the fastest drain cannot end the process under + // the slowest. Everything else stays immediately fatal: an accept + // loop failing, a panic unwinding, or a worker stopping without a + // shutdown signal has to bring the process down rather than leave it + // serving on fewer listeners than it reported binding. + let shutdown_seen = cancel; + tokio::task::spawn_blocking(move || { + for _ in 0..workers { + match exit_rx.recv() { + Ok(Ok(())) if *shutdown_seen.borrow() => {} + Ok(Ok(())) => { + return Err(anyhow::anyhow!( + "a proxy worker stopped serving without a shutdown signal" + )); + } + Ok(Err(e)) => return Err(e), + Err(_) => { + return Err(anyhow::anyhow!("a proxy worker exited without reporting")); + } + } + } + Ok(()) + }) + .await? +} + +/// Reports a worker's exit exactly once, including when a panic unwinds +/// out of it and there is no return value to send. +struct WorkerExit { + tx: std::sync::mpsc::SyncSender>, + worker: usize, + outcome: Option>, +} + +impl Drop for WorkerExit { + fn drop(&mut self) { + let outcome = self.outcome.take().unwrap_or_else(|| { + Err(anyhow::anyhow!( + "proxy worker {} panicked; see the panic above", + self.worker + )) + }); + let _ = self.tx.send(outcome); + } +} + +/// One thread-per-core worker: its own current-thread runtime, its own +/// listener, its own upstream connection pool. +#[allow(clippy::too_many_arguments)] +fn run_tpc_worker( + addr: std::net::SocketAddr, + listener: std::net::TcpListener, + router: axum::Router, + tls_config: Option, + idle_timeout: Option, + cancel: watch::Receiver, + label: &'static str, + worker: usize, +) -> anyhow::Result<()> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + // Every dispatch from this thread now uses this thread's pool, so an + // upstream response is read by the same runtime that is waiting for + // it. Marked inside the worker because the marker is per thread. + aisix_gateway::upstream_tls::mark_worker_thread(); + rt.block_on(async move { + let handle = axum_server::Handle::new(); + tokio::spawn({ + let handle = handle.clone(); + async move { + shutdown_signal(cancel, label).await; + // `None` = drain without a deadline, as on the shared + // runtime: an in-flight LLM stream can run for minutes. + handle.graceful_shutdown(None); + } + }); + + let make_service = router.into_make_service_with_connect_info::(); + match tls_config { + None => { + tracing::info!(%addr, label, worker, "aisix listening (http, thread-per-core)"); + let mut server = axum_server::from_tcp(listener).handle(handle); + apply_idle_timeout(server.http_builder(), idle_timeout); + server.serve(make_service).await?; + } + Some(tls_config) => { + tracing::info!(%addr, label, worker, "aisix listening (https, thread-per-core)"); + let mut server = axum_server::from_tcp_rustls(listener, tls_config).handle(handle); + apply_idle_timeout(server.http_builder(), idle_timeout); + server.serve(make_service).await?; + } + } + Ok(()) + }) +} + +/// A listener that shares `addr` with the other workers' listeners. +/// +/// `SO_REUSEPORT` has to be set before the bind, and every socket on the +/// address has to set it, which is why this cannot go through +/// `TcpListener::bind`. +fn bind_reuseport_listener(addr: std::net::SocketAddr) -> std::io::Result { + #[cfg(not(unix))] + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "thread-per-core serving needs SO_REUSEPORT, which this platform \ + does not have; set proxy.thread_per_core: false", + )); + + #[cfg(unix)] + { + let domain = if addr.is_ipv4() { + socket2::Domain::IPV4 + } else { + socket2::Domain::IPV6 + }; + let socket = + socket2::Socket::new(domain, socket2::Type::STREAM, Some(socket2::Protocol::TCP))?; + socket.set_reuse_address(true)?; + socket.set_reuse_port(true)?; + socket.bind(&addr.into())?; + // Matches the backlog `std::net::TcpListener::bind` requests, so + // the two modes queue the same number of pending connections per + // socket. + socket.listen(128)?; + Ok(socket.into()) + } +} + /// Close an accepted HTTP/1.1 connection that sits idle for `idle_timeout`. /// /// hyper arms this timer only when it is waiting for a request head, and diff --git a/tests/e2e/src/cases/listener-tls-e2e.test.ts b/tests/e2e/src/cases/listener-tls-e2e.test.ts index 0b2d5704..8c9e6f5d 100644 --- a/tests/e2e/src/cases/listener-tls-e2e.test.ts +++ b/tests/e2e/src/cases/listener-tls-e2e.test.ts @@ -6,7 +6,7 @@ import { randomUUID } from "node:crypto"; import { stringify as yamlStringify } from "yaml"; import { Agent, request } from "undici"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { EtcdClient, pickFreePorts } from "../harness/index.js"; +import { EtcdClient, pickFreePorts, suiteThreadPerCore } from "../harness/index.js"; // E2E: `proxy.tls` / `admin.tls` actually serve HTTPS. // @@ -77,6 +77,13 @@ describe("listener TLS (#473)", () => { addr: `127.0.0.1:${proxyPort}`, request_body_limit_bytes: 10485760, tls: { cert_file: certFile, key_file: keyFile }, + // This file spawns the binary itself, so it has to honour the + // suite-wide serving mode the shared harness applies — HTTPS is + // served by a different code path in each mode, and this is the + // only test that covers either of them. + ...(suiteThreadPerCore !== undefined + ? { thread_per_core: suiteThreadPerCore } + : {}), }, admin: { addr: `127.0.0.1:${adminPort}`, diff --git a/tests/e2e/src/cases/shutdown-drain-e2e.test.ts b/tests/e2e/src/cases/shutdown-drain-e2e.test.ts new file mode 100644 index 00000000..472909e1 --- /dev/null +++ b/tests/e2e/src/cases/shutdown-drain-e2e.test.ts @@ -0,0 +1,136 @@ +import { createHash } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + EtcdClient, + SeedClient, + spawnApp, + waitConfigPropagation, + type SpawnedApp, +} from "../harness/index.js"; + +// E2E: graceful shutdown drains in-flight requests, in whichever serving +// mode the suite leg selects. +// +// Pinned contract: a request the gateway has accepted before SIGTERM +// receives its complete response, and only then does the process exit. +// In thread-per-core mode every worker drains on its own clock, and a +// worker with no connections finishes instantly — that fast drain must +// not end the process while a sibling still holds an in-flight upstream +// call (the regression this file guards). + +const KEY = "sk-shutdown-drain-e2e"; +const sha256 = (s: string) => createHash("sha256").update(s).digest("hex"); +// Long enough that SIGTERM reliably lands mid-flight, short enough to +// finish inside the harness's 3s SIGTERM→SIGKILL escalation window. +const UPSTREAM_DELAY_MS = 2_000; + +describe("graceful shutdown drains in-flight requests", () => { + let app: SpawnedApp | undefined; + let upstream: Server | undefined; + let etcdReachable = false; + let sawRequest = () => {}; + const upstreamGotRequest = new Promise((resolve) => { + sawRequest = resolve; + }); + + beforeAll(async () => { + const etcd = new EtcdClient(); + etcdReachable = await etcd.ping(); + if (!etcdReachable) return; + + // A deliberately slow upstream: answers every POST with a valid + // chat completion after UPSTREAM_DELAY_MS, so one request is + // reliably in flight when SIGTERM lands. + upstream = createServer((req, res) => { + req.resume(); + req.on("end", () => { + sawRequest(); + setTimeout(() => { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + id: "chatcmpl-drain", + object: "chat.completion", + created: 1, + model: "gpt-4o-mini", + choices: [ + { + index: 0, + message: { role: "assistant", content: "drained" }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + ); + }, UPSTREAM_DELAY_MS); + }); + }); + await new Promise((resolve) => upstream!.listen(0, "127.0.0.1", resolve)); + const upstreamPort = (upstream!.address() as { port: number }).port; + + app = await spawnApp({}); + const seed = new SeedClient(new EtcdClient(), app.etcdPrefix); + const pk = await seed.createProviderKey({ + display_name: "drain-pk", + secret: "sk-mock", + api_base: `http://127.0.0.1:${upstreamPort}/v1`, + }); + await seed.createModel({ + display_name: "drain-model", + provider: "openai", + model_name: "gpt-4o-mini", + provider_key_id: pk.id, + }); + await seed.createApiKey({ + key_hash: sha256(KEY), + allowed_models: ["drain-model"], + }); + + await waitConfigPropagation(async () => { + const res = await fetch(`${app!.proxyUrl}/v1/models`, { + headers: { authorization: `Bearer ${KEY}` }, + }); + if (res.status !== 200) return false; + const body = (await res.json()) as { data?: Array<{ id?: string }> }; + return body.data?.some((m) => m.id === "drain-model") ?? false; + }); + }, 60_000); + + afterAll(async () => { + await app?.exit(); + if (upstream) await new Promise((resolve) => upstream!.close(() => resolve())); + }); + + test("a request in flight at SIGTERM still completes", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + const inFlight = fetch(`${app.proxyUrl}/v1/chat/completions`, { + method: "POST", + headers: { + authorization: `Bearer ${KEY}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "drain-model", + messages: [{ role: "user", content: "hold the line" }], + }), + }); + + // Only signal once the request has actually reached the upstream — + // from here the gateway holds it in flight for UPSTREAM_DELAY_MS. + await upstreamGotRequest; + app.signal("SIGTERM"); + + // The drain contract: the response completes despite the shutdown. + // A process that exits from under the request turns this await into + // a socket error instead of a response. + const res = await inFlight; + expect(res.status).toBe(200); + const body = (await res.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + expect(body.choices?.[0]?.message?.content).toBe("drained"); + }, 20_000); +}); diff --git a/tests/e2e/src/cases/status-config-e2e.test.ts b/tests/e2e/src/cases/status-config-e2e.test.ts index 1996d7c2..80172a36 100644 --- a/tests/e2e/src/cases/status-config-e2e.test.ts +++ b/tests/e2e/src/cases/status-config-e2e.test.ts @@ -12,6 +12,7 @@ import { pickFreePorts, spawnApp, startOpenAiUpstream, + suiteThreadPerCore, waitConfigPropagation, type OpenAiUpstream, type SpawnedApp, @@ -345,7 +346,13 @@ async function spawnPointedAtDeadEtcd(): Promise { dial_timeout_ms: 2000, request_timeout_ms: 2000, }, - proxy: { addr: `127.0.0.1:${proxyPort}`, request_body_limit_bytes: 10485760 }, + proxy: { + addr: `127.0.0.1:${proxyPort}`, + request_body_limit_bytes: 10485760, + // This file spawns the binary itself, so it has to honour the + // suite-wide serving mode the shared harness applies. + ...(suiteThreadPerCore !== undefined ? { thread_per_core: suiteThreadPerCore } : {}), + }, admin: { addr: `127.0.0.1:${adminPort}`, admin_keys: [`admin-${randomUUID()}`] }, observability: { service_name: "aisix-status-nl", diff --git a/tests/e2e/src/cases/upstream-pool-idle-e2e.test.ts b/tests/e2e/src/cases/upstream-pool-idle-e2e.test.ts index 574a8f9f..c874662b 100644 --- a/tests/e2e/src/cases/upstream-pool-idle-e2e.test.ts +++ b/tests/e2e/src/cases/upstream-pool-idle-e2e.test.ts @@ -131,9 +131,20 @@ describe("upstream pool idle timeout", () => { expiringUpstream = await startCountingUpstream(); holdingUpstream = await startCountingUpstream(); - expiring = await spawnApp({ extra: { upstream: { pool_idle_timeout_secs: POOL_IDLE_S } } }); + // One pool, so the connection counts below measure the idle deadline + // and nothing else. Under thread-per-core serving each worker keeps + // its own pool and the kernel decides which worker takes each + // connection, which would make "the second call reused the first + // call's connection" a question about that choice instead. + expiring = await spawnApp({ + threadPerCore: false, + extra: { upstream: { pool_idle_timeout_secs: POOL_IDLE_S } }, + }); // 0 switches the knob off, leaving reqwest's own 90s pool lifetime. - holding = await spawnApp({ extra: { upstream: { pool_idle_timeout_secs: 0 } } }); + holding = await spawnApp({ + threadPerCore: false, + extra: { upstream: { pool_idle_timeout_secs: 0 } }, + }); await seedInto(expiring, etcd, expiringUpstream.baseUrl); await seedInto(holding, etcd, holdingUpstream.baseUrl); diff --git a/tests/e2e/src/harness/app.ts b/tests/e2e/src/harness/app.ts index e6854363..fb3fc620 100644 --- a/tests/e2e/src/harness/app.ts +++ b/tests/e2e/src/harness/app.ts @@ -65,6 +65,17 @@ export interface AppOverrides { * AccessKey deliberately never travels on the config path. */ extraEnv?: Record; + /** + * `proxy.thread_per_core`. Omitted, the binary picks its platform + * default, which is what the suite should normally exercise. + * + * Pin it to `false` in a test whose subject is per-connection or + * per-pool state: with thread-per-core serving the kernel picks which + * worker accepts each connection, and each worker keeps its own + * upstream pool, so a count taken across two calls depends on that + * choice. Pinning keeps such an assertion measuring its own subject. + */ + threadPerCore?: boolean; /** * Log level for the spawned binary. Defaults to `warn` — quiet enough * that the suite's output stays readable. Tests that assert on a line @@ -148,6 +159,21 @@ const BIN_PATH = const READY_TIMEOUT_MS = 10_000; const SHUTDOWN_GRACE_MS = 3_000; +/** + * Suite-wide `proxy.thread_per_core`, from `E2E_THREAD_PER_CORE`, so CI + * can run the whole suite in each serving mode. Unset leaves the binary + * on its platform default; a per-test `threadPerCore` still wins over + * both. + * + * Every site that spawns the binary has to read this — a spawn site that + * ignores it silently stays on one mode forever, and the leg that was + * supposed to cover the other one goes green without ever running it. + */ +export const suiteThreadPerCore: boolean | undefined = + process.env.E2E_THREAD_PER_CORE === undefined + ? undefined + : process.env.E2E_THREAD_PER_CORE !== "false"; + /** * Per-test handle to a spawned `aisix` binary. Each call writes a fresh * config YAML into a tmp dir, picks three free ports (proxy, admin, @@ -240,6 +266,9 @@ async function spawnAppOnce(overrides: AppOverrides = {}): Promise { addr: `127.0.0.1:${proxyPort}`, request_body_limit_bytes: overrides.requestBodyLimitBytes ?? 10485760, ...(overrides.realIp ? { real_ip: overrides.realIp } : {}), + ...((overrides.threadPerCore ?? suiteThreadPerCore) !== undefined + ? { thread_per_core: overrides.threadPerCore ?? suiteThreadPerCore } + : {}), ...(overrides.urlRewrites ? { url_rewrites: overrides.urlRewrites } : {}), }, admin: adminEnabled diff --git a/tests/e2e/src/harness/index.ts b/tests/e2e/src/harness/index.ts index f5bd5ce6..7830d1fb 100644 --- a/tests/e2e/src/harness/index.ts +++ b/tests/e2e/src/harness/index.ts @@ -1,4 +1,4 @@ -export { spawnApp, type SpawnedApp, type AppOverrides } from "./app.js"; +export { spawnApp, suiteThreadPerCore, type SpawnedApp, type AppOverrides } from "./app.js"; export { AdminClient, waitConfigPropagation, awaitWindowHeadroom } from "./admin.js"; export { ProxyClient } from "./proxy.js"; export { EtcdClient } from "./etcd.js";