Content root: hard-link the base files on Windows instead of copying them

mod_content::link mirrors the base resource root into the content root: a
symlink per entry on Unix, but a copy of every file on Windows, where symlinks
need a privilege. That is ~28,000 files and 0.6 GiB written again on every
rebuild, and the root is rebuilt after every update because its stamp holds
the executable.

A hard link needs no privilege on NTFS and costs a name, not the bytes. It
shares the base file's data, which is safe here because the root is only ever
replaced name by name (Overlay::write removes the name before it writes) and
nothing else writes into it. Where a link cannot be made (another volume, a
filesystem without links, the NTFS limit on links to one file) the file is
copied as before, and a symlink in the base is still copied through. The
resource-pack overlay's link has done the same since 3c7e4ba57.

Replaying the file operations of a 76-mod pack's rebuild (NVMe, Defender on),
the base step took about 24 s as copies and about 9 s as links. The same
build() on a prepared root with one small mod took 19-20 s with copies and
11-12 s with links.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
mmmaarian03-cryptoandClaude Sonnet 5.5 committed 2026-11-02 15:48:14 +02:00
1 parent b9a80d6199
commit 7bd3ccc656
2 files changed
+183 -1

No files matched your search

+18 -1
View File
@@ -2033,9 +2033,26 @@ fn link(target: &Path, at: &Path) -> std::io::Result<()> {
}
Ok(())
} else {
fs::copy(target, at).map(|_| ())
link_file(target, at, |target, at| fs::hard_link(target, at))
}
}
/// A file of the overlay where symlinks need a privilege: a hard link to the base's file, which costs a name where
/// a copy costs the file's bytes (the base is ~28,000 files, 0.6 GiB), or a copy when no link can be made (another
/// volume, a filesystem without links, the NTFS limit on links to one file). A link shares the base file's data,
/// which is safe because the overlay is only ever replaced name by name (`Overlay::write` removes the name before it
/// writes), never written in place. A symlink is copied through, as before: linked, it would stay a symlink.
#[cfg(any(not(unix), test))]
fn link_file(
target: &Path,
at: &Path,
hard_link: impl FnOnce(&Path, &Path) -> std::io::Result<()>,
) -> std::io::Result<()> {
let symlink = fs::symlink_metadata(target).is_ok_and(|m| m.file_type().is_symlink());
if !symlink && hard_link(target, at).is_ok() {
return Ok(());
}
fs::copy(target, at).map(|_| ())
}
/// A tag file's JSON text with `more` appended to its values (no repeats).
fn merge_tag(text: Option<&str>, more: &[J], what: &str) -> Result<String> {
@@ -3774,3 +3774,168 @@ fn a_feature_gives_a_mods_sign_its_block_entity() {
assert_eq!(generate(true), (placed, placed));
let _ = fs::remove_dir_all(&scratch);
}
/// A small stand-in for the base root: nested folders, an empty file, an empty folder.
fn small_base(base: &Path) {
for (name, bytes) in [
("items.json", &b"{}"[..]),
("empty.log", &b""[..]),
("assets/minecraft/lang/en_us.json", &b"{\"a\": 1}"[..]),
(
"data/minecraft/tags/block/logs.json",
&b"{\"values\": []}"[..],
),
] {
let path = base.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
}
fs::create_dir_all(base.join("worldgen/empty-folder")).unwrap();
}
/// Every folder (`None`) and file (`Some(bytes)`) under `root`, by relative path.
fn tree(root: &Path) -> BTreeMap<String, Option<Vec<u8>>> {
fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, Option<Vec<u8>>>) {
for entry in fs::read_dir(dir).unwrap().flatten() {
let path = entry.path();
let rel = path
.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
if path.is_dir() {
out.insert(rel, None);
walk(root, &path, out);
} else {
out.insert(rel, Some(fs::read(&path).unwrap()));
}
}
}
let mut out = BTreeMap::new();
walk(root, root, &mut out);
out
}
/// Writes into a file where it is (same data), not by replacing it: what a link shares and a copy does not.
fn edit_in_place(path: &Path, bytes: &[u8]) {
use std::io::Write;
let mut file = fs::File::options()
.write(true)
.truncate(true)
.open(path)
.unwrap();
file.write_all(bytes).unwrap();
}
/// The overlay looks like the base, and shares its files (a hard link on Windows, a symlink elsewhere) rather than
/// holding copies of them.
#[test]
fn the_overlay_shares_the_base_files() {
let scratch = std::env::temp_dir().join(format!("horizon-mod-share-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
let (base, out) = (scratch.join("base"), scratch.join("root"));
small_base(&base);
Overlay::new(&base, &out).unwrap();
assert_eq!(tree(&out), tree(&base));
edit_in_place(
&base.join("assets/minecraft/lang/en_us.json"),
b"{\"a\": 2}",
);
assert_eq!(
fs::read(out.join("assets/minecraft/lang/en_us.json")).unwrap(),
b"{\"a\": 2}",
"the overlay copied the base file instead of linking it"
);
let _ = fs::remove_dir_all(&scratch);
}
/// Writing a file of the overlay replaces the name, so the base file it was linked to keeps its bytes and the other
/// files of the overlay still follow the base.
#[test]
fn writing_into_the_overlay_leaves_the_base_alone() {
let scratch = std::env::temp_dir().join(format!("horizon-mod-write-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
let (base, out) = (scratch.join("base"), scratch.join("root"));
small_base(&base);
let overlay = Overlay::new(&base, &out).unwrap();
let before = tree(&base);
overlay
.write(
"data/minecraft/tags/block/logs.json",
b"{\"values\": [\"mod:log\"]}",
)
.unwrap();
overlay.write("items.json", b"{\"mod\": 1}").unwrap();
overlay.write("assets/mod/new.json", b"{}").unwrap();
assert_eq!(tree(&base), before, "a write reached the base");
assert_eq!(
fs::read(out.join("data/minecraft/tags/block/logs.json")).unwrap(),
b"{\"values\": [\"mod:log\"]}"
);
assert_eq!(fs::read(out.join("items.json")).unwrap(), b"{\"mod\": 1}");
edit_in_place(
&base.join("assets/minecraft/lang/en_us.json"),
b"{\"a\": 3}",
);
assert_eq!(
fs::read(out.join("assets/minecraft/lang/en_us.json")).unwrap(),
b"{\"a\": 3}"
);
// A rebuild starts by removing the overlay: that removes names, not the base's data.
fs::remove_dir_all(&out).unwrap();
assert_eq!(fs::read(base.join("items.json")).unwrap(), b"{}");
let _ = fs::remove_dir_all(&scratch);
}
/// Where a hard link cannot be made (another volume, a filesystem without links) a file is copied, as it always was
/// on Windows; where it can, the file is linked.
#[test]
fn a_file_is_linked_or_else_copied() {
let scratch = std::env::temp_dir().join(format!("horizon-mod-linkfile-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
fs::create_dir_all(&scratch).unwrap();
let target = scratch.join("base.json");
fs::write(&target, b"original").unwrap();
let linked = scratch.join("linked.json");
link_file(&target, &linked, |from, to| fs::hard_link(from, to)).unwrap();
edit_in_place(&target, b"edited");
assert_eq!(fs::read(&linked).unwrap(), b"edited", "not a link");
let copied = scratch.join("copied.json");
fs::write(&target, b"original").unwrap();
link_file(&target, &copied, |_, _| {
Err(std::io::Error::other("no links here"))
})
.unwrap();
edit_in_place(&target, b"edited");
assert_eq!(fs::read(&copied).unwrap(), b"original", "not a copy");
// A target that is not there is an error whichever way the file is made.
let missing = link_file(
&scratch.join("missing.json"),
&scratch.join("x.json"),
|from, to| fs::hard_link(from, to),
);
assert!(missing.is_err());
let _ = fs::remove_dir_all(&scratch);
}
/// A symlink in the base is copied through, as before; linked, the overlay's file would be a symlink too.
#[cfg(unix)]
#[test]
fn a_symlink_file_is_copied_through() {
let scratch = std::env::temp_dir().join(format!("horizon-mod-symfile-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
fs::create_dir_all(&scratch).unwrap();
fs::write(scratch.join("real.json"), b"real").unwrap();
std::os::unix::fs::symlink("real.json", scratch.join("alias.json")).unwrap();
let at = scratch.join("copy.json");
link_file(&scratch.join("alias.json"), &at, |from, to| {
fs::hard_link(from, to)
})
.unwrap();
assert!(!fs::symlink_metadata(&at).unwrap().file_type().is_symlink());
assert_eq!(fs::read(&at).unwrap(), b"real");
let _ = fs::remove_dir_all(&scratch);
}