Repository: rust-lang/mdBook (language: rust)

Issue:
mdbook serve doesn't work in Docker
### Problem

Hello, my idea was to change files on host machine, then it will changes files in container and rebuild docs. And of cource, there will be port exposing to open docs on host machine in browser. 
I created Docker file and run container. It didn't work. I checked up, that files, that were changed on my machine, were changed in container and vice versa. According logs md serve didn't respond to changes. 
I'm only begginer, so don't rule out the option, that I did smth wrong.


### Steps

### Dockerfile

```
FROM ubuntu
RUN apt-get update && apt-get install -y curl tar
RUN curl -sSL https://github.com/rust-lang/mdBook/releases/download/v0.4.27/mdbook-v0.4.27-x86_64-unknown-linux-gnu.tar.gz | tar -xz --directory=/bin
COPY /docs /docs
```

### Commads:

* `docker build -f docker/Dockerfile.local.docs -t docs-local-image .`
* `docker run -dp 45000:45000 -w /docs -v "$(pwd)/docs:/docs" --name docs-local docs-local-image sh -c "mdbook serve -n 0.0.0.0 -p 45000"`

### Possible Solution(s)

Mb it's Docker bug?

### Notes

Host OS: Windows 10
Docker version: 24.0.2 

### Version

_No response_


Discussion / hints:
The mdbook serve command is supposed to watch your files for changes and automatically rebuild your book, but there are known issues with file changes not being detected when those files are inside a Docker container. This is not a Docker bug per se, but rather a characteristic of how file change notifications are propagated from the host to the container.
So is it possible to do, what I want?
I experienced the same problem.

I'm wondering how I can solve this problem.
What works for me:

```Dockerfile
FROM alpine:latest

RUN apk update && apk add --no-cache \
    curl \
    tar

RUN curl -L https://github.com/rust-lang/mdBook/releases/download/v0.4.34/mdbook-v0.4.34-x86_64-unknown-linux-musl.tar.gz | tar -xzC /usr/local/bin

WORKDIR /src

# Some workaround to capture ctrl-c
RUN printf "#!/bin/sh\n\$@\n" > /entrypoint.sh && chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]
CMD ["/usr/local/bin/mdbook", "serve", "-p", "3000", "-n", "0.0.0.0"]
```

```console
docker build . -t mdbook
docker run -v $(pwd):/src -p 3000:3000 -it mdbook
```

For me this picks up the changes performed on the host and serves them.
@dioni-dev 

> The mdbook serve command is supposed to watch your files for changes and automatically rebuild your book, but ***there are known issues*** with file changes not being detected when those files are inside a Docker container. This is not a Docker bug per se, but rather a characteristic of how file change notifications are propagated from the host to the container.

Do you have any source? I have the same problem, so I'd like to investigate the issue you refer to.


I can't find the issue related to Docker container specifically, but here goes:

Currently, we are listening for filesystem changes. In theory, this is best: we don't spam the filesystem with pointless calls, and we get immediate feedback on update. Unfortunately, in reality listening for filesystem changes is extremely unreliable, and we should use `PollWatcher` instead. Here's a quick list of issues I've found:

- Notify documentation lists 5 common issues that occur with filesystem events and suggests using `PollWatcher`: https://docs.rs/notify/6.1.1/notify/index.html#known-problems
- Epic footgun due to platform differences: https://github.com/notify-rs/notify/issues/403
- MacOS notify issue: https://github.com/notify-rs/notify/issues/240
- WSL2 notify issue: https://github.com/notify-rs/notify/issues/254

There are probably more.

Alternatively, we can use recommended watcher based on the platform (curiously enough, `PollWatcher` is actually recommended for Linux instead of what we do now): https://docs.rs/notify/6.1.1/src/notify/lib.rs.html#371

Related: 
https://github.com/rust-lang/mdBook/blob/dc21f1497bed2085249ad6fd8537f906ad398ccf/src/cmd/watch.rs#L111C49-L111C49
https://github.com/rust-lang/mdBook/issues/2035
https://github.com/rust-lang/mdBook/issues/1441 (I think?)
https://github.com/rust-lang/mdBook/issues/383

Fix patch:
diff --git a/Cargo.toml b/Cargo.toml
index 2beec26ac9..db1b18253d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -41,6 +41,7 @@ notify = { version = "6.1.1", optional = true }
 notify-debouncer-mini = { version = "0.4.1", optional = true }
 ignore = { version = "0.4.20", optional = true }
 pathdiff = { version = "0.2.1", optional = true }
+walkdir = { version = "2.3.3", optional = true }
 
 # Serve feature
 futures-util = { version = "0.3.28", optional = true }
@@ -61,7 +62,7 @@ walkdir = "2.3.3"
 
 [features]
 default = ["watch", "serve", "search"]
-watch = ["dep:notify", "dep:notify-debouncer-mini", "dep:ignore", "dep:pathdiff"]
+watch = ["dep:notify", "dep:notify-debouncer-mini", "dep:ignore", "dep:pathdiff", "dep:walkdir"]
 serve = ["dep:futures-util", "dep:tokio", "dep:warp"]
 search = ["dep:elasticlunr-rs", "dep:ammonia"]
 
diff --git a/guide/src/cli/arg-watcher.md b/guide/src/cli/arg-watcher.md
new file mode 100644
index 0000000000..bddf2dd206
--- /dev/null
+++ b/guide/src/cli/arg-watcher.md
@@ -0,0 +1,7 @@
+#### `--watcher`
+
+There are different backends used to determine when a file has changed.
+
+* `poll` (default) --- Checks for file modifications by scanning the filesystem every second.
+* `native` --- Uses the native operating system facilities to receive notifications when files change.
+  This can have less constant overhead, but may not be as reliable as the `poll` based watcher. See these issues for more information: [#383](https://github.com/rust-lang/mdBook/issues/383) [#1441](https://github.com/rust-lang/mdBook/issues/1441) [#1707](https://github.com/rust-lang/mdBook/issues/1707) [#2035](https://github.com/rust-lang/mdBook/issues/2035) [#2102](https://github.com/rust-lang/mdBook/issues/2102)
diff --git a/guide/src/cli/serve.md b/guide/src/cli/serve.md
index 62ea26c274..c5ca86ac59 100644
--- a/guide/src/cli/serve.md
+++ b/guide/src/cli/serve.md
@@ -44,6 +44,8 @@ book. Relative paths are interpreted relative to the book's root directory. If
 not specified it will default to the value of the `build.build-dir` key in
 `book.toml`, or to `./book`.
 
+{{#include arg-watcher.md}}
+
 #### Specify exclude patterns
 
 The `serve` command will not automatically trigger a build for files listed in
diff --git a/guide/src/cli/watch.md b/guide/src/cli/watch.md
index 3522f43c65..9e1c648a19 100644
--- a/guide/src/cli/watch.md
+++ b/guide/src/cli/watch.md
@@ -27,6 +27,7 @@ book. Relative paths are interpreted relative to the book's root directory. If
 not specified it will default to the value of the `build.build-dir` key in
 `book.toml`, or to `./book`.
 
+{{#include arg-watcher.md}}
 
 #### Specify exclude patterns
 
diff --git a/src/cmd/command_prelude.rs b/src/cmd/command_prelude.rs
index b6362e6033..ea2d3095fd 100644
--- a/src/cmd/command_prelude.rs
+++ b/src/cmd/command_prelude.rs
@@ -36,6 +36,19 @@ pub trait CommandExt: Sized {
     fn arg_open(self) -> Self {
         self._arg(arg!(-o --open "Opens the compiled book in a web browser"))
     }
+
+    fn arg_watcher(self) -> Self {
+        #[cfg(feature = "watch")]
+        return self._arg(
+            Arg::new("watcher")
+                .long("watcher")
+                .value_parser(["poll", "native"])
+                .default_value("poll")
+                .help("The filesystem watching technique"),
+        );
+        #[cfg(not(feature = "watch"))]
+        return self;
+    }
 }
 
 impl CommandExt for Command {
diff --git a/src/cmd/serve.rs b/src/cmd/serve.rs
index eeb19cb371..5e637a023b 100644
--- a/src/cmd/serve.rs
+++ b/src/cmd/serve.rs
@@ -6,7 +6,6 @@ use clap::builder::NonEmptyStringValueParser;
 use futures_util::sink::SinkExt;
 use futures_util::StreamExt;
 use mdbook::errors::*;
-use mdbook::utils;
 use mdbook::utils::fs::get_404_output_file;
 use mdbook::MDBook;
 use std::net::{SocketAddr, ToSocketAddrs};
@@ -43,12 +42,13 @@ pub fn make_subcommand() -> Command {
                 .help("Port to use for HTTP connections"),
         )
         .arg_open()
+        .arg_watcher()
 }
 
 // Serve command implementation
 pub fn execute(args: &ArgMatches) -> Result<()> {
     let book_dir = get_book_dir(args);
-    let mut book = MDBook::load(book_dir)?;
+    let mut book = MDBook::load(&book_dir)?;
 
     let port = args.get_one::<String>("port").unwrap();
     let hostname = args.get_one::<String>("hostname").unwrap();
@@ -97,23 +97,12 @@ pub fn execute(args: &ArgMatches) -> Result<()> {
     }
 
     #[cfg(feature = "watch")]
-    watch::trigger_on_change(&book, move |paths, book_dir| {
-        info!("Files changed: {:?}", paths);
-        info!("Building book...");
-
-        // FIXME: This area is really ugly because we need to re-set livereload :(
-        let result = MDBook::load(book_dir).and_then(|mut b| {
-            update_config(&mut b);
-            b.build()
-        });
-
-        if let Err(e) = result {
-            error!("Unable to load the book");
-            utils::log_backtrace(&e);
-        } else {
+    {
+        let watcher = watch::WatcherKind::from_str(args.get_one::<String>("watcher").unwrap());
+        watch::rebuild_on_change(watcher, &book_dir, &update_config, &move || {
             let _ = tx.send(Message::text("reload"));
-        }
-    });
+        });
+    }
 
     let _ = thread_handle.join();
 
diff --git a/src/cmd/watch.rs b/src/cmd/watch.rs
index ef3371d87a..7adb2bbb58 100644
--- a/src/cmd/watch.rs
+++ b/src/cmd/watch.rs
@@ -1,14 +1,11 @@
 use super::command_prelude::*;
 use crate::{get_book_dir, open};
-use ignore::gitignore::Gitignore;
 use mdbook::errors::Result;
-use mdbook::utils;
 use mdbook::MDBook;
-use pathdiff::diff_paths;
 use std::path::{Path, PathBuf};
-use std::sync::mpsc::channel;
-use std::thread::sleep;
-use std::time::Duration;
+
+mod native;
+mod poller;
 
 // Create clap subcommand arguments
 pub fn make_subcommand() -> Command {
@@ -17,12 +14,28 @@ pub fn make_subcommand() -> Command {
         .arg_dest_dir()
         .arg_root_dir()
         .arg_open()
+        .arg_watcher()
+}
+
+pub enum WatcherKind {
+    Poll,
+    Native,
+}
+
+impl WatcherKind {
+    pub fn from_str(s: &str) -> WatcherKind {
+        match s {
+            "poll" => WatcherKind::Poll,
+            "native" => WatcherKind::Native,
+            _ => panic!("unsupported watcher {s}"),
+        }
+    }
 }
 
 // Watch command implementation
 pub fn execute(args: &ArgMatches) -> Result<()> {
     let book_dir = get_book_dir(args);
-    let mut book = MDBook::load(book_dir)?;
+    let mut book = MDBook::load(&book_dir)?;
 
     let update_config = |book: &mut MDBook| {
         if let Some(dest_dir) = args.get_one::<PathBuf>("dest-dir") {
@@ -41,42 +54,21 @@ pub fn execute(args: &ArgMatches) -> Result<()> {
         open(path);
     }
 
-    trigger_on_change(&book, |paths, book_dir| {
-        info!("Files changed: {:?}\nBuilding book...\n", paths);
-        let result = MDBook::load(book_dir).and_then(|mut b| {
-            update_config(&mut b);
-            b.build()
-        });
-
-        if let Err(e) = result {
-            error!("Unable to build the book");
-            utils::log_backtrace(&e);
-        }
-    });
+    let watcher = WatcherKind::from_str(args.get_one::<String>("watcher").unwrap());
+    rebuild_on_change(watcher, &book_dir, &update_config, &|| {});
 
     Ok(())
 }
 
-fn remove_ignored_files(book_root: &Path, paths: &[PathBuf]) -> Vec<PathBuf> {
-    if paths.is_empty() {
-        return vec![];
-    }
-
-    match find_gitignore(book_root) {
-        Some(gitignore_path) => {
-            let (ignore, err) = Gitignore::new(&gitignore_path);
-            if let Some(err) = err {
-                warn!(
-                    "error reading gitignore `{}`: {err}",
-                    gitignore_path.display()
-                );
-            }
-            filter_ignored_files(ignore, paths)
-        }
-        None => {
-            // There is no .gitignore file.
-            paths.iter().map(|path| path.to_path_buf()).collect()
-        }
+pub fn rebuild_on_change(
+    kind: WatcherKind,
+    book_dir: &Path,
+    update_config: &dyn Fn(&mut MDBook),
+    post_build: &dyn Fn(),
+) {
+    match kind {
+        WatcherKind::Poll => self::poller::rebuild_on_change(book_dir, update_config, post_build),
+        WatcherKind::Native => self::native::rebuild_on_change(book_dir, update_config, post_build),
     }
 }
 
@@ -86,144 +78,3 @@ fn find_gitignore(book_root: &Path) -> Option<PathBuf> {
         .map(|p| p.join(".gitignore"))
         .find(|p| p.exists())
 }
-
-// Note: The usage of `canonicalize` may encounter occasional failures on the Windows platform, presenting a potential risk.
-// For more details, refer to [Pull Request #2229](https://github.com/rust-lang/mdBook/pull/2229#discussion_r1408665981).
-fn filter_ignored_files(ignore: Gitignore, paths: &[PathBuf]) -> Vec<PathBuf> {
-    let ignore_root = ignore
-        .path()
-        .canonicalize()
-        .expect("ignore root canonicalize error");
-
-    paths
-        .iter()
-        .filter(|path| {
-            let relative_path =
-                diff_paths(path, &ignore_root).expect("One of the paths should be an absolute");
-            !ignore
-                .matched_path_or_any_parents(&relative_path, relative_path.is_dir())
-                .is_ignore()
-        })
-        .map(|path| path.to_path_buf())
-        .collect()
-}
-
-/// Calls the closure when a book source file is changed, blocking indefinitely.
-pub fn trigger_on_change<F>(book: &MDBook, closure: F)
-where
-    F: Fn(Vec<PathBuf>, &Path),
-{
-    use notify::RecursiveMode::*;
-
-    // Create a channel to receive the events.
-    let (tx, rx) = channel();
-
-    let mut debouncer = match notify_debouncer_mini::new_debouncer(Duration::from_secs(1), tx) {
-        Ok(d) => d,
-        Err(e) => {
-            error!("Error while trying to watch the files:\n\n\t{:?}", e);
-            std::process::exit(1)
-        }
-    };
-    let watcher = debouncer.watcher();
-
-    // Add the source directory to the watcher
-    if let Err(e) = watcher.watch(&book.source_dir(), Recursive) {
-        error!("Error while watching {:?}:\n    {:?}", book.source_dir(), e);
-        std::process::exit(1);
-    };
-
-    let _ = watcher.watch(&book.theme_dir(), Recursive);
-
-    // Add the book.toml file to the watcher if it exists
-    let _ = watcher.watch(&book.root.join("book.toml"), NonRecursive);
-
-    for dir in &book.config.build.extra_watch_dirs {
-        let path = book.root.join(dir);
-        let canonical_path = path.canonicalize().unwrap_or_else(|e| {
-            error!("Error while watching extra directory {path:?}:\n    {e}");
-            std::process::exit(1);
-        });
-
-        if let Err(e) = watcher.watch(&canonical_path, Recursive) {
-            error!(
-                "Error while watching extra directory {:?}:\n    {:?}",
-                canonical_path, e
-            );
-            std::process::exit(1);
-        }
-    }
-
-    info!("Listening for changes...");
-
-    loop {
-        let first_event = rx.recv().unwrap();
-        sleep(Duration::from_millis(50));
-        let other_events = rx.try_iter();
-
-        let all_events = std::iter::once(first_event).chain(other_events);
-
-        let paths: Vec<_> = all_events
-            .filter_map(|event| match event {
-                Ok(events) => Some(events),
-                Err(error) => {
-                    log::warn!("error while watching for changes: {error}");
-                    None
-                }
-            })
-            .flatten()
-            .map(|event| event.path)
-            .collect();
-
-        // If we are watching files outside the current repository (via extra-watch-dirs), then they are definitionally
-        // ignored by gitignore. So we handle this case by including such files into the watched paths list.
-        let any_external_paths = paths.iter().filter(|p| !p.starts_with(&book.root)).cloned();
-        let mut paths = remove_ignored_files(&book.root, &paths[..]);
-        paths.extend(any_external_paths);
-
-        if !paths.is_empty() {
-            closure(paths, &book.root);
-        }
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use ignore::gitignore::GitignoreBuilder;
-    use std::env;
-
-    #[test]
-    fn test_filter_ignored_files() {
-        let current_dir = env::current_dir().unwrap();
-
-        let ignore = GitignoreBuilder::new(&current_dir)
-            .add_line(None, "*.html")
-            .unwrap()
-            .build()
-            .unwrap();
-        let should_remain = current_dir.join("record.text");
-        let should_filter = current_dir.join("index.html");
-
-        let remain = filter_ignored_files(ignore, &[should_remain.clone(), should_filter]);
-        assert_eq!(remain, vec![should_remain])
-    }
-
-    #[test]
-    fn filter_ignored_files_should_handle_parent_dir() {
-        let current_dir = env::current_dir().unwrap();
-
-        let ignore = GitignoreBuilder::new(&current_dir)
-            .add_line(None, "*.html")
-            .unwrap()
-            .build()
-            .unwrap();
-
-        let parent_dir = current_dir.join("..");
-        let should_remain = parent_dir.join("record.text");
-        let should_filter = parent_dir.join("index.html");
-
-        let remain = filter_ignored_files(ignore, &[should_remain.clone(), should_filter]);
-        assert_eq!(remain, vec![should_remain])
-    }
-}
diff --git a/src/cmd/watch/native.rs b/src/cmd/watch/native.rs
new file mode 100644
index 0000000000..fad8d7ce85
--- /dev/null
+++ b/src/cmd/watch/native.rs
@@ -0,0 +1,189 @@
+//! A filesystem watcher using native operating system facilities.
+
+use ignore::gitignore::Gitignore;
+use mdbook::MDBook;
+use std::path::{Path, PathBuf};
+use std::sync::mpsc::channel;
+use std::thread::sleep;
+use std::time::Duration;
+
+pub fn rebuild_on_change(
+    book_dir: &Path,
+    update_config: &dyn Fn(&mut MDBook),
+    post_build: &dyn Fn(),
+) {
+    use notify::RecursiveMode::*;
+
+    let mut book = MDBook::load(book_dir).unwrap_or_else(|e| {
+        error!("failed to load book: {e}");
+        std::process::exit(1);
+    });
+
+    // Create a channel to receive the events.
+    let (tx, rx) = channel();
+
+    let mut debouncer = match notify_debouncer_mini::new_debouncer(Duration::from_secs(1), tx) {
+        Ok(d) => d,
+        Err(e) => {
+            error!("Error while trying to watch the files:\n\n\t{:?}", e);
+            std::process::exit(1)
+        }
+    };
+    let watcher = debouncer.watcher();
+
+    // Add the source directory to the watcher
+    if let Err(e) = watcher.watch(&book.source_dir(), Recursive) {
+        error!("Error while watching {:?}:\n    {:?}", book.source_dir(), e);
+        std::process::exit(1);
+    };
+
+    let _ = watcher.watch(&book.theme_dir(), Recursive);
+
+    // Add the book.toml file to the watcher if it exists
+    let _ = watcher.watch(&book.root.join("book.toml"), NonRecursive);
+
+    for dir in &book.config.build.extra_watch_dirs {
+        let path = book.root.join(dir);
+        let canonical_path = path.canonicalize().unwrap_or_else(|e| {
+            error!("Error while watching extra directory {path:?}:\n    {e}");
+            std::process::exit(1);
+        });
+
+        if let Err(e) = watcher.watch(&canonical_path, Recursive) {
+            error!(
+                "Error while watching extra directory {:?}:\n    {:?}",
+                canonical_path, e
+            );
+            std::process::exit(1);
+        }
+    }
+
+    info!("Listening for changes...");
+
+    loop {
+        let first_event = rx.recv().unwrap();
+        sleep(Duration::from_millis(50));
+        let other_events = rx.try_iter();
+
+        let all_events = std::iter::once(first_event).chain(other_events);
+
+        let paths: Vec<_> = all_events
+            .filter_map(|event| match event {
+                Ok(events) => Some(events),
+                Err(error) => {
+                    log::warn!("error while watching for changes: {error}");
+                    None
+                }
+            })
+            .flatten()
+            .map(|event| event.path)
+            .collect();
+
+        // If we are watching files outside the current repository (via extra-watch-dirs), then they are definitionally
+        // ignored by gitignore. So we handle this case by including such files into the watched paths list.
+        let any_external_paths = paths.iter().filter(|p| !p.starts_with(&book.root)).cloned();
+        let mut paths = remove_ignored_files(&book.root, &paths[..]);
+        paths.extend(any_external_paths);
+
+        if !paths.is_empty() {
+            info!("Files changed: {paths:?}");
+            match MDBook::load(book_dir) {
+                Ok(mut b) => {
+                    update_config(&mut b);
+                    if let Err(e) = b.build() {
+                        error!("failed to build the book: {e:?}");
+                    } else {
+                        post_build();
+                    }
+                    book = b;
+                }
+                Err(e) => error!("failed to load book config: {e:?}"),
+            }
+        }
+    }
+}
+
+fn remove_ignored_files(book_root: &Path, paths: &[PathBuf]) -> Vec<PathBuf> {
+    if paths.is_empty() {
+        return vec![];
+    }
+
+    match super::find_gitignore(book_root) {
+        Some(gitignore_path) => {
+            let (ignore, err) = Gitignore::new(&gitignore_path);
+            if let Some(err) = err {
+                warn!(
+                    "error reading gitignore `{}`: {err}",
+                    gitignore_path.display()
+                );
+            }
+            filter_ignored_files(ignore, paths)
+        }
+        None => {
+            // There is no .gitignore file.
+            paths.iter().map(|path| path.to_path_buf()).collect()
+        }
+    }
+}
+
+// Note: The usage of `canonicalize` may encounter occasional failures on the Windows platform, presenting a potential risk.
+// For more details, refer to [Pull Request #2229](https://github.com/rust-lang/mdBook/pull/2229#discussion_r1408665981).
+fn filter_ignored_files(ignore: Gitignore, paths: &[PathBuf]) -> Vec<PathBuf> {
+    let ignore_root = ignore
+        .path()
+        .canonicalize()
+        .expect("ignore root canonicalize error");
+
+    paths
+        .iter()
+        .filter(|path| {
+            let relative_path = pathdiff::diff_paths(&path, &ignore_root)
+                .expect("One of the paths should be an absolute");
+            !ignore
+                .matched_path_or_any_parents(&relative_path, relative_path.is_dir())
+                .is_ignore()
+        })
+        .map(|path| path.to_path_buf())
+        .collect()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use ignore::gitignore::GitignoreBuilder;
+    use std::env;
+
+    #[test]
+    fn test_filter_ignored_files() {
+        let current_dir = env::current_dir().unwrap();
+
+        let ignore = GitignoreBuilder::new(&current_dir)
+            .add_line(None, "*.html")
+            .unwrap()
+            .build()
+            .unwrap();
+        let should_remain = current_dir.join("record.text");
+        let should_filter = current_dir.join("index.html");
+
+        let remain = filter_ignored_files(ignore, &[should_remain.clone(), should_filter]);
+        assert_eq!(remain, vec![should_remain])
+    }
+
+    #[test]
+    fn filter_ignored_files_should_handle_parent_dir() {
+        let current_dir = env::current_dir().unwrap();
+
+        let ignore = GitignoreBuilder::new(&current_dir)
+            .add_line(None, "*.html")
+            .unwrap()
+            .build()
+            .unwrap();
+
+        let parent_dir = current_dir.join("..");
+        let should_remain = parent_dir.join("record.text");
+        let should_filter = parent_dir.join("index.html");
+
+        let remain = filter_ignored_files(ignore, &[should_remain.clone(), should_filter]);
+        assert_eq!(remain, vec![should_remain])
+    }
+}
diff --git a/src/cmd/watch/poller.rs b/src/cmd/watch/poller.rs
new file mode 100644
index 0000000000..5e1d149748
--- /dev/null
+++ b/src/cmd/watch/poller.rs
@@ -0,0 +1,386 @@
+//! A simple poll-based filesystem watcher.
+//!
+//! This exists because the native change notifications have historically had
+//! lots of problems. Various operating systems and different filesystems have
+//! had problems correctly reporting changes.
+
+use ignore::gitignore::Gitignore;
+use mdbook::MDBook;
+use pathdiff::diff_paths;
+use std::collections::HashMap;
+use std::fs::FileType;
+use std::path::{Path, PathBuf};
+use std::time::{Duration, Instant, SystemTime};
+use walkdir::WalkDir;
+
+/// Calls the closure when a book source file is changed, blocking indefinitely.
+pub fn rebuild_on_change(
+    book_dir: &Path,
+    update_config: &dyn Fn(&mut MDBook),
+    post_build: &dyn Fn(),
+) {
+    let mut book = MDBook::load(book_dir).unwrap_or_else(|e| {
+        error!("failed to load book: {e}");
+        std::process::exit(1);
+    });
+
+    let mut watcher = Watcher::new(book_dir);
+
+    info!("Watching for changes...");
+    // Scan once to initialize the starting point.
+    watcher.set_roots(&book);
+    watcher.scan();
+
+    // Track average scan time, to help investigate if the poller is taking
+    // undesirably long. This is not a rigorous benchmark, just a rough
+    // estimate.
+    const AVG_SIZE: usize = 60;
+    let mut avgs = vec![0.0; AVG_SIZE];
+    let mut avg_i = 0;
+
+    loop {
+        std::thread::sleep(Duration::new(1, 0));
+        watcher.set_roots(&book);
+        let start = Instant::now();
+        let paths = watcher.scan();
+        let elapsed = start.elapsed().as_secs_f64();
+        avgs[avg_i] = elapsed;
+        avg_i += 1;
+        if avg_i >= AVG_SIZE {
+            avg_i = 0;
+            let avg = avgs.iter().sum::<f64>() / (avgs.len() as f64);
+            trace!(
+                "scan average time: {avg:.2}s, scan size is {}",
+                watcher.path_data.len()
+            );
+        }
+
+        if !paths.is_empty() {
+            info!("Files changed: {paths:?}");
+            match MDBook::load(book_dir) {
+                Ok(mut b) => {
+                    update_config(&mut b);
+                    if let Err(e) = b.build() {
+                        error!("failed to build the book: {e:?}");
+                    } else {
+                        post_build();
+                    }
+                    book = b;
+                }
+                Err(e) => error!("failed to load book config: {e:?}"),
+            }
+        }
+    }
+}
+
+#[derive(PartialEq)]
+struct PathData {
+    file_type: FileType,
+    mtime: SystemTime,
+    size: u64,
+}
+
+/// A very simple poll-watcher that scans for modified files.
+#[derive(Default)]
+struct Watcher {
+    /// The root paths where it will recursively scan for changes.
+    root_paths: Vec<PathBuf>,
+    /// Data about files on disk.
+    path_data: HashMap<PathBuf, PathData>,
+    /// Filters paths that will be watched.
+    ignore: Option<(PathBuf, Gitignore)>,
+}
+
+impl Watcher {
+    fn new(book_root: &Path) -> Watcher {
+        // FIXME: ignore should be reloaded when it changes.
+        let ignore = super::find_gitignore(book_root).map(|gitignore_path| {
+            let (ignore, err) = Gitignore::new(&gitignore_path);
+            if let Some(err) = err {
+                warn!(
+                    "error reading gitignore `{}`: {err}",
+                    gitignore_path.display()
+                );
+            }
+            // Note: The usage of `canonicalize` may encounter occasional
+            // failures on the Windows platform, presenting a potential risk.
+            // For more details, refer to [Pull Request
+            // #2229](https://github.com/rust-lang/mdBook/pull/2229#discussion_r1408665981).
+            let ignore_path = ignore
+                .path()
+                .canonicalize()
+                .expect("ignore root canonicalize error");
+            (ignore_path, ignore)
+        });
+
+        Watcher {
+            ignore,
+            ..Default::default()
+        }
+    }
+
+    /// Sets the root directories where scanning will start.
+    fn set_roots(&mut self, book: &MDBook) {
+        let mut root_paths = vec![
+            book.source_dir(),
+            book.theme_dir(),
+            book.root.join("book.toml"),
+        ];
+        root_paths.extend(
+            book.config
+                .build
+                .extra_watch_dirs
+                .iter()
+                .map(|path| book.root.join(path)),
+        );
+        if let Some(html_config) = book.config.html_config() {
+            root_paths.extend(
+                html_config
+                    .additional_css
+                    .iter()
+                    .chain(html_config.additional_js.iter())
+                    .map(|path| book.root.join(path)),
+            );
+        }
+
+        self.root_paths = root_paths;
+    }
+
+    /// Scans for changes.
+    ///
+    /// Returns the paths that have changed.
+    fn scan(&mut self) -> Vec<PathBuf> {
+        let ignore = &self.ignore;
+        let new_path_data: HashMap<_, _> = self
+            .root_paths
+            .iter()
+            .filter(|root| root.exists())
+            .flat_map(|root| {
+                WalkDir::new(root)
+                    .follow_links(true)
+                    .into_iter()
+                    .filter_entry(|entry| {
+                        if let Some((ignore_path, ignore)) = ignore {
+                            let path = entry.path();
+                            // Canonicalization helps with removing `..` and
+                            // `.` entries, which can cause issues with
+                            // diff_paths.
+                            let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
+                            let relative_path = diff_paths(&path, &ignore_path)
+                                .expect("One of the paths should be an absolute");
+                            if ignore
+                                .matched_path_or_any_parents(&relative_path, relative_path.is_dir())
+                                .is_ignore()
+                            {
+                                trace!("ignoring {path:?}");
+                                return false;
+                            }
+                        }
+                        true
+                    })
+                    .filter_map(move |entry| {
+                        let entry = match entry {
+                            Ok(e) => e,
+                            Err(e) => {
+                                debug!("failed to scan {root:?}: {e}");
+                                return None;
+                            }
+                        };
+                        if entry.file_type().is_dir() {
+                            // Changes to directories themselves aren't
+                            // particularly interesting.
+                            return None;
+                        }
+                        let path = entry.path().to_path_buf();
+
+                        let meta = match entry.metadata() {
+                            Ok(meta) => meta,
+                            Err(e) => {
+                                debug!("failed to scan {path:?}: {e}");
+                                return None;
+                            }
+                        };
+                        let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
+                        let pd = PathData {
+                            file_type: meta.file_type(),
+                            mtime,
+                            size: meta.len(),
+                        };
+                        Some((path, pd))
+                    })
+            })
+            .collect();
+        let mut paths = Vec::new();
+        for (new_path, new_data) in &new_path_data {
+            match self.path_data.get(new_path) {
+                Some(old_data) => {
+                    if new_data != old_data {
+                        paths.push(new_path.to_path_buf());
+                    }
+                }
+                None => {
+                    paths.push(new_path.clone());
+                }
+            }
+        }
+        for old_path in self.path_data.keys() {
+            if !new_path_data.contains_key(old_path) {
+                paths.push(old_path.to_path_buf());
+            }
+        }
+        self.path_data = new_path_data;
+        paths
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Helper for testing the watcher.
+    fn check_watch_behavior(
+        gitignore_path: &str,
+        gitignore: &str,
+        book_root_path: &str,
+        ignored: &[&str],
+        not_ignored: &[&str],
+        extra_setup: &dyn Fn(&Path),
+    ) {
+        // Create the book and initialize things.
+        let temp = tempfile::Builder::new()
+            .prefix("mdbook-")
+            .tempdir()
+            .unwrap();
+        let root = temp.path();
+        let book_root = root.join(book_root_path);
+        // eprintln!("book_root={book_root:?}",);
+        MDBook::init(&book_root).build().unwrap();
+        std::fs::write(root.join(gitignore_path), gitignore).unwrap();
+        let create = |paths: &[&str]| {
+            let mut paths = paths
+                .iter()
+                .map(|path| root.join(path))
+                .inspect(|path| {
+                    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+                    std::fs::write(path, "initial content").unwrap();
+                })
+                .map(|path| path.canonicalize().unwrap())
+                .collect::<Vec<_>>();
+            paths.sort();
+            paths
+        };
+        let ignored = create(ignored);
+        let not_ignored = create(not_ignored);
+        extra_setup(&book_root);
+        // Create a watcher and check its behavior.
+        let book = MDBook::load(&book_root).unwrap();
+        let mut watcher = Watcher::new(&book_root);
+        watcher.set_roots(&book);
+        // Do an initial scan to initialize its state.
+        watcher.scan();
+        // Verify the steady state is empty.
+        let changed = watcher.scan();
+        assert_eq!(changed, Vec::<PathBuf>::new());
+        // Modify all files, and verify that only not_ignored are detected.
+        for path in ignored.iter().chain(not_ignored.iter()) {
+            std::fs::write(path, "modified").unwrap();
+        }
+        let changed = watcher.scan();
+        let mut changed = changed
+            .into_iter()
+            .map(|p| p.canonicalize().unwrap())
+            .collect::<Vec<_>>();
+        changed.sort();
+        assert_eq!(changed, not_ignored);
+        // Verify again that steady state is empty.
+        let changed = watcher.scan();
+        assert_eq!(changed, Vec::<PathBuf>::new());
+    }
+
+    #[test]
+    fn test_ignore() {
+        // Basic gitignore test.
+        check_watch_behavior(
+            "foo/.gitignore",
+            "*.tmp",
+            "foo",
+            &["foo/src/somefile.tmp"],
+            &["foo/src/chapter.md"],
+            &|_book_root| {},
+        );
+    }
+
+    #[test]
+    fn test_ignore_in_parent() {
+        // gitignore is in the parent of the book
+        check_watch_behavior(
+            ".gitignore",
+            "*.tmp\nsomedir/\n/inroot\n/foo/src/inbook\n",
+            "foo",
+            &[
+                "foo/src/somefile.tmp",
+                "foo/src/somedir/somefile",
+                "inroot/somefile",
+                "foo/src/inbook/somefile",
+            ],
+            &["foo/src/inroot/somefile"],
+            &|_book_root| {},
+        );
+    }
+
+    #[test]
+    fn test_ignore_canonical() {
+        // test with path with ..
+        check_watch_behavior(
+            ".gitignore",
+            "*.tmp\nsomedir/\n/foo/src/inbook\n",
+            "bar/../foo",
+            &[
+                "foo/src/somefile.tmp",
+                "foo/src/somedir/somefile",
+                "foo/src/inbook/somefile",
+            ],
+            &["foo/src/chapter.md"],
+            &|_book_root| {},
+        );
+    }
+
+    #[test]
+    fn test_scan_extra_watch() {
+        // Check behavior with extra-watch-dirs
+        check_watch_behavior(
+            ".gitignore",
+            "*.tmp\n/outside-root/ignoreme\n/foo/examples/ignoreme\n",
+            "foo",
+            &[
+                "foo/src/somefile.tmp",
+                "foo/examples/example.tmp",
+                "outside-root/somefile.tmp",
+                "outside-root/ignoreme",
+                "foo/examples/ignoreme",
+            ],
+            &[
+                "foo/src/chapter.md",
+                "foo/examples/example.rs",
+                "foo/examples/example2.rs",
+                "outside-root/image.png",
+            ],
+            &|book_root| {
+                std::fs::write(
+                    book_root.join("book.toml"),
+                    r#"
+                        [book]
+                        title = "foo"
+
+                        [build]
+                        extra-watch-dirs = [
+                            "examples",
+                            "../outside-root",
+                        ]
+                    "#,
+                )
+                .unwrap();
+            },
+        );
+    }
+}


Test patch: