diff --git a/extensions/mdbook/private/mdbook.bzl b/extensions/mdbook/private/mdbook.bzl index d4c63d5219..1fac752918 100644 --- a/extensions/mdbook/private/mdbook.bzl +++ b/extensions/mdbook/private/mdbook.bzl @@ -105,23 +105,39 @@ def _rlocationpath(file, workspace_name): return "{}/{}".format(workspace_name, file.short_path) +def _src_dest_path(file): + """Returns the path of `file` inside the staged workdir, mirroring the build action's `_map_inputs`.""" + dest = file.short_path + if dest.startswith("../"): + dest = "external/" + dest.removeprefix("../") + return dest + def _mdbook_server_impl(ctx): toolchain = ctx.toolchains["@rules_rust_mdbook//:toolchain_type"] book_info = ctx.attr.book[MdBookInfo] args = ctx.actions.args() - args.add("--mdbook={}".format(_rlocationpath(toolchain.mdbook, ctx.workspace_name))) - args.add("--config={}".format(_rlocationpath(book_info.config, ctx.workspace_name))) + workspace_name = ctx.workspace_name + + args.add("--mdbook={}".format(_rlocationpath(toolchain.mdbook, workspace_name))) + args.add("--config={}".format(_src_dest_path(book_info.config))) args.add("--hostname={}".format(ctx.attr.hostname)) args.add("--port={}".format(ctx.attr.port)) - workspace_name = ctx.workspace_name + def _src_map(file): + return "--src={}={}".format(_rlocationpath(file, workspace_name), _src_dest_path(file)) - def _runfile_map(file): + # The set of files that must be staged into the workdir for `mdbook serve` to + # see a consistent source tree. `book.toml` is included so that referencing it + # by `--config` resolves to a real file rather than a runfiles symlink. + book_inputs = depset([book_info.config], transitive = [book_info.srcs]) + args.add_all(book_inputs, map_each = _src_map, allow_closure = True) + + def _plugin_map(file): return "--plugin={}".format(_rlocationpath(file, workspace_name)) - args.add_all(depset(transitive = [book_info.plugins, toolchain.plugins]), map_each = _runfile_map, allow_closure = True) + args.add_all(depset(transitive = [book_info.plugins, toolchain.plugins]), map_each = _plugin_map, allow_closure = True) args_file = ctx.actions.declare_file("{}.mdbook_serve_args.txt".format(ctx.label.name)) ctx.actions.write( @@ -167,7 +183,32 @@ def _mdbook_server_impl(ctx): mdbook_server = rule( implementation = _mdbook_server_impl, - doc = "Spawn an mdbook server for a given `mdbook` target.", + doc = """\ +Spawn an mdbook server for a given `mdbook` target. + +The server stages every input (including generated sources) into an isolated \ +working directory before invoking `mdbook serve`, so the running book always \ +reflects the bazel-built sources rather than the workspace checkout. + +For live-reload during development, add `tags = ["ibazel_notify_changes"]` and \ +invoke with [ibazel](https://github.com/bazelbuild/bazel-watcher): + +```python +mdbook_server( + name = "book_server", + book = ":book", + tags = ["ibazel_notify_changes"], +) +``` + +```sh +ibazel run //path/to:book_server +``` + +ibazel will rebuild on source changes and signal the running server via stdin; \ +the server re-stages the freshly built inputs and `mdbook serve` reloads any \ +connected browsers. +""", attrs = { "book": attr.label( doc = "The `mdbook` target to serve.", diff --git a/extensions/mdbook/private/server.rs b/extensions/mdbook/private/server.rs index 8856a29992..2b32388ddd 100644 --- a/extensions/mdbook/private/server.rs +++ b/extensions/mdbook/private/server.rs @@ -1,10 +1,27 @@ //! A process wrapper for `mdbook serve`. - -use std::path::PathBuf; +//! +//! `mdbook serve` cannot be pointed directly at the runfiles tree: `book.toml` +//! and source files appear as symlinks back to the workspace (for hand-written +//! sources) and to `bazel-out/` (for generated sources). When mdbook resolves +//! relative paths it follows the `book.toml` symlink to the workspace, fails to +//! find generated chapters there, and helpfully writes empty stubs back into +//! the workspace. Instead, stage every input into an isolated workdir of real +//! files and serve from that. +//! +//! When run under `ibazel` with the `ibazel_notify_changes` tag, the server +//! listens on stdin for `IBAZEL_BUILD_COMPLETED SUCCESS` messages and +//! re-stages the inputs each time, which `mdbook serve`'s internal file +//! watcher picks up and reloads any connected browsers. + +use std::collections::BTreeMap; +use std::io::{self, BufRead}; +use std::path::{Path, PathBuf}; use std::process::Command; -use std::{env, fs}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::{env, fs, thread}; -use runfiles::rlocation; +use runfiles::{rlocation, Runfiles}; #[cfg(target_family = "unix")] const PATH_SEP: &str = ":"; @@ -12,10 +29,13 @@ const PATH_SEP: &str = ":"; #[cfg(target_family = "windows")] const PATH_SEP: &str = ";"; +const RULES_MDBOOK_TMP_NAME: &str = "rules_mdbook_server"; + struct Args { pub mdbook: PathBuf, - pub config: PathBuf, + /// Path of `book.toml` relative to the staging workdir. + pub config_dest: PathBuf, pub hostname: String, @@ -23,94 +43,191 @@ struct Args { pub plugins: Vec, + /// Map of `dest_relative_to_workdir -> absolute_runfiles_path` for every + /// input that must be staged for `mdbook serve` to see a consistent tree. + pub srcs: BTreeMap, + pub mdbook_args: Vec, } impl Args { - pub fn parse() -> Self { - let runfiles = runfiles::Runfiles::create().unwrap(); - + pub fn parse(runfiles: &Runfiles) -> Self { let args_env = env::var("RULES_MDBOOK_SERVE_ARGS_FILE").unwrap(); let args_file = rlocation!(runfiles, args_env).unwrap(); let raw_args = action_args::try_parse_args(&args_file).unwrap(); let mut mdbook: Option = None; - let mut config: Option = None; + let mut config_dest: Option = None; let mut hostname: Option = None; let mut port: Option = None; let mut plugins: Vec = Vec::new(); + let mut srcs: BTreeMap = BTreeMap::new(); for arg in raw_args { - if arg.starts_with("--mdbook=") { - let val = arg.split_once("=").unwrap().1; + if let Some(val) = arg.strip_prefix("--mdbook=") { mdbook = Some(rlocation!(runfiles, val).unwrap()); - } else if arg.starts_with("--plugin=") { - let val = arg.split_once("=").unwrap().1.to_string(); + } else if let Some(val) = arg.strip_prefix("--plugin=") { plugins.push(rlocation!(runfiles, val).unwrap()); - } else if arg.starts_with("--config=") { - let val = arg.split_once("=").unwrap().1.to_string(); - config = Some(rlocation!(runfiles, val).unwrap()); - } else if arg.starts_with("--hostname=") { - hostname = Some(arg.split_once("=").unwrap().1.to_string()); - } else if arg.starts_with("--port=") { - port = Some(arg.split_once("=").unwrap().1.to_string()); + } else if let Some(val) = arg.strip_prefix("--config=") { + config_dest = Some(PathBuf::from(val)); + } else if let Some(val) = arg.strip_prefix("--hostname=") { + hostname = Some(val.to_string()); + } else if let Some(val) = arg.strip_prefix("--port=") { + port = Some(val.to_string()); + } else if let Some(val) = arg.strip_prefix("--src=") { + let (rloc, dest) = val.split_once('=').unwrap_or_else(|| { + panic!("Malformed --src arg (expected `rlocation=dest`): {}", val) + }); + let resolved = rlocation!(runfiles, rloc) + .unwrap_or_else(|| panic!("Failed to resolve src runfile: {}", rloc)); + srcs.insert(PathBuf::from(dest), resolved); } } Self { mdbook: mdbook.unwrap(), - config: config.unwrap(), + config_dest: config_dest.unwrap(), hostname: hostname.unwrap(), port: port.unwrap(), plugins, + srcs, mdbook_args: env::args().skip(1).collect(), } } } -const RULES_MDBOOK_TMP_NAME: &str = "rules_mdbook_server"; - -fn make_temp_dir() -> PathBuf { - if let Ok(var) = env::var("TMP") { - return PathBuf::from(var).join(RULES_MDBOOK_TMP_NAME); - } - - if let Ok(var) = env::var("TEMP") { - return PathBuf::from(var).join(RULES_MDBOOK_TMP_NAME); - } - - if let Ok(var) = env::var("TMPDIR") { - return PathBuf::from(var).join(RULES_MDBOOK_TMP_NAME); - } - - if let Ok(var) = env::var("TEMPDIR") { - return PathBuf::from(var).join(RULES_MDBOOK_TMP_NAME); +fn tmp_root() -> PathBuf { + for var in ["TMP", "TEMP", "TMPDIR", "TEMPDIR"] { + if let Ok(val) = env::var(var) { + return PathBuf::from(val); + } } let tmp = PathBuf::from("/tmp"); if tmp.exists() { - return tmp.join(RULES_MDBOOK_TMP_NAME); + return tmp; } - if let Ok(var) = env::var("USERPROFILE") { - let tmp = PathBuf::from(var) + if let Ok(val) = env::var("USERPROFILE") { + let tmp = PathBuf::from(val) .join("AppData") .join("Local") .join("Temp"); if tmp.exists() { - return tmp.join(RULES_MDBOOK_TMP_NAME); + return tmp; } } panic!("Could not determine how to create temp dir.") } +/// Create a fresh, process-unique scratch directory under the system temp root. +fn make_scratch_dir(suffix: &str) -> PathBuf { + let pid = std::process::id(); + let path = tmp_root() + .join(RULES_MDBOOK_TMP_NAME) + .join(format!("{}-{}", pid, suffix)); + if path.exists() { + fs::remove_dir_all(&path).expect("failed to clear stale scratch dir"); + } + fs::create_dir_all(&path).expect("failed to create scratch dir"); + path +} + +/// Copy every staged input into `workdir`, replacing any existing copy. +/// Removing-then-copying ensures the destination gets a fresh inode and mtime, +/// which is what mdbook's notify-based watcher reacts to on re-stage. +fn stage_inputs(workdir: &Path, srcs: &BTreeMap) -> io::Result<()> { + for (dest, src) in srcs { + let abs_dest = workdir.join(dest); + if !abs_dest.starts_with(workdir) { + panic!( + "Refusing to stage outside workdir: {} -> {}", + src.display(), + abs_dest.display() + ); + } + if let Some(parent) = abs_dest.parent() { + fs::create_dir_all(parent)?; + } + // Best-effort remove so the write produces a new inode for inotify. + let _ = fs::remove_file(&abs_dest); + fs::copy(src, &abs_dest).map_err(|e| { + io::Error::new( + e.kind(), + format!( + "copy `{}` -> `{}`: {}", + src.display(), + abs_dest.display(), + e + ), + ) + })?; + } + Ok(()) +} + +/// If `IBAZEL_NOTIFY_CHANGES=y` is set, ibazel will write +/// `IBAZEL_BUILD_STARTED\n` / `IBAZEL_BUILD_COMPLETED \n` to +/// our stdin. Re-stage inputs on each successful completion so `mdbook serve`'s +/// file watcher reloads connected browsers. +fn spawn_ibazel_watcher(workdir: PathBuf, srcs: Arc>) { + if env::var("IBAZEL_NOTIFY_CHANGES").ok().as_deref() != Some("y") { + return; + } + + thread::spawn(move || { + let stdin = io::stdin(); + let mut reader = stdin.lock(); + let mut line = String::new(); + let reloads = AtomicU64::new(0); + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) => break, + Ok(_) => { + let trimmed = line.trim(); + if trimmed.starts_with("IBAZEL_BUILD_COMPLETED") && trimmed.ends_with("SUCCESS") + { + match stage_inputs(&workdir, &srcs) { + Ok(()) => { + let n = reloads.fetch_add(1, Ordering::Relaxed) + 1; + eprintln!( + "[mdbook_server] re-staged sources (#{}) after ibazel build", + n + ); + } + Err(e) => eprintln!("[mdbook_server] re-stage failed: {}", e), + } + } + } + Err(_) => break, + } + } + }); +} + fn main() { - let args = Args::parse(); + let runfiles = Runfiles::create().unwrap(); + let args = Args::parse(&runfiles); + + let workdir = make_scratch_dir("workdir"); + let dest_dir = make_scratch_dir("dest"); + + stage_inputs(&workdir, &args.srcs).expect("Initial stage of mdbook inputs failed"); + + let staged_config = workdir.join(&args.config_dest); + let book_dir = staged_config + .parent() + .expect("staged book.toml has no parent") + .to_path_buf(); + + let srcs = Arc::new(args.srcs); + spawn_ibazel_watcher(workdir.clone(), Arc::clone(&srcs)); let mut command = Command::new(&args.mdbook); - // Inject plugin paths into PATH + // Inject plugin paths into PATH. let pwd = env::current_dir().expect("Unable to determine current working directory"); if !args.plugins.is_empty() { let path = env::var("PATH").unwrap_or_default(); @@ -119,12 +236,12 @@ fn main() { .plugins .iter() .map(|p| { - if p.is_absolute() { - p.parent().unwrap().to_string_lossy().to_string() + let abs = if p.is_absolute() { + p.clone() } else { - let abs = pwd.join(p); - abs.parent().unwrap().to_string_lossy().to_string() - } + pwd.join(p) + }; + abs.parent().unwrap().to_string_lossy().to_string() }) .collect::>() .join(PATH_SEP); @@ -132,12 +249,8 @@ fn main() { command.env("PATH", format!("{}{}{}", plugin_path, PATH_SEP, path)); } - command - .arg("serve") - .arg(args.config.parent().unwrap()) - .args(&args.mdbook_args); + command.arg("serve").arg(&book_dir).args(&args.mdbook_args); - // Add default hostname value if commandline was not specified. if !args.mdbook_args.iter().any(|arg| { ["-n", "--hostname"].contains(&arg.as_str()) || arg.starts_with("-n=") @@ -146,7 +259,6 @@ fn main() { command.args(["--hostname", &args.hostname]); } - // Add default port value if commandline was not specified. if !args.mdbook_args.iter().any(|arg| { ["-p", "--port"].contains(&arg.as_str()) || arg.starts_with("-p=") @@ -155,26 +267,24 @@ fn main() { command.args(["--port", &args.port]); } - // Check if `-d` or `--dest-dir` was passed. If not, make a temp dir - let temp_dir: Option = if !args.mdbook_args.iter().any(|a| { + // We always own the output dir; users overriding `--dest-dir` is rare and + // mostly meaningful in build-mode only. + let user_dest_dir = args.mdbook_args.iter().any(|a| { ["-d", "--dest-dir"].contains(&a.as_str()) || a.starts_with("-d=") || a.starts_with("--dest-dir=") - }) { - let temp_dir = make_temp_dir(); - command.arg("--dest-dir").arg(&temp_dir); - Some(temp_dir) - } else { - None - }; - - // Run mdbook and save output + }); + if !user_dest_dir { + command.arg("--dest-dir").arg(&dest_dir); + } + let status = command .status() .unwrap_or_else(|e| panic!("Failed to spawn mdbook command\n{:?}\n{:#?}", e, command)); - if let Some(path) = temp_dir { - fs::remove_dir_all(&path).unwrap(); + let _ = fs::remove_dir_all(&workdir); + if !user_dest_dir { + let _ = fs::remove_dir_all(&dest_dir); } if !status.success() {