Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 125 additions & 19 deletions tests/mouse_oracle.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
Assumes an X display (the wrapper uses xvfb-run). Needs the gfx build
(EIGENSCRIPT), xdotool, xwd, PIL.
"""
import os, re, struct, subprocess, sys, tempfile, time, shutil
import os, re, signal, struct, subprocess, sys, tempfile, time, shutil
from PIL import Image

EIGS = os.environ.get("EIGENSCRIPT", "eigenscript")
Expand Down Expand Up @@ -83,6 +83,7 @@
ADVANCE_MIN = 1
ACTION_TIMEOUT = 8.0
POLL_INTERVAL = 0.05
OWNED_APPS = [] # atlas/orbit, including setup failure


# ---------- X plumbing ----------
Expand All @@ -105,27 +106,115 @@ def app_output(proc):


def ensure_live(proc):
# Support a synchronous launcher, not detached/daemonized children: an
# exited launcher is an infrastructure failure, followed by group cleanup.
if proc.poll() is not None:
raise RuntimeError("app exited rc%d: %s" % (proc.returncode, app_output(proc)))


def start_app(args, cwd, tmp, name):
path = os.path.join(tmp, name + ".log")
with open(path, "w") as log:
proc = subprocess.Popen([EIGS] + args, cwd=cwd, env=ENV,
stdout=log, stderr=subprocess.STDOUT, text=True)
proc.output_path = path
# Defer TERM until the new session is registered for main's finally,
# including an interruption before the caller receives the Popen object.
old_mask = signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGTERM})
try:
with open(path, "w") as log:
proc = subprocess.Popen([EIGS] + args, cwd=cwd, env=ENV,
stdout=log, stderr=subprocess.STDOUT, text=True,
start_new_session=True,
# This oracle is single-threaded. Do not
# leave the child's TERM masked at exec.
preexec_fn=lambda: signal.pthread_sigmask(
signal.SIG_SETMASK, old_mask))
proc.output_path = path
OWNED_APPS.append(proc)
finally:
signal.pthread_sigmask(signal.SIG_SETMASK, old_mask)
return proc


def stop_app(proc):
if proc.poll() is None:
proc.terminate()
def owned_pid(proc, pid):
# EIGENSCRIPT may be a non-exec counting launcher. Its runtime child,
# not Popen.pid, owns the X window; both must remain in our new session.
try:
return os.getsid(pid) == proc.pid and os.getpgid(pid) == proc.pid
except ProcessLookupError:
return False


def owned_live_pids(proc):
# Linux/X11 QA: zombies have exited and cannot retain windows or run work.
members = []
for entry in os.scandir("/proc"):
if not entry.name.isdecimal():
continue
pid = int(entry.name)
if not owned_pid(proc, pid):
continue
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
with open("/proc/%d/stat" % pid) as fh:
state = fh.read().rsplit(")", 1)[1].split()[0]
except FileNotFoundError:
continue
if state != "Z":
members.append(pid)
return members


def stop_app(proc, deadline=None):
# TERM during normal finally must not interrupt cleanup before group KILL.
old_mask = signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGTERM})
try:
_stop_app(proc, deadline)
finally:
signal.pthread_sigmask(signal.SIG_SETMASK, old_mask)


def _stop_app(proc, deadline=None):
# Cleanup the owned family even if its launcher has already exited.
# One shared budget, comfortably below the row supervisor's 2s TERM
# grace. A TERM-ignoring runtime must not strand its isolated group.
if deadline is None:
deadline = time.monotonic() + 0.8
term_deadline = min(deadline, time.monotonic() + 0.25)
for sig, until in ((signal.SIGTERM, term_deadline),
(signal.SIGKILL, deadline)):
if owned_live_pids(proc):
try:
os.killpg(proc.pid, sig)
except ProcessLookupError:
pass
while time.monotonic() < until and owned_live_pids(proc):
time.sleep(min(POLL_INTERVAL, max(0, until - time.monotonic())))
proc.wait(timeout=max(0.01, deadline - time.monotonic()))
remaining = owned_live_pids(proc)
if remaining:
raise RuntimeError("owned app cleanup deadline: %r" % remaining)
if proc in OWNED_APPS:
OWNED_APPS.remove(proc)


def stop_apps():
# Defer TERM across the whole drain, not just the first app. A pending
# TERM is delivered after restoring the mask and remains a nonzero error.
old_mask = signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGTERM})
errors = []
deadline = time.monotonic() + 0.8
try:
for proc in list(OWNED_APPS):
try:
stop_app(proc, deadline)
except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
errors.append(str(exc))
if errors:
raise RuntimeError("owned app cleanup: " + "; ".join(errors))
finally:
signal.pthread_sigmask(signal.SIG_SETMASK, old_mask)


def interrupted(signum, frame):
signal.signal(signal.SIGTERM, signal.SIG_IGN)
raise RuntimeError("mouse oracle interrupted by signal %d" % signum)


def xwd_to_image(path):
Expand Down Expand Up @@ -158,11 +247,22 @@ def wait_for_window(proc, title):
deadline = time.monotonic() + 30.0
while time.monotonic() < deadline:
ensure_live(proc)
windows = xdo("search", "--all", "--onlyvisible", "--pid", proc.pid,
"--name", "^" + re.escape(title) + "$", search=True,
timeout=min(3.0, max(0.01, deadline - time.monotonic())))
if windows.strip():
return windows.strip().splitlines()[0]
for pid in owned_live_pids(proc):
if time.monotonic() >= deadline:
break
# Search conjunctively by exact title AND a verified owned PID.
# Foreign/no-PID windows never enter the accepted population.
windows = xdo("search", "--all", "--onlyvisible", "--pid", pid,
"--name", "^" + re.escape(title) + "$", search=True,
timeout=min(3.0, max(0.01, deadline - time.monotonic())))
if windows.strip() and pid in owned_live_pids(proc):
ensure_live(proc)
if time.monotonic() >= deadline:
break
wid = windows.strip().splitlines()[0]
print("OWNED window=%s pid=%d session/group=%d" %
(wid, pid, proc.pid), flush=True)
return wid
time.sleep(min(POLL_INTERVAL, max(0, deadline - time.monotonic())))
# Never read a live stdout pipe on timeout: that would defeat the bound.
raise RuntimeError("window deadline expired: " + app_output(proc))
Expand Down Expand Up @@ -601,8 +701,11 @@ def pause_to(value):
infrastructure_error = str(exc)
print("ERROR mouse oracle: " + infrastructure_error)
finally:
if proc is not None:
stop_app(proc)
try:
stop_apps()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Block SIGTERM before entering the final cleanup

When SIGTERM arrives after normal execution has entered this finally block but before stop_apps() executes its first pthread_sigmask, interrupted() raises immediately and changes SIGTERM to SIG_IGN. Depending on the exact bytecode, that exception either skips this call entirely or is caught by the nested except, which only records the error and never retries cleanup. Because the applications were launched with start_new_session=True, the active GUI process can then outlive the oracle and ignore the supervisor's subsequent TERM; block SIGTERM before entering this cleanup region or ensure the exception path still drains OWNED_APPS.

Useful? React with 👍 / 👎.

except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
infrastructure_error = str(exc)
print("ERROR mouse oracle: " + infrastructure_error)
if evidence:
for name in ("orbit.log", "atlas.log"):
path = os.path.join(tmp, name)
Expand All @@ -619,4 +722,7 @@ def pause_to(value):


if __name__ == "__main__":
# Ordinary termination still runs atlas/orbit finally cleanup. SIGKILL
# cannot run Python cleanup and remains an external supervisor concern.
signal.signal(signal.SIGTERM, interrupted)
main()
Loading