Skip to content

Commit 5b7158c

Browse files
committed
fix(windows): prefer the manifest over the sparse runfiles directory
PR #59 changed runfiles source selection to prefer a directory over a manifest at equal precedence. On Windows, Bazel does not materialize the runfiles symlink tree by default, so a sibling `<exe>.runfiles` / `RUNFILES_DIR` exists but is sparse and only the manifest maps runfiles to real paths. Directory-first selection there resolves rlocations to files that do not exist, so the launcher fails to start its target (and drops RUNFILES_MANIFEST_FILE from the child environment). On Linux/macOS the tree is materialized, so the directory is fully populated and directory-first is correct. Gate the within-tier directory-vs-manifest preference on a per-backend `PREFER_DIRECTORY_SOURCE` constant (true on Linux/macOS, false on Windows), consumed by a new `select_source` helper in runfiles.rs. The environment-over-adjacent tiering is unchanged; only Windows reverts to the pre-#59 manifest-first order. Update `test_runfiles_source_precedence` to assert the platform-correct winner and to cover a sparse-directory no-fallthrough case per platform, and document the Windows exception in the README.
1 parent c1fd36a commit 5b7158c

6 files changed

Lines changed: 193 additions & 82 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,9 @@ At startup the finalized launcher selects one runfiles source from:
152152
2. `<executable>.runfiles/` and `<executable>.runfiles_manifest`
153153

154154
Valid environment-provided sources take precedence over adjacent sources. When both
155-
sources exist at the same precedence, the runfiles directory wins. Every argument
155+
sources exist at the same precedence, the runfiles directory wins on platforms that
156+
materialize the runfiles tree (Linux, macOS); on Windows the tree is not materialized
157+
by default and the directory is sparse, so the manifest wins there. Every argument
156158
marked `--transform` resolves only through the selected source (manifest lookup or
157159
directory join; tree-artifact prefixes supported), and only that source is exported to
158160
the child. Absolute paths (leading `/`) pass through unchanged. The launcher then

integration-tests/src/main.rs

Lines changed: 110 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,11 @@ fn test_add_numbers_runtime_args(config: &TestConfig) -> Result<(), String> {
444444
fn test_runfiles_source_precedence(config: &TestConfig) -> Result<(), String> {
445445
println!(" Running test: runfiles_source_precedence");
446446

447+
// The launcher prefers a directory at equal precedence where Bazel
448+
// materializes the runfiles tree; on Windows the tree is sparse, so the
449+
// manifest wins. Mirror that choice when asserting the selected source.
450+
let prefer_directory = !cfg!(windows);
451+
447452
let test_dir = config.work_dir.join("test_runfiles_source_precedence");
448453
fs::create_dir_all(&test_dir).map_err(|e| format!("Failed to create test dir: {}", e))?;
449454

@@ -475,8 +480,9 @@ fn test_runfiles_source_precedence(config: &TestConfig) -> Result<(), String> {
475480
&[0],
476481
)?;
477482

478-
// Both environment sources have the same precedence, so the directory owns
479-
// both resolution and the environment exported to the child.
483+
// Both environment sources have the same precedence. Where the runfiles tree
484+
// is materialized the directory owns both resolution and the exported
485+
// environment; on Windows the tree is sparse, so the manifest wins.
480486
let mut command = Command::new(&stub_path);
481487
command
482488
.env("RUNFILES_DIR", &runfiles.runfiles_dir)
@@ -487,14 +493,21 @@ fn test_runfiles_source_precedence(config: &TestConfig) -> Result<(), String> {
487493
.map_err(|e| format!("Failed to run stub with both runfiles variables: {}", e))?;
488494
let stdout = String::from_utf8_lossy(&output.stdout);
489495
let stderr = String::from_utf8_lossy(&output.stderr);
490-
if !output.status.success()
491-
|| !stdout.contains("ARGC:3")
492-
|| environment_path(&stdout, "RUNFILES_DIR") != Some(runfiles.runfiles_dir.as_path())
493-
|| environment_path(&stdout, "JAVA_RUNFILES") != Some(runfiles.runfiles_dir.as_path())
494-
|| !stdout.contains("ENV:RUNFILES_MANIFEST_FILE=<unset>")
495-
{
496+
// The directory maps `tool` to print-env (prints ARGC/ENV), the manifest to
497+
// add-numbers (prints SUM), so the output identifies which source was chosen.
498+
let selected_expected = if prefer_directory {
499+
output.status.success()
500+
&& stdout.contains("ARGC:3")
501+
&& environment_path(&stdout, "RUNFILES_DIR") == Some(runfiles.runfiles_dir.as_path())
502+
&& environment_path(&stdout, "JAVA_RUNFILES") == Some(runfiles.runfiles_dir.as_path())
503+
&& stdout.contains("ENV:RUNFILES_MANIFEST_FILE=<unset>")
504+
} else {
505+
output.status.success() && stdout.contains("SUM:15") && !stdout.contains("ARGC:3")
506+
};
507+
if !selected_expected {
496508
return Err(format!(
497-
"Directory environment source did not win the tie.\nstdout: {}\nstderr: {}",
509+
"Same-precedence environment sources did not select the {} source.\nstdout: {}\nstderr: {}",
510+
if prefer_directory { "directory" } else { "manifest" },
498511
stdout, stderr
499512
));
500513
}
@@ -561,7 +574,8 @@ fn test_runfiles_source_precedence(config: &TestConfig) -> Result<(), String> {
561574
}
562575

563576
// With no environment source, the adjacent directory and manifest have equal
564-
// precedence, so the directory wins and is exported consistently.
577+
// precedence: the directory wins where the tree is materialized, the manifest
578+
// on Windows, and the winner is exported consistently.
565579
let mut command = Command::new(&stub_path);
566580
command
567581
.env_remove("RUNFILES_DIR")
@@ -572,65 +586,112 @@ fn test_runfiles_source_precedence(config: &TestConfig) -> Result<(), String> {
572586
.map_err(|e| format!("Failed to run stub with adjacent sources: {}", e))?;
573587
let stdout = String::from_utf8_lossy(&output.stdout);
574588
let stderr = String::from_utf8_lossy(&output.stderr);
575-
if !output.status.success()
576-
|| !stdout.contains("ARGC:3")
577-
|| environment_path(&stdout, "RUNFILES_DIR") != Some(runfiles.runfiles_dir.as_path())
578-
|| environment_path(&stdout, "JAVA_RUNFILES") != Some(runfiles.runfiles_dir.as_path())
579-
|| !stdout.contains("ENV:RUNFILES_MANIFEST_FILE=<unset>")
580-
{
589+
let selected_expected = if prefer_directory {
590+
output.status.success()
591+
&& stdout.contains("ARGC:3")
592+
&& environment_path(&stdout, "RUNFILES_DIR") == Some(runfiles.runfiles_dir.as_path())
593+
&& environment_path(&stdout, "JAVA_RUNFILES") == Some(runfiles.runfiles_dir.as_path())
594+
&& stdout.contains("ENV:RUNFILES_MANIFEST_FILE=<unset>")
595+
} else {
596+
output.status.success() && stdout.contains("SUM:15") && !stdout.contains("ARGC:3")
597+
};
598+
if !selected_expected {
581599
return Err(format!(
582-
"Adjacent directory did not win the tie.\nstdout: {}\nstderr: {}",
600+
"Adjacent {} source did not win the tie.\nstdout: {}\nstderr: {}",
601+
if prefer_directory { "directory" } else { "manifest" },
583602
stdout, stderr,
584603
));
585604
}
586605

587-
// Source selection is global, not per key. A missing directory entry must
588-
// not fall through to either an environment or adjacent manifest.
589-
let missing_rlocation = format!("{}/bin/missing{}", WORKSPACE_NAME, EXE_EXT);
590-
let missing_stub_name = format!("missing_stub{}", EXE_EXT);
591-
let missing_stub = test_dir.join(&missing_stub_name);
592-
let missing_manifest = test_dir.join(format!("{}.runfiles_manifest", missing_stub_name));
593-
let add_target = add_binary.to_string_lossy();
594-
#[cfg(windows)]
595-
let add_target = add_target.replace('\\', "/");
596-
fs::write(
597-
&missing_manifest,
598-
format!("{} {}\n", missing_rlocation, add_target),
599-
)
600-
.map_err(|e| format!("Failed to write no-fallback fixture: {}", e))?;
601-
finalize_stub(
602-
config,
603-
&missing_stub,
604-
&[&missing_rlocation, "7", "8"],
605-
&[0],
606-
)?;
607-
for (case, manifest_env) in [
608-
("environment manifest", Some(&missing_manifest)),
609-
("adjacent manifest", None),
610-
] {
611-
let mut command = Command::new(&missing_stub);
606+
// Source selection is global, not per key: a key that only the NON-selected
607+
// source can resolve must not fall through to it.
608+
//
609+
// Case A: both sources come from the environment, so the platform preference
610+
// picks the winner. Embed a key that lives ONLY in the loser — any
611+
// fall-through would resolve it and betray the mixing.
612+
{
613+
let a_stub_name = format!("no_fallthrough_env_stub{}", EXE_EXT);
614+
let a_stub = test_dir.join(&a_stub_name);
615+
let a_manifest = test_dir.join("no_fallthrough_env.runfiles_manifest");
616+
// Manifest RHS values use forward slashes (Bazel's Windows convention);
617+
// harmless on Unix, where paths carry no backslashes.
618+
let add_target = add_binary.to_string_lossy().replace('\\', "/");
619+
620+
let embedded_key = if prefer_directory {
621+
// Directory wins; put the embedded key only in the manifest.
622+
let key = format!("{}/bin/only_in_manifest{}", WORKSPACE_NAME, EXE_EXT);
623+
fs::write(&a_manifest, format!("{} {}\n", key, add_target))
624+
.map_err(|e| format!("Failed to write no-fallthrough manifest: {}", e))?;
625+
key
626+
} else {
627+
// Manifest wins; reuse `tool` (present only in the directory, as
628+
// print-env) and give the manifest an unrelated entry so it loads but
629+
// cannot resolve the key.
630+
let decoy = format!("{}/bin/decoy{}", WORKSPACE_NAME, EXE_EXT);
631+
fs::write(&a_manifest, format!("{} {}\n", decoy, add_target))
632+
.map_err(|e| format!("Failed to write no-fallthrough manifest: {}", e))?;
633+
executable_rlocation.clone()
634+
};
635+
finalize_stub(config, &a_stub, &[&embedded_key, "7", "8"], &[0])?;
636+
637+
let mut command = Command::new(&a_stub);
612638
command
613639
.env("RUNFILES_DIR", &runfiles.runfiles_dir)
640+
.env("RUNFILES_MANIFEST_FILE", &a_manifest)
614641
.env_remove("JAVA_RUNFILES");
615-
if let Some(manifest) = manifest_env {
616-
command.env("RUNFILES_MANIFEST_FILE", manifest);
642+
let output = command
643+
.output()
644+
.map_err(|e| format!("Failed to test no-fallthrough environment sources: {}", e))?;
645+
let stdout = String::from_utf8_lossy(&output.stdout);
646+
let stderr = String::from_utf8_lossy(&output.stderr);
647+
// The loser's happy-path signature must never appear: SUM from the
648+
// manifest's add-numbers, or ARGC from the directory's print-env.
649+
let loser_ran = if prefer_directory {
650+
stdout.contains("SUM:15")
617651
} else {
618-
command.env_remove("RUNFILES_MANIFEST_FILE");
652+
stdout.contains("ARGC")
653+
};
654+
if output.status.success() || loser_ran {
655+
return Err(format!(
656+
"Selected source fell through to the other (environment sources).\nstdout: {}\nstderr: {}",
657+
stdout, stderr
658+
));
619659
}
660+
}
661+
662+
// Case B: RUNFILES_DIR is the only environment source (the manifest is merely
663+
// adjacent), so the environment directory wins on every platform; a key only
664+
// the adjacent manifest holds must not fall through to it.
665+
{
666+
let missing_rlocation = format!("{}/bin/missing{}", WORKSPACE_NAME, EXE_EXT);
667+
let missing_stub_name = format!("missing_stub{}", EXE_EXT);
668+
let missing_stub = test_dir.join(&missing_stub_name);
669+
let missing_manifest =
670+
test_dir.join(format!("{}.runfiles_manifest", missing_stub_name));
671+
let add_target = add_binary.to_string_lossy().replace('\\', "/");
672+
fs::write(&missing_manifest, format!("{} {}\n", missing_rlocation, add_target))
673+
.map_err(|e| format!("Failed to write no-fallthrough adjacent fixture: {}", e))?;
674+
finalize_stub(config, &missing_stub, &[&missing_rlocation, "7", "8"], &[0])?;
675+
676+
let mut command = Command::new(&missing_stub);
677+
command
678+
.env("RUNFILES_DIR", &runfiles.runfiles_dir)
679+
.env_remove("RUNFILES_MANIFEST_FILE")
680+
.env_remove("JAVA_RUNFILES");
620681
let output = command
621682
.output()
622-
.map_err(|e| format!("Failed to test no-fallback {}: {}", case, e))?;
683+
.map_err(|e| format!("Failed to test no-fallthrough adjacent manifest: {}", e))?;
623684
let stdout = String::from_utf8_lossy(&output.stdout);
624685
let stderr = String::from_utf8_lossy(&output.stderr);
625686
if output.status.success() || stdout.contains("SUM:15") {
626687
return Err(format!(
627-
"Directory selection fell through to {}.\nstdout: {}\nstderr: {}",
628-
case, stdout, stderr
688+
"Environment directory fell through to the adjacent manifest.\nstdout: {}\nstderr: {}",
689+
stdout, stderr
629690
));
630691
}
631692
}
632693

633-
println!(" PASS (environment precedence, then directory preference)");
694+
println!(" PASS (environment precedence, then platform source preference)");
634695
Ok(())
635696
}
636697

runfiles-stub/src/linux.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ const AT_EXECFN: usize = 31;
116116
pub const SEP: char = '/';
117117
pub const NEWLINE: &[u8] = b"\n";
118118

119+
// Linux materializes the runfiles symlink tree, so a runfiles directory is fully
120+
// populated and preferred over an equal-precedence manifest. See runfiles.rs
121+
// `select_source`.
122+
pub const PREFER_DIRECTORY_SOURCE: bool = true;
123+
119124
pub fn is_absolute(path: &str) -> bool {
120125
path.starts_with('/')
121126
}

runfiles-stub/src/macos.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ const MAXPATHLEN: usize = 1024;
9595
pub const SEP: char = '/';
9696
pub const NEWLINE: &[u8] = b"\n";
9797

98+
// macOS materializes the runfiles symlink tree, so a runfiles directory is fully
99+
// populated and preferred over an equal-precedence manifest. See runfiles.rs
100+
// `select_source`.
101+
pub const PREFER_DIRECTORY_SOURCE: bool = true;
102+
98103
pub fn is_absolute(path: &str) -> bool {
99104
path.starts_with('/')
100105
}

runfiles-stub/src/runfiles.rs

Lines changed: 63 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -24,47 +24,59 @@ pub enum Runfiles {
2424
impl Runfiles {
2525
pub fn create(rt: &platform::RuntimeArgs) -> Option<Self> {
2626
// Environment-provided sources take precedence over sources discovered
27-
// next to the executable. At the same precedence, prefer a directory.
28-
if let Some(runfiles_dir) = platform::get_env_var(b"RUNFILES_DIR")
29-
.filter(|path| !path.is_empty() && dir_exists(path))
30-
{
31-
return Some(Self::Directory { path: runfiles_dir });
32-
}
33-
if let Some(manifest_path) = platform::get_env_var(b"RUNFILES_MANIFEST_FILE")
34-
.filter(|path| !path.is_empty())
35-
{
36-
if let Some(manifest) = load_manifest(&manifest_path) {
37-
return Some(Self::Manifest {
38-
manifest,
39-
path: manifest_path,
40-
logical_dir: None,
41-
});
42-
}
27+
// next to the executable. Within a tier, `select_source` applies the
28+
// platform's directory-vs-manifest preference.
29+
if let Some(rf) = select_source(
30+
|| {
31+
platform::get_env_var(b"RUNFILES_DIR")
32+
.filter(|path| !path.is_empty() && dir_exists(path))
33+
.map(|path| Self::Directory { path })
34+
},
35+
|| {
36+
platform::get_env_var(b"RUNFILES_MANIFEST_FILE")
37+
.filter(|path| !path.is_empty())
38+
.and_then(|path| {
39+
load_manifest(&path).map(|manifest| Self::Manifest {
40+
manifest,
41+
path,
42+
logical_dir: None,
43+
})
44+
})
45+
},
46+
) {
47+
return Some(rf);
4348
}
4449

45-
// Locate runfiles next to the launching executable:
46-
// <executable>.runfiles directory first, then
47-
// <executable>.runfiles_manifest file. The executable path comes from the
48-
// OS (an absolute, non-symlink-resolved launch path), not from argv[0].
50+
// Locate runfiles next to the launching executable: the
51+
// <executable>.runfiles directory and the <executable>.runfiles_manifest
52+
// file, in the platform's preferred order. The executable path comes from
53+
// the OS (an absolute, non-symlink-resolved launch path), not from argv[0].
4954
if let Some(exe_path) = rt.executable_path() {
5055
let exe_len = cstr_len(&exe_path);
5156
if exe_len > 0 {
5257
// Convert the executable path to a string (if valid UTF-8).
5358
let exe_str = core::str::from_utf8(&exe_path[..exe_len]).ok()?;
5459
let runfiles_dir = String::from(exe_str) + ".runfiles";
55-
if dir_exists(&runfiles_dir) {
56-
return Some(Self::Directory { path: runfiles_dir });
57-
}
58-
5960
let manifest_path = String::from(exe_str) + ".runfiles_manifest";
60-
if let Some(manifest) = load_manifest(&manifest_path) {
61-
return Some(Self::Manifest {
62-
manifest,
63-
path: manifest_path,
64-
// Preserve the logical path even though the sibling tree
65-
// is absent; the manifest selected the actual executable.
66-
logical_dir: Some(runfiles_dir),
67-
});
61+
62+
if let Some(rf) = select_source(
63+
|| {
64+
dir_exists(&runfiles_dir).then(|| Self::Directory {
65+
path: runfiles_dir.clone(),
66+
})
67+
},
68+
|| {
69+
load_manifest(&manifest_path).map(|manifest| Self::Manifest {
70+
manifest,
71+
path: manifest_path.clone(),
72+
// Preserve the logical path even though the sibling
73+
// tree is absent; the manifest selected the actual
74+
// executable.
75+
logical_dir: Some(runfiles_dir.clone()),
76+
})
77+
},
78+
) {
79+
return Some(rf);
6880
}
6981
}
7082
}
@@ -107,6 +119,25 @@ impl Runfiles {
107119
}
108120
}
109121

122+
/// Choose between a directory and a manifest source at equal precedence.
123+
///
124+
/// Both are evaluated lazily — we never probe a directory or open a manifest we
125+
/// do not end up selecting. The directory is preferred where the platform
126+
/// materializes the runfiles tree; on Windows the tree is not materialized by
127+
/// default, so the sibling directory is sparse and the manifest must win
128+
/// (otherwise `rlocation`s resolve to files that do not exist). See
129+
/// `platform::PREFER_DIRECTORY_SOURCE`.
130+
fn select_source(
131+
directory: impl FnOnce() -> Option<Runfiles>,
132+
manifest: impl FnOnce() -> Option<Runfiles>,
133+
) -> Option<Runfiles> {
134+
if platform::PREFER_DIRECTORY_SOURCE {
135+
directory().or_else(manifest)
136+
} else {
137+
manifest().or_else(directory)
138+
}
139+
}
140+
110141
fn dir_exists(path: &str) -> bool {
111142
// A trailing separator makes the existing-path probe directory-specific on
112143
// Unix and Windows: regular files cannot be traversed as directories.

runfiles-stub/src/windows.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ extern "system" {
135135
pub const SEP: char = '\\';
136136
pub const NEWLINE: &[u8] = b"\r\n";
137137

138+
// Windows does not materialize the runfiles symlink tree by default (creating
139+
// symlinks requires privileges), so a sibling `<exe>.runfiles` / `RUNFILES_DIR`
140+
// is sparse — only the manifest maps runfiles to real paths. Prefer the manifest
141+
// at equal precedence; a directory-first choice would resolve to files that do
142+
// not exist. See runfiles.rs `select_source`.
143+
pub const PREFER_DIRECTORY_SOURCE: bool = false;
144+
138145
pub fn is_absolute(path: &str) -> bool {
139146
let b = path.as_bytes();
140147
b.len() >= 2

0 commit comments

Comments
 (0)