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:
HenglieandCursor Agent authored and GitHub committed 2026-10-30 18:29:18 +08:00
1 parent 767f6afc18
commit b1ff7cdacc
10 files changed
+675 -45

No files matched your search

+183 -37
View File
@@ -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),