Files
scst/scripts/lint_common.py
Gleb Chesnokov dfcd25997e scripts: Keep analyzer stdin flowing during progress updates
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")
2026-10-20 15:48:45 +03:00

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