From a25052ea5d60ac43c4e10953cb85a8948c3c78dd Mon Sep 17 00:00:00 2001 From: John Ericson Date: Thu, 7 May 2026 14:02:11 -0400 Subject: [PATCH] Support socket activation for `mdbook serve` Add a `--socket-activate` flag that adopts a pre-bound TCP listener from `LISTEN_FDS` instead of binding a new one. This allows process managers like foreman (with Socketfile support) or systemd to own the socket, so it survives server restarts. Three modes: - `--socket-activate`: require a passed-in socket, fail if absent - `--port N` / `--hostname H`: always bind, ignore `LISTEN_FDS` - Neither: try `LISTEN_FDS` first, fall back to binding the default The listener is now bound in the main thread before spawning the server, so the actual address is always known for logging and `--open`. Also parse `--port` as `u16` at arg-parse time instead of leaving it as a string. Socket activation is normally associated with systemd. And indeed, it would be peculiar to wire up this development command with systemd, but it is also used in other contexts more appropriate to this command, like https://github.com/mitsuhiko/systemfd, a development tool. From a Capsicum/WASI perspective, it also is generally better when tools can consume resources provided by a more privileged caller, rather than having to open them themselves. For these reasons, I think everything should support socket-activation. --- Cargo.lock | 113 +++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 3 +- src/cmd/serve.rs | 68 +++++++++++++++++++++------- 3 files changed, 168 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 20ccbc8101..b565babd75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -212,6 +212,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "bytes" version = "1.11.1" @@ -910,6 +916,18 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -966,6 +984,17 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "listenfd" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b87bc54a4629b4294d0b3ef041b64c40c611097a677d9dc07b2c67739fe39dba" +dependencies = [ + "libc", + "uuid", + "winapi", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -1050,6 +1079,7 @@ dependencies = [ "futures-util", "glob", "ignore", + "listenfd", "mdbook-core", "mdbook-driver", "mdbook-html", @@ -1715,6 +1745,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.23" @@ -2385,6 +2421,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -2431,6 +2477,51 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2477,6 +2568,22 @@ dependencies = [ "string_cache_codegen 0.6.1", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2486,6 +2593,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index ff8766c268..33b6da0c7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,6 +108,7 @@ walkdir = { workspace = true, optional = true } # Serve feature axum = { workspace = true, features = ["ws"], optional = true } futures-util = { workspace = true, optional = true } +listenfd = { version = "1", optional = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"], optional = true } tower-http = { workspace = true, features = ["fs", "trace"], optional = true } @@ -126,7 +127,7 @@ walkdir.workspace = true [features] default = ["watch", "serve", "search"] watch = ["dep:notify", "dep:notify-debouncer-mini", "dep:ignore", "dep:pathdiff", "dep:walkdir"] -serve = ["dep:futures-util", "dep:tokio", "dep:axum", "dep:tower-http"] +serve = ["dep:futures-util", "dep:listenfd", "dep:tokio", "dep:axum", "dep:tower-http"] search = ["mdbook-html/search"] [[bin]] diff --git a/src/cmd/serve.rs b/src/cmd/serve.rs index 255c077d98..0304de2108 100644 --- a/src/cmd/serve.rs +++ b/src/cmd/serve.rs @@ -40,9 +40,16 @@ pub fn make_subcommand() -> Command { .long("port") .num_args(1) .default_value("3000") - .value_parser(NonEmptyStringValueParser::new()) + .value_parser(clap::value_parser!(u16)) .help("Port to use for HTTP connections"), ) + .arg( + Arg::new("socket-activate") + .long("socket-activate") + .num_args(0) + .conflicts_with_all(["hostname", "port"]) + .help("Use a pre-bound socket from LISTEN_FDS (systemd/foreman socket activation)"), + ) .arg_open() .arg_watcher() } @@ -52,11 +59,13 @@ pub fn execute(args: &ArgMatches) -> Result<()> { let book_dir = get_book_dir(args); let mut book = MDBook::load(&book_dir)?; - let port = args.get_one::("port").unwrap(); + let port = *args.get_one::("port").unwrap(); let hostname = args.get_one::("hostname").unwrap(); let open_browser = args.get_flag("open"); - - let address = format!("{hostname}:{port}"); + let bind_explicitly_set = args.value_source("port") + == Some(clap::parser::ValueSource::CommandLine) + || args.value_source("hostname") == Some(clap::parser::ValueSource::CommandLine); + let socket_activate = args.get_flag("socket-activate"); let update_config = |book: &mut MDBook| { book.config @@ -69,10 +78,37 @@ pub fn execute(args: &ArgMatches) -> Result<()> { update_config(&mut book); book.build()?; - let sockaddr: SocketAddr = address - .to_socket_addrs()? - .next() - .ok_or_else(|| anyhow::anyhow!("no address found for {}", address))?; + // Two ways to obtain a listener; depending on the flags we try + // one or both, in order. + let from_env = || -> Option { + listenfd::ListenFd::from_env() + .take_tcp_listener(0) + .expect("failed to take listenfd TCP listener") + }; + let from_bind = || -> Result { + let address = format!("{hostname}:{port}"); + let sockaddr: SocketAddr = address + .to_socket_addrs()? + .next() + .ok_or_else(|| anyhow::anyhow!("no address found for {}", address))?; + Ok(std::net::TcpListener::bind(sockaddr)?) + }; + + let listener = if socket_activate { + from_env().ok_or_else(|| { + anyhow::anyhow!( + "LISTEN_FDS not set or no TCP listener at fd 3; \ + --socket-activate requires exactly one pre-bound TCP socket" + ) + })? + } else if bind_explicitly_set { + from_bind()? + } else { + from_env().map_or_else(|| from_bind(), Ok)? + }; + + let local_addr = listener.local_addr()?; + let build_dir = book.build_dir_for("html"); let html_config = book.config.html_config().unwrap_or_default(); let file_404 = html_config.get_404_output_file(); @@ -82,11 +118,11 @@ pub fn execute(args: &ArgMatches) -> Result<()> { let reload_tx = tx.clone(); let thread_handle = std::thread::spawn(move || { - serve(build_dir, sockaddr, reload_tx, &file_404); + serve(build_dir, listener, reload_tx, &file_404); }); - let serving_url = format!("http://{address}"); - info!("Serving on: {}", serving_url); + let serving_url = format!("http://{local_addr}"); + info!("Serving on: {serving_url}"); if open_browser { open(serving_url); @@ -108,7 +144,7 @@ pub fn execute(args: &ArgMatches) -> Result<()> { #[tokio::main] async fn serve( build_dir: PathBuf, - address: SocketAddr, + std_listener: std::net::TcpListener, reload_tx: broadcast::Sender, file_404: &str, ) { @@ -132,9 +168,11 @@ async fn serve( std::process::exit(1); })); - let listener = tokio::net::TcpListener::bind(&address) - .await - .unwrap_or_else(|e| panic!("Unable to bind to {address}: {e}")); + std_listener + .set_nonblocking(true) + .expect("failed to set nonblocking"); + let listener = tokio::net::TcpListener::from_std(std_listener) + .expect("failed to convert listener to tokio"); axum::serve(listener, app).await.unwrap(); }