diff --git a/tests/support.py b/tests/support.py index c3d7244..ea0e329 100644 --- a/tests/support.py +++ b/tests/support.py @@ -9,6 +9,8 @@ from collections.abc import Callable from datetime import datetime, timedelta, timezone import json +import os +import sys import threading import time from typing import Any @@ -60,6 +62,27 @@ def wait_until( time.sleep(0.005) +def real_interpreter() -> str: + """The Python interpreter itself, for a child a test places in a job object. + + Inside a Windows virtual environment ``sys.executable`` is a small + redirector that starts the base interpreter as its own child, in a job + object of its own. A test that puts ``sys.executable`` in a job and asks the + child which job it is in then reads the redirector's job, and a job limited + to one process refuses the interpreter as the second. The workers are not + affected: ``multiprocessing`` starts the base interpreter directly for the + same reason. The base interpreter does not see the virtual environment's + packages, so run only standard-library code with it. Outside a virtual + environment this is ``sys.executable``. + """ + + if os.name == "nt": + base = getattr(sys, "_base_executable", None) + if base and os.path.isfile(base): + return str(base) + return sys.executable + + def live_cortex_threads(baseline: set[threading.Thread]) -> list[threading.Thread]: """Return the ``cortex-*`` threads alive now that were not in ``baseline``.""" diff --git a/tests/test_brokered_process.py b/tests/test_brokered_process.py index 0efd632..9ceef16 100644 --- a/tests/test_brokered_process.py +++ b/tests/test_brokered_process.py @@ -28,6 +28,7 @@ CodeExecutionError, ) import job_probe +from support import real_interpreter windows_only = pytest.mark.skipif(os.name != "nt", reason="uses Windows job objects") @@ -187,7 +188,7 @@ def test_a_brokered_process_reads_back_the_job_it_was_placed_in(): """Run through the real broker: the child's first act is to ask which job it is in.""" result = code_execution._run_brokered_process( - [sys.executable, str(Path(job_probe.__file__))], workspace=Path.cwd(), timeout=60 + [real_interpreter(), str(Path(job_probe.__file__))], workspace=Path.cwd(), timeout=60 ) assert result["returncode"] == 0 diff --git a/tests/test_worker_isolation.py b/tests/test_worker_isolation.py index 80a48d2..36b77c3 100644 --- a/tests/test_worker_isolation.py +++ b/tests/test_worker_isolation.py @@ -55,7 +55,7 @@ from cortex_backend.execution.local_scratch_attempt import SCRATCH_WORKER_JOB_LIMITS, LocalScratchAttempt from cortex_backend.execution.recipe_coordinator import RecipeExecutionError from cortex_backend.execution.recipes import parse_image_transform -from support import wait_until +from support import real_interpreter, wait_until import job_probe import worker_probes @@ -94,10 +94,12 @@ def _run_in_job(limits: JobLimits, *arguments: str) -> str: The child waits for one line on stdin before it does anything, so the job is attached first; that is the whole point of holding a worker at a checkpoint. + The child is the interpreter itself, not a virtual environment's redirector + (see ``real_interpreter``), so the job is attached to the process that runs. """ child = subprocess.Popen( - [sys.executable, *arguments], + [real_interpreter(), *arguments], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, @@ -114,6 +116,23 @@ def _run_in_job(limits: JobLimits, *arguments: str) -> str: child.wait(timeout=30) +def test_the_interpreter_the_job_tests_start_is_the_process_that_runs(): + """The process handed to the job is the one that executes the test's code. + + Under a Windows virtual environment ``sys.executable`` fails this: the + process started is a redirector and the code runs in its child. + """ + + child = subprocess.Popen( + [real_interpreter(), "-c", "import os; print(os.getpid())"], + stdout=subprocess.PIPE, + text=True, + ) + output, _ = child.communicate(timeout=60) + + assert int(output) == child.pid + + @windows_only def test_a_real_job_carries_exactly_the_limits_it_was_given(): limits = JobLimits(