fix(toolchain): fix crash on Windows when precompiling is enabled (#4082)
`python/private/py_executable.bzl`'s `_maybe_add_test_main_validation`
fix (#4079) noted `precompile.bzl` as a remaining user of the same
`exec_interpreter` relocation issue, needing the same migration.
There was no existing test exercising `_precompile`'s action at all:
`tests/base_rules/precompile`'s suite is `analysis_test`-only, checking
declared providers, never actually running the precompiler.
Reproducing this on Windows therefore required a real `bazel build`, via
the new `test_precompile_enabled_succeeds`:
```
bazel test \
//tests/base_rules/precompile:test_precompile_enabled_succeeds
...
ERROR: .../tests/base_rules/precompile/BUILD.bazel:3:22: Python
precompiling .../test_precompile_enabled_succeeds_main.py into
.../test_precompile_enabled_succeeds_main.cpython-311.pyc
failed: Worker process did not return a WorkResponse:
---8<---8<--- Start of log, file at
.../multiplex-worker-1-PyCompile.log ---8<---8<---
(empty)
---8<---8<--- End of log ---8<---8<---
```
The worker crashes at startup, unable to find its DLLs, before it can
write anything to its own log or respond over the worker protocol.
`_precompile` now uses `actions_run()` with `exec_runtime`, exactly as
`_maybe_add_test_main_validation` does, instead of
`exec_tools_info.exec_interpreter[DefaultInfo].files_to_run`.
Reproducing and fixing this also uncovered two more problems, both
specific to the precompiler's worker mode and unrelated to
`exec_interpreter`.
First, `tools/precompiler/precompiler.py`'s persistent worker reads each
JSON request as a single line via `asyncio.StreamReader`, whose default
64KiB limit is exceeded once every interpreter distribution file,
previously hidden by relocation into a much smaller symlink tree, shows
up as an actual, individually-digested action input:
```
ValueError: Separator is not found, and chunk exceed the limit
```
A CPython 3.11 distribution's ~2,260 inputs measure ~470KiB this way; `1
<< 22` (4MiB) leaves ample headroom.
Second, the worker's default implementation, `_AsyncPersistentWorker`,
can't start on Windows at all: `asyncio`'s `ProactorEventLoop` fails to
wrap `stdin`/`stdout` as pipe transports, with:
```
OSError: [WinError 6] The handle is invalid
```
Bazel gives workers anonymous pipes (`CreatePipe`) for stdio, which
never support overlapped I/O, so `asyncio`'s `ProactorEventLoop` can't
register them with an I/O completion port.
This is unrelated to precompiling's relocation bug: nothing exercises
this worker on Windows today.
`_SerialPersistentWorker`, the blocking-I/O alternative already present
in the file, has no such issue, so `--worker_impl` now defaults to
`serial` on Windows.
`tests/base_rules/precompile:test_precompile_enabled_succeeds` is a
real, executing `py_test` with `precompile = "enabled"`, added alongside
the analysis-only suite to close this gap: it forces the precompiler
action to actually run, and needs no CI wiring since it carries no tag
excluding it from the existing Windows job's default test sweep.
diff --git a/news/4082.fixed.md b/news/4082.fixed.md
new file mode 100644
index 0000000..12c6a51
--- /dev/null
+++ b/news/4082.fixed.md
@@ -0,0 +1,3 @@
+(toolchain) Fixed a crash on Windows when precompiling is enabled:
+the precompiler's interpreter couldn't find its DLLs once relocated
+([#4082](https://github.com/bazel-contrib/rules_python/issues/4082)).
diff --git a/python/private/precompile.bzl b/python/private/precompile.bzl
index c12882b..898dc3e 100644
--- a/python/private/precompile.bzl
+++ b/python/private/precompile.bzl
@@ -15,6 +15,7 @@
load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo")
load(":attributes.bzl", "PrecompileAttr", "PrecompileInvalidationModeAttr", "PrecompileSourceRetentionAttr")
+load(":common.bzl", "actions_run")
load(":flags.bzl", "PrecompileFlag")
load(":py_interpreter_program.bzl", "PyInterpreterProgramInfo")
load(":toolchain_types.bzl", "EXEC_TOOLS_TOOLCHAIN_TYPE", "TARGET_TOOLCHAIN_TYPE")
@@ -108,25 +109,8 @@
exec_tools_info = ctx.toolchains[EXEC_TOOLS_TOOLCHAIN_TYPE].exec_tools
target_toolchain = ctx.toolchains[TARGET_TOOLCHAIN_TYPE].py3_runtime
- # These args control starting the precompiler, e.g., when run as a worker,
- # these args are only passed once.
- precompiler_startup_args = ctx.actions.args()
-
- env = {}
- tools = []
-
precompiler = exec_tools_info.precompiler
- if PyInterpreterProgramInfo in precompiler:
- precompiler_executable = exec_tools_info.exec_interpreter[DefaultInfo].files_to_run
- program_info = precompiler[PyInterpreterProgramInfo]
- env.update(program_info.env)
- precompiler_startup_args.add_all(program_info.interpreter_args)
- default_info = precompiler[DefaultInfo]
- precompiler_startup_args.add(default_info.files_to_run.executable)
- tools.append(default_info.files_to_run)
- elif precompiler[DefaultInfo].files_to_run:
- precompiler_executable = precompiler[DefaultInfo].files_to_run
- else:
+ if PyInterpreterProgramInfo not in precompiler and not precompiler[DefaultInfo].files_to_run:
fail(("Unrecognized precompiler: target '{}' does not provide " +
"PyInterpreterProgramInfo nor appears to be executable").format(
precompiler,
@@ -159,12 +143,6 @@
else:
invalidation_mode = PrecompileInvalidationModeAttr.CHECKED_HASH
- # Though --modify_execution_info exists, it can only set keys with
- # empty values, which doesn't work for persistent worker settings.
- execution_requirements = {}
- if testing.ExecutionInfo in precompiler:
- execution_requirements.update(precompiler[testing.ExecutionInfo].requirements)
-
# These args are passed for every precompilation request, e.g. as part of
# a request to a worker process.
precompile_request_args = ctx.actions.args()
@@ -188,20 +166,13 @@
python_version = "{}.{}".format(version_info.major, version_info.minor)
precompile_request_args.add("--python_version", python_version)
- ctx.actions.run(
- executable = precompiler_executable,
- arguments = [precompiler_startup_args, precompile_request_args],
+ actions_run(
+ ctx,
+ executable = precompiler,
+ arguments = [precompile_request_args],
inputs = [src],
outputs = [pyc],
mnemonic = "PyCompile",
progress_message = "Python precompiling %{input} into %{output}",
- tools = tools,
- env = env | {
- "PYTHONHASHSEED": "0", # Helps avoid non-deterministic behavior
- "PYTHONNOUSERSITE": "1", # Helps avoid non-deterministic behavior
- "PYTHONSAFEPATH": "1", # Helps avoid incorrect import issues
- },
- execution_requirements = execution_requirements,
- toolchain = EXEC_TOOLS_TOOLCHAIN_TYPE,
)
return pyc
diff --git a/tests/base_rules/precompile/precompile_tests.bzl b/tests/base_rules/precompile/precompile_tests.bzl
index bff994a..d2c1da6 100644
--- a/tests/base_rules/precompile/precompile_tests.bzl
+++ b/tests/base_rules/precompile/precompile_tests.bzl
@@ -14,6 +14,7 @@
"""Tests for precompiling behavior."""
+load("@bazel_skylib//rules:write_file.bzl", "write_file")
load("@rules_testing//lib:analysis_test.bzl", "analysis_test")
load("@rules_testing//lib:test_suite.bzl", "test_suite")
load("@rules_testing//lib:truth.bzl", "matching")
@@ -510,6 +511,24 @@
_tests.append(_test_precompile_attr_inherit_pyc_collection_disabled_precompile_flag_enabled)
+# buildifier: disable=function-docstring-header
+def _test_precompile_enabled_succeeds(name):
+ """Verify that a `py_test` target actually builds and runs with
+ precompiling (the above `analysis_test`s only check declared providers).
+ """
+ write_file(
+ name = name + "_main",
+ out = name + "_main.py",
+ )
+ py_test(
+ name = name,
+ srcs = [name + "_main.py"],
+ main = name + "_main.py",
+ precompile = "enabled",
+ )
+
+_tests.append(_test_precompile_enabled_succeeds)
+
def runfiles_contains_at_least_predicates(runfiles, predicates):
for predicate in predicates:
runfiles.contains_predicate(predicate)
diff --git a/tools/precompiler/precompiler.py b/tools/precompiler/precompiler.py
index f83dd15..7c44a32 100644
--- a/tools/precompiler/precompiler.py
+++ b/tools/precompiler/precompiler.py
@@ -34,7 +34,11 @@
parser.add_argument("--persistent_worker", action="store_true")
parser.add_argument("--log_level", default="ERROR")
- parser.add_argument("--worker_impl", default="async")
+ # Bazel workers use anonymous pipes for stdio, which don't support
+ # overlapped I/O required by asyncio on Windows.
+ parser.add_argument(
+ "--worker_impl", default="serial" if sys.platform == "win32" else "async"
+ )
return parser
@@ -167,7 +171,9 @@
outstream: "typing.TextIO", # noqa: F821
) -> "tuple[asyncio.StreamReader, asyncio.StreamWriter]":
loop = asyncio.get_event_loop()
- reader = asyncio.StreamReader()
+ # Cap reader at 4 MiB, leaving enough headroom over the default 64 KiB
+ # for request lines with numerous inputs (~470 KiB as of CPython 3.11).
+ reader = asyncio.StreamReader(limit=1 << 22)
protocol = asyncio.StreamReaderProtocol(reader)
await loop.connect_read_pipe(lambda: protocol, instream)