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
7 changes: 6 additions & 1 deletion crates/postghost-server/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ pub struct AppState {
}

pub fn build_router(state: AppState) -> Router {
let state = Arc::new(state);
build_router_from_arc(Arc::new(state))
}

/// Build a router from an already-Arc'd state. Used when the server shares
/// the state with the background scheduler task.
pub fn build_router_from_arc(state: Arc<AppState>) -> Router {
Router::new()
.route("/health", get(health))
.route("/api/v1/content", get(list_content).post(create_content))
Expand Down
59 changes: 55 additions & 4 deletions crates/postghost-server/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
pub mod api;
pub mod config;
pub mod iris;
pub mod scheduler;
pub mod storage;

use std::net::SocketAddr;

use anyhow::Context;
use tokio::sync::watch;

pub async fn run(config: config::ServerConfig) -> anyhow::Result<()> {
tracing_subscriber::fmt()
Expand All @@ -27,17 +29,66 @@ pub async fn run(config: config::ServerConfig) -> anyhow::Result<()> {
config.database_url
);

let state = api::AppState {
let state = std::sync::Arc::new(api::AppState {
storage,
iris_client,
config: config.clone(),
};
});

let app = api::build_router(state);
// Shutdown signal — a watch channel the scheduler monitors. axum's graceful
// shutdown drives it via the `with_graceful_shutdown` closure below.
let (shutdown_tx, shutdown_rx) = watch::channel(false);

// Bind the listener BEFORE spawning the scheduler, so a port-in-use failure
// returns early without leaking a detached polling task.
let app = api::build_router_from_arc(state.clone());
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!("PostGhost server listening on {}", addr);
axum::serve(listener, app).await?;

// Spawn the background scheduler now that the listener is bound.
let sched = tokio::spawn(scheduler::run(state.clone(), shutdown_rx));

axum::serve(listener, app)
.with_graceful_shutdown(async move {
// Wait for SIGINT (Ctrl+C) OR SIGTERM — either signals shutdown.
wait_for_shutdown_signal().await;
tracing::info!("shutdown signal received — stopping scheduler");
let _ = shutdown_tx.send(true);
})
.await?;

// After axum returns, wait for the scheduler task to finish so we don't
// exit mid-publish. Bound by a timeout so a hung Iris call can't pin the
// process forever.
match tokio::time::timeout(std::time::Duration::from_secs(10), sched).await {
Ok(Ok(())) => tracing::info!("scheduler stopped cleanly"),
Ok(Err(e)) => tracing::error!("scheduler task error: {e:#}"),
Err(_) => tracing::warn!("scheduler did not stop within 10s — exiting anyway"),
}
Ok(())
}

/// Wait for either SIGINT (Ctrl+C) or SIGTERM.
///
/// `tokio::signal::ctrl_c()` only catches SIGINT. Production shutdown paths
/// (docker stop, systemd, k8s) send SIGTERM, so we must handle both.
#[cfg(unix)]
async fn wait_for_shutdown_signal() {
use tokio::signal::unix::{signal, SignalKind};

let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler");
let mut int = signal(SignalKind::interrupt()).expect("install SIGINT handler");

tokio::select! {
_ = term.recv() => tracing::info!("received SIGTERM"),
_ = int.recv() => tracing::info!("received SIGINT"),
}
}

/// Non-Unix fallback (Windows) — only SIGINT is available.
#[cfg(not(unix))]
async fn wait_for_shutdown_signal() {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("received interrupt signal");
}
Loading
Loading