OXYGENHUB

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