Skip to content

Commit e72378b

Browse files
committed
fix(bootstrap): preserve custom executables across platforms
1 parent e8dceea commit e72378b

6 files changed

Lines changed: 96 additions & 39 deletions

File tree

‎python/private/_rules_python_bootstrap/process.py‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,19 +45,23 @@ def check(self):
4545
def restore(self):
4646
for signum, previous in self.handlers.items():
4747
signal.signal(signum, previous)
48-
if self.mask is not None:
48+
if os.name == "posix" and self.mask is not None:
4949
signal.pthread_sigmask(signal.SIG_SETMASK, self.mask)
5050

5151
def prepare_exec(self):
52-
# Stop recording cancellation before restoring the caller's mask.
53-
# Pending kernel signals then see the caller's dispositions; requests
54-
# already recorded by Python are checked after every handler is restored.
55-
signal.pthread_sigmask(signal.SIG_BLOCK, self.handlers)
56-
self.check()
57-
for signum, previous in self.handlers.items():
58-
signal.signal(signum, previous)
59-
self.check()
60-
signal.pthread_sigmask(signal.SIG_SETMASK, self.mask)
52+
if os.name == "posix":
53+
assert self.mask is not None, "POSIX cancellation context was not entered"
54+
# Stop recording cancellation before restoring the caller's mask.
55+
# Pending kernel signals then see the caller's dispositions; requests
56+
# already recorded by Python are checked after every handler is restored.
57+
signal.pthread_sigmask(signal.SIG_BLOCK, self.handlers)
58+
self.check()
59+
for signum, previous in self.handlers.items():
60+
signal.signal(signum, previous)
61+
self.check()
62+
signal.pthread_sigmask(signal.SIG_SETMASK, self.mask)
63+
else:
64+
raise RuntimeError("Native exec requires a POSIX cancellation context")
6165

6266
def __exit__(self, _kind, error, _traceback):
6367
self.restore()

‎python/private/zipapp/py_zipapp_rule.bzl‎

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -156,17 +156,9 @@ def _create_zip(ctx, py_runtime, py_executable, stage2_bootstrap):
156156

157157
runfiles = runfiles.build(ctx)
158158

159-
interpreter_symlinks = []
160-
if py_executable.venv_python_exe and py_runtime.interpreter:
161-
# Runtime-created venvs represent this file with an `actual:` marker.
162-
# A standalone archive can create the real relative link at build time,
163-
# regardless of the original executable's venv strategy.
164-
interpreter_symlinks.append(struct(
165-
runfiles_path = runfiles_root_path(ctx, py_executable.venv_python_exe.short_path),
166-
link_to_path = runfiles_root_path(ctx, py_runtime.interpreter.short_path),
167-
files = depset([py_runtime.interpreter]),
168-
))
169-
explicit_symlinks = depset(direct = interpreter_symlinks, transitive = [
159+
# A custom rule may supply its own executable. Only replace archive files
160+
# with links that the provider explicitly declares.
161+
explicit_symlinks = depset(transitive = [
170162
py_executable.venv_interpreter_symlinks,
171163
py_executable.venv_app_symlinks,
172164
])

‎tests/bootstrap_impls/BUILD.bazel‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
15+
load("@rules_python_internal//:rules_python_config.bzl", "config")
1516
load("@rules_shell//shell:sh_test.bzl", "sh_test")
1617
load("//python:py_runtime.bzl", "py_runtime")
1718
load("//python:py_runtime_pair.bzl", "py_runtime_pair")
@@ -21,7 +22,7 @@ load("//tests/support:py_reconfig.bzl", "py_reconfig_binary", "py_reconfig_test"
2122
load("//tests/support:sh_py_run_test.bzl", "sh_py_run_test")
2223
load("//tests/support:support.bzl", "SUPPORTS_BOOTSTRAP_SCRIPT")
2324
load("//tests/support/pytest_test:pytest_test.bzl", "pytest_test")
24-
load(":application_tests.bzl", "application_test_suite", "public_executable", "template_fixture")
25+
load(":application_tests.bzl", "application_test_suite", "public_executable", "template_fixture", "template_output")
2526
load(":venv_relative_path_tests.bzl", "relative_path_test_suite")
2627

2728
py_reconfig_binary(
@@ -256,11 +257,25 @@ template_fixture(
256257
contents = "custom zip\r\nworkspace=%workspace_name%\r\nmain=%stage2_bootstrap%",
257258
)
258259

260+
template_fixture(
261+
name = "custom_application_interpreter",
262+
contents = "This interpreter is packaged for template inspection, never executed.",
263+
)
264+
259265
py_runtime(
260266
name = "custom_application_runtime",
261267
# Also supplies the non-test current_interpreter_executable toolchain alias.
262268
bootstrap_template = ":custom_directory_template",
263-
interpreter_path = "/custom/python",
269+
# Windows venvs declare an interpreter symlink even for custom templates.
270+
# Use a declared file so remote packaging does not need a worker-local path.
271+
interpreter = select({
272+
"@platforms//os:windows": ":custom_application_interpreter",
273+
"//conditions:default": None,
274+
}),
275+
interpreter_path = select({
276+
"@platforms//os:windows": "",
277+
"//conditions:default": "/custom/python",
278+
}),
264279
supports_build_time_venv = False,
265280
zip_main_template = ":custom_zip_template",
266281
)
@@ -296,6 +311,12 @@ py_zipapp_binary(
296311
executable = False,
297312
)
298313

314+
template_output(
315+
name = "custom_application_bootstrap",
316+
testonly = True,
317+
binary = ":custom_application",
318+
)
319+
299320
filegroup(
300321
name = "custom_application_archive",
301322
testonly = True,
@@ -334,18 +355,17 @@ pytest_test(
334355
name = "application_templates_test",
335356
srcs = ["application_templates_test.py"],
336357
data = [
337-
":custom_application",
338358
":custom_application_archive",
359+
":custom_application_bootstrap",
339360
":public_external_archive",
340361
"//python/private:stage1_bootstrap_template",
341362
],
342363
env = {
343-
"CUSTOM_BINARY": "$(rlocationpath :custom_application)",
364+
"CUSTOM_BOOTSTRAP": "$(rlocationpath :custom_application_bootstrap)",
344365
"CUSTOM_ZIP": "$(rlocationpath :custom_application_archive)",
345366
"PUBLIC_ZIP": "$(rlocationpath :public_external_archive)",
346367
"RAW_SHELL_TEMPLATE": "$(rlocationpath //python/private:stage1_bootstrap_template)",
347368
},
348-
target_compatible_with = SUPPORTS_BOOTSTRAP_SCRIPT,
349369
deps = ["//python/runfiles"],
350370
)
351371

@@ -472,6 +492,8 @@ pytest_test(
472492
"PEER_RLOCATION": "$(rlocationpath cleanup_terminal_peer.py)",
473493
"PROBE_RLOCATION": "$(rlocationpath cleanup_probe.py)",
474494
"PYVENV_RLOCATION": "$(rlocationpath :cleanup_pyvenv)",
495+
# Bazel 7's system_python bootstrap uses its interpreter directly.
496+
"PYVENV_TEMPORARY": "1" if config.bazel_8_or_later else "0",
475497
"PYZIP_RLOCATION": "$(rlocationpath :cleanup_pyzip)",
476498
"STARTUP_RLOCATION": "$(rlocationpath cleanup_sitecustomize.py)",
477499
"VENV_RLOCATION": "$(rlocationpath :cleanup_venv)",

‎tests/bootstrap_impls/application_templates_test.py‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@
1313

1414

1515
def test_custom_directory_template_preserves_bytes():
16-
path = runfiles.CreateOrRaise().Rlocation(os.environ["CUSTOM_BINARY"])
16+
path = runfiles.CreateOrRaise().Rlocation(os.environ["CUSTOM_BOOTSTRAP"])
1717
assert path is not None
1818
contents = Path(path).read_bytes()
19-
assert contents.startswith(b"custom\r\nworkspace=_main\r\nmain=_main/")
19+
workspace = os.environ["TEST_WORKSPACE"]
20+
assert contents.startswith(
21+
f"custom\r\nworkspace={workspace}\r\nmain={workspace}/".encode()
22+
)
2023
assert contents.endswith(b"_custom_application_stage2_bootstrap.py")
2124
assert b"_rules_python_bootstrap" not in contents
2225

@@ -26,7 +29,8 @@ def test_public_provider_keeps_independently_supplied_executable():
2629
assert path is not None
2730
with zipfile.ZipFile(path) as archive:
2831
contents = archive.read(
29-
"runfiles/_main/tests/bootstrap_impls/supplied_venv.txt"
32+
f"runfiles/{os.environ['TEST_WORKSPACE']}/"
33+
"tests/bootstrap_impls/supplied_venv.txt"
3034
)
3135
assert contents == b"custom executable supplied without interpreter runfiles"
3236

@@ -36,12 +40,16 @@ def test_custom_zip_template_preserves_public_substitutions():
3640
assert path is not None
3741
with zipfile.ZipFile(path) as archive:
3842
contents = archive.read("__main__.py")
39-
assert contents.startswith(b"custom zip\r\nworkspace=_main\r\nmain=_main/")
43+
workspace = os.environ["TEST_WORKSPACE"]
44+
assert contents.startswith(
45+
f"custom zip\r\nworkspace={workspace}\r\nmain={workspace}/".encode()
46+
)
4047
assert contents.endswith(b"_custom_application_stage2_bootstrap.py")
4148
assert b"_rules_python_bootstrap" not in contents
4249

4350

4451
@pytest.mark.parametrize("status", [0, 23])
52+
@pytest.mark.skipif(os.name == "nt", reason="The raw template uses Bash")
4553
def test_raw_shell_template_accepts_historical_substitutions(tmp_path, status):
4654
template = runfiles.CreateOrRaise().Rlocation(os.environ["RAW_SHELL_TEMPLATE"])
4755
assert template is not None

‎tests/bootstrap_impls/application_tests.bzl‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,21 @@ template_fixture = rule(
6262
attrs = {"contents": attr.string()},
6363
)
6464

65+
def _template_output_impl(ctx):
66+
binary = ctx.attr.binary[DefaultInfo]
67+
path = binary.files_to_run.executable.path
68+
if path.endswith(".exe"):
69+
path = path[:-4]
70+
outputs = [file for file in binary.files.to_list() if file.path == path]
71+
if len(outputs) != 1:
72+
fail("Expected one text bootstrap output for {}".format(ctx.attr.binary.label))
73+
return [DefaultInfo(files = depset(outputs))]
74+
75+
template_output = rule(
76+
implementation = _template_output_impl,
77+
attrs = {"binary": attr.label(mandatory = True)},
78+
)
79+
6580
def _public_executable_impl(ctx):
6681
binary = ctx.attr.binary
6782
public = binary[PyExecutableInfo]

‎tests/bootstrap_impls/bootstrap_cleanup_test.py‎

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ def __init__(self, root, mode):
4242
"wrapper_zip",
4343
)
4444
self.archive = mode not in ("venv", "pyvenv", "wrapper")
45+
self.temporary_runtime = (
46+
mode != "pyvenv" or os.environ["PYVENV_TEMPORARY"] == "1"
47+
)
4548
self.files = runfiles.CreateOrRaise()
4649
self.scratch = root / "temporary runtime with spaces"
4750
self.scratch.mkdir()
@@ -155,7 +158,10 @@ def test_spaces_tmpdir_stdin_arguments_and_status(launcher, status):
155158
actual = json.loads(result.stdout)
156159
assert actual["argv"] == ["argument with spaces"]
157160
assert actual["stdin"] == "input with spaces"
158-
assert Path(actual["executable"]).is_relative_to(launcher.scratch)
161+
assert (
162+
Path(actual["executable"]).is_relative_to(launcher.scratch)
163+
== launcher.temporary_runtime
164+
)
159165
assert actual["ignored"] == [False, False]
160166
assert (
161167
actual["xoptions"]["bootstrap_target"]
@@ -212,7 +218,9 @@ def test_shell_handoff_exports_selected_runfiles_with_manifest(venv):
212218
root = venv.root / "provided.runfiles"
213219
root.symlink_to(os.fspath(venv.files.root()), target_is_directory=True)
214220
manifest = venv.root / "provided.runfiles_manifest"
215-
manifest.write_text("_main/fixture " + str(venv.binary) + "\n")
221+
manifest.write_text(
222+
os.environ["TEST_WORKSPACE"] + "/fixture " + str(venv.binary) + "\n"
223+
)
216224
result = venv.run(
217225
"echo",
218226
"0",
@@ -285,7 +293,7 @@ def test_signal_waits_for_application_cleanup(launcher, value, group):
285293
lambda: (launcher.root / "stopping").exists(), "cancellation did not arrive"
286294
)
287295
assert process.poll() is None
288-
assert list(launcher.scratch.iterdir())
296+
assert bool(list(launcher.scratch.iterdir())) == launcher.temporary_runtime
289297
process.send_signal(signal.SIGTERM)
290298
process.send_signal(signal.SIGHUP)
291299
assert process.stdin is not None
@@ -422,10 +430,13 @@ def test_relative_tmpdir_and_zip_working_directory(launcher):
422430
result = launcher.run("echo", "0", environment=environment)
423431
assert result.returncode == 0, result.stderr
424432
actual = json.loads(result.stdout)
425-
assert Path(actual["executable"]).is_relative_to(launcher.scratch)
433+
assert (
434+
Path(actual["executable"]).is_relative_to(launcher.scratch)
435+
== launcher.temporary_runtime
436+
)
426437
if launcher.archive:
427438
assert actual["cwd"] == os.path.normpath(
428-
str(Path(actual["runfiles"]) / "_main")
439+
str(Path(actual["runfiles"]) / os.environ["TEST_WORKSPACE"])
429440
)
430441
launcher.cleaned()
431442

@@ -519,7 +530,7 @@ def test_warm_shell_zip_executes_without_preparation(tmp_path):
519530
assert actual["argv"] == ["warm argument"]
520531
assert actual["runfiles"] == prepared["runfiles"]
521532
assert actual["executable"] == prepared["executable"]
522-
assert Path(actual["cwd"]).samefile(runfiles_root / "_main")
533+
assert Path(actual["cwd"]).samefile(runfiles_root / os.environ["TEST_WORKSPACE"])
523534
assert actual["xoptions"]["warm_cache"] == "once"
524535
assert "RULES_PYTHON_ZIP_DIR" not in actual["xoptions"]
525536
assert sentinel.read_text() == "keep"
@@ -757,7 +768,10 @@ def test_inherited_path_variables_cannot_select_cleanup(launcher, allocation_fai
757768
if allocation_fails:
758769
environment["TMPDIR"] = str(launcher.root / "missing temporary root")
759770
result = launcher.run("echo", "0", environment=environment)
760-
if allocation_fails and not launcher.shell:
771+
if not launcher.temporary_runtime:
772+
assert result.returncode == 0, result.stderr
773+
assert Path(json.loads(result.stdout)["executable"]).is_file()
774+
elif allocation_fails and not launcher.shell:
761775
# tempfile falls back from an invalid TMPDIR to the platform temp root.
762776
assert result.returncode == 0, result.stderr
763777
wait_until(
@@ -843,7 +857,9 @@ def test_verbose_mode_retains_runtime(launcher):
843857
assert Path(json.loads(result.stdout)["executable"]).is_file()
844858
if not launcher.shell:
845859
assert "rules_python bootstrap: launching interpreter" in result.stderr
846-
assert "rules_python bootstrap: retaining workspace" in result.stderr
860+
assert (
861+
"rules_python bootstrap: retaining workspace" in result.stderr
862+
) == launcher.temporary_runtime
847863
if launcher.archive:
848864
assert "rules_python bootstrap: extracting archive" in result.stderr
849865

@@ -949,7 +965,7 @@ def test_foreground_input_and_job_control(launcher, ignored_continue):
949965
lambda: os.tcgetpgrp(terminal) == shell_pid,
950966
"shell did not recover foreground",
951967
)
952-
assert list(launcher.scratch.iterdir()), "stopped process lost its runtime"
968+
assert bool(list(launcher.scratch.iterdir())) == launcher.temporary_runtime
953969
stopped = launcher.root / "background-stop"
954970
# Wait in the owning shell for the new SIGTTIN stop. Sampling `ps`
955971
# here can observe the old Ctrl-Z stop before `bg` has run.

0 commit comments

Comments
 (0)