diff --git a/.github/workflows/help-compatibility.yml b/.github/workflows/help-compatibility.yml index 39c85dbe..330530df 100644 --- a/.github/workflows/help-compatibility.yml +++ b/.github/workflows/help-compatibility.yml @@ -690,7 +690,7 @@ jobs: shell: pwsh timeout-minutes: 1 run: python -I -B scripts/test_wsl_pager.py - - name: Build the Linux pager test executable + - name: Build the Linux compatibility test executables shell: pwsh timeout-minutes: 6 env: @@ -704,12 +704,14 @@ jobs: $ErrorActionPreference = 'Stop' go test -c -o (Join-Path $env:RUNNER_TEMP 'wsl-pager.test') ./pkg/custom if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + go test -c -o (Join-Path $env:RUNNER_TEMP 'wsl-process.test') ./cmd/openai + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } go mod verify exit $LASTEXITCODE - - name: Run the pager regression in disposable Ubuntu WSL 1 + - name: Run compatibility checks in disposable Ubuntu WSL 1 shell: pwsh timeout-minutes: 16 - run: python -I -B scripts/check-wsl-pager.py --binary (Join-Path $env:RUNNER_TEMP 'wsl-pager.test') + run: python -I -B scripts/check-wsl-pager.py --binary (Join-Path $env:RUNNER_TEMP 'wsl-pager.test') --process-binary (Join-Path $env:RUNNER_TEMP 'wsl-process.test') - name: Reconcile the owned WSL distribution if: always() shell: pwsh diff --git a/scripts/check-wsl-pager.py b/scripts/check-wsl-pager.py index c68d0d17..e5d9b54c 100644 --- a/scripts/check-wsl-pager.py +++ b/scripts/check-wsl-pager.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Run the pager descriptor regression in one disposable Ubuntu WSL 1 distro. +"""Run fixed compatibility checks in one disposable Ubuntu WSL 1 distro. This is a process check. It does not test Windows Terminal or WSL 2. The calling workflow cross-compiles the test binary before importing Ubuntu. @@ -21,6 +21,7 @@ import sys import threading import time +import urllib.error import urllib.request @@ -31,6 +32,82 @@ OUTPUT_LIMIT = 8 * 1024 * 1024 ROOT = "TestSocketPairPagerFailureKeepsReusedDescriptorOpen" TESTS = {ROOT, ROOT + "/LookPath", ROOT + "/ForkExec"} + +# Public stdin, download, and output contracts. These invoke main() in subprocesses. +PROCESS_CASES = { + "TestMainShellExplicitTextInput": [ + "plain/untrusted=false", "plain/untrusted=true", "JSON_stays_text/untrusted=false", + "JSON_stays_text/untrusted=true", "YAML_stays_text/untrusted=false", "YAML_stays_text/untrusted=true", + "empty/untrusted=false", "empty/untrusted=true", "unicode_and_control/untrusted=false", + "unicode_and_control/untrusted=true", "base64/untrusted=false", "base64/untrusted=true", + "non_UTF8_auto_base64/untrusted=false", "non_UTF8_auto_base64/untrusted=true", + "stdin_content_cannot_expand_files/untrusted=false", + "stdin_content_cannot_expand_files/untrusted=true", + ], + "TestMainShellWholeRequestAndRepeatedFlags": [ + "JSON_override_and_unknown_null", "YAML", "last_scalar_releases_stdin", "last_scalar_selects_stdin", + "nested_field_selects_stdin", "nested_replacement_releases_stdin", + "empty_nullable_replacement_releases_stdin", "null_nullable_replacement_releases_stdin", + "untrusted_reference_remains_literal", + ], + "TestMainShellStdinConflictBeforeRead": [ + "responses_create_--model_model_synthetic_--input_@-_--instructions_@-", + "audio_transcriptions_create_--model_whisper-1_--file_-_--prompt_@-", + "images_edit_--model_dall-e-2_--prompt_synthetic_--image_-_--image_-", + "responses_create_--model_model_synthetic_--input_[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"@-\"},{\"type\":\"input_text\",\"text\":\"@data://-\"}]}]", + "chat_completions_create_--model_model_synthetic_--message_{\"role\":\"user\",\"content\":\"@-\"}_--message_{\"role\":\"user\",\"content\":\"@file://-\"}", + ], + "TestMainShellStreamingInputDispatch": [ + "JSON_true", "YAML_true", "explicit_false", "explicit_null", "last_stream_false", "last_stream_null", + "last_stream_true", "body_false", "body_null", "omitted", + ], + "TestMainShellMachineStreamDeliversBeforeNextEvent": [ + "body/jsonl", "body/json", "body/pretty", "body/raw", "body/yaml", "flag/jsonl", "flag/json", + "flag/pretty", "flag/raw", "flag/yaml", + ], + "TestMainShellSaveReceiptWaitsForBodyCompletion": [], + "TestMainShellClosedStdoutStopsDownload": [], + "TestMainShellClosedStdoutPreservesStreamFailure": [], + "TestMainFilesWorkflowUploadPaths": [ + "spaces", "options_first", "apostrophe", "literal_at", "literal_dash", "literal_double_dash", "empty", + "binary", "upload_flag", "create_flag", "upload_assigned_flag", "upload_repeated_flag", + "create_repeated_flag", + ], + "TestMainFilesWorkflowUploadValidation": [ + "missing_purpose", "missing_path", "missing_local_file", "extra_positional", + "positional_and_file_flag", "file_flag_and_positional", "create_keeps_flag_syntax", + ], + "TestMainFilesWorkflowExplicitBinaryStdin": [ + "empty", "binary", "JSON_stays_file_bytes", + ], + "TestMainFilesWorkflowMachineOutput": [ + "piped_default", "explicit_auto", "explicit_text", "json", "jsonl", "raw", "extract_ID", "raw_ID", + "raw_timestamp", + ], + "TestMainFilesWorkflowDownloadBytes": [ + "text", "binary", + ], + "TestMainDispatchOutputQuietPreservesSelectedPayloads": [ + "models_retrieve_model_synthetic", "--format_json_models_retrieve_model_synthetic", + "--format_jsonl_files_list", "--format_yaml_files_list", "--format_raw_files_list", + "--format_pretty_models_retrieve_model_synthetic", + "--transform_extra.note_--raw-output_models_retrieve_model_synthetic", + "files_content_file_synthetic_--output_-", "containers_delete_cntr_synthetic", + ], + "TestMainDispatchOutputVerboseKeepsDataAndProtectsMachineErrors": [ + "human_success", "machine_stdout_text_stderr", "machine_success", "machine_failure", + "explicit_machine_error", "human_failure", "quiet_takes_precedence", + ], + "TestMainDispatchOutputVerboseBinaryKeepsBytesAndLabelsFormatOption": [], + "TestMainDispatchOutputQuietKeepsFailurePayloadsAndSuppressesFallbackNotice": [ + "json", "jsonl", "raw", "explore", + ], +} +PROCESS_TESTS = {root for root in PROCESS_CASES} | { + root + "/" + child for root, children in PROCESS_CASES.items() for child in children +} +PROCESS_SELECTOR = "^(" + "|".join(PROCESS_CASES) + ")$" + STOP = 0 @@ -96,17 +173,17 @@ def require_native_windows_process(): return identity -def require_test_pass(stdout, stderr, code): +def require_test_pass(stdout, stderr, code, tests=TESTS, label="pager"): text = stdout.decode("utf-8", errors="strict") stderr.decode("utf-8", errors="strict") if code != 0 or stderr: - raise RuntimeError("The WSL pager test failed or wrote stderr") + raise RuntimeError(f"The WSL {label} test failed or wrote stderr") starts = Counter(re.findall(r"^=== RUN\s+(\S+)\s*$", text, re.MULTILINE)) passes = Counter(re.findall(r"^\s*--- PASS: (\S+) \([^\n]*\)\s*$", text, re.MULTILINE)) - if (starts != Counter(TESTS) or passes != Counter(TESTS) or + if (starts != Counter(tests) or passes != Counter(tests) or re.search(r"--- (?:FAIL|SKIP):|panic:|fatal error:|no tests to run", text) or len(re.findall(r"^PASS\s*$", text, re.MULTILINE)) != 1): - raise RuntimeError("Missing, skipped, duplicated, or failed pager test execution") + raise RuntimeError(f"Missing, skipped, duplicated, or failed {label} test execution") def require_linux_binary(path): @@ -231,11 +308,45 @@ def redirect_request(self, request, fp, code, message, headers, newurl): return super().redirect_request(request, fp, code, message, headers, newurl) -def download_image(path, deadline): +def open_image(opener, deadline, attempts): + # Retry only startup timeouts. No response or output file exists yet. + for index in range(2): + started = time.monotonic() + remaining = deadline - started + if STOP or remaining <= 0: + raise RuntimeError("Ubuntu download stopped or timed out") + attempt = {"number": index + 1, "outcome": "opening"} + attempts.append(attempt) + try: + response = opener.open(IMAGE_URL, timeout=min(10, remaining)) + except Exception as error: + reason = error.reason if isinstance(error, urllib.error.URLError) else error + attempt.update(outcome="failed", error_type=type(error).__name__, + reason_type=type(reason).__name__, + elapsed_seconds=round(time.monotonic() - started, 3)) + if index or isinstance(error, urllib.error.HTTPError) or not isinstance(reason, TimeoutError): + raise + if STOP or deadline - time.monotonic() < 11: + raise + time.sleep(1) + if STOP or deadline - time.monotonic() < 10: + raise + continue + attempt.update(outcome="opened", elapsed_seconds=round(time.monotonic() - started, 3)) + if STOP or time.monotonic() >= deadline: + attempt["outcome"] = "rejected_late_or_stopped" + response.close() + raise RuntimeError("Ubuntu download stopped or timed out") + return response + raise RuntimeError("Ubuntu download failed to open within retry policy") + + +def download_image(path, deadline, attempts=None): # A socket read can take ten seconds. Reserve that time inside the 480-second cap. end = min(deadline, time.monotonic() + 470) hasher, size = hashlib.sha256(), 0 - with urllib.request.build_opener(HTTPSOnly()).open(IMAGE_URL, timeout=10) as response, path.open("xb") as output: + opener = urllib.request.build_opener(HTTPSOnly()) + with open_image(opener, end, attempts if attempts is not None else []) as response, path.open("xb") as output: if int(response.headers.get("Content-Length", "0")) > IMAGE_LIMIT: raise RuntimeError("Ubuntu image exceeds its download limit") while True: @@ -305,7 +416,27 @@ def write_json(path, data): path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") -def pilot(binary, cleanup_only=False): +def copy_payload(commands, root, user, binary, target, expected_hash, prefix=""): + translated = commands.run(prefix + "binary-path", root + ["/usr/bin/wslpath", "-u", str(binary)], 20)[0].decode("utf-8").strip() + if not translated.startswith("/mnt/") or "\n" in translated: + raise RuntimeError("Unexpected Windows payload translation") + commands.run(prefix + "copy", root + ["/usr/bin/install", "-m", "700", "-o", "cli-compat", "-g", "cli-compat", "--", translated, target], 30) + copied = commands.run(prefix + "binary-hash", user + ["/usr/bin/sha256sum", target], 10)[0].decode("ascii").split()[0] + if copied != expected_hash: + raise RuntimeError("The copied Linux binary changed") + + +def run_process_suite(commands, user, target): + # Reserve the full bounded stage before launching any selected process tests. + if commands.deadline - time.monotonic() < 170: + raise RuntimeError("Insufficient work time for the WSL process suite") + test = ["/usr/bin/timeout", "--signal=TERM", "--kill-after=5s", "160s", target, + "-test.run=" + PROCESS_SELECTOR, "-test.timeout=150s", "-test.v"] + stdout, stderr, code = commands.run("process", user + test, 170) + require_test_pass(stdout, stderr, code, PROCESS_TESTS, "process") + + +def pilot(binary, cleanup_only=False, process_binary=None): # Validate process bitness before selecting System32, which WOW64 can redirect. host_process = require_native_windows_process() name, directory = owned_paths() @@ -335,6 +466,10 @@ def pilot(binary, cleanup_only=False): if not cleanup_only: result["binary_sha256"] = require_linux_binary(binary) binary = binary.resolve(strict=True) + if process_binary is not None: + result["process_binary_sha256"] = require_linux_binary(process_binary) + process_binary = process_binary.resolve(strict=True) + result["scope"] += "; public stdin, download, and output processes" result["wsl_sha256"] = digest(Path(wsl)) result["source"] = commands.run("source", ["git", "rev-parse", "HEAD", "HEAD^{tree}"], 10)[0].decode("ascii").splitlines() result["wsl_version"] = decode_metadata(commands.run("wsl-version", [wsl, "--version"], 20)[0]) @@ -349,7 +484,8 @@ def pilot(binary, cleanup_only=False): if shutil.disk_usage(directory).free < 6 * 1024**3: raise RuntimeError("Insufficient free space for the disposable distro") image = directory / "ubuntu.wsl" - result["image_bytes"] = download_image(image, commands.deadline) + result["image_open_attempts"] = [] + result["image_bytes"] = download_image(image, commands.deadline, result["image_open_attempts"]) result["image_sha256"] = IMAGE_SHA256 state["import_attempted"] = True write_json(state_path, state) @@ -360,17 +496,14 @@ def pilot(binary, cleanup_only=False): "/usr/sbin/useradd --create-home --uid 2000 --user-group --shell /bin/bash cli-compat; " "install -d -m 700 -o cli-compat -g cli-compat /home/cli-compat/payload") commands.run("user", root + ["/bin/bash", "--noprofile", "--norc", "-c", setup], 30) - translated = commands.run("binary-path", root + ["/usr/bin/wslpath", "-u", str(binary)], 20)[0].decode("utf-8").strip() - if not translated.startswith("/mnt/") or "\n" in translated: - raise RuntimeError("Unexpected Windows payload translation") target = "/home/cli-compat/payload/custom.test" - commands.run("copy", root + ["/usr/bin/install", "-m", "700", "-o", "cli-compat", "-g", "cli-compat", "--", translated, target], 30) user = [wsl, "--distribution", name, "--user", "cli-compat", "--exec", "/usr/bin/env", "-i", "HOME=/home/cli-compat", "TMPDIR=/home/cli-compat", "PATH=/usr/bin:/bin", "LC_ALL=C.UTF-8", "TERM=dumb", "NO_COLOR=1", "GOMAXPROCS=2"] - copied = commands.run("binary-hash", user + ["/usr/bin/sha256sum", target], 10)[0].decode("ascii").split()[0] - if copied != result["binary_sha256"]: - raise RuntimeError("The copied Linux binary changed") + copy_payload(commands, root, user, binary, target, result["binary_sha256"]) + process_target = "/home/cli-compat/payload/process.test" + if process_binary is not None: + copy_payload(commands, root, user, process_binary, process_target, result["process_binary_sha256"], "process-") identity = "set -eu; test $(id -u) = 2000; test $(uname -m) = x86_64; test $(dpkg --print-architecture) = amd64; cat /etc/os-release; uname -a; id; /bin/bash --version; stat -f -c %T /home/cli-compat" observed = commands.run("ubuntu-identity", user + ["/bin/bash", "--noprofile", "--norc", "-c", identity], 20)[0].decode("utf-8") if not re.search(r"^ID=ubuntu$", observed, re.MULTILINE): @@ -384,12 +517,17 @@ def pilot(binary, cleanup_only=False): "-test.run=^" + ROOT + "$", "-test.timeout=45s", "-test.v"] stdout, stderr, code = commands.run("pager", user + test, 70) require_test_pass(stdout, stderr, code) + result["tests"] = sorted(TESTS) + if process_binary is not None: + run_process_suite(commands, user, process_target) + result["process_tests"] = sorted(PROCESS_TESTS) + if digest(process_binary) != result["process_binary_sha256"]: + raise RuntimeError("The host process test binary changed") if digest(binary) != result["binary_sha256"]: raise RuntimeError("The host test binary changed") final_source = commands.run("source-final", ["git", "rev-parse", "HEAD", "HEAD^{tree}"], 10)[0].decode("ascii").splitlines() if final_source != result["source"]: raise RuntimeError("The source commit changed during execution") - result["tests"] = sorted(TESTS) except Exception as error: failure = str(error) finally: @@ -417,6 +555,7 @@ def pilot(binary, cleanup_only=False): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--binary", type=Path) + parser.add_argument("--process-binary", type=Path, help="Also run the fixed noninteractive process suite") parser.add_argument("--cleanup", action="store_true") args = parser.parse_args() if not args.cleanup and args.binary is None: @@ -424,7 +563,7 @@ def main(): for number in (signal.SIGINT, signal.SIGTERM): signal.signal(number, interrupted) try: - code = pilot(args.binary, args.cleanup) + code = pilot(args.binary, args.cleanup, args.process_binary) except Exception as error: print(f"WSL pager pilot failed: {error}", file=sys.stderr) code = 128 + STOP if STOP else 1 diff --git a/scripts/test_wsl_pager.py b/scripts/test_wsl_pager.py index da8a90ba..b175bc3a 100644 --- a/scripts/test_wsl_pager.py +++ b/scripts/test_wsl_pager.py @@ -6,11 +6,13 @@ import io import os from pathlib import Path +import socket +import ssl import sys import tempfile import time import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch spec = importlib.util.spec_from_file_location("wsl_pager", Path(__file__).with_name("check-wsl-pager.py")) @@ -26,6 +28,11 @@ def passing_output(): f" --- PASS: {root}/ForkExec (0.01s)\nPASS\n").encode() +def process_passing_output(): + names = sorted(pilot.PROCESS_TESTS) + return ("".join(f"=== RUN {name}\n--- PASS: {name} (0.01s)\n" for name in names) + "PASS\n").encode() + + class MetadataTests(unittest.TestCase): def test_enabled_feature(self): report = ("Deployment Image Servicing and Management tool\nVersion: synthetic\n\n" @@ -140,6 +147,97 @@ def test_ubuntu_output_is_not_metadata(self): with self.assertRaises(UnicodeDecodeError): pilot.require_test_pass(b"\xff\xfe" + passing_output().decode().encode("utf-16-le"), b"", 0) + def test_process_exact_inventory(self): + self.assertEqual(len(pilot.PROCESS_CASES), 17) + self.assertEqual(len(pilot.PROCESS_TESTS), 121) + self.assertNotIn("TestMainDispatchProcess", pilot.PROCESS_TESTS) + pilot.require_test_pass(process_passing_output(), b"", 0, pilot.PROCESS_TESTS, "process") + + def test_process_rejects_missing_duplicate_and_unexpected_events(self): + good = process_passing_output() + name = "TestMainFilesWorkflowExplicitBinaryStdin/JSON_stays_file_bytes" + run = f"=== RUN {name}\n".encode() + passed = f"--- PASS: {name} (0.01s)\n".encode() + bad = {"missing run": good.replace(run, b""), "missing pass": good.replace(passed, b""), + "duplicate run": good + run, "duplicate pass": good + passed, + "unexpected": good + b"=== RUN TestOther\n--- PASS: TestOther (0.01s)\n", + "skipped": good + b"--- SKIP: TestOther (0.01s)\n", + "failed": good + b"--- FAIL: TestOther (0.01s)\n", + "no final pass": good.removesuffix(b"PASS\n"), "duplicate final pass": good + b"PASS\n", + "pager instead": passing_output(), "empty": b"PASS\n"} + for label, raw in bad.items(): + with self.subTest(label=label): + with self.assertRaisesRegex(RuntimeError, "process test execution"): + pilot.require_test_pass(raw, b"", 0, pilot.PROCESS_TESTS, "process") + + def test_process_preserves_exit_stderr_and_encoding_gates(self): + for stderr, code in ((b"", 7), (b"diagnostic", 0)): + with self.subTest(code=code, stderr=stderr): + with self.assertRaisesRegex(RuntimeError, "process test failed or wrote stderr"): + pilot.require_test_pass(process_passing_output(), stderr, code, pilot.PROCESS_TESTS, "process") + with self.assertRaises(UnicodeDecodeError): + pilot.require_test_pass(b"\xff" + process_passing_output(), b"", 0, pilot.PROCESS_TESTS, "process") + + def test_each_payload_uses_its_own_destination_and_hash(self): + class FakeCommands: + def __init__(self, observed): + self.observed, self.calls = observed, [] + + def run(self, label, argv, seconds): + self.calls.append((label, argv, seconds)) + value = b"/mnt/c/synthetic test\n" if label.endswith("binary-path") else self.observed + return value, b"", 0 + + for prefix, target, expected in (("", "/home/cli-compat/payload/custom.test", "pager-digest"), + ("process-", "/home/cli-compat/payload/process.test", "process-digest")): + with self.subTest(prefix=prefix): + commands = FakeCommands((expected + " " + target + "\n").encode()) + pilot.copy_payload(commands, ["root"], ["user"], Path("C:/synthetic test"), target, expected, prefix) + self.assertEqual([x[0] for x in commands.calls], + [prefix + "binary-path", prefix + "copy", prefix + "binary-hash"]) + self.assertEqual(commands.calls[1][1], ["root", "/usr/bin/install", "-m", "700", "-o", "cli-compat", "-g", "cli-compat", "--", "/mnt/c/synthetic test", target]) + self.assertEqual(commands.calls[2][1], ["user", "/usr/bin/sha256sum", target]) + with self.assertRaisesRegex(RuntimeError, "copied Linux binary changed"): + pilot.copy_payload(FakeCommands(b"wrong target\n"), ["root"], ["user"], Path("test"), target, expected, prefix) + + def test_process_stage_requires_full_admission_and_fixed_command(self): + class FakeCommands: + deadline = 200.0 + + def __init__(self): + self.calls = [] + + def run(self, label, argv, seconds): + self.calls.append((label, argv, seconds)) + return process_passing_output(), b"", 0 + + commands = FakeCommands() + with patch.object(pilot.time, "monotonic", return_value=30.001): + with self.assertRaisesRegex(RuntimeError, "Insufficient work time"): + pilot.run_process_suite(commands, ["user"], "/process.test") + self.assertEqual(commands.calls, []) + with patch.object(pilot.time, "monotonic", return_value=30.0): + pilot.run_process_suite(commands, ["user"], "/process.test") + self.assertEqual(commands.calls, [("process", ["user", "/usr/bin/timeout", "--signal=TERM", "--kill-after=5s", "160s", "/process.test", "-test.run=" + pilot.PROCESS_SELECTOR, "-test.timeout=150s", "-test.v"], 170)]) + + def test_process_stage_does_not_accept_a_false_command_result(self): + class FakeCommands: + deadline = 200.0 + + def __init__(self, result): + self.result = result + + def run(self, *_args): + return self.result + + with patch.object(pilot.time, "monotonic", return_value=0): + for result in ((process_passing_output(), b"", 1), + (process_passing_output(), b"unexpected", 0), + (b"PASS\n", b"", 0)): + with self.subTest(code=result[2], stderr=result[1]): + with self.assertRaises(RuntimeError): + pilot.run_process_suite(FakeCommands(result), ["user"], "/process.test") + def test_binary_architecture(self): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "test" @@ -212,6 +310,176 @@ def open(self, *_args, **_kwargs): pilot.download_image(Path(directory) / "late-close", 1.0) +class DownloadRetryTests(unittest.TestCase): + class Response(io.BytesIO): + headers = {} + + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.path = Path(self.directory.name) / "ubuntu.wsl" + self.addCleanup(setattr, pilot, "STOP", 0) + + def test_startup_timeout_retries_once_before_creating_image(self): + data = b"synthetic image" + for failure in (TimeoutError("synthetic timeout"), pilot.urllib.error.URLError(TimeoutError("synthetic timeout"))): + with self.subTest(type=type(failure).__name__): + response = self.Response(data) + opener = Mock() + opener.open.side_effect = [failure, response] + attempts = [] + with patch.object(pilot.urllib.request, "build_opener", return_value=opener), \ + patch.object(pilot.time, "sleep") as sleep, \ + patch.object(pilot, "IMAGE_SHA256", pilot.hashlib.sha256(data).hexdigest()): + self.assertEqual(pilot.download_image(self.path, time.monotonic() + 100, attempts), len(data)) + self.assertEqual(self.path.read_bytes(), data) + self.path.unlink() + self.assertTrue(response.closed) + self.assertEqual(opener.open.call_count, 2) + for call in opener.open.call_args_list: + self.assertEqual(call.args, (pilot.IMAGE_URL,)) + self.assertEqual(call.kwargs, {"timeout": 10}) + sleep.assert_called_once_with(1) + self.assertEqual([a["outcome"] for a in attempts], ["failed", "opened"]) + self.assertEqual(attempts[0]["reason_type"], "TimeoutError") + + def test_repeated_startup_timeout_preserves_final_failure_without_image(self): + final = pilot.urllib.error.URLError(TimeoutError("second timeout")) + opener = Mock() + opener.open.side_effect = [TimeoutError("first timeout"), final] + attempts = [] + with patch.object(pilot.urllib.request, "build_opener", return_value=opener), patch.object(pilot.time, "sleep"): + with self.assertRaises(pilot.urllib.error.URLError) as caught: + pilot.download_image(self.path, time.monotonic() + 100, attempts) + self.assertIs(caught.exception, final) + self.assertEqual(opener.open.call_count, 2) + self.assertEqual([a["outcome"] for a in attempts], ["failed", "failed"]) + self.assertFalse(self.path.exists()) + + def test_other_startup_failures_never_retry(self): + failures = [ssl.SSLCertVerificationError(1, "synthetic certificate failure"), + pilot.urllib.error.URLError(ssl.SSLCertVerificationError(1, "synthetic certificate failure")), + ssl.SSLError(1, "synthetic TLS failure"), + pilot.urllib.error.HTTPError(pilot.IMAGE_URL, 503, "timeout-looking text", None, None), + pilot.urllib.error.HTTPError(pilot.IMAGE_URL, 503, TimeoutError("synthetic HTTP reason"), None, None), + pilot.urllib.error.URLError(socket.gaierror(-2, "synthetic DNS failure")), + ConnectionResetError("synthetic reset"), + pilot.urllib.error.URLError("The handshake operation timed out")] + for failure in failures: + with self.subTest(type=type(failure).__name__, message=str(failure)): + opener = Mock() + opener.open.side_effect = failure + attempts = [] + with patch.object(pilot.time, "sleep") as sleep: + with self.assertRaises(type(failure)) as caught: + pilot.open_image(opener, time.monotonic() + 100, attempts) + self.assertIs(caught.exception, failure) + opener.open.assert_called_once() + sleep.assert_not_called() + self.assertEqual(len(attempts), 1) + self.assertFalse(self.path.exists()) + + def test_body_timeout_never_retries_and_owned_partial_file_is_removed(self): + class Partial(self.Response): + reads = 0 + + def read1(self, _size): + self.reads += 1 + if self.reads == 1: + return b"partial" + raise TimeoutError("synthetic body timeout") + + response = Partial() + opener = Mock() + opener.open.return_value = response + with patch.object(pilot.urllib.request, "build_opener", return_value=opener): + with self.assertRaisesRegex(TimeoutError, "synthetic body timeout"): + pilot.download_image(self.path, time.monotonic() + 100) + opener.open.assert_called_once() + self.assertTrue(response.closed) + self.assertEqual(self.path.read_bytes(), b"partial") + commands = Mock() + commands.run.side_effect = AssertionError("No WSL command is needed before import") + pilot.clean_distro(commands, "wsl.exe", "owned", {"import_attempted": False, "directory": self.directory.name}) + self.assertFalse(self.path.exists()) + + def test_integrity_and_file_failures_never_retry(self): + class WriteFailure(io.BytesIO): + def write(self, _data): + raise OSError("synthetic write failure") + + for phase in ("checksum", "existing file", "write"): + with self.subTest(phase=phase): + response = self.Response(b"synthetic") + opener = Mock() + opener.open.return_value = response + with patch.object(pilot.urllib.request, "build_opener", return_value=opener): + if phase == "write": + output = WriteFailure() + with patch.object(Path, "open", return_value=output): + with self.assertRaisesRegex(OSError, "synthetic write failure"): + pilot.download_image(self.path, time.monotonic() + 100) + self.assertTrue(output.closed) + elif phase == "existing file": + self.path.write_bytes(b"preserved") + with self.assertRaises(FileExistsError): + pilot.download_image(self.path, time.monotonic() + 100) + self.assertEqual(self.path.read_bytes(), b"preserved") + self.path.unlink() + else: + with self.assertRaisesRegex(RuntimeError, "checksum mismatch"): + pilot.download_image(self.path, time.monotonic() + 100) + self.path.unlink() + opener.open.assert_called_once() + self.assertTrue(response.closed) + + def test_stop_and_remaining_budget_prevent_second_attempt(self): + for boundary in ("before delay", "after delay", "stop"): + with self.subTest(boundary=boundary): + pilot.STOP = 0 + clock = [0.0] + failure = TimeoutError("first timeout") + opener = Mock() + opener.open.side_effect = failure + + def delay(_seconds): + clock[0] = 91.0 + if boundary == "stop": + pilot.STOP = 15 + + deadline = 10.99 if boundary == "before delay" else 100.0 + with patch.object(pilot.time, "monotonic", side_effect=lambda: clock[0]), \ + patch.object(pilot.time, "sleep", side_effect=delay): + with self.assertRaises(TimeoutError) as caught: + pilot.open_image(opener, deadline, []) + self.assertIs(caught.exception, failure) + opener.open.assert_called_once() + + def test_late_or_stopped_open_closes_response_without_acceptance(self): + for stopped in (False, True): + with self.subTest(stopped=stopped): + pilot.STOP = 0 + clock = [0.0] + response = self.Response(b"synthetic") + + def opened(*_args, **_kwargs): + if stopped: + pilot.STOP = 2 + else: + clock[0] = 100.0 + return response + + opener = Mock() + opener.open.side_effect = opened + attempts = [] + with patch.object(pilot.time, "monotonic", side_effect=lambda: clock[0]): + with self.assertRaisesRegex(RuntimeError, "stopped or timed out"): + pilot.open_image(opener, 100.0, attempts) + opener.open.assert_called_once() + self.assertTrue(response.closed) + self.assertEqual(attempts[0]["outcome"], "rejected_late_or_stopped") + + class LifecycleTests(unittest.TestCase): def setUp(self): self.directory = tempfile.TemporaryDirectory()