Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- Hot-reload config storage: `Arc<RwLock<Config>>` → `Arc<ArcSwap<Config>>` (lock-free reads, `Arc` snapshots instead of full `Config` clones)
- `Proxy::config_snapshot()` is now synchronous and returns `Arc<Config>` (was `async fn` returning owned `Config`)
- `Proxy::update_config()` is now synchronous (was `async fn`)

### Fixed

- Hot-reload now applies on every HTTP request, including subsequent requests on keep-alive connections
- Hot-reload on TLS listeners started via `start_with_addr` / `start_tls` now picks up routing changes without restart

### Added

- Dependency: `arc-swap`
- Integration test: config hot-reload over a single keep-alive connection

## [0.4.0] - 2026-05-25

### Added
Expand Down
14 changes: 12 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ thiserror = "2.0.18"
bytes = "1.0"
num_cpus = "1.16"
uuid = { version = "1", features = ["v4"] }
arc-swap = "1"

serde = { version = "1.0", features = ["derive"], optional = true }
serde_json = { version = "1.0", optional = true }
Expand Down
27 changes: 12 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,8 @@ async fn main() -> anyhow::Result<()> {

#### Hot-Reload Configuration

Update configuration at runtime without restart. The proxy uses `Arc<RwLock<Config>>` internally,
so routing and directive changes take effect immediately for new connections.
Update configuration at runtime without restart. The proxy uses `Arc<ArcSwap<Config>>` internally,
so routing and directive changes take effect on the next request (including keep-alive connections).

> **TLS certificates**: cert/key files and `TlsAcceptor` are loaded when a listener starts.
> Hot-reload updates site routing and directives, but **not** TLS certificates — to pick up
Expand All @@ -173,9 +173,9 @@ so routing and directive changes take effect immediately for new connections.
Example:

```rust
use arc_swap::ArcSwap;
use tiny_proxy::{Config, Proxy};
use std::sync::Arc;
use tokio::sync::RwLock;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
Expand All @@ -192,12 +192,9 @@ async fn main() -> anyhow::Result<()> {
}
});

// Update config at runtime — takes effect immediately
// Update config at runtime — takes effect on the next request
let new_config = Config::from_file("new-config.conf")?;
{
let mut guard = config_handle.write().await;
*guard = new_config;
}
config_handle.store(Arc::new(new_config));

handle.await?;
Ok(())
Expand All @@ -208,7 +205,7 @@ Or use the built-in `update_config` method:

```rust
let new_config = Config::from_file("updated-config.conf")?;
proxy.update_config(new_config).await;
proxy.update_config(new_config);
```

## Configuration
Expand Down Expand Up @@ -493,11 +490,11 @@ Enable TLS support — both **frontend TLS termination** (HTTPS listeners) and *
Management API for runtime configuration:

```rust
use arc_swap::ArcSwap;
use tiny_proxy::api;
use std::sync::Arc;
use tokio::sync::RwLock;

let config = Arc::new(RwLock::new(Config::from_file("config.conf")?));
let config = Arc::new(ArcSwap::from_pointee(Config::from_file("config.conf")?));
api::start_api_server("127.0.0.1:8081", config).await?;
```

Expand All @@ -517,11 +514,11 @@ See the [module documentation](https://docs.rs/tiny-proxy) for detailed API refe
- `Config::from_file(path)` - Load configuration from file
- `Config::from_str(content)` - Parse configuration from string
- `Proxy::new(config)` - Create proxy instance
- `Proxy::from_shared(config)` - Create proxy from shared `Arc<RwLock<Config>>`
- `Proxy::from_shared(config)` - Create proxy from shared `Arc<ArcSwap<Config>>`
- `Proxy::start(addr)` - Start proxy server
- `Proxy::shared_config()` - Get `Arc<RwLock<Config>>` for external config updates
- `Proxy::config_snapshot()` - Read current configuration as owned value
- `Proxy::update_config(config)` - Update configuration at runtime (async)
- `Proxy::shared_config()` - Get `Arc<ArcSwap<Config>>` for external config updates
- `Proxy::config_snapshot()` - Read current configuration as `Arc<Config>`
- `Proxy::update_config(config)` - Update configuration at runtime

## Testing

Expand Down
83 changes: 20 additions & 63 deletions examples/hot_reload.rs
Original file line number Diff line number Diff line change
@@ -1,108 +1,65 @@
//! Example of hot-reloading configuration without restarting the proxy
//!
//! This example demonstrates how to:
//! - Load configuration from a file
//! - Create a Proxy instance
//! - Start the proxy server in the background
//! - Monitor the configuration file for changes
//! - Hot-reload the configuration when the file changes
//!
//! Run with:
//! ```bash
//! cargo run --example hot_reload
//! ```
//!
//! Then edit file.conf while the proxy is running to see hot-reload in action.
//! Then edit `file.conf` while the proxy is running.

use arc_swap::ArcSwap;
use std::sync::Arc;
use tiny_proxy::{Config, Proxy};
use tokio::time::{sleep, Duration};
use tracing::{error, info, warn};
use tracing::{error, info};
use tracing_subscriber::{fmt, EnvFilter};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Initialize logging
fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into()))
.init();

info!("Starting tiny-proxy hot-reload example");

let config_path = "file.conf";

// Load initial configuration from file
let config = Config::from_file(config_path)?;
info!(
"Loaded initial configuration for {} site(s)",
config.sites.len()
);

// Create proxy instance
let proxy = std::sync::Arc::new(Proxy::new(config));
let shared = Arc::new(ArcSwap::from_pointee(config));
let proxy = Proxy::from_shared(shared.clone());

// Spawn the proxy in a background task
let _proxy_handle = tokio::spawn(async move {
if let Err(e) = proxy.start("127.0.0.1:8080").await {
eprintln!("Proxy error: {}", e);
}
});

info!("Proxy started in background on http://127.0.0.1:8080");
info!("Proxy started on http://127.0.0.1:8080");
info!("Monitoring {} for changes...", config_path);
info!("Edit the configuration file to see hot-reload in action");

// Track the last modification time of the config file
let mut last_modified = tokio::fs::metadata(config_path).await?.modified()?;

// Monitor for configuration changes
loop {
sleep(Duration::from_secs(2)).await;

// Check if file has been modified
match tokio::fs::metadata(config_path).await {
Ok(metadata) => {
if let Ok(modified) = metadata.modified() {
if modified != last_modified {
info!("Configuration file changed, reloading...");

// Try to load the new configuration
match Config::from_file(config_path) {
Ok(new_config) => {
info!(
"Successfully loaded new configuration with {} site(s)",
new_config.sites.len()
);

// Note: We can't update the running proxy directly from here
// because it's in a separate task. In a real application, you would:
// 1. Use Arc<Mutex<Proxy>> to share the proxy between tasks
// 2. Or use a channel to send the new config to the proxy task
// 3. Or implement a shared configuration store with Arc<RwLock<Config>>

warn!("Note: This example demonstrates hot-reload detection.");
warn!("To actually update the running proxy, you need to share");
warn!("the proxy instance with Arc<Mutex<Proxy>> or similar.");

// For demonstration purposes, we just show the detection
let site_count = new_config.sites.len();
info!(
"New configuration would be applied with {} site(s)",
site_count
);
let metadata = tokio::fs::metadata(config_path).await?;
let modified = metadata.modified()?;
if modified == last_modified {
continue;
}

// Update last_modified timestamp
last_modified = modified;
}
Err(e) => {
error!("Failed to reload configuration: {}", e);
warn!("Continuing with current configuration");
}
}
}
}
info!("Configuration file changed, reloading...");
match Config::from_file(config_path) {
Ok(new_config) => {
let sites_count = new_config.sites.len();
shared.store(Arc::new(new_config));
info!("Configuration updated ({} sites)", sites_count);
last_modified = modified;
}
Err(e) => {
error!("Failed to read file metadata: {}", e);
error!("Failed to reload configuration: {}", e);
}
}
}
Expand Down
22 changes: 11 additions & 11 deletions src/api/endpoints.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! API endpoints for proxy management

use anyhow::Result;
use arc_swap::ArcSwap;
use bytes::Bytes;
use http_body::Body;
use http_body_util::{BodyExt, Full};
Expand All @@ -13,12 +14,12 @@ use crate::config::Config;
/// Handle GET /config
pub async fn handle_get_config<B>(
_req: Request<B>,
config: Arc<tokio::sync::RwLock<Config>>,
config: Arc<ArcSwap<Config>>,
) -> Result<Response<Full<Bytes>>>
where
B: Body,
{
let config = config.read().await;
let config = config.load_full();

let json = serde_json::to_string_pretty(&*config)
.unwrap_or_else(|_| r#"{"error": "Failed to serialize config"}"#.to_string());
Expand Down Expand Up @@ -57,7 +58,7 @@ where
/// ```
pub async fn handle_post_config<B>(
req: Request<B>,
config: Arc<tokio::sync::RwLock<Config>>,
config: Arc<ArcSwap<Config>>,
) -> Result<Response<Full<Bytes>>>
where
B: Body,
Expand Down Expand Up @@ -121,9 +122,8 @@ where

// Atomically replace the configuration
{
let mut guard = config.write().await;
let sites_count = new_config.sites.len();
*guard = new_config;
config.store(Arc::new(new_config));
info!(
"POST /config - Configuration updated successfully ({} sites)",
sites_count
Expand Down Expand Up @@ -182,7 +182,7 @@ mod tests {

#[tokio::test]
async fn test_handle_get_config() {
let config = Arc::new(tokio::sync::RwLock::new(Config {
let config = Arc::new(ArcSwap::from_pointee(Config {
sites: HashMap::new(),
}));

Expand All @@ -194,7 +194,7 @@ mod tests {

#[tokio::test]
async fn test_handle_post_config_valid_json() {
let config = Arc::new(tokio::sync::RwLock::new(Config {
let config = Arc::new(ArcSwap::from_pointee(Config {
sites: HashMap::new(),
}));

Expand All @@ -219,14 +219,14 @@ mod tests {
assert_eq!(response.status(), 200);

// Verify config was actually updated
let guard = config.read().await;
let guard = config.load_full();
assert_eq!(guard.sites.len(), 1);
assert!(guard.sites.contains_key("localhost:8080"));
}

#[tokio::test]
async fn test_handle_post_config_invalid_json() {
let config = Arc::new(tokio::sync::RwLock::new(Config {
let config = Arc::new(ArcSwap::from_pointee(Config {
sites: HashMap::new(),
}));

Expand All @@ -240,13 +240,13 @@ mod tests {
assert_eq!(response.status(), 400);

// Verify config was NOT updated
let guard = config.read().await;
let guard = config.load_full();
assert_eq!(guard.sites.len(), 0);
}

#[tokio::test]
async fn test_handle_post_config_empty_body() {
let config = Arc::new(tokio::sync::RwLock::new(Config {
let config = Arc::new(ArcSwap::from_pointee(Config {
sites: HashMap::new(),
}));

Expand Down
4 changes: 2 additions & 2 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
//! use tiny_proxy::api;
//! use tiny_proxy::Config;
//! use std::sync::Arc;
//! use tokio::sync::RwLock;
//! use arc_swap::ArcSwap;
//!
//! # #[tokio::main]
//! # async fn main() -> anyhow::Result<()> {
//! let config = Arc::new(RwLock::new(Config::from_file("config.conf")?));
//! let config = Arc::new(ArcSwap::from_pointee(Config::from_file("config.conf")?));
//!
//! // Start the management API server
//! api::start_api_server("127.0.0.1:8081", config).await?;
Expand Down
Loading
Loading