bazel-contrib / bazel-contrib/bazel-lib
`create_windows_native_launcher_script`: launcher `.bat` cannot resolve its own paths past `MAX_PATH`
- Dominant language
- Starlark
- Stars
- 182
- Forks
- 134
- Avg merge
- 1d 46m
- Merged PRs (30d)
- 1
Description
## Summary
The `.bat` wrapper emitted by `create_windows_native_launcher_script`
(`lib/windows_utils.bzl`) resolves two paths at runtime — the bash launcher it wraps, and
its own runfiles manifest — and both resolutions break once the path exceeds 260
characters. Windows `LongPathsEnabled` does not help: `cmd.exe` and its built-in console
tools are a documented exception to that policy and keep enforcing the legacy `MAX_PATH`
limit regardless of how it is set.
The two defects are independent and have different mechanisms, so fixing either one alone
still leaves the ruleset unusable on a default `output_user_root`:
1. The launcher path is looked up by grepping the runfiles `MANIFEST` with `findstr.exe`,
which cannot open a manifest whose path is over 260 characters.
2. The runfiles manifest path is derived as `%~f0.runfiles`, which produces a
non-existent directory once Bazel starts the `.bat` through its 8.3 short path.
Both are described below, with a suggested fix that addresses them together.
## Environment
- `bazel_lib` 3.5.0 (bzlmod), consumed transitively via `aspect_rules_js` 3.3.1
- Bazel 9.2.0, Windows 11 Enterprise 26200
- `HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled = 1`, confirmed with
`fsutil behavior query longpaths` — long paths are enabled system-wide
- `build:windows --enable_runfiles` is set, so a real runfiles tree exists
Reproduced with `js_binary`-derived tools (Angular `ng_library` targets) built from a
default `--output_user_root` of `C:\Users\\_bazel_\\...` under a nested
package path.
## Defect 1: `findstr` manifest lookup ignores `LongPathsEnabled`
A target whose launcher sits 249 characters deep, with a 267-character manifest path:
```
ERROR: .../lib-message-formatting-interface-api/BUILD.bazel:31:11: NgBuild ... failed: (Exit 1):
_lib-message-formatting-interface-api.ng_binary.bat failed: error executing NgBuild command
FINDSTR: Cannot open C:\Users\\_bazel_\\execroot\_main\bazel-out\x64_windows-opt-exec\bin\js\client\projects\lib-message-formatting-interface-api\_lib-message-formatting-interface-api.ng_binary_\_lib-message-formatting-interface-api.ng_binary.bat.runfiles\MANIFEST
ERROR: _main/js/client/projects/lib-message-formatting-interface-api/_lib-message-formatting-interface-api.ng_binary_/_lib-message-formatting-interface-api.ng_binary not found in runfiles manifest
```
The batch file itself launches fine and `%~f0` expands correctly — it is specifically
`findstr.exe` that cannot open the path. Because `abs_path` is then left empty, the script
reports `not found in runfiles manifest`, which points at a missing runfiles entry rather
than at a path-length problem.
The lookup, as released in 3.5.0:
```bat
for /F "tokens=2* usebackq" %%i in (`%SYSTEMROOT%\system32\findstr.exe /l /c:"!runfile_path! " "%MF%"`) do (
set abs_path=%%i
)
```
(`main` now has `findstr.exe /b /l /c:`; the `/b` came from #1271 and is an anchoring fix
that does not affect the path-length behaviour.)
Bazel solved this same problem in its native launcher years ago — `launcher.cc` parses the
same manifest from C++:
```cpp
ifstream manifest_file(AsAbsoluteWindowsPath(manifest_path.c_str()).c_str());
```
`AsAbsoluteWindowsPath()` prepends the `\\?\` long-path prefix, making the native launcher
`MAX_PATH`-safe. Reimplementing the lookup in `cmd.exe` reintroduces the limit Bazel had
already engineered around.
## Defect 2: 8.3 short-name invocation breaks `%~f0.runfiles`
Once the launcher's own path exceeds `MAX_PATH`, Bazel starts the `.bat` through its 8.3
short path, so every `%~f0` and `%~dp0` expansion yields short components. On a target
whose `.bat` sits 267 characters deep:
```
FATAL: aspect_rules_js[js_binary]: node binary
'/c/Users//_BAZEL~1//execroot/_main/BAZEL-~1/X64_WI~2/bin/js/client/projects/LIB-AL~1/_LIB-A~2.NG_/_LIB-A~1.BAT.runfiles/_main/../rules_nodejs++node+nodejs_windows_amd64/bin/nodejs/node.exe'
not found
```
`%~f0` expands to `..._LIB-A~1.BAT`, the 8.3 alias of
`_lib-alpha-payment-qualification-module-api.ng_binary.bat`. Appending `.runfiles` to that
alias yields `_LIB-A~1.BAT.runfiles`, which does not exist: 8.3 aliases are allocated per
**file**, so a literal suffix cannot be appended to one. The real directory is
`_lib-alpha-...ng_binary.bat.runfiles`, and its own alias is unrelated:
```
$ ls -d .../_LIB-A~2.NG_/_LIB-A~1.BAT.runfiles
No such file or directory
$ ls -d .../_LIB-A~2.NG_/*.bat.runfiles
.../_lib-alpha-payment-qualification-module-api.ng_binary.bat.runfiles
```
`RUNFILES_MANIFEST_FILE` therefore points at a path that does not exist, the wrapped
program derives a bogus runfiles root, and the failure surfaces far from its cause as a
missing `node` binary.
## Suggested fix
The `.bat` is declared as a sibling of the script it wraps:
```python
win_launcher = ctx.actions.declare_file(name + ".bat", sibling = shell_script)
```
so the bash launcher is always in the same directory as the `.bat` — in the execroot output
tree and in every runfiles tree alike — and both basenames are known at analysis time.
Deriving both paths from `%~dp0` plus a real basename removes `findstr`, the `MANIFEST`
read, and the short-name hazard together:
```bat
set RUNFILES_MANIFEST_FILE=%~dp0.runfiles\MANIFEST
...
set RUNFILES_MANIFEST_FILE=%~dp0.runfiles_manifest
...
set "run_script=%~dp0"
set "run_script=!run_script:\=/!"
```
The backslash-to-forward-slash conversion keeps `run_script` byte-compatible with what the
manifest previously yielded — manifest values already use forward slashes, e.g.
`C:/.../bin/...` — so no new escaping is needed downstream.
One further change on the line this already touches: quoting the resolved path.
```bat
"{bash_bin}" -c "'!run_script!' !args!"
```
Unquoted, any space in the output path breaks the launcher, and the default
`output_user_root` is derived from the user profile — so every developer whose Windows
account is named e.g. `First Last` currently gets
`bash: C:/Users/First: No such file or directory`. That is a pre-existing bug independent
of path length.
What the fix deliberately preserves:
- `set RUNFILES_MANIFEST_ONLY=1` and the `RUNFILES_MANIFEST_FILE` / `RUNFILES_DIR`
resolution stay, because those variables are inherited by the wrapped program. The
`.bat`'s runfiles live at `.runfiles` and the wrapped `.sh` has no `.runfiles`
sibling of its own to fall back on, so the launcher must keep exporting them. Only the
way the paths are *derived* changes.
One trade-off worth a second opinion: the fix drops the
`if not exist "%MF%" ( ... exit 1 )` guard that previously reported a missing manifest by
name. `if exist` is itself a `cmd.exe` built-in bound by `MAX_PATH`, so on exactly the deep
trees this targets it reports a false negative and would abort a working build. (The same
applies to the surviving `if not exist "%RUNFILES_MANIFEST_FILE%"` test, which past 260
characters always takes the `.runfiles_manifest` branch — harmless only because that
candidate is equally valid.) Correct derivation removes the need for the guard, but a
reviewer may still prefer a long-path-safe existence check to none: while developing this
fix, having no guard while `%~f0.runfiles` was still in place turned defect 2 into an
unrelated "node binary not found" rather than naming the missing manifest.
Two smaller observations about the same function:
1. It is named `create_windows_**native**_launcher_script`, but it does not use Bazel's
native launcher — it writes a `cmd.exe` batch file. The comment above
`BATCH_RLOCATION_FUNCTION` likewise says *"Use this to write actions that don't require
bash"*, while the emitted script invokes `bash.exe` on its last line.
2. If a manifest lookup has to be retained for other callers of
`BATCH_RLOCATION_FUNCTION`, it belongs somewhere long-path-safe — in `bash`, which this
launcher already requires, or in a tool that honours `LongPathsEnabled` — rather than in
a `cmd.exe` built-in.
A patch against 3.5.0 is attached in this report -
`bazel_lib_windows_launcher_max_path.patch`; happy to open it as a PR.
## Verification
With both derivations fixed, a previously-failing Angular workspace builds and runs on the
**default** `output_user_root`, with the launcher `.bat` itself at a 267-character path:
2096 actions, 129 executed locally, `bazel run` completing successfully.
Each defect was confirmed independent by reverting one half at a time: reverting the
`%~f0.runfiles` change reproduces `node binary ... not found`, and reverting the `findstr`
change reproduces `FINDSTR: Cannot open`.
## Workaround without the patch
Point `output_user_root` at a short path to buy back characters, e.g. in `.bazelrc`:
```
startup:windows --output_user_root=C:/_bzl
```
This is a path-length budget rather than a fix: it defers the failure until labels or
package nesting grow, and it puts every Windows developer on a fixed, non-per-user output
root. (The `:windows` suffix is honoured for `startup` lines and is correctly inert on
other platforms, so the line is safe to commit to a shared `.bazelrc`.)
[bazel_lib_windows_launcher_max_path.patch](https://github.com/user-attachments/files/31306165/bazel_lib_windows_launcher_max_path.patch)
Contributor guide
Research direction
Start in lib/windows_utils.bzl at create_windows_native_launcher_script and compare the emitted batch script with the attached patch; trace how the launcher and runfiles manifest paths are derived. Reproduce the reported Windows build/run with the default output_user_root and a 267-character launcher path. Done means the Angular workspace completes successfully and both independent path-length failures remain fixed.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- shell
- Domain
- build-system
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 64/100