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(); }