mirror of
https://github.com/SCST-project/scst.git
synced 2026-10-22 06:02:33 +00:00
A progress timeout can occur before the child consumes all supplied
input. On Python 3.12, retrying communicate() without input stops
scheduling writes to stdin. The child waits for EOF while the driver
keeps waiting for output, which can hang the analyzer script tests
until the CI job timeout.
Run communicate() once in an I/O worker while the main thread waits
for its result and updates progress. On interruption, stop the owned
process group before joining the worker so blocked pipes cannot
prevent cleanup.
Make the regression wait for two progress updates before reading stdin
to force the pending-input case. Bound the entire child with an alarm
so a regression reports a failure instead of hanging the suite.
Validation: reproduce both check modes failing on Python 3.12.14
before the fix; all 75 lint-test fixtures pass on chesnokov-vm2
afterwards. All 11 process/log fixtures also pass locally on Python
3.14.8.
Fixes: c8648f9410 ("scripts: Share analyzer process and raw-log handling")
177 lines
5.9 KiB
Python
177 lines
5.9 KiB
Python
"""Process and report helpers for the analyzer drivers."""
|
|
|
|
import contextlib
|
|
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout
|
|
import os
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import signal
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
PROGRESS_INTERVAL = 5
|
|
SPINNER_INTERVAL = 0.1
|
|
SPINNER_FRAMES = "|/-\\"
|
|
VERBOSE = os.environ.get("V", "0") not in {"", "0"}
|
|
|
|
|
|
def show_log(path, label="Full log"):
|
|
print(f" {label}: {Path(path).resolve()}", file=sys.stderr, flush=True)
|
|
|
|
|
|
class Progress:
|
|
"""Animate only a quiet child; redirected output gets periodic plain lines."""
|
|
|
|
def __init__(self, label, *, quiet=True):
|
|
self.label = label
|
|
self.started = time.monotonic()
|
|
self.animated = (bool(label) and quiet and sys.stderr.isatty()
|
|
and os.environ.get("TERM") != "dumb")
|
|
self.interval = SPINNER_INTERVAL if self.animated else PROGRESS_INTERVAL
|
|
self.frame = 0
|
|
self.visible = False
|
|
|
|
def update(self):
|
|
elapsed = int(time.monotonic() - self.started)
|
|
if self.animated:
|
|
elapsed -= elapsed % PROGRESS_INTERVAL
|
|
frame = SPINNER_FRAMES[self.frame % len(SPINNER_FRAMES)]
|
|
text = f" {frame} {self.label} {elapsed // 60:02}:{elapsed % 60:02}"
|
|
width = shutil.get_terminal_size().columns
|
|
print("\r\033[2K" + text[:max(1, width - 1)], end="", file=sys.stderr, flush=True)
|
|
self.frame += 1
|
|
else:
|
|
print(f" {self.label}: running ({elapsed}s elapsed)", file=sys.stderr, flush=True)
|
|
self.visible = True
|
|
|
|
def clear(self):
|
|
if self.animated and self.visible:
|
|
print("\r\033[2K", end="", file=sys.stderr, flush=True)
|
|
|
|
def finish(self, status):
|
|
self.clear()
|
|
if self.visible:
|
|
elapsed = int(time.monotonic() - self.started)
|
|
print(f" {self.label}: finished ({elapsed}s, exit {status})",
|
|
file=sys.stderr, flush=True)
|
|
|
|
|
|
def jobserver_fds(env):
|
|
"""Forward an inherited pipe jobserver without acquiring its tokens."""
|
|
matches = re.findall(r"--jobserver-(?:auth|fds)=(\S+)", env.get("MAKEFLAGS", ""))
|
|
if not matches or not re.fullmatch(r"\d+,\d+", matches[-1]):
|
|
return ()
|
|
descriptors = tuple(map(int, matches[-1].split(",")))
|
|
try:
|
|
for descriptor in descriptors:
|
|
if (not os.get_inheritable(descriptor)
|
|
or not stat.S_ISFIFO(os.fstat(descriptor).st_mode)):
|
|
return ()
|
|
except OSError:
|
|
return ()
|
|
return descriptors
|
|
|
|
|
|
def start(command, *, env=None, **kwargs):
|
|
environment = os.environ if env is None else env
|
|
if VERBOSE:
|
|
print(" $ " + shlex.join(map(str, command)), file=sys.stderr, flush=True)
|
|
return subprocess.Popen(command, env=environment, start_new_session=True,
|
|
pass_fds=jobserver_fds(environment), **kwargs)
|
|
|
|
|
|
def stop(process):
|
|
"""Stop the owned process group, including children of an exited driver."""
|
|
with contextlib.suppress(ProcessLookupError):
|
|
os.killpg(process.pid, signal.SIGTERM)
|
|
try:
|
|
process.wait(timeout=2)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
with contextlib.suppress(ProcessLookupError):
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
process.wait()
|
|
|
|
|
|
def exit_status(returncode):
|
|
return returncode if returncode >= 0 else 128 - returncode
|
|
|
|
|
|
def run(command, *, input=None, check=False, progress=None, **kwargs):
|
|
if input is not None:
|
|
kwargs["stdin"] = subprocess.PIPE
|
|
process = start(command, **kwargs)
|
|
indicator = Progress(progress, quiet=kwargs.get("stdout") is not None
|
|
and kwargs.get("stderr") is not None)
|
|
# Python 3.12 can stop writing pending stdin after a communicate timeout.
|
|
# Keep one I/O operation running while the main thread updates progress.
|
|
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
try:
|
|
if indicator.animated:
|
|
indicator.update()
|
|
communication = executor.submit(process.communicate, input)
|
|
while True:
|
|
try:
|
|
stdout, stderr = communication.result(
|
|
timeout=indicator.interval if progress else None)
|
|
break
|
|
except FutureTimeout:
|
|
indicator.update()
|
|
except BaseException:
|
|
indicator.clear()
|
|
# Close the process group's pipes before joining the I/O worker.
|
|
stop(process)
|
|
raise
|
|
result = subprocess.CompletedProcess(command, exit_status(process.returncode), stdout, stderr)
|
|
indicator.finish(result.returncode)
|
|
if check:
|
|
result.check_returncode()
|
|
return result
|
|
|
|
|
|
def log_path(root, name, *, truncate=True):
|
|
path = root / ".cache/lint" / name
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
if truncate:
|
|
path.write_bytes(b"")
|
|
return path
|
|
|
|
|
|
def copy_output(path, output=None):
|
|
destination = output if output is not None else sys.stderr.buffer
|
|
with Path(path).open("rb") as stream:
|
|
shutil.copyfileobj(stream, destination)
|
|
destination.flush()
|
|
|
|
|
|
class Interrupted(Exception):
|
|
def __init__(self, signum):
|
|
self.signum = signum
|
|
|
|
|
|
def entrypoint(main):
|
|
global VERBOSE
|
|
VERBOSE = VERBOSE or "--verbose" in sys.argv[1:]
|
|
sys.argv[1:] = [arg for arg in sys.argv[1:] if arg != "--verbose"]
|
|
|
|
def interrupt(signum, _frame):
|
|
raise Interrupted(signum)
|
|
|
|
signal.signal(signal.SIGTERM, interrupt)
|
|
try:
|
|
return main()
|
|
except Interrupted as error:
|
|
return 128 + error.signum
|
|
except KeyboardInterrupt:
|
|
return 130
|
|
except subprocess.CalledProcessError as error:
|
|
print(f"Command failed ({error.returncode}): {error.cmd}", file=sys.stderr)
|
|
return error.returncode
|
|
except (OSError, ValueError) as error:
|
|
print(str(error), file=sys.stderr)
|
|
return 2
|