From 0a77f6cb224b97a21d0f7b367cdfbb0a1c3c8b9f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Fri, 19 Jun 2026 00:38:06 +0800 Subject: [PATCH] Add AC_wait_for_process: block until a process appears or exits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the wait family (file / port / process). Polls a psutil-backed process list until a process whose name contains the target exists (present=True) or is gone (present=False) — the companion to launch_process / kill_process. Follows the smart-waits pattern (injectable lister, WaitOutcome, hard timeout) and is wired through the full stack: wait_until_process, facade, AC_wait_for_process, the ac_wait_for_process MCP tool, a Script Builder entry, and the v5 / README docs. --- README.md | 1 + README/README_zh-CN.md | 1 + README/README_zh-TW.md | 1 + .../Eng/doc/new_features/v5_features_doc.rst | 16 +++-- .../Zh/doc/new_features/v5_features_doc.rst | 10 ++- je_auto_control/__init__.py | 6 +- .../gui/script_builder/command_schema.py | 12 ++++ .../utils/executor/action_executor.py | 11 ++++ .../utils/mcp_server/tools/_factories.py | 16 +++++ .../utils/mcp_server/tools/_handlers.py | 9 +++ je_auto_control/utils/smart_waits/__init__.py | 16 +++-- je_auto_control/utils/smart_waits/waits.py | 46 ++++++++++++- .../headless/test_wait_for_process.py | 66 +++++++++++++++++++ 13 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 test/unit_test/headless/test_wait_for_process.py diff --git a/README.md b/README.md index 92f11326..ed6d10b5 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ reference page: **Smart waits** - **Wait for a file** — `wait_until_file` (`AC_wait_for_file`) blocks until a file exists and its size stops growing (a download finished writing). - **Wait for a TCP port** — `wait_until_port` (`AC_wait_for_port`) blocks until `host:port` accepts connections (pairs with `launch_process`). +- **Wait for a process** — `wait_until_process` (`AC_wait_for_process`) blocks until a process appears or exits — the companion to `launch_process` / `kill_process` (requires psutil). **Security** — HTTP / SMTP enforce http/https or TLS with verified certificates and explicit timeouts; SQL is read-only and parameter-bound; file paths are resolved before I/O. diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 037d66e2..b7bdfe7b 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -77,6 +77,7 @@ **智能等待** - **等待文件** — `wait_until_file`(`AC_wait_for_file`):等到文件存在且大小停止增长(下载写完)。 - **等待 TCP 端口** — `wait_until_port`(`AC_wait_for_port`):等到 `host:port` 可连接(与 `launch_process` 互补)。 +- **等待进程** — `wait_until_process`(`AC_wait_for_process`):等到进程出现或退出(与 `launch_process` / `kill_process` 互补;需 psutil)。 **安全性** — HTTP/SMTP 强制 http/https 或已验证 TLS 与明确超时;SQL 只读且参数绑定;文件路径 I/O 前以 `realpath` 解析。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 97e00192..38a0791c 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -77,6 +77,7 @@ **智慧等待** - **等待檔案** — `wait_until_file`(`AC_wait_for_file`):等到檔案存在且大小停止增長(下載寫完)。 - **等待 TCP 連接埠** — `wait_until_port`(`AC_wait_for_port`):等到 `host:port` 可連線(與 `launch_process` 互補)。 +- **等待行程** — `wait_until_process`(`AC_wait_for_process`):等到行程出現或結束(與 `launch_process` / `kill_process` 互補;需 psutil)。 **安全性** — HTTP/SMTP 強制 http/https 或已驗證 TLS 與明確逾時;SQL 唯讀且參數綁定;檔案路徑 I/O 前以 `realpath` 解析。 diff --git a/docs/source/Eng/doc/new_features/v5_features_doc.rst b/docs/source/Eng/doc/new_features/v5_features_doc.rst index d927eae0..3562b82c 100644 --- a/docs/source/Eng/doc/new_features/v5_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v5_features_doc.rst @@ -140,18 +140,24 @@ Smart waits Two waits that replace unreliable ``sleep`` calls:: - from je_auto_control import wait_until_file, wait_until_port + from je_auto_control import ( + wait_until_file, wait_until_port, wait_until_process) wait_until_file("~/Downloads/report.pdf", stable_for_s=1.0) wait_until_port("127.0.0.1", 8080, timeout_s=30.0) + wait_until_process("myserver", present=True, timeout_s=30.0) ``wait_until_file`` returns once a file exists, is at least ``min_size`` bytes, and its size has held steady for ``stable_for_s`` (a download has finished). ``wait_until_port`` returns once a TCP connection to -``host:port`` succeeds — the companion to launching a server. Both return -a ``WaitOutcome`` and honour a hard ``timeout_s`` cap. - -Executor commands: ``AC_wait_for_file``, ``AC_wait_for_port``. +``host:port`` succeeds — the companion to launching a server. ``wait_until_process`` +returns once a process whose name contains the target appears (or, with +``present=False``, exits) — the companion to ``launch_process`` / +``kill_process`` (requires psutil). All return a ``WaitOutcome`` and honour +a hard ``timeout_s`` cap. + +Executor commands: ``AC_wait_for_file``, ``AC_wait_for_port``, +``AC_wait_for_process``. Security diff --git a/docs/source/Zh/doc/new_features/v5_features_doc.rst b/docs/source/Zh/doc/new_features/v5_features_doc.rst index b95665cc..103ca57c 100644 --- a/docs/source/Zh/doc/new_features/v5_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v5_features_doc.rst @@ -132,17 +132,23 @@ PDF 兩個用來取代不可靠 ``sleep`` 的等待:: - from je_auto_control import wait_until_file, wait_until_port + from je_auto_control import ( + wait_until_file, wait_until_port, wait_until_process) wait_until_file("~/Downloads/report.pdf", stable_for_s=1.0) wait_until_port("127.0.0.1", 8080, timeout_s=30.0) + wait_until_process("myserver", present=True, timeout_s=30.0) ``wait_until_file`` 會在檔案存在、達到 ``min_size`` 位元組、且大小持續 ``stable_for_s`` 秒不變(下載寫完)後回傳。``wait_until_port`` 會在 ``host:port`` 可接受 TCP 連線後回傳——是啟動伺服器的最佳搭檔。兩者都 回傳 ``WaitOutcome`` 並有硬性 ``timeout_s`` 上限。 -執行器指令:``AC_wait_for_file``、``AC_wait_for_port``。 +``wait_until_process`` 會在名稱含目標字串的行程出現(或 ``present=False`` +時結束)後回傳——是 ``launch_process`` / ``kill_process`` 的搭檔(需 +psutil)。 + +執行器指令:``AC_wait_for_file``、``AC_wait_for_port``、``AC_wait_for_process``。 安全性 diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 39ec3034..52a4b118 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -149,8 +149,8 @@ # Smart waits (frame-diff replacements for time.sleep) from je_auto_control.utils.smart_waits import ( WaitOutcome, wait_until_clipboard_changes, wait_until_file, - wait_until_pixel_changes, wait_until_port, wait_until_region_idle, - wait_until_screen_stable, wait_until_window_closed, + wait_until_pixel_changes, wait_until_port, wait_until_process, + wait_until_region_idle, wait_until_screen_stable, wait_until_window_closed, ) # Assertion DSL (verify screen state; raise on mismatch) from je_auto_control.utils.assertion import ( @@ -569,7 +569,7 @@ def start_autocontrol_gui(*args, **kwargs): "WaitOutcome", "wait_until_pixel_changes", "wait_until_region_idle", "wait_until_screen_stable", "wait_until_clipboard_changes", "wait_until_window_closed", - "wait_until_file", "wait_until_port", + "wait_until_file", "wait_until_port", "wait_until_process", # Assertion DSL "AssertionResult", "assert_image", "assert_pixel", "assert_text", "assert_window", "assert_clipboard", "assert_process", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 1f400733..64ee6904 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -355,6 +355,18 @@ def _add_window_specs(specs: List[CommandSpec]) -> None: ), description="Wait until a TCP host:port accepts connections.", )) + specs.append(CommandSpec( + "AC_wait_for_process", "Flow", "Wait for Process", + fields=( + FieldSpec("name", FieldType.STRING), + FieldSpec("present", FieldType.BOOL, optional=True, default=True), + FieldSpec("timeout_s", FieldType.FLOAT, optional=True, + default=30.0), + FieldSpec("poll_interval_s", FieldType.FLOAT, optional=True, + default=0.25, min_value=0.01), + ), + description="Wait until a process appears (or exits). Requires psutil.", + )) specs.append(CommandSpec("AC_list_windows", "Window", "List Windows")) specs.append(CommandSpec( "AC_capture_window", "Window", "Capture Window", diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 67e21a71..5c969cc9 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -412,6 +412,16 @@ def _wait_for_port(host: str, port: int, timeout_s: float = 30.0, ).to_dict() +def _wait_for_process(name: str, present: bool = True, timeout_s: float = 30.0, + poll_interval_s: float = 0.25) -> Dict[str, Any]: + """Executor adapter: wait until a process appears or exits.""" + from je_auto_control.utils.smart_waits import wait_until_process + return wait_until_process( + name, present=bool(present), timeout_s=float(timeout_s), + poll_interval_s=float(poll_interval_s), + ).to_dict() + + def _ocr_read_structure(region: Optional[List[int]] = None, lang: str = "eng", min_confidence: float = 60.0, @@ -2393,6 +2403,7 @@ def __init__(self): "AC_wait_region_idle": _wait_region_idle, "AC_wait_for_file": _wait_for_file, "AC_wait_for_port": _wait_for_port, + "AC_wait_for_process": _wait_for_process, "AC_wait_clipboard_change": _wait_clipboard_change, "AC_wait_window_closed": _wait_window_closed, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 0fbc2a7f..6e534ec9 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -1295,6 +1295,22 @@ def smart_wait_tools() -> List[MCPTool]: handler=h.wait_for_port, annotations=READ_ONLY, ), + MCPTool( + name="ac_wait_for_process", + description=("Block until a process whose name contains 'name' " + "appears (present=true) or exits (present=false) — " + "e.g. after launching or killing one. Requires " + "psutil. Returns a WaitOutcome (succeeded/reason/" + "elapsed_s)."), + input_schema=schema({ + "name": {"type": "string"}, + "present": {"type": "boolean"}, + "timeout_s": {"type": "number"}, + "poll_interval_s": {"type": "number"}, + }, required=["name"]), + handler=h.wait_for_process, + annotations=READ_ONLY, + ), ] diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index e1fad24d..5c809087 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -976,6 +976,15 @@ def wait_for_port(host: str, port: int, timeout_s: float = 30.0, ).to_dict() +def wait_for_process(name: str, present: bool = True, timeout_s: float = 30.0, + poll_interval_s: float = 0.25) -> Dict[str, Any]: + from je_auto_control.utils.smart_waits import wait_until_process + return wait_until_process( + name, present=bool(present), timeout_s=float(timeout_s), + poll_interval_s=float(poll_interval_s), + ).to_dict() + + def wait_pixel_changes(x: int, y: int, timeout_s: float = 10.0, poll_interval_s: float = 0.1, diff --git a/je_auto_control/utils/smart_waits/__init__.py b/je_auto_control/utils/smart_waits/__init__.py index d70b69a3..e71bb355 100644 --- a/je_auto_control/utils/smart_waits/__init__.py +++ b/je_auto_control/utils/smart_waits/__init__.py @@ -8,17 +8,19 @@ ) """ from je_auto_control.utils.smart_waits.waits import ( - ClipboardReader, FileStatReader, Frame, PortConnector, ScreenSampler, - WaitOutcome, WindowFinder, wait_until_clipboard_changes, wait_until_file, - wait_until_pixel_changes, wait_until_port, wait_until_region_idle, - wait_until_screen_stable, wait_until_window_closed, + ClipboardReader, FileStatReader, Frame, PortConnector, ProcessLister, + ScreenSampler, WaitOutcome, WindowFinder, wait_until_clipboard_changes, + wait_until_file, wait_until_pixel_changes, wait_until_port, + wait_until_process, wait_until_region_idle, wait_until_screen_stable, + wait_until_window_closed, ) __all__ = [ "ClipboardReader", "FileStatReader", "Frame", "PortConnector", - "ScreenSampler", "WaitOutcome", "WindowFinder", + "ProcessLister", "ScreenSampler", "WaitOutcome", "WindowFinder", "wait_until_clipboard_changes", "wait_until_file", - "wait_until_pixel_changes", "wait_until_port", "wait_until_region_idle", - "wait_until_screen_stable", "wait_until_window_closed", + "wait_until_pixel_changes", "wait_until_port", "wait_until_process", + "wait_until_region_idle", "wait_until_screen_stable", + "wait_until_window_closed", ] diff --git a/je_auto_control/utils/smart_waits/waits.py b/je_auto_control/utils/smart_waits/waits.py index bb203236..44d89808 100644 --- a/je_auto_control/utils/smart_waits/waits.py +++ b/je_auto_control/utils/smart_waits/waits.py @@ -345,6 +345,45 @@ def _default_port_connector(host: str, port: int, timeout: float) -> bool: return False +ProcessLister = Callable[[str], List[str]] + + +def wait_until_process(name: str, *, present: bool = True, + timeout_s: float = 30.0, + poll_interval_s: float = 0.25, + lister: Optional[ProcessLister] = None, + ) -> WaitOutcome: + """Return when a process whose name contains ``name`` appears, or exits. + + The companion to launching / killing a process: poll until a matching + process exists (``present=True``) or is gone (``present=False``). + ``lister(name) -> [matching names]`` is injectable so tests need no + real processes; the default uses psutil. + """ + if timeout_s <= 0: + raise ValueError(_TIMEOUT_POSITIVE) + if poll_interval_s <= 0: + raise ValueError(_POLL_POSITIVE) + find = lister or _default_process_lister + started = time.monotonic() + deadline = started + float(timeout_s) + samples = 0 + verb = "appeared" if present else "exited" + while time.monotonic() < deadline: + samples += 1 + if bool(find(name)) == bool(present): + return _finish(True, f"process {name!r} {verb}", started, samples) + time.sleep(float(poll_interval_s)) + return _finish(False, f"timeout waiting for process {name!r} to {verb}", + started, samples) + + +def _default_process_lister(name: str) -> List[str]: + """List running process names matching ``name`` (requires psutil).""" + from je_auto_control.utils.assertion.assertions import _running_process_names + return _running_process_names(name) + + # --- internals ------------------------------------------------- def _frame_diff(a: Frame, b: Frame) -> int: @@ -382,8 +421,9 @@ def _finish(succeeded: bool, reason: str, started: float, __all__ = [ "ClipboardReader", "FileStatReader", "Frame", "PortConnector", - "ScreenSampler", "WaitOutcome", "WindowFinder", + "ProcessLister", "ScreenSampler", "WaitOutcome", "WindowFinder", "wait_until_clipboard_changes", "wait_until_file", - "wait_until_pixel_changes", "wait_until_port", "wait_until_region_idle", - "wait_until_screen_stable", "wait_until_window_closed", + "wait_until_pixel_changes", "wait_until_port", "wait_until_process", + "wait_until_region_idle", "wait_until_screen_stable", + "wait_until_window_closed", ] diff --git a/test/unit_test/headless/test_wait_for_process.py b/test/unit_test/headless/test_wait_for_process.py new file mode 100644 index 00000000..98cc794d --- /dev/null +++ b/test/unit_test/headless/test_wait_for_process.py @@ -0,0 +1,66 @@ +"""Headless tests for AC_wait_for_process / wait_until_process. + +The process lister is injectable, so the tests need neither psutil nor +real processes. +""" +import pytest + +import je_auto_control as ac +from je_auto_control.utils.smart_waits.waits import wait_until_process + + +def _lister(values): + """Return a lister yielding ``values`` then repeating the last one.""" + seq = list(values) + + def find(_name): + return seq.pop(0) if len(seq) > 1 else seq[0] + return find + + +def test_succeeds_when_process_present(): + outcome = wait_until_process("chrome", lister=_lister([["chrome.exe"]])) + assert outcome.succeeded is True + assert "appeared" in outcome.reason + + +def test_succeeds_after_process_appears(): + outcome = wait_until_process( + "svc", timeout_s=1.0, poll_interval_s=0.01, + lister=_lister([[], [], ["svc"]])) + assert outcome.succeeded is True + assert outcome.samples_taken >= 3 + + +def test_succeeds_when_process_absent(): + outcome = wait_until_process("gone", present=False, lister=_lister([[]])) + assert outcome.succeeded is True + assert "exited" in outcome.reason + + +def test_times_out_when_never_present(): + outcome = wait_until_process( + "missing", timeout_s=0.2, poll_interval_s=0.02, lister=_lister([[]])) + assert outcome.succeeded is False + assert "timeout" in outcome.reason + + +@pytest.mark.parametrize("kwargs", [{"timeout_s": 0}, {"poll_interval_s": 0}]) +def test_validation_errors(kwargs): + with pytest.raises(ValueError): + wait_until_process("x", lister=_lister([["x"]]), **kwargs) + + +def test_facade_executor_and_mcp_wiring(monkeypatch): + import je_auto_control.utils.smart_waits.waits as waits_module + monkeypatch.setattr(waits_module, "_default_process_lister", + lambda _name: []) # no real psutil / processes + assert ac.wait_until_process is wait_until_process + assert "AC_wait_for_process" in ac.executor.known_commands() + record = ac.execute_action( + [["AC_wait_for_process", {"name": "anything", "present": False, + "timeout_s": 0.5, "poll_interval_s": 0.02}]]) + assert any("exited" in str(v) for v in record.values()) + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + names = {tool.name for tool in build_default_tool_registry()} + assert "ac_wait_for_process" in names