Skip to content

Commit a8da753

Browse files
committed
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.
1 parent 9190b5d commit a8da753

3 files changed

Lines changed: 174 additions & 16 deletions

File tree

Cargo.lock

Lines changed: 113 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ walkdir = { workspace = true, optional = true }
108108
# Serve feature
109109
axum = { workspace = true, features = ["ws"], optional = true }
110110
futures-util = { workspace = true, optional = true }
111+
listenfd = { version = "1", optional = true }
111112
tokio = { workspace = true, features = ["macros", "rt-multi-thread"], optional = true }
112113
tower-http = { workspace = true, features = ["fs", "trace"], optional = true }
113114

@@ -126,7 +127,7 @@ walkdir.workspace = true
126127
[features]
127128
default = ["watch", "serve", "search"]
128129
watch = ["dep:notify", "dep:notify-debouncer-mini", "dep:ignore", "dep:pathdiff", "dep:walkdir"]
129-
serve = ["dep:futures-util", "dep:tokio", "dep:axum", "dep:tower-http"]
130+
serve = ["dep:futures-util", "dep:listenfd", "dep:tokio", "dep:axum", "dep:tower-http"]
130131
search = ["mdbook-html/search"]
131132

132133
[[bin]]

src/cmd/serve.rs

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,15 @@ pub fn make_subcommand() -> Command {
4040
.long("port")
4141
.num_args(1)
4242
.default_value("3000")
43-
.value_parser(NonEmptyStringValueParser::new())
43+
.value_parser(clap::value_parser!(u16))
4444
.help("Port to use for HTTP connections"),
4545
)
46+
.arg(
47+
Arg::new("socket-activate")
48+
.long("socket-activate")
49+
.num_args(0)
50+
.help("Use a pre-bound socket from LISTEN_FDS (systemd/foreman socket activation)"),
51+
)
4652
.arg_open()
4753
.arg_watcher()
4854
}
@@ -52,11 +58,18 @@ pub fn execute(args: &ArgMatches) -> Result<()> {
5258
let book_dir = get_book_dir(args);
5359
let mut book = MDBook::load(&book_dir)?;
5460

55-
let port = args.get_one::<String>("port").unwrap();
61+
let port = *args.get_one::<u16>("port").unwrap();
5662
let hostname = args.get_one::<String>("hostname").unwrap();
5763
let open_browser = args.get_flag("open");
58-
59-
let address = format!("{hostname}:{port}");
64+
let port_explicitly_set =
65+
args.value_source("port") == Some(clap::parser::ValueSource::CommandLine);
66+
let hostname_explicitly_set =
67+
args.value_source("hostname") == Some(clap::parser::ValueSource::CommandLine);
68+
let socket_activate = args.get_flag("socket-activate");
69+
70+
if socket_activate && (port_explicitly_set || hostname_explicitly_set) {
71+
anyhow::bail!("--socket-activate cannot be used with --port or --hostname");
72+
}
6073

6174
let update_config = |book: &mut MDBook| {
6275
book.config
@@ -69,10 +82,31 @@ pub fn execute(args: &ArgMatches) -> Result<()> {
6982
update_config(&mut book);
7083
book.build()?;
7184

72-
let sockaddr: SocketAddr = address
73-
.to_socket_addrs()?
74-
.next()
75-
.ok_or_else(|| anyhow::anyhow!("no address found for {}", address))?;
85+
// Bind or adopt the listener in the parent thread so we can log
86+
// the actual address before spawning the server.
87+
let listener = if socket_activate {
88+
take_listenfd().ok_or_else(|| {
89+
anyhow::anyhow!(
90+
"LISTEN_FDS not set or no TCP listener at fd 3; \
91+
--socket-activate requires exactly one pre-bound TCP socket"
92+
)
93+
})?
94+
} else if let Some(l) = (!port_explicitly_set && !hostname_explicitly_set)
95+
.then(take_listenfd)
96+
.flatten()
97+
{
98+
l
99+
} else {
100+
let address = format!("{hostname}:{port}");
101+
let sockaddr: SocketAddr = address
102+
.to_socket_addrs()?
103+
.next()
104+
.ok_or_else(|| anyhow::anyhow!("no address found for {}", address))?;
105+
std::net::TcpListener::bind(sockaddr)?
106+
};
107+
108+
let local_addr = listener.local_addr()?;
109+
76110
let build_dir = book.build_dir_for("html");
77111
let html_config = book.config.html_config().unwrap_or_default();
78112
let file_404 = html_config.get_404_output_file();
@@ -82,11 +116,11 @@ pub fn execute(args: &ArgMatches) -> Result<()> {
82116

83117
let reload_tx = tx.clone();
84118
let thread_handle = std::thread::spawn(move || {
85-
serve(build_dir, sockaddr, reload_tx, &file_404);
119+
serve(build_dir, listener, reload_tx, &file_404);
86120
});
87121

88-
let serving_url = format!("http://{address}");
89-
info!("Serving on: {}", serving_url);
122+
let serving_url = format!("http://{local_addr}");
123+
info!("Serving on: {serving_url}");
90124

91125
if open_browser {
92126
open(serving_url);
@@ -108,7 +142,7 @@ pub fn execute(args: &ArgMatches) -> Result<()> {
108142
#[tokio::main]
109143
async fn serve(
110144
build_dir: PathBuf,
111-
address: SocketAddr,
145+
std_listener: std::net::TcpListener,
112146
reload_tx: broadcast::Sender<Message>,
113147
file_404: &str,
114148
) {
@@ -132,13 +166,23 @@ async fn serve(
132166
std::process::exit(1);
133167
}));
134168

135-
let listener = tokio::net::TcpListener::bind(&address)
136-
.await
137-
.unwrap_or_else(|e| panic!("Unable to bind to {address}: {e}"));
169+
std_listener
170+
.set_nonblocking(true)
171+
.expect("failed to set nonblocking");
172+
let listener = tokio::net::TcpListener::from_std(std_listener)
173+
.expect("failed to convert listener to tokio");
138174

139175
axum::serve(listener, app).await.unwrap();
140176
}
141177

178+
/// Try to adopt a TCP listener from LISTEN_FDS.
179+
fn take_listenfd() -> Option<std::net::TcpListener> {
180+
let mut listenfd = listenfd::ListenFd::from_env();
181+
listenfd
182+
.take_tcp_listener(0)
183+
.expect("failed to take listenfd TCP listener")
184+
}
185+
142186
async fn websocket_connection(ws: WebSocket, reload_tx: broadcast::Sender<Message>) {
143187
let (mut user_ws_tx, _user_ws_rx) = ws.split();
144188
let mut rx = reload_tx.subscribe();

0 commit comments

Comments
 (0)