From ff88e4484f1f5ee14803132d0fc19ac4bfa0fea6 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 26 Apr 2026 23:36:43 +0300 Subject: [PATCH 1/5] fix: prevent dangling symlink from crashing the build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dangling symlink in the docs directory would crash `mkdocs build` with an unhandled FileNotFoundError during file copy. `os.walk` lists the symlink as a regular file, but when `shutil.copyfile` (or `os.path.getmtime`) follows the symlink to a missing target, the uncaught OSError aborts the entire build. Fix: - `is_modified()`: catch OSError when calling getmtime on the source — return False so the file is skipped silently. - `copy_file()`: catch OSError around copy and log a warning, then skip the file gracefully instead of crashing. Closes: https://github.com/mkdocs/mkdocs/issues/4048 --- mkdocs/structure/files.py | 11 ++++++++--- mkdocs/tests/structure/file_tests.py | 29 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/mkdocs/structure/files.py b/mkdocs/structure/files.py index 98822377..5bffa4a9 100644 --- a/mkdocs/structure/files.py +++ b/mkdocs/structure/files.py @@ -514,6 +514,8 @@ def copy_file(self, dirty: bool = False) -> None: utils.copy_file(self.abs_src_path, output_path) except shutil.SameFileError: pass # Let plugins write directly into site_dir. + except OSError as e: + log.warning(f"Error copying '{self.src_uri}': {e}") elif isinstance(content, str): with open(output_path, "w", encoding="utf-8") as output_file: output_file.write(content) @@ -526,9 +528,12 @@ def is_modified(self) -> bool: return True assert self.abs_src_path is not None if os.path.isfile(self.abs_dest_path): - return os.path.getmtime(self.abs_dest_path) < os.path.getmtime( - self.abs_src_path - ) + try: + return os.path.getmtime(self.abs_dest_path) < os.path.getmtime( + self.abs_src_path + ) + except OSError: + return False return True def is_documentation_page(self) -> bool: diff --git a/mkdocs/tests/structure/file_tests.py b/mkdocs/tests/structure/file_tests.py index b01af37a..c218c078 100644 --- a/mkdocs/tests/structure/file_tests.py +++ b/mkdocs/tests/structure/file_tests.py @@ -803,6 +803,35 @@ def test_copy_file_from_content(self, dest_dir): with open(dest_path, encoding="utf-8") as f: self.assertEqual(f.read(), "ö") + @tempdir() + @tempdir() + def test_copy_file_dangling_symlink(self, src_dir, dest_dir): + dangling = os.path.join(src_dir, "dangling.jpg") + try: + os.symlink(os.path.join(src_dir, "nonexistent"), dangling) + except (OSError, NotImplementedError): + self.skipTest("Creating symlinks not supported") + + file = File("dangling.jpg", src_dir, dest_dir, use_directory_urls=False) + with self.assertLogs("mkdocs.structure.files") as cm: + file.copy_file() + self.assertRegex( + "\n".join(cm.output), + r"^WARNING:mkdocs.structure.files:Error copying 'dangling.jpg'", + ) + + @tempdir() + @tempdir() + def test_copy_file_missing_source(self, src_dir, dest_dir): + """File deleted between discovery and copy should warn, not crash.""" + file = File("missing.txt", src_dir, dest_dir, use_directory_urls=False) + with self.assertLogs("mkdocs.structure.files") as cm: + file.copy_file() + self.assertRegex( + "\n".join(cm.output), + r"^WARNING:mkdocs.structure.files:Error copying 'missing.txt'", + ) + def test_files_append_remove_src_paths(self): fs = [ File("index.md", "/path/to/docs", "/path/to/site", use_directory_urls=True), From ea28da5872baf9ff727c5cbfb52603bb281c1247 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 26 Apr 2026 23:39:53 +0300 Subject: [PATCH 2/5] docs: add release note for dangling symlink crash fix --- docs/about/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/about/release-notes.md b/docs/about/release-notes.md index 1bfc906e..979a6831 100644 --- a/docs/about/release-notes.md +++ b/docs/about/release-notes.md @@ -39,6 +39,7 @@ The current members of the MkDocs-NG team. * Fix `validation.links.not_found` is always reported as INFO for excluded pages. #32 * Fix `mkdocs serve` cleanup so temporary site directories are removed when the process receives `SIGTERM`. #36 * Fix mkdocs theme color mode switching when `highlightjs` is disabled. #39 +* Fix a crash when a dangling symlink exists in the docs directory, so `mkdocs build` and `mkdocs serve` log a warning and continue instead of aborting. #4048 ### Maintenance From 6a544adb3909fb1d189492d732fb5ac9b252f07d Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Sun, 26 Apr 2026 23:41:14 +0300 Subject: [PATCH 3/5] Apply suggestions from code review Co-authored-by: Xianpeng Shen --- docs/about/release-notes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/about/release-notes.md b/docs/about/release-notes.md index 979a6831..b18e4690 100644 --- a/docs/about/release-notes.md +++ b/docs/about/release-notes.md @@ -39,7 +39,7 @@ The current members of the MkDocs-NG team. * Fix `validation.links.not_found` is always reported as INFO for excluded pages. #32 * Fix `mkdocs serve` cleanup so temporary site directories are removed when the process receives `SIGTERM`. #36 * Fix mkdocs theme color mode switching when `highlightjs` is disabled. #39 -* Fix a crash when a dangling symlink exists in the docs directory, so `mkdocs build` and `mkdocs serve` log a warning and continue instead of aborting. #4048 +* Fix a crash when a dangling symlink exists in the docs directory, so `mkdocs build` and `mkdocs serve` log a warning and continue instead of aborting. #43 ### Maintenance From f786f493420ce2cd3ee1c91be39e1f39d038312f Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Mon, 27 Apr 2026 00:13:02 +0300 Subject: [PATCH 4/5] FIx trim trailing whitespace --- docs/about/release-notes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/about/release-notes.md b/docs/about/release-notes.md index b18e4690..f0ca0d3b 100644 --- a/docs/about/release-notes.md +++ b/docs/about/release-notes.md @@ -39,7 +39,7 @@ The current members of the MkDocs-NG team. * Fix `validation.links.not_found` is always reported as INFO for excluded pages. #32 * Fix `mkdocs serve` cleanup so temporary site directories are removed when the process receives `SIGTERM`. #36 * Fix mkdocs theme color mode switching when `highlightjs` is disabled. #39 -* Fix a crash when a dangling symlink exists in the docs directory, so `mkdocs build` and `mkdocs serve` log a warning and continue instead of aborting. #43 +* Fix a crash when a dangling symlink exists in the docs directory, so `mkdocs build` and `mkdocs serve` log a warning and continue instead of aborting. #43 ### Maintenance From 530e18717892392c2e2c23a671672c77818e0ac7 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Mon, 27 Apr 2026 00:32:47 +0300 Subject: [PATCH 5/5] fix: resolve log routing conflict between two dangling symlink fixes PR #3785 added early broken-symlink detection in utils.copy_file() that logged a warning and returned early. PR #4048 added an except OSError handler in files.py expecting the exception to propagate. Because the early return short-circuited before shutil.copyfile, the OSError handler was never reached, and the mkdocs.utils warning leaked to lastResort. Fix: raise FileNotFoundError in utils.copy_file() instead of logging, allowing the existing except OSError handler in files.py to catch it and log with the mkdocs.structure.files logger as expected. --- mkdocs/tests/utils/utils_tests.py | 8 ++------ mkdocs/utils/__init__.py | 3 +-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/mkdocs/tests/utils/utils_tests.py b/mkdocs/tests/utils/utils_tests.py index 940902a8..ca004682 100644 --- a/mkdocs/tests/utils/utils_tests.py +++ b/mkdocs/tests/utils/utils_tests.py @@ -386,15 +386,11 @@ def test_copy_files_without_permissions(self, src_dir, dst_dir): @tempdir() def test_copy_broken_symlink(self, src_dir, dst_dir): # Regression test for mkdocs/mkdocs#3785: dangling symlinks should - # not crash the build. A warning should be logged and the file skipped. + # not crash the build. A warning should be raised and the file skipped. broken_link = os.path.join(src_dir, "broken_link") os.symlink("/nonexistent/path", broken_link) - with self.assertLogs("mkdocs", level="WARNING") as cm: + with self.assertRaises(FileNotFoundError): utils.copy_file(broken_link, os.path.join(dst_dir, "broken_link")) - self.assertTrue( - any("Symlink broken" in m for m in cm.output), - f"Expected a warning about broken symlink, got: {cm.output}", - ) # The broken symlink should not have been copied. self.assertFalse(os.path.exists(os.path.join(dst_dir, "broken_link"))) diff --git a/mkdocs/utils/__init__.py b/mkdocs/utils/__init__.py index 5e56b5e9..9536dffb 100644 --- a/mkdocs/utils/__init__.py +++ b/mkdocs/utils/__init__.py @@ -122,8 +122,7 @@ def copy_file(source_path: str, output_path: str) -> None: if os.path.isdir(output_path): output_path = os.path.join(output_path, os.path.basename(source_path)) if os.path.islink(source_path) and not os.path.exists(os.readlink(source_path)): - log.warning(f"Symlink broken, could not copy file: {source_path}") - return + raise FileNotFoundError(f"Broken symlink: {source_path}") shutil.copyfile(source_path, output_path)