rust_web_server/config_reload/mod.rs
1//! Hot configuration reload.
2//!
3//! Re-reads `rws.config.toml` without restarting the server. Non-binding
4//! settings — those that would require a new TCP socket (IP, port,
5//! thread count) or a new TLS acceptor (cert/key paths) — are logged as
6//! ignored. All other settings take effect on the next incoming request.
7//!
8//! # Triggering a reload
9//!
10//! * **Unix signal**: send `SIGHUP` to the server process.
11//! ```bash
12//! kill -HUP $(pidof rws)
13//! ```
14//! * **HTTP endpoint**: `POST /admin/config/reload` (no body required).
15//!
16//! # What is hot-reloadable
17//!
18//! | Setting | Env var |
19//! |---------|---------|
20//! | CORS — all fields | `RWS_CONFIG_CORS_*` |
21//! | Rate-limit thresholds | `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS`, `RWS_CONFIG_RATE_LIMIT_WINDOW_SECS` |
22//! | Log format | `RWS_CONFIG_LOG_FORMAT` |
23//! | Request allocation size | `RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES` |
24//! | Max body size | `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` |
25//!
26//! # What is NOT hot-reloadable (requires restart)
27//!
28//! | Setting | Why |
29//! |---------|-----|
30//! | IP / Port | Bound socket cannot be moved |
31//! | Thread count | Thread pool is fixed at startup |
32//! | TLS cert / key | TLS acceptor is built once at startup |
33
34#[cfg(test)]
35mod tests;
36
37use std::sync::{OnceLock, RwLock};
38use std::sync::atomic::AtomicBool;
39#[cfg(all(unix, feature = "http1"))]
40use std::sync::atomic::Ordering;
41
42use crate::entry_point::Config;
43use crate::entry_point::config_file::override_environment_variables_from_config;
44use crate::rate_limit;
45
46/// Global flag set by the SIGHUP signal handler.
47///
48/// The accept loop checks this between connections and calls [`reload`] when
49/// it is `true`, then resets it to `false`.
50pub static RELOAD_REQUESTED: AtomicBool = AtomicBool::new(false);
51
52/// Snapshot of all hot-reloadable configuration values at a point in time.
53///
54/// Obtain the current snapshot with [`current()`]. The snapshot is updated
55/// atomically every time [`reload()`] completes — no partial reads are possible.
56#[derive(Debug, Clone)]
57pub struct ConfigSnapshot {
58 /// `RWS_CONFIG_CORS_ALLOW_ALL`
59 pub cors_allow_all: bool,
60 /// `RWS_CONFIG_CORS_ALLOW_ORIGINS`
61 pub cors_allow_origins: String,
62 /// `RWS_CONFIG_CORS_ALLOW_CREDENTIALS`
63 pub cors_allow_credentials: String,
64 /// `RWS_CONFIG_CORS_ALLOW_METHODS`
65 pub cors_allow_methods: String,
66 /// `RWS_CONFIG_CORS_ALLOW_HEADERS`
67 pub cors_allow_headers: String,
68 /// `RWS_CONFIG_CORS_EXPOSE_HEADERS`
69 pub cors_expose_headers: String,
70 /// `RWS_CONFIG_CORS_MAX_AGE`
71 pub cors_max_age: String,
72 /// `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS`
73 pub rate_limit_max_requests: u32,
74 /// `RWS_CONFIG_RATE_LIMIT_WINDOW_SECS`
75 pub rate_limit_window_secs: u64,
76 /// `RWS_CONFIG_LOG_FORMAT`
77 pub log_format: String,
78 /// `RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES`
79 pub request_allocation_size: i64,
80 /// `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES`
81 pub max_body_size: u64,
82}
83
84impl ConfigSnapshot {
85 fn from_env() -> Self {
86 let read = |key: &str| std::env::var(key).unwrap_or_default();
87 Self {
88 cors_allow_all: read(Config::RWS_CONFIG_CORS_ALLOW_ALL)
89 .eq_ignore_ascii_case("true"),
90 cors_allow_origins: read(Config::RWS_CONFIG_CORS_ALLOW_ORIGINS),
91 cors_allow_credentials: read(Config::RWS_CONFIG_CORS_ALLOW_CREDENTIALS),
92 cors_allow_methods: read(Config::RWS_CONFIG_CORS_ALLOW_METHODS),
93 cors_allow_headers: read(Config::RWS_CONFIG_CORS_ALLOW_HEADERS),
94 cors_expose_headers: read(Config::RWS_CONFIG_CORS_EXPOSE_HEADERS),
95 cors_max_age: read(Config::RWS_CONFIG_CORS_MAX_AGE),
96 rate_limit_max_requests: std::env::var("RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS")
97 .ok()
98 .and_then(|v| v.parse().ok())
99 .unwrap_or(1000),
100 rate_limit_window_secs: std::env::var("RWS_CONFIG_RATE_LIMIT_WINDOW_SECS")
101 .ok()
102 .and_then(|v| v.parse().ok())
103 .unwrap_or(60),
104 log_format: read(Config::RWS_CONFIG_LOG_FORMAT),
105 request_allocation_size: std::env::var(
106 Config::RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES,
107 )
108 .ok()
109 .and_then(|v| v.parse().ok())
110 .unwrap_or(*Config::RWS_DEFAULT_REQUEST_ALLOCATION_SIZE_IN_BYTES),
111 max_body_size: crate::entry_point::get_max_body_size(),
112 }
113 }
114}
115
116static SNAPSHOT: OnceLock<RwLock<ConfigSnapshot>> = OnceLock::new();
117
118fn global() -> &'static RwLock<ConfigSnapshot> {
119 SNAPSHOT.get_or_init(|| RwLock::new(ConfigSnapshot::from_env()))
120}
121
122/// Returns a clone of the current hot-reloadable configuration snapshot.
123///
124/// This takes a brief read lock and clones a handful of strings — safe to call
125/// on every request if needed.
126pub fn current() -> ConfigSnapshot {
127 global().read().unwrap().clone()
128}
129
130/// Re-read `rws.config.toml` and apply all hot-reloadable changes in-place.
131///
132/// Called automatically when [`RELOAD_REQUESTED`] is set (SIGHUP on Unix) or
133/// from the `POST /admin/config/reload` handler.
134///
135/// Settings that cannot be changed without a restart (IP, port, thread count,
136/// TLS cert/key) are silently ignored — they are re-parsed from the file but
137/// have no effect until the next process start.
138pub fn reload() {
139 // Re-parse rws.config.toml → updates process env vars for CORS, log format, etc.
140 // Only one thread ever calls this (the signal handler or admin endpoint),
141 // while workers only read env vars, so the single-writer / many-readers
142 // pattern is safe in practice on all supported platforms.
143 override_environment_variables_from_config(None);
144
145 let snapshot = ConfigSnapshot::from_env();
146
147 // Apply rate-limit changes to the live global limiter immediately.
148 rate_limit::global().set_limits(
149 snapshot.rate_limit_max_requests,
150 snapshot.rate_limit_window_secs,
151 );
152
153 // Publish the new snapshot atomically.
154 *global().write().unwrap() = snapshot.clone();
155
156 println!(
157 "Config reloaded — cors_allow_all={} rate_limit={}/{} log_format={}",
158 snapshot.cors_allow_all,
159 snapshot.rate_limit_max_requests,
160 snapshot.rate_limit_window_secs,
161 snapshot.log_format,
162 );
163}
164
165/// Install a `SIGHUP` signal handler that sets [`RELOAD_REQUESTED`].
166///
167/// Call this once at server startup (before the accept loop). Safe to call
168/// on non-Unix platforms — it compiles to a no-op.
169///
170/// The handler itself does the minimum allowed in a signal context: it stores
171/// `true` into an `AtomicBool`. The actual [`reload()`] call happens on the
172/// main thread between connection accepts.
173pub fn install_sighup_handler() {
174 #[cfg(all(unix, feature = "http1"))]
175 // SAFETY: The handler only writes to a process-global AtomicBool which is
176 // async-signal-safe. No allocation, no locks, no I/O.
177 unsafe {
178 libc::signal(libc::SIGHUP, sighup_handler as *const () as libc::sighandler_t);
179 }
180}
181
182#[cfg(all(unix, feature = "http1"))]
183extern "C" fn sighup_handler(_: libc::c_int) {
184 RELOAD_REQUESTED.store(true, Ordering::SeqCst);
185}