Improved self-cleaning tests.
This commit is contained in:
1 parent
77db0b83ab
commit
e3de71e594
14 files changed
+487
-39
No files matched your search
@@ -0,0 +1,209 @@
|
||||
"""Own disposable Wine test files, saved output and prefix teardown."""
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
from pathlib import Path
|
||||
import selectors
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
def add_test_options(parser):
|
||||
parser.add_argument("--keep", action="store_true",
|
||||
default=os.environ.get("WINE_ACCESS_TEST_KEEP") == "1",
|
||||
help="retain temporary files even after success")
|
||||
parser.add_argument("--clean-on-failure", dest="keepOnFailure", action="store_false",
|
||||
default=os.environ.get("WINE_ACCESS_TEST_KEEP_ON_FAILURE", "1") != "0",
|
||||
help="remove temporary files after failure too; logs remain")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def wine_test_directory(prefix):
|
||||
# The outer runner owns deletion, after all child processes have stopped.
|
||||
# In particular, an inner finally/TemporaryDirectory must not erase the
|
||||
# failed fixture before the runner can retain it for inspection.
|
||||
yield tempfile.mkdtemp(prefix=prefix, dir=os.environ["WINE_ACCESS_TEST_ROOT"])
|
||||
|
||||
|
||||
@contextmanager
|
||||
def test_signals():
|
||||
def interrupted(signum, frame):
|
||||
raise KeyboardInterrupt(signum)
|
||||
|
||||
handlers = {signum: signal.signal(signum, interrupted)
|
||||
for signum in (signal.SIGINT, signal.SIGTERM)}
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for signum, handler in handlers.items():
|
||||
signal.signal(signum, handler)
|
||||
|
||||
|
||||
def stop_wine_prefixes(testRoot, env, log):
|
||||
prefixes = set()
|
||||
if (testRoot / "prefix").exists():
|
||||
prefixes.add(testRoot / "prefix")
|
||||
for current, dirs, files in os.walk(testRoot, followlinks=False):
|
||||
if "system.reg" in files or "drive_c" in dirs:
|
||||
prefixes.add(Path(current))
|
||||
dirs.clear()
|
||||
stopped = True
|
||||
for prefix in sorted(prefixes):
|
||||
# These paths originate in this run's private directory. Never stop a
|
||||
# server selected by the caller's WINEPREFIX or a symlink outside it.
|
||||
if not prefix.resolve().is_relative_to(testRoot):
|
||||
stopped = False
|
||||
continue
|
||||
prefixEnv = dict(env, WINEPREFIX=str(prefix))
|
||||
try:
|
||||
# -k also returns 1 when the fixture already stopped its server.
|
||||
# -w confirms the final state without treating that as a failure.
|
||||
subprocess.run(["wineserver", "-k"], env=prefixEnv, check=False,
|
||||
timeout=5, stdout=log, stderr=subprocess.STDOUT)
|
||||
subprocess.run(["wineserver", "-w"], env=prefixEnv, check=True,
|
||||
timeout=5, stdout=log, stderr=subprocess.STDOUT)
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
log.write(f"Wine prefix teardown failed: {prefix}: {error}\n".encode())
|
||||
stopped = False
|
||||
return stopped
|
||||
|
||||
|
||||
def run_wine_test(command, buildDir, name, *, timeout=120, keep=False,
|
||||
keepOnFailure=True, minFreeMiB=None):
|
||||
if not name or any(char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" for char in name):
|
||||
raise ValueError("test name must contain only letters, digits, underscores or hyphens")
|
||||
if timeout <= 0:
|
||||
raise ValueError("test timeout must be positive")
|
||||
tempBase = Path(tempfile.gettempdir())
|
||||
if minFreeMiB is None:
|
||||
minFreeMiB = int(os.environ.get("WINE_ACCESS_TEST_MIN_FREE_MIB", "1024"))
|
||||
if minFreeMiB < 0:
|
||||
raise ValueError("minimum free space must not be negative")
|
||||
freeMiB = shutil.disk_usage(tempBase).free // (1024 * 1024)
|
||||
if freeMiB < minFreeMiB:
|
||||
print(f"Wine test {name}: {tempBase} has {freeMiB} MiB free; "
|
||||
f"at least {minFreeMiB} MiB is required. Clean up retained test directories first.",
|
||||
file=sys.stderr, flush=True)
|
||||
return 2
|
||||
|
||||
logDir = Path(buildDir).resolve() / "Testing" / "WineAccessLogs"
|
||||
logDir.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ")
|
||||
logPath = logDir / f"{name}-{stamp}-{os.getpid()}.log"
|
||||
testRoot = Path(tempfile.mkdtemp(prefix=f"wine-access-{name}-", dir=tempBase)).resolve()
|
||||
env = os.environ.copy()
|
||||
env.update(WINE_ACCESS_TEST_ROOT=str(testRoot), TMPDIR=str(testRoot),
|
||||
WINEPREFIX=str(testRoot / "prefix"), CTHULHU_WINE_ACCESS_MANAGED="1",
|
||||
WINEDEBUG=env.get("WINE_ACCESS_TEST_DEBUG", "-all"),
|
||||
WINEDLLOVERRIDES="mscoree,mshtml=", PYTHONUNBUFFERED="1")
|
||||
process = None
|
||||
returnCode = 1
|
||||
|
||||
with logPath.open("wb", buffering=0) as log, test_signals():
|
||||
def report(message):
|
||||
print(message, flush=True)
|
||||
log.write((message + "\n").encode())
|
||||
|
||||
report(f"Wine test directory: {testRoot}")
|
||||
report(f"Wine test log: {logPath}")
|
||||
try:
|
||||
process = subprocess.Popen(command, env=env, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, start_new_session=True)
|
||||
deadline = time.monotonic() + timeout
|
||||
exitedAt = None
|
||||
with selectors.DefaultSelector() as selector:
|
||||
selector.register(process.stdout, selectors.EVENT_READ)
|
||||
while selector.get_map():
|
||||
now = time.monotonic()
|
||||
if process.poll() is None and now >= deadline:
|
||||
raise subprocess.TimeoutExpired(command, timeout)
|
||||
if process.poll() is not None:
|
||||
if exitedAt is None:
|
||||
exitedAt = now
|
||||
# Detached Wine services may retain the output pipe.
|
||||
# Prefix teardown below closes them; do not wait forever.
|
||||
if now - exitedAt >= 1:
|
||||
break
|
||||
for key, _ in selector.select(.1):
|
||||
data = os.read(key.fd, 32768)
|
||||
if not data:
|
||||
selector.unregister(key.fileobj)
|
||||
continue
|
||||
log.write(data)
|
||||
if hasattr(sys.stdout, "buffer"):
|
||||
sys.stdout.buffer.write(data)
|
||||
sys.stdout.buffer.flush()
|
||||
else:
|
||||
sys.stdout.write(data.decode(errors="replace"))
|
||||
returnCode = process.wait(timeout=max(.1, deadline - time.monotonic()))
|
||||
if returnCode < 0:
|
||||
returnCode = 128 - returnCode
|
||||
except subprocess.TimeoutExpired:
|
||||
returnCode = 124
|
||||
report(f"Wine test timed out after {timeout} seconds")
|
||||
except KeyboardInterrupt as error:
|
||||
returnCode = 128 + (error.args[0] if error.args else signal.SIGINT)
|
||||
report("Wine test interrupted")
|
||||
except OSError as error:
|
||||
report(f"Wine test could not run: {error}")
|
||||
finally:
|
||||
# Ignore repeated interrupts during the bounded teardown, then
|
||||
# restore the caller's handlers. Never signal another test's group.
|
||||
for signum in (signal.SIGINT, signal.SIGTERM):
|
||||
signal.signal(signum, signal.SIG_IGN)
|
||||
if process is not None:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
process.wait(timeout=1)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.wait(timeout=2)
|
||||
stopped = stop_wine_prefixes(testRoot, env, log)
|
||||
if process is not None:
|
||||
# Save any last diagnostics from services stopped above.
|
||||
os.set_blocking(process.stdout.fileno(), False)
|
||||
while True:
|
||||
try:
|
||||
data = os.read(process.stdout.fileno(), 32768)
|
||||
except BlockingIOError:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
log.write(data)
|
||||
process.stdout.close()
|
||||
if not stopped:
|
||||
returnCode = returnCode or 1
|
||||
report("Wine teardown did not finish; retaining temporary files")
|
||||
evidenceSaved = True
|
||||
try:
|
||||
for current, _, files in os.walk(testRoot, followlinks=False):
|
||||
for name in files:
|
||||
source = Path(current) / name
|
||||
if source.suffix != ".log" or source.is_symlink():
|
||||
continue
|
||||
destination = logDir / logPath.stem / source.relative_to(testRoot)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, destination)
|
||||
except OSError as error:
|
||||
evidenceSaved = False
|
||||
returnCode = returnCode or 1
|
||||
report(f"Could not save fixture logs; retaining temporary files: {error}")
|
||||
retain = keep or (returnCode != 0 and keepOnFailure) or not stopped or not evidenceSaved
|
||||
if retain:
|
||||
report(f"Retained Wine test directory: {testRoot}")
|
||||
else:
|
||||
try:
|
||||
shutil.rmtree(testRoot)
|
||||
report(f"Removed Wine test directory: {testRoot}")
|
||||
except OSError as error:
|
||||
returnCode = returnCode or 1
|
||||
report(f"Could not remove Wine test directory: {testRoot}: {error}")
|
||||
report(f"Wine test exit status: {returnCode}; log saved: {logPath}")
|
||||
return returnCode
|
||||
Reference in new issue
Block a user