Restarts drain: SIGTERM stops taking connections and lets requests finish c92a99490cba
The hub listened only for Ctrl-C, so systemctl restart's SIGTERM killed it with requests in flight. Now SIGTERM (or Ctrl-C) begins stopping: the servers stop taking connections (axum's graceful shutdown, axum-server's handle for HTTPS), the live event streams end so they can't hold the drain open (browsers reconnect), and requests in flight get up to 30 s. Requests that arrive between the old process and the new one need systemd to hold the socket across the restart: the hub now serves a socket systemd passes (LISTEN_FDS, socket activation) and binds server.bind otherwise. docs/deploy.md gives oxygenhub.socket and the service's changes. Earth needs them installed (Andy) before restarts are invisible. A test of its own (stopping is process-wide) runs serve, holds an event stream open, begins stopping, and checks the stream ends and serve returns well within the bound. Refs #233
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 21:29 UTC
- Signature
- ✔ signed by Claude Code
- Hash
c92a99490cba0ace087f519730a310ee05b0c0b5fc540d91f7a4c7416eff5769- Depends on
- 5e6d6484c28e 60f11b71a740 67be8ac00c24 83940d53664f 93be9ce4653e b64e2537d702 baebdd18aaa2 c5f3bde466d1 c8684d44d549 d23464c0ac60 de505df5cf8d f6529a2fb9db
- Depended on by
- e57765dee61a 9aee52d392a6 529c703db24d
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 17 atoms, 2 tree operations, 7 files
Browse the tree at this change
added crates/hub/src/shutdown.rs +76 −0
| 1 | + | //! Stopping without cutting anyone off (oxygenhub#233). On SIGTERM (a |
|
| 2 | + | //! `systemctl restart`) or Ctrl-C the hub stops taking connections, ends |
|
| 3 | + | //! its event streams (browsers reconnect by themselves), and gives the |
|
| 4 | + | //! requests in flight up to `DRAIN` to finish. Given a listening socket by |
|
| 5 | + | //! systemd (socket activation, docs/deploy.md), it serves that rather than |
|
| 6 | + | //! binding: the socket outlives the restart, so a request that arrives |
|
| 7 | + | //! between the old process and the new one waits in its queue instead of |
|
| 8 | + | //! being refused. |
|
| 9 | + | ||
| 10 | + | use std::sync::OnceLock; |
|
| 11 | + | use tokio::sync::watch; |
|
| 12 | + | ||
| 13 | + | /// How long the requests in flight get to finish. |
|
| 14 | + | pub const DRAIN: std::time::Duration = std::time::Duration::from_secs(30); |
|
| 15 | + | ||
| 16 | + | fn sender() -> &'static watch::Sender<bool> { |
|
| 17 | + | static STOP: OnceLock<watch::Sender<bool>> = OnceLock::new(); |
|
| 18 | + | STOP.get_or_init(|| watch::channel(false).0) |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | /// Begin stopping. |
|
| 22 | + | pub fn begin() { |
|
| 23 | + | sender().send_replace(true); |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | pub fn is_stopping() -> bool { |
|
| 27 | + | *sender().borrow() |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | /// Done when the hub begins stopping: for event streams to end on, and for |
|
| 31 | + | /// the servers' graceful shutdown. |
|
| 32 | + | pub async fn stopping() { |
|
| 33 | + | let mut rx = sender().subscribe(); |
|
| 34 | + | let _ = rx.wait_for(|s| *s).await; |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | /// Wait for SIGTERM or Ctrl-C, then begin stopping. Installed before the |
|
| 38 | + | /// servers start, so a signal never kills the process outright. |
|
| 39 | + | pub fn on_signal() -> anyhow::Result<impl std::future::Future<Output = ()>> { |
|
| 40 | + | #[cfg(unix)] |
|
| 41 | + | let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; |
|
| 42 | + | Ok(async move { |
|
| 43 | + | #[cfg(unix)] |
|
| 44 | + | tokio::select! { |
|
| 45 | + | _ = term.recv() => tracing::info!("SIGTERM: stopping (requests in flight may take {} s)", DRAIN.as_secs()), |
|
| 46 | + | _ = tokio::signal::ctrl_c() => tracing::info!("stopping"), |
|
| 47 | + | } |
|
| 48 | + | #[cfg(not(unix))] |
|
| 49 | + | { |
|
| 50 | + | let _ = tokio::signal::ctrl_c().await; |
|
| 51 | + | tracing::info!("stopping"); |
|
| 52 | + | } |
|
| 53 | + | begin(); |
|
| 54 | + | }) |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | /// The listening socket systemd passed (LISTEN_FDS, first descriptor 3), |
|
| 58 | + | /// when it passed one to this process. |
|
| 59 | + | pub fn systemd_listener() -> Option<std::net::TcpListener> { |
|
| 60 | + | #[cfg(unix)] |
|
| 61 | + | { |
|
| 62 | + | use std::os::fd::FromRawFd; |
|
| 63 | + | let pid: u32 = std::env::var("LISTEN_PID").ok()?.parse().ok()?; |
|
| 64 | + | let fds: u32 = std::env::var("LISTEN_FDS").ok()?.parse().ok()?; |
|
| 65 | + | if pid != std::process::id() || fds < 1 { |
|
| 66 | + | return None; |
|
| 67 | + | } |
|
| 68 | + | // SAFETY: systemd hands this process descriptor 3 as an open, |
|
| 69 | + | // listening socket (sd_listen_fds(3)); nothing else owns it. |
|
| 70 | + | let listener = unsafe { std::net::TcpListener::from_raw_fd(3) }; |
|
| 71 | + | listener.set_nonblocking(true).ok()?; |
|
| 72 | + | Some(listener) |
|
| 73 | + | } |
|
| 74 | + | #[cfg(not(unix))] |
|
| 75 | + | None |
|
| 76 | + | } |
added crates/hub/tests/shutdown.rs +51 −0
| 1 | + | //! Stopping drains (oxygenhub#233): its own test binary, as stopping is |
|
| 2 | + | //! for the whole process. |
|
| 3 | + | ||
| 4 | + | use oxygenhub::config::Config; |
|
| 5 | + | use oxygenhub::db; |
|
| 6 | + | use tokio::io::{AsyncReadExt, AsyncWriteExt}; |
|
| 7 | + | ||
| 8 | + | /// A live event stream ends when the hub begins stopping, so it cannot |
|
| 9 | + | /// hold the drain open, and `serve` returns well within the drain's bound. |
|
| 10 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 11 | + | async fn stopping_ends_event_streams_and_serve_returns() { |
|
| 12 | + | let home = tempfile::tempdir().unwrap(); |
|
| 13 | + | std::env::set_var("OXY_HOME", home.path()); |
|
| 14 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 15 | + | let mut config = Config::load(dir.path()).unwrap(); |
|
| 16 | + | let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); |
|
| 17 | + | config.server.bind = format!("127.0.0.1:{port}"); |
|
| 18 | + | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| 19 | + | let app = oxygenhub::build(config).await.unwrap(); |
|
| 20 | + | let repo = db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
|
| 21 | + | let server = tokio::spawn(oxygenhub::serve(app.clone())); |
|
| 22 | + | ||
| 23 | + | // An open event stream, as a browser on a live page holds. |
|
| 24 | + | let mut stream = None; |
|
| 25 | + | for _ in 0..50 { |
|
| 26 | + | if let Ok(s) = tokio::net::TcpStream::connect(("127.0.0.1", port)).await { |
|
| 27 | + | stream = Some(s); |
|
| 28 | + | break; |
|
| 29 | + | } |
|
| 30 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
|
| 31 | + | } |
|
| 32 | + | let mut stream = stream.expect("the hub listens"); |
|
| 33 | + | let request = format!("GET /live?topics=repo:{} HTTP/1.1\r\nHost: 127.0.0.1\r\nAccept: text/event-stream\r\n\r\n", repo.id); |
|
| 34 | + | stream.write_all(request.as_bytes()).await.unwrap(); |
|
| 35 | + | let mut head = vec![0u8; 512]; |
|
| 36 | + | let n = stream.read(&mut head).await.unwrap(); |
|
| 37 | + | let head = String::from_utf8_lossy(&head[..n]).to_string(); |
|
| 38 | + | assert!(head.starts_with("HTTP/1.1 200") && head.contains("text/event-stream"), "{head}"); |
|
| 39 | + | ||
| 40 | + | let started = std::time::Instant::now(); |
|
| 41 | + | oxygenhub::shutdown::begin(); |
|
| 42 | + | // The stream ends (the server closes it) rather than staying open. |
|
| 43 | + | let mut rest = Vec::new(); |
|
| 44 | + | tokio::time::timeout(std::time::Duration::from_secs(10), stream.read_to_end(&mut rest)) |
|
| 45 | + | .await |
|
| 46 | + | .expect("the event stream ends when stopping begins") |
|
| 47 | + | .unwrap(); |
|
| 48 | + | tokio::time::timeout(std::time::Duration::from_secs(10), server).await.expect("serve returns once drained").unwrap().unwrap(); |
|
| 49 | + | assert!(started.elapsed() < oxygenhub::shutdown::DRAIN, "drained in {:?}", started.elapsed()); |
|
| 50 | + | assert!(oxygenhub::shutdown::is_stopping()); |
|
| 51 | + | } |
modified crates/hub/src/lib.rs +34 −4
| … | 32 unchanged lines |
||
| 33 | 33 | pub mod repos; |
|
| 34 | 34 | pub mod secrets; |
|
| 35 | 35 | pub mod setup; |
|
| 36 | + | pub mod shutdown; |
|
| 36 | 37 | pub mod signin; |
|
| 37 | 38 | pub mod social; |
|
| 38 | 39 | pub mod storage; |
|
| … | 241 unchanged lines |
||
| 280 | 281 | if http.is_none() && https.is_none() { |
|
| 281 | 282 | anyhow::bail!("nothing to bind: set server.bind or server.tls_bind"); |
|
| 282 | 283 | } |
|
| 284 | + | // Installed first: from here a SIGTERM drains rather than kills. |
|
| 285 | + | let signal = shutdown::on_signal()?; |
|
| 283 | 286 | let mut tasks = tokio::task::JoinSet::new(); |
|
| 284 | 287 | if let Some(addr) = http { |
|
| 285 | - | let listener = tokio::net::TcpListener::bind(addr).await?; |
|
| 286 | - | tracing::info!("http on {addr}"); |
|
| 288 | + | let listener = match shutdown::systemd_listener() { |
|
| 289 | + | Some(l) => { |
|
| 290 | + | tracing::info!("http on the socket systemd holds ({})", l.local_addr().map(|a| a.to_string()).unwrap_or_default()); |
|
| 291 | + | tokio::net::TcpListener::from_std(l)? |
|
| 292 | + | } |
|
| 293 | + | None => { |
|
| 294 | + | tracing::info!("http on {addr}"); |
|
| 295 | + | tokio::net::TcpListener::bind(addr).await? |
|
| 296 | + | } |
|
| 297 | + | }; |
|
| 287 | 298 | let r = router.clone(); |
|
| 288 | 299 | tasks.spawn(async move { |
|
| 289 | - | axum::serve(listener, r.into_make_service_with_connect_info::<std::net::SocketAddr>()).await.map_err(anyhow::Error::from) |
|
| 300 | + | axum::serve(listener, r.into_make_service_with_connect_info::<std::net::SocketAddr>()) |
|
| 301 | + | .with_graceful_shutdown(shutdown::stopping()) |
|
| 302 | + | .await |
|
| 303 | + | .map_err(anyhow::Error::from) |
|
| 290 | 304 | }); |
|
| 291 | 305 | } |
|
| 292 | 306 | if let Some(addr) = https { |
|
| … | 3 unchanged lines |
||
| 296 | 310 | .map_err(|e| anyhow::anyhow!("loading TLS files: {e}"))?; |
|
| 297 | 311 | tracing::info!("https on {addr}"); |
|
| 298 | 312 | let r = router.clone(); |
|
| 313 | + | let handle = axum_server::Handle::new(); |
|
| 314 | + | let draining = handle.clone(); |
|
| 315 | + | tokio::spawn(async move { |
|
| 316 | + | shutdown::stopping().await; |
|
| 317 | + | draining.graceful_shutdown(Some(shutdown::DRAIN)); |
|
| 318 | + | }); |
|
| 299 | 319 | tasks.spawn(async move { |
|
| 300 | 320 | axum_server::bind_rustls(addr, tls) |
|
| 321 | + | .handle(handle) |
|
| 301 | 322 | .serve(r.into_make_service_with_connect_info::<std::net::SocketAddr>()) |
|
| 302 | 323 | .await |
|
| 303 | 324 | .map_err(anyhow::Error::from) |
|
| … | 1 unchanged lines |
||
| 305 | 326 | } |
|
| 306 | 327 | tokio::select! { |
|
| 307 | 328 | r = tasks.join_next() => { r.transpose()?.transpose()?; Ok(()) } |
|
| 308 | - | _ = tokio::signal::ctrl_c() => { tracing::info!("stopping"); Ok(()) } |
|
| 329 | + | _ = signal => { |
|
| 330 | + | // The servers stop taking connections and finish what they |
|
| 331 | + | // have; whatever is still running after DRAIN is cut off. |
|
| 332 | + | let drained = tokio::time::timeout(shutdown::DRAIN, async { while tasks.join_next().await.is_some() {} }).await; |
|
| 333 | + | if drained.is_err() { |
|
| 334 | + | tracing::warn!("requests still running after {} s: stopping anyway", shutdown::DRAIN.as_secs()); |
|
| 335 | + | } |
|
| 336 | + | tracing::info!("stopped"); |
|
| 337 | + | Ok(()) |
|
| 338 | + | } |
|
| 309 | 339 | } |
|
| 310 | 340 | } |
modified crates/hub/src/web/live_stream.rs +3 −1
| … | 126 unchanged lines |
||
| 127 | 127 | // A first comment at once, so a proxy that waits for body bytes (the |
|
| 128 | 128 | // tunnel does) sends the headers now rather than at the first keep-alive. |
|
| 129 | 129 | let hello = std::iter::once(Ok(Event::default().comment("live"))); |
|
| 130 | - | Sse::new(futures::stream::iter(hello.chain(replay)).chain(live)).keep_alive(KeepAlive::default()).into_response() |
|
| 130 | + | Sse::new(futures::StreamExt::take_until(futures::stream::iter(hello.chain(replay)).chain(live), crate::shutdown::stopping())) |
|
| 131 | + | .keep_alive(KeepAlive::default()) |
|
| 132 | + | .into_response() |
|
| 131 | 133 | } |
|
| 132 | 134 | ||
| 133 | 135 | #[cfg(test)] |
|
| … | 16 unchanged lines |
modified crates/hub/src/web/proposals.rs +2 −1
| … | 1321 unchanged lines |
||
| 1322 | 1322 | _ => None, |
|
| 1323 | 1323 | } |
|
| 1324 | 1324 | }); |
|
| 1325 | - | Ok(axum::response::Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default())) |
|
| 1325 | + | Ok(axum::response::Sse::new(futures::StreamExt::take_until(stream, crate::shutdown::stopping())) |
|
| 1326 | + | .keep_alive(axum::response::sse::KeepAlive::default())) |
|
| 1326 | 1327 | } |
|
| 1327 | 1328 | ||
| 1328 | 1329 | // -------------------------------------------------------------------- API |
|
| … | 434 unchanged lines |
modified crates/hub/src/web/resolve.rs +2 −1
| … | 645 unchanged lines |
||
| 646 | 646 | let _held = &seat; |
|
| 647 | 647 | std::future::ready(item.ok().map(|e| event(&e))) |
|
| 648 | 648 | }); |
|
| 649 | - | Ok(axum::response::Sse::new(first.chain(rest)).keep_alive(axum::response::sse::KeepAlive::default())) |
|
| 649 | + | Ok(axum::response::Sse::new(futures::StreamExt::take_until(first.chain(rest), crate::shutdown::stopping())) |
|
| 650 | + | .keep_alive(axum::response::sse::KeepAlive::default())) |
|
| 650 | 651 | } |
|
| 651 | 652 | ||
| 652 | 653 | #[derive(serde::Deserialize)] |
|
| … | 38 unchanged lines |
modified docs/deploy.md +41 −0
| … | 25 unchanged lines |
||
| 26 | 26 | 7. **First repositories**: on the development machine, `oxy remote add earth https://earth.andymccall.xyz:7343/andymccall/oxygen` in each of `oxygen`, `oxygen-vscode`, `oxygenhub`, then `oxy push earth`. |
|
| 27 | 27 | 8. **Updates**: rsync, build, `sudo install`, `sudo systemctl restart oxygenhub`. `make deploy` in the hub wraps steps 2, 3 and 8. |
|
| 28 | 28 | ||
| 29 | + | ## Restarts without errors (oxygenhub#233) |
|
| 30 | + | ||
| 31 | + | A deploy restarts the hub. Since #233, on SIGTERM (what `systemctl restart` |
|
| 32 | + | sends) the hub stops taking connections, ends its live event streams |
|
| 33 | + | (browsers reconnect by themselves) and gives the requests in flight up to |
|
| 34 | + | 30 seconds to finish. Requests that arrive *between* the old process and the |
|
| 35 | + | new one are refused unless systemd holds the listening socket across the |
|
| 36 | + | restart: socket activation. The hub serves a socket systemd passes |
|
| 37 | + | (`LISTEN_FDS`) and binds `server.bind` itself otherwise. |
|
| 38 | + | ||
| 39 | + | To turn it on, add `/etc/systemd/system/oxygenhub.socket`: |
|
| 40 | + | ||
| 41 | + | ```ini |
|
| 42 | + | [Unit] |
|
| 43 | + | Description=Oxygenhub's listening socket, held across restarts |
|
| 44 | + | ||
| 45 | + | [Socket] |
|
| 46 | + | ListenStream=0.0.0.0:7300 |
|
| 47 | + | # A restart's queue: connections wait here, not refused. |
|
| 48 | + | Backlog=512 |
|
| 49 | + | NoDelay=yes |
|
| 50 | + | ||
| 51 | + | [Install] |
|
| 52 | + | WantedBy=sockets.target |
|
| 53 | + | ``` |
|
| 54 | + | ||
| 55 | + | Then give `oxygenhub.service` `Requires=oxygenhub.socket`, |
|
| 56 | + | `After=oxygenhub.socket` and `TimeoutStopSec=40`, and run: |
|
| 57 | + | ||
| 58 | + | ```sh |
|
| 59 | + | sudo systemctl daemon-reload |
|
| 60 | + | sudo systemctl stop oxygenhub |
|
| 61 | + | sudo systemctl enable --now oxygenhub.socket |
|
| 62 | + | sudo systemctl start oxygenhub |
|
| 63 | + | ``` |
|
| 64 | + | ||
| 65 | + | The service's log then says "http on the socket systemd holds". From then on |
|
| 66 | + | `systemctl restart oxygenhub` keeps the socket, so nobody sees a refused |
|
| 67 | + | connection or a Cloudflare 502. HTTPS (7343, the developer edition's |
|
| 68 | + | self-signed listener) still binds itself. |
|
| 69 | + | ||
| 29 | 70 | ## Where things are on Earth |
|
| 30 | 71 | ||
| 31 | 72 | | | | |
|
| … | 77 unchanged lines |