diff --git a/.github/workflows/windows-check.yml b/.github/workflows/windows-check.yml index d27b9e81..eb520a64 100644 --- a/.github/workflows/windows-check.yml +++ b/.github/workflows/windows-check.yml @@ -10,7 +10,7 @@ jobs: build: if: github.repository_owner == 'wolfssl' runs-on: windows-latest - timeout-minutes: 10 + timeout-minutes: 20 env: BUILD_PLATFORM: x64 @@ -54,5 +54,5 @@ jobs: - name: Run tests working-directory: wolfclu - run: python tests/run_tests.py + run: python tests/run_tests_parallel.py diff --git a/tests/dgst/dgst-test.py b/tests/dgst/dgst-test.py index 75325f2a..457da714 100644 --- a/tests/dgst/dgst-test.py +++ b/tests/dgst/dgst-test.py @@ -9,7 +9,8 @@ import unittest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from wolfclu_test import CERTS_DIR, is_fips, run_wolfssl, test_main +from wolfclu_test import (CERTS_DIR, is_fips, run_wolfssl, test_main, + truncate_sparse) DGST_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -253,7 +254,7 @@ def setUpClass(cls): try: for p in (cls.original, cls.tampered): with open(p, "wb") as f: - f.truncate(cls.LARGE_FILE_SIZE) + truncate_sparse(f, cls.LARGE_FILE_SIZE) with open(cls.tampered, "r+b") as f: f.seek(-1, os.SEEK_END) f.write(b"X") diff --git a/tests/hash/hash-test.py b/tests/hash/hash-test.py index 78dd4a10..af06b517 100644 --- a/tests/hash/hash-test.py +++ b/tests/hash/hash-test.py @@ -8,7 +8,7 @@ import unittest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from wolfclu_test import CERTS_DIR, run_wolfssl, test_main +from wolfclu_test import (CERTS_DIR, run_wolfssl, test_main, truncate_sparse) HASH_DIR = os.path.dirname(os.path.abspath(__file__)) CERT_FILE = os.path.join(CERTS_DIR, "ca-cert.pem") @@ -135,7 +135,7 @@ def setUpClass(cls): try: for p in (cls.original, cls.tampered): with open(p, "wb") as f: - f.truncate(cls.LARGE_FILE_SIZE) + truncate_sparse(f, cls.LARGE_FILE_SIZE) with open(cls.tampered, "r+b") as f: f.seek(-1, os.SEEK_END) f.write(b"X") diff --git a/tests/ocsp/ocsp-test.py b/tests/ocsp/ocsp-test.py index b9a05ccc..92e73884 100644 --- a/tests/ocsp/ocsp-test.py +++ b/tests/ocsp/ocsp-test.py @@ -8,7 +8,6 @@ import os import re import shutil -import socket import subprocess import sys import tempfile @@ -16,17 +15,11 @@ import unittest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from wolfclu_test import WOLFSSL_BIN, CERTS_DIR, test_main +from wolfclu_test import WOLFSSL_BIN, CERTS_DIR, test_main, find_free_port HAS_OPENSSL = shutil.which("openssl") is not None -def _find_free_port(): - """Bind to port 0 to let the OS assign a free ephemeral port.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - INDEX_VALID = ( "V\t991231235959Z\t\t01\tunknown\t" "/C=US/ST=Montana/L=Bozeman/O=wolfSSL/OU=Support" @@ -135,7 +128,7 @@ def setUpClass(cls): raise unittest.SkipTest( f"OCSP not supported by {cls.RESPONDER_BIN}") - cls.PORT = _find_free_port() + cls.PORT = find_free_port() cls._tmpdir = tempfile.mkdtemp() cls._responder = None diff --git a/tests/run_tests.py b/tests/run_tests.py deleted file mode 100755 index 7ffb1cb7..00000000 --- a/tests/run_tests.py +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env python3 -"""Test runner for wolfCLU Python tests. - -Discovers and runs all *-test.py files under the tests/ directory. -Intended for use on Windows where `make check` is not available. -""" - -import glob -import importlib.util -import os -import sys -import unittest - - -def load_tests_from_file(path): - """Load a unittest module from a file path (supports hyphens in names).""" - name = os.path.splitext(os.path.basename(path))[0].replace("-", "_") - spec = importlib.util.spec_from_file_location(name, path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return unittest.TestLoader().loadTestsFromModule(module) - - -def main(): - # Run from the project root so tests can find ./wolfssl and ./certs - script_dir = os.path.dirname(os.path.abspath(__file__)) - project_root = os.path.dirname(script_dir) - os.chdir(project_root) - - # Ensure tests can import the shared wolfclu_test helper - if script_dir not in sys.path: - sys.path.insert(0, script_dir) - - suite = unittest.TestSuite() - pattern = os.path.join(script_dir, "**", "*-test.py") - for test_file in sorted(glob.glob(pattern, recursive=True)): - suite.addTests(load_tests_from_file(test_file)) - - kwargs = dict(verbosity=2) - if sys.version_info >= (3, 12): - kwargs["durations"] = 5 - runner = unittest.TextTestRunner(**kwargs) - result = runner.run(suite) - sys.exit(0 if result.wasSuccessful() else 1) - - -if __name__ == "__main__": - main() diff --git a/tests/run_tests_parallel.py b/tests/run_tests_parallel.py new file mode 100644 index 00000000..243e457b --- /dev/null +++ b/tests/run_tests_parallel.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Parallel test runner for wolfCLU Python tests. + +Runs each *-test.py file in its own process concurrently. The tests are +I/O-bound (each spawns the wolfssl binary), so file-level parallelism gives +near-linear speedup over the serial run_tests.py. + +""" + +import glob +import os +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed + +# Per-file cap so a single hung test can't run until the CI job-level timeout. +PER_TEST_TIMEOUT = 600 + + +def run_one(test_file, project_root): + """Run a single test file in its own process.""" + + try: + proc = subprocess.run( + [sys.executable, test_file], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=project_root, + universal_newlines=True, + timeout=PER_TEST_TIMEOUT, + ) + return test_file, proc.returncode, proc.stdout + except subprocess.TimeoutExpired as e: + out = e.output or "" + return test_file, 1, out + "\n[TIMEOUT] killed after {}s\n".format( + PER_TEST_TIMEOUT) + + +def report(script_dir, test_file, rc): + """Print a one-line status; return True if the file failed.""" + name = os.path.relpath(test_file, script_dir) + # 77 is the automake SKIP exit code emitted by test_main(). + status = "PASS" if rc == 0 else ("SKIP" if rc == 77 else "FAIL") + print("[{}] {}".format(status, name), flush=True) + return rc not in (0, 77) + + +def main(): + script_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(script_dir) + + pattern = os.path.join(script_dir, "**", "*-test.py") + all_files = sorted(glob.glob(pattern, recursive=True)) + + default_workers = os.cpu_count() or 4 + workers_env = os.environ.get("WOLFCLU_TEST_JOBS") + if workers_env: + try: + workers = max(1, int(workers_env)) + except ValueError: + workers = default_workers + else: + workers = default_workers + + failed = [] # (name, output) + skipped = [] # (name) + + with ThreadPoolExecutor(max_workers=workers) as ex: + futures = [ex.submit(run_one, f, project_root) for f in all_files] + # Report as each file finishes so live status isn't gated on the + # slowest file (and a hang can't hide already-completed results). + for fut in as_completed(futures): + test_file, rc, out = fut.result() + if report(script_dir, test_file, rc): + failed.append((os.path.relpath(test_file, script_dir), out)) + if rc == 77: + skipped.append(os.path.relpath(test_file, script_dir)) + + + # Dump captured output for any failures so it isn't lost in the noise. + for name, out in failed: + print("\n===== FAILED: {} =====".format(name)) + if out: + print(out, end="" if out.endswith("\n") else "\n") + + total = len(all_files) - len(skipped) + print( + "\n{}/{} test files passed. {} skipped.".format(total - len(failed), + total, len(skipped)), + flush=True, + ) + sys.exit(1 if failed else 0) + + +if __name__ == "__main__": + main() diff --git a/tests/server/server-test.py b/tests/server/server-test.py index 54eae670..bfe32aac 100644 --- a/tests/server/server-test.py +++ b/tests/server/server-test.py @@ -8,7 +8,7 @@ import unittest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from wolfclu_test import WOLFSSL_BIN, CERTS_DIR, test_main +from wolfclu_test import WOLFSSL_BIN, CERTS_DIR, test_main, find_free_port class ServerClientTest(unittest.TestCase): @@ -30,9 +30,11 @@ def test_server_client(self): if os.path.exists(readyfile): os.remove(readyfile) + port = find_free_port() + # Start server in background server = subprocess.Popen( - [WOLFSSL_BIN, "s_server", "-port", "11111", + [WOLFSSL_BIN, "s_server", "-port", str(port), "-key", os.path.join(CERTS_DIR, "server-key.pem"), "-cert", os.path.join(CERTS_DIR, "server-cert.pem"), "-noVerify", "-readyFile", readyfile], @@ -54,7 +56,8 @@ def test_server_client(self): # Connect with client client = subprocess.run( - [WOLFSSL_BIN, "s_client", "-connect", "127.0.0.1:11111", + [WOLFSSL_BIN, "s_client", "-connect", + "127.0.0.1:{}".format(port), "-CAfile", os.path.join(CERTS_DIR, "ca-cert.pem"), "-verify_return_error", "-disable_stdin_check"], capture_output=True, stdin=subprocess.DEVNULL, timeout=30, diff --git a/tests/wolfclu_test.py b/tests/wolfclu_test.py index c599aecf..d49e4ada 100644 --- a/tests/wolfclu_test.py +++ b/tests/wolfclu_test.py @@ -5,12 +5,23 @@ import subprocess import sys import unittest +import socket _TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) _PROJECT_ROOT = os.path.dirname(_TESTS_DIR) PROJECT_ROOT = _PROJECT_ROOT +def find_free_port(): + """Return an ephemeral TCP port number chosen by the OS. + This does *not* reserve the port after the socket is closed, so callers that + bind/listen should be prepared to retry if the port is claimed concurrently. """ + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + def _find_wolfssl_bin(): """Locate the wolfssl binary, searching common build output paths. @@ -83,6 +94,76 @@ def is_fips(): return "FIPS" in (r.stdout + r.stderr) +def make_sparse(fileobj): + """Mark an open file as sparse on Windows before it is extended. + + NTFS does not treat a file as sparse unless the sparse flag is set + explicitly, so a subsequent truncate() physically allocates and + zero-fills every byte. Setting FSCTL_SET_SPARSE first keeps the + extension sparse, matching the behaviour of ext4/APFS where truncate() + is already sparse. This must be called before extending the file. + + No-op on non-Windows platforms. + """ + if sys.platform != "win32": + return + import ctypes + import msvcrt + from ctypes import wintypes + + FSCTL_SET_SPARSE = 0x000900C4 + handle = msvcrt.get_osfhandle(fileobj.fileno()) + bytes_returned = wintypes.DWORD(0) + ok = ctypes.windll.kernel32.DeviceIoControl( + wintypes.HANDLE(handle), + FSCTL_SET_SPARSE, + None, 0, # no input buffer => set the sparse flag (TRUE) + None, 0, # no output buffer + ctypes.byref(bytes_returned), + None, + ) + if not ok: + raise ctypes.WinError() + + +def truncate_sparse(fileobj, size): + """Extend an open file to `size` bytes without physically allocating it. + + On POSIX, truncate() already produces a sparse file. On Windows, Python's + truncate() routes through the CRT _chsize_s, which *writes zeros* over the + extended range and so allocates every cluster even when the sparse flag is + set. Instead we mark the file sparse and move the end-of-file pointer with + SetEndOfFile directly: no bytes are written, so the range stays sparse (and + it is instant rather than a multi-GB zero-fill). + """ + if sys.platform != "win32": + fileobj.truncate(size) + return + + import ctypes + import msvcrt + from ctypes import wintypes + + make_sparse(fileobj) # set FSCTL_SET_SPARSE first + + k32 = ctypes.windll.kernel32 + handle = wintypes.HANDLE(msvcrt.get_osfhandle(fileobj.fileno())) + + set_ptr = k32.SetFilePointerEx + set_ptr.argtypes = [wintypes.HANDLE, ctypes.c_longlong, + ctypes.POINTER(ctypes.c_longlong), wintypes.DWORD] + set_ptr.restype = wintypes.BOOL + FILE_BEGIN = 0 + if not set_ptr(handle, ctypes.c_longlong(size), None, FILE_BEGIN): + raise ctypes.WinError() + + set_eof = k32.SetEndOfFile + set_eof.argtypes = [wintypes.HANDLE] + set_eof.restype = wintypes.BOOL + if not set_eof(handle): + raise ctypes.WinError() + + def test_main(): """Run tests with automake-compatible exit codes.