Skip to content

Commit d5fca0a

Browse files
authored
Merge pull request #457 from Integration-Automation/fix/crash-and-interruption-hardening
Prevent crashes and silent automation interruptions
2 parents db4d2b9 + 08e9538 commit d5fca0a

14 files changed

Lines changed: 127 additions & 14 deletions

File tree

je_auto_control/utils/chatops/router.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,8 @@ def _dispatch_argv(self, argv: List[str],
144144
return spec.handler(rest, context)
145145
except ChatOpsError as error:
146146
return CommandResult(text=f"{name}: {error}", succeeded=False)
147-
except (RuntimeError, OSError, ValueError, TypeError,
148-
AutoControlException, sqlite3.Error) as error:
147+
except (RuntimeError, OSError, ValueError, TypeError, LookupError,
148+
AttributeError, AutoControlException, sqlite3.Error) as error:
149149
return CommandResult(
150150
text=f"{name} failed: {type(error).__name__}: {error}",
151151
succeeded=False,

je_auto_control/utils/hotkey/hotkey_daemon.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from dataclasses import dataclass
1717
from typing import Callable, Dict, FrozenSet, List, Optional, Tuple
1818

19+
from je_auto_control.utils.exception.exceptions import AutoControlException
1920
from je_auto_control.utils.json.json_file import read_action_json
2021
from je_auto_control.utils.logging.logging_instance import autocontrol_logger
2122
from je_auto_control.utils.run_history.artifact_manager import (
@@ -186,7 +187,11 @@ def _fire_binding(self, binding_id: str) -> None:
186187
try:
187188
actions = read_action_json(match.script_path)
188189
self._execute(actions)
189-
except (OSError, ValueError, RuntimeError) as error:
190+
except (OSError, ValueError, RuntimeError, AutoControlException) as error:
191+
# AutoControlException covers the common cases — a missing/renamed
192+
# script (AutoControlJsonActionException) or an action that raises
193+
# (image/window not found). Without it the exception escaped the
194+
# backend run-loop and silently killed the whole hotkey daemon.
190195
status = STATUS_ERROR
191196
error_text = repr(error)
192197
autocontrol_logger.error("hotkey %s failed: %r",

je_auto_control/utils/observer/observer.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@
2626
_ALL_EVENTS = (EVENT_APPEAR, EVENT_VANISH, EVENT_CHANGE)
2727

2828
# Errors a predicate/handler may raise that must not kill the poll loop.
29+
# LookupError/StopIteration/ArithmeticError cover user callbacks that index a
30+
# dict/list, exhaust an iterator, or divide — an uncaught one kills the daemon
31+
# thread and silently stops every rule.
2932
_RULE_ERRORS = (OSError, RuntimeError, ValueError, AttributeError, TypeError,
33+
LookupError, StopIteration, ArithmeticError,
3034
AutoControlException)
3135
_UNSET = object()
3236

je_auto_control/utils/remote_desktop/host_service.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,13 +135,13 @@ def run_daemon(config: HostServiceConfig) -> None:
135135
session_id, multi.session_count(),
136136
)
137137
time.sleep(config.poll_interval_s)
138-
except (signaling_client.SignalingError, OSError, RuntimeError) as error:
139-
autocontrol_logger.warning("host_service loop: %r", error)
140-
time.sleep(min(30.0, config.poll_interval_s * 5))
141138
except KeyboardInterrupt:
142139
autocontrol_logger.info("host_service: shutting down")
143140
multi.stop_all()
144141
return
142+
except Exception as error: # noqa: BLE001 # reason: a daemon must survive ANY transient error (signaling/aiortc/av/ValueError) and retry, not exit the loop
143+
autocontrol_logger.warning("host_service loop: %r", error)
144+
time.sleep(min(30.0, config.poll_interval_s * 5))
145145

146146

147147
# --- service installation helpers ----------------------------------------

je_auto_control/utils/remote_desktop/webrtc_host.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,7 @@
2424
from je_auto_control.utils.remote_desktop.fingerprint import (
2525
load_or_create_host_fingerprint,
2626
)
27-
from je_auto_control.utils.remote_desktop.input_dispatch import (
28-
InputDispatchError, dispatch_input,
29-
)
27+
from je_auto_control.utils.remote_desktop.input_dispatch import dispatch_input
3028
from je_auto_control.utils.remote_desktop.permissions import SessionPermissions
3129
from je_auto_control.utils.remote_desktop.rate_limit import (
3230
RateLimitConfig, RateLimiter,
@@ -976,7 +974,7 @@ def _dispatch_input_safely(self, payload: Any) -> None:
976974
return
977975
try:
978976
self._dispatch(payload)
979-
except InputDispatchError as error:
977+
except Exception as error: # noqa: BLE001 # reason: isolation boundary — a malformed/failing remote input must not kill the channel bridge (dispatch can raise AutoControl*/OSError, not just InputDispatchError)
980978
autocontrol_logger.warning("input dispatch: %r", error)
981979

982980
def _send_ctrl(self, payload: Mapping[str, Any]) -> None:

je_auto_control/utils/rest_api/rest_server.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ class _RestRequestHandler(BaseHTTPRequestHandler):
9393
"""Stdlib request handler — delegates to gate + route table."""
9494

9595
server_version = "AutoControlREST/2.0"
96+
# socketserver applies this to the connection socket in setup(); it bounds
97+
# every read (the body is read before the auth gate) so a client that
98+
# declares a Content-Length then stalls cannot pin a worker thread forever.
99+
timeout = 30.0
96100

97101
def log_message(self, format, *args) -> None: # noqa: A002 # pylint: disable=redefined-builtin # reason: stdlib BaseHTTPRequestHandler override
98102
autocontrol_logger.info("rest-api %s - %s",

je_auto_control/utils/socket_server/auto_control_socket_server.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,20 @@ def _close() -> None:
5656
daemon=True).start()
5757

5858

59+
_HANDLER_TIMEOUT_S = 30.0
60+
61+
5962
class TCPServerHandler(socketserver.BaseRequestHandler):
6063

6164
def handle(self) -> None:
62-
command_string = _read_command(self.request)
65+
try:
66+
self.request.settimeout(_HANDLER_TIMEOUT_S)
67+
command_string = _read_command(self.request)
68+
except OSError as error:
69+
# A client that connects and never sends a terminator would
70+
# otherwise block this handler thread forever; the timeout drops it.
71+
autocontrol_logger.info("socket command read dropped: %r", error)
72+
return
6373
socket = self.request
6474
autocontrol_logger.info("command is: %s", command_string)
6575
if command_string == "quit_server":
@@ -93,12 +103,18 @@ def handle(self) -> None:
93103
class TCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
94104
"""Threaded TCP command server for AutoControl.
95105
106+
``daemon_threads`` so a stalled handler thread never blocks interpreter
107+
exit (and, with the per-handler read timeout, stalled clients are dropped
108+
rather than accumulating non-daemon threads).
109+
96110
``close_flag`` used to live here. It was written on quit_server and read
97111
by nobody in the tree — a dead flag standing in for the ``server_close()``
98112
that was actually missing. Ask the socket instead: ``server.socket``
99113
is closed once quit_server has run.
100114
"""
101115

116+
daemon_threads = True
117+
102118

103119
def start_autocontrol_socket_server(host: str = "127.0.0.1", port: int = 9938) -> TCPServer:
104120
"""

je_auto_control/utils/triggers/webhook_server.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@
5050

5151
_DEFAULT_BIND = "127.0.0.1"
5252
_MAX_BODY_BYTES = 1 << 20 # 1 MiB cap
53+
# Automation is serialised (one script drives the shared input devices at a
54+
# time). Bound how long a concurrent webhook waits for the running script so a
55+
# long/stuck one can't pile up handler threads indefinitely — reject as busy.
56+
_FIRE_LOCK_TIMEOUT_S = 120.0
5357
# Cap how much we'll drain from a rejected request so a hostile client
5458
# can't make us spin reading a multi-GiB body. 4× the body cap covers
5559
# typical "client sent slightly too much" cases; beyond that we close
@@ -106,6 +110,9 @@ class _WebhookHandler(BaseHTTPRequestHandler):
106110
"""HTTP handler dispatched by :class:`WebhookTriggerServer`."""
107111

108112
server_version = "AutoControlWebhook/1.0"
113+
# Bound every read so a stalled client (valid Content-Length then dribble)
114+
# can't pin a worker thread forever.
115+
timeout = 30.0
109116

110117
# Signature must mirror BaseHTTPRequestHandler.log_message exactly,
111118
# including the parameter name 'format' — pylint W0221 trips on
@@ -196,6 +203,12 @@ def _dispatch(self, method: str) -> None:
196203
),
197204
}
198205
run_id = registry.fire(trigger, payload)
206+
if run_id is None:
207+
self._send_json(
208+
HTTPStatus.SERVICE_UNAVAILABLE,
209+
{"fired": False, "error": "automation busy, retry later"},
210+
)
211+
return
199212
self._send_json(HTTPStatus.OK, {"run_id": run_id, "fired": True})
200213

201214
def do_GET(self) -> None: # noqa: N802 - http.server contract
@@ -303,8 +316,19 @@ def authorize(self, trigger: WebhookTrigger,
303316

304317
def fire(self, trigger: WebhookTrigger,
305318
payload: Dict[str, Any]) -> Optional[int]:
306-
"""Run the trigger's script with ``payload`` seeded into variables."""
307-
with self._fire_lock:
319+
"""Run the trigger's script with ``payload`` seeded into variables.
320+
321+
Returns the run id, or ``None`` if the automation stayed busy past
322+
:data:`_FIRE_LOCK_TIMEOUT_S` (the caller answers 503 rather than
323+
blocking this handler thread forever).
324+
"""
325+
if not self._fire_lock.acquire(timeout=_FIRE_LOCK_TIMEOUT_S):
326+
autocontrol_logger.warning(
327+
"webhook %s rejected: automation busy for >%.0fs",
328+
trigger.webhook_id, _FIRE_LOCK_TIMEOUT_S,
329+
)
330+
return None
331+
try:
308332
run_id = default_history_store.start_run(
309333
SOURCE_TRIGGER, f"webhook:{trigger.webhook_id}",
310334
trigger.script_path,
@@ -337,6 +361,8 @@ def fire(self, trigger: WebhookTrigger,
337361
live.fired += 1
338362
live.last_status = 200 if status == STATUS_OK else 500
339363
return run_id
364+
finally:
365+
self._fire_lock.release()
340366

341367
def start(self, host: str = _DEFAULT_BIND, port: int = 0) -> Tuple[str, int]:
342368
"""Start the HTTP server; idempotent if already running."""

je_auto_control/utils/usbip/server.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ def _accept_loop(self) -> None:
116116
target=self._handle_client, args=(client_sock,),
117117
name="usbip-client", daemon=True,
118118
)
119+
# Drop finished workers so the list doesn't grow without bound over
120+
# a long session with many short-lived connections (each dead Thread
121+
# object would otherwise be retained until stop()).
122+
self._workers[:] = [w for w in self._workers if w.is_alive()]
119123
self._workers.append(worker)
120124
worker.start()
121125

je_auto_control/utils/visual_match/visual_match.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,36 @@
1212
(grab the screen) is device-bound. OpenCV + NumPy come in via the project's
1313
``je_open_cv`` dependency and are imported lazily. Imports no ``PySide6``.
1414
"""
15+
import functools
1516
from dataclasses import asdict, dataclass
1617
from typing import Any, Dict, List, Optional, Sequence
1718

19+
from je_auto_control.utils.exception.exceptions import AutoControlScreenException
20+
1821
# cv2 method name -> the OpenCV constant is resolved lazily in _method().
1922
_METHOD_NAMES = ("ccoeff_normed", "ccorr_normed", "sqdiff_normed")
2023
ImageSource = Any
2124

2225

26+
def _contain_cv2_error(fn):
27+
"""Convert OpenCV's ``cv2.error`` into a contained AutoControlScreenException.
28+
29+
A degenerate template/mask makes ``cv2.matchTemplate``/``minMaxLoc`` raise
30+
``cv2.error`` — a bare ``Exception`` subclass that is NOT in the executor's
31+
containment tuple, so it would escape and abort the whole automation run
32+
instead of being recorded as a failed match step.
33+
"""
34+
@functools.wraps(fn)
35+
def wrapper(*args, **kwargs):
36+
import cv2
37+
try:
38+
return fn(*args, **kwargs)
39+
except cv2.error as error:
40+
raise AutoControlScreenException(
41+
f"{fn.__name__} failed: {error}") from error
42+
return wrapper
43+
44+
2345
@dataclass(frozen=True)
2446
class Match:
2547
"""One template match: top-left (x, y), size, correlation score, scale."""
@@ -107,6 +129,7 @@ def _resize(template, scale: float):
107129
return cv2.resize(template, new_size)
108130

109131

132+
@_contain_cv2_error
110133
def _score_map(template: ImageSource, haystack: Optional[ImageSource] = None, *,
111134
region: Optional[Sequence[int]] = None,
112135
method: str = "ccoeff_normed", scale: float = 1.0):
@@ -128,6 +151,7 @@ def _score_map(template: ImageSource, haystack: Optional[ImageSource] = None, *,
128151
return result, tmpl
129152

130153

154+
@_contain_cv2_error
131155
def match_template(template: ImageSource, *, haystack: Optional[ImageSource] = None,
132156
region: Optional[Sequence[int]] = None,
133157
scales: Sequence[float] = (1.0,), min_score: float = 0.8,
@@ -201,6 +225,7 @@ def _select_candidates(result, min_score: float, width: int, height: int,
201225
for x, y, s in zip(xs, ys, scores)]
202226

203227

228+
@_contain_cv2_error
204229
def match_template_all(template: ImageSource, *,
205230
haystack: Optional[ImageSource] = None,
206231
region: Optional[Sequence[int]] = None,
@@ -285,6 +310,7 @@ def _masked_scores(template: ImageSource, mask: Optional[ImageSource],
285310
return np.nan_to_num(result, nan=0.0, posinf=0.0, neginf=0.0), tmpl
286311

287312

313+
@_contain_cv2_error
288314
def match_masked(template: ImageSource, *, mask: Optional[ImageSource] = None,
289315
haystack: Optional[ImageSource] = None,
290316
region: Optional[Sequence[int]] = None,
@@ -308,6 +334,7 @@ def match_masked(template: ImageSource, *, mask: Optional[ImageSource] = None,
308334
round(float(max_val), 4), 1.0)
309335

310336

337+
@_contain_cv2_error
311338
def match_masked_all(template: ImageSource, *, mask: Optional[ImageSource] = None,
312339
haystack: Optional[ImageSource] = None,
313340
region: Optional[Sequence[int]] = None,

0 commit comments

Comments
 (0)