mirror of
https://github.com/morluto/rea.git
synced 2026-11-13 10:12:50 +00:00
fix(hopper): distinguish an absent demo dialog from publisher rejection (#8074)
* fix(hopper): distinguish an absent demo dialog from publisher rejection The Linux demo watcher collapsed an admitted launcher with no first-run dialog into unsupported_demo_dialog. Report that post-authentication timeout as demo_dialog_absent, keep the unsupported dialog code for an observed but unrecognized Hopper window, and allow REA_HOPPER_DEMO_DIALOG_TIMEOUT_MS to widen the fixed wait. Closes #8008. * fix(hopper): tolerate callers without the dialog timeout argument * fix(hopper): report an invalid demo dialog timeout as its own failure An invalid REA_HOPPER_DEMO_DIALOG_TIMEOUT_MS exited 73 and was shown as invalid_launch_command. Exit 82 is invalid_dialog_timeout. The public error names the setting, shows the rejected value and source, lists the allowed range, and says to restart REA. Drive the demo watcher with controlled windows and time. No window exits 81, a Hopper window without a supported dialog exits 76, and a dialog that arrives after the old 10 second limit is still accepted. * fix(hopper): keep startup stderr capture on AdapterFailure DemoWatchStop was inserted above with_stderr, so launch failures called a method they no longer had and became unexpected_failure. Restore with_stderr on AdapterFailure and reject a watcher fixture that cannot capture stderr. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
1 parent
767f6afc18
commit
b1ff7cdacc
10 files changed
+675
-45
No files matched your search
+183
-37
@@ -4,6 +4,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections.abc import Callable
|
||||
import ctypes
|
||||
import hashlib
|
||||
import json
|
||||
@@ -35,6 +36,14 @@ UNEXPECTED_DISPLAY_GEOMETRY = 77
|
||||
X11_INPUT_FAILED = 78
|
||||
RUNTIME_DEPENDENCY_UNAVAILABLE = 79
|
||||
X11_SOCKET_DIRECTORY_UNUSABLE = 80
|
||||
DEMO_DIALOG_ABSENT = 81
|
||||
INVALID_DIALOG_TIMEOUT = 82
|
||||
DEFAULT_DIALOG_TIMEOUT_MS = 10_000
|
||||
DIALOG_TIMEOUT_MINIMUM_MS = 1
|
||||
# Node schedules a delay above this signed 32-bit bound as 1 ms. Keep the
|
||||
# watcher on the same ceiling as REA's other millisecond settings.
|
||||
DIALOG_TIMEOUT_MAXIMUM_MS = 2_147_483_647
|
||||
DIALOG_TIMEOUT_ENV = "REA_HOPPER_DEMO_DIALOG_TIMEOUT_MS"
|
||||
DIAGNOSTIC_PREFIX = "REA_X11_DIAGNOSTIC="
|
||||
X11_SOCKET_DIRECTORY = Path("/tmp/.X11-unix")
|
||||
class AdapterFailure(Exception):
|
||||
@@ -44,17 +53,25 @@ class AdapterFailure(Exception):
|
||||
reason: str,
|
||||
message: str,
|
||||
stderr_bytes: int = 0,
|
||||
dialog_timeout: dict[str, object] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.reason = reason
|
||||
self.stderr_bytes = stderr_bytes
|
||||
self.dialog_timeout = dialog_timeout
|
||||
|
||||
def with_stderr(self, capture: CapturedStderr) -> AdapterFailure:
|
||||
self.stderr_bytes, _ = capture.snapshot()
|
||||
return self
|
||||
|
||||
|
||||
class DemoWatchStop(Exception):
|
||||
def __init__(self, reason: str) -> None:
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
|
||||
|
||||
class CapturedStderr:
|
||||
def __init__(self, stream: object) -> None:
|
||||
self._stream = stream
|
||||
@@ -111,6 +128,80 @@ class XWindowAttributes(ctypes.Structure):
|
||||
]
|
||||
|
||||
|
||||
def rejected_timeout_value(value: str) -> str:
|
||||
visible = "".join(
|
||||
character if character.isprintable() else "\ufffd" for character in value
|
||||
)
|
||||
if len(visible) <= 128:
|
||||
return visible
|
||||
return f"{visible[:127]}…"
|
||||
|
||||
|
||||
def dialog_timeout_message(value: str | None) -> str:
|
||||
current = (
|
||||
"was rejected"
|
||||
if value is None
|
||||
else f"is {json.dumps(value, ensure_ascii=False)} ms"
|
||||
)
|
||||
return (
|
||||
f"The effective {DIALOG_TIMEOUT_ENV} value {current} "
|
||||
f"(source setting; default {DEFAULT_DIALOG_TIMEOUT_MS} ms, "
|
||||
f"floor {DIALOG_TIMEOUT_MINIMUM_MS} ms, "
|
||||
f"maximum {DIALOG_TIMEOUT_MAXIMUM_MS} ms). "
|
||||
f"Set {DIALOG_TIMEOUT_ENV} in the environment that starts REA to a "
|
||||
f"positive decimal integer from {DIALOG_TIMEOUT_MINIMUM_MS} to "
|
||||
f"{DIALOG_TIMEOUT_MAXIMUM_MS}, then restart REA."
|
||||
)
|
||||
|
||||
|
||||
def dialog_timeout_decimal(value: str) -> int | None:
|
||||
if re.fullmatch(r"[1-9]\d{0,9}", value) is None:
|
||||
return None
|
||||
parsed = int(value)
|
||||
if parsed > DIALOG_TIMEOUT_MAXIMUM_MS:
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def dialog_timeout_rejection(value: str) -> AdapterFailure:
|
||||
shown = rejected_timeout_value(value)
|
||||
return AdapterFailure(
|
||||
INVALID_DIALOG_TIMEOUT,
|
||||
"invalid_dialog_timeout",
|
||||
dialog_timeout_message(shown),
|
||||
dialog_timeout={
|
||||
"setting": DIALOG_TIMEOUT_ENV,
|
||||
"value": shown,
|
||||
"source": "setting",
|
||||
"minimum_ms": DIALOG_TIMEOUT_MINIMUM_MS,
|
||||
"maximum_ms": DIALOG_TIMEOUT_MAXIMUM_MS,
|
||||
"default_ms": DEFAULT_DIALOG_TIMEOUT_MS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def positive_timeout(value: str) -> int:
|
||||
parsed = dialog_timeout_decimal(value)
|
||||
if parsed is None:
|
||||
raise argparse.ArgumentTypeError(
|
||||
"dialog timeout must be a positive decimal integer from "
|
||||
f"{DIALOG_TIMEOUT_MINIMUM_MS} to {DIALOG_TIMEOUT_MAXIMUM_MS}"
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def configured_dialog_timeout_ms(requested: int | None) -> int:
|
||||
if requested is not None:
|
||||
return requested
|
||||
configured = os.environ.get(DIALOG_TIMEOUT_ENV)
|
||||
if configured is None:
|
||||
return DEFAULT_DIALOG_TIMEOUT_MS
|
||||
parsed = dialog_timeout_decimal(configured)
|
||||
if parsed is None:
|
||||
raise dialog_timeout_rejection(configured)
|
||||
return parsed
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--probe", action="store_true")
|
||||
@@ -127,6 +218,7 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--xvfb", default="/usr/bin/Xvfb")
|
||||
parser.add_argument("--xauth", default="/usr/bin/xauth")
|
||||
parser.add_argument("--display-number", type=int)
|
||||
parser.add_argument("--dialog-timeout-ms", type=positive_timeout)
|
||||
parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||
return parser.parse_args()
|
||||
|
||||
@@ -275,6 +367,70 @@ def demo_click(dialog: tuple[int, int, int, int, int]) -> tuple[int, int]:
|
||||
return x + DEMO_CLICK_LEFT, y + height - DEMO_CLICK_BOTTOM
|
||||
|
||||
|
||||
def watch_for_demo_dialog(
|
||||
x11: ctypes.CDLL,
|
||||
display: int,
|
||||
timeout_ms: int,
|
||||
*,
|
||||
owner_alive: Callable[[], bool],
|
||||
socket_ready: Callable[[], bool],
|
||||
process_owned: Callable[[], bool],
|
||||
process_exited: Callable[[], bool],
|
||||
monotonic: Callable[[], float],
|
||||
sleep: Callable[[float], None],
|
||||
list_windows: Callable[[], list[tuple[int, int, int, int, int]]],
|
||||
) -> tuple[int, int, int, int, int]:
|
||||
deadline = monotonic() + timeout_ms / 1000
|
||||
hopper_window_observed = False
|
||||
while monotonic() < deadline:
|
||||
if not owner_alive():
|
||||
raise DemoWatchStop("owner_closed")
|
||||
if socket_ready():
|
||||
raise DemoWatchStop("socket_ready")
|
||||
if not process_owned():
|
||||
if not process_exited():
|
||||
sleep(0.1)
|
||||
continue
|
||||
raise AdapterFailure(
|
||||
HOPPER_EXITED_DURING_STARTUP,
|
||||
"hopper_exited_during_startup",
|
||||
"owned Hopper process exited",
|
||||
)
|
||||
observed = list_windows()
|
||||
hopper_window_observed = hopper_window_observed or any(
|
||||
window_title(x11, display, candidate[0]) == HOPPER_WINDOW_TITLE
|
||||
for candidate in observed
|
||||
)
|
||||
dialog = find_demo_dialog(x11, display, observed)
|
||||
if dialog is not None:
|
||||
return dialog
|
||||
sleep(0.1)
|
||||
raise classify_watch_timeout(process_owned(), hopper_window_observed)
|
||||
|
||||
|
||||
def classify_watch_timeout(
|
||||
owned: bool,
|
||||
hopper_window_observed: bool,
|
||||
) -> AdapterFailure:
|
||||
if not owned:
|
||||
return AdapterFailure(
|
||||
PROCESS_OWNERSHIP_MISMATCH,
|
||||
"process_ownership_mismatch",
|
||||
"Hopper process ownership mismatch",
|
||||
)
|
||||
if hopper_window_observed:
|
||||
return AdapterFailure(
|
||||
UNSUPPORTED_DEMO_DIALOG,
|
||||
"unsupported_demo_dialog",
|
||||
"Hopper did not present a supported registration dialog",
|
||||
)
|
||||
return AdapterFailure(
|
||||
DEMO_DIALOG_ABSENT,
|
||||
"demo_dialog_absent",
|
||||
"Hopper started without presenting a registration dialog",
|
||||
)
|
||||
|
||||
|
||||
def configure_x11(x11: ctypes.CDLL, xtst: ctypes.CDLL) -> None:
|
||||
window_pointer = ctypes.POINTER(ctypes.c_ulong)
|
||||
x11.XOpenDisplay.argtypes = [ctypes.c_char_p]
|
||||
@@ -372,7 +528,7 @@ def diagnostic(
|
||||
) -> dict[str, object]:
|
||||
mode, read_only, _ = socket_directory_state()
|
||||
captured_bytes, _ = (0, "") if capture is None else capture.snapshot()
|
||||
return {
|
||||
record = {
|
||||
"component": "hopper_private_display",
|
||||
"operation": operation,
|
||||
"status": status,
|
||||
@@ -390,6 +546,9 @@ def diagnostic(
|
||||
if failure is None
|
||||
else failure.stderr_bytes,
|
||||
}
|
||||
if failure is not None and failure.dialog_timeout is not None:
|
||||
record["dialog_timeout"] = failure.dialog_timeout
|
||||
return record
|
||||
|
||||
|
||||
def failure_name(code: int) -> str:
|
||||
@@ -405,6 +564,8 @@ def failure_name(code: int) -> str:
|
||||
X11_INPUT_FAILED: "x11_input_failed",
|
||||
RUNTIME_DEPENDENCY_UNAVAILABLE: "runtime_dependency_unavailable",
|
||||
X11_SOCKET_DIRECTORY_UNUSABLE: "x11_socket_directory_unusable",
|
||||
DEMO_DIALOG_ABSENT: "demo_dialog_absent",
|
||||
INVALID_DIALOG_TIMEOUT: "invalid_dialog_timeout",
|
||||
}[code]
|
||||
|
||||
|
||||
@@ -699,6 +860,9 @@ def run_launch(args: argparse.Namespace) -> int:
|
||||
"invalid_launch_command",
|
||||
"invalid Hopper launch command",
|
||||
)
|
||||
timeout_ms = configured_dialog_timeout_ms(
|
||||
getattr(args, "dialog_timeout_ms", None)
|
||||
)
|
||||
xvfb, display_name, authority, capture = start_xvfb(socket.parent, args)
|
||||
child: subprocess.Popen[bytes] | None = None
|
||||
display: int | None = None
|
||||
@@ -752,44 +916,26 @@ def run_launch(args: argparse.Namespace) -> int:
|
||||
"unexpected private display geometry",
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + 10
|
||||
observed: list[tuple[int, int, int, int, int]] = []
|
||||
dialog: tuple[int, int, int, int, int] | None = None
|
||||
while time.monotonic() < deadline:
|
||||
if not owner.alive():
|
||||
return 0
|
||||
if socket.exists():
|
||||
x11.XCloseDisplay(display)
|
||||
display = None
|
||||
return wait_for_hopper(child, owner)
|
||||
if not hopper_is_owned(leader, hopper):
|
||||
if child.poll() is None:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
raise AdapterFailure(
|
||||
HOPPER_EXITED_DURING_STARTUP,
|
||||
"hopper_exited_during_startup",
|
||||
"owned Hopper process exited",
|
||||
)
|
||||
observed = windows(x11, display, root)
|
||||
dialog = find_demo_dialog(x11, display, observed)
|
||||
if dialog is not None:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
if not hopper_is_owned(leader, hopper):
|
||||
raise AdapterFailure(
|
||||
PROCESS_OWNERSHIP_MISMATCH,
|
||||
"process_ownership_mismatch",
|
||||
"Hopper process ownership mismatch",
|
||||
)
|
||||
raise AdapterFailure(
|
||||
UNSUPPORTED_DEMO_DIALOG,
|
||||
"unsupported_demo_dialog",
|
||||
"expected Hopper demo dialog not found",
|
||||
try:
|
||||
dialog = watch_for_demo_dialog(
|
||||
x11,
|
||||
display,
|
||||
timeout_ms,
|
||||
owner_alive=owner.alive,
|
||||
socket_ready=socket.exists,
|
||||
process_owned=lambda: hopper_is_owned(leader, hopper),
|
||||
process_exited=lambda: child is not None and child.poll() is not None,
|
||||
monotonic=time.monotonic,
|
||||
sleep=time.sleep,
|
||||
list_windows=lambda: windows(x11, display, root),
|
||||
)
|
||||
except DemoWatchStop as stopped:
|
||||
if stopped.reason == "owner_closed":
|
||||
return 0
|
||||
x11.XCloseDisplay(display)
|
||||
display = None
|
||||
return wait_for_hopper(child, owner)
|
||||
|
||||
assert dialog is not None
|
||||
click_x, click_y = demo_click(dialog)
|
||||
input_results = (
|
||||
xtst.XTestFakeMotionEvent(display, -1, click_x, click_y, 0),
|
||||
|
||||
Reference in new issue
Block a user