test_openclaw_runtime_process.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. from __future__ import annotations
  2. import os
  3. import shlex
  4. import socket
  5. import sys
  6. import time
  7. from dataclasses import replace
  8. from pathlib import Path
  9. import pytest
  10. from agency_swarm.integrations.openclaw import OpenClawRuntime
  11. from tests.integration.fastapi._openclaw_test_support import _build_openclaw_config
  12. def _reserve_free_port() -> int:
  13. try:
  14. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
  15. sock.bind(("127.0.0.1", 0))
  16. return int(sock.getsockname()[1])
  17. except PermissionError as exc:
  18. pytest.skip(f"loopback bind unavailable in this environment: {exc}")
  19. def _write_fake_node_binary(tmp_path: Path) -> Path:
  20. script_path = tmp_path / "node"
  21. script_path.write_text(
  22. """#!/bin/sh
  23. if [ "$1" = "--version" ]; then
  24. echo "v22.12.0"
  25. exit 0
  26. fi
  27. echo "unexpected invocation: $@" >&2
  28. exit 1
  29. """,
  30. encoding="utf-8",
  31. )
  32. script_path.chmod(0o755)
  33. return script_path
  34. def _write_gateway_script(tmp_path: Path) -> Path:
  35. script_path = tmp_path / "fake_gateway.py"
  36. script_path.write_text(
  37. """
  38. from __future__ import annotations
  39. import argparse
  40. import signal
  41. import sys
  42. import time
  43. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  44. class Handler(BaseHTTPRequestHandler):
  45. def log_message(self, *_args, **_kwargs):
  46. return None
  47. def do_GET(self):
  48. self.send_response(200)
  49. self.end_headers()
  50. self.wfile.write(b"ok")
  51. def main() -> int:
  52. parser = argparse.ArgumentParser()
  53. parser.add_argument("--mode", required=True)
  54. parser.add_argument("--port", required=True, type=int)
  55. parser.add_argument("--pid-file")
  56. args = parser.parse_args()
  57. if args.pid_file:
  58. with open(args.pid_file, "w", encoding="utf-8") as handle:
  59. handle.write(str(os.getpid()))
  60. if args.mode == "exit":
  61. print("fatal gateway error", flush=True)
  62. return 1
  63. if args.mode == "sleep":
  64. signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
  65. while True:
  66. time.sleep(1)
  67. server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
  68. signal.signal(signal.SIGTERM, lambda *_: server.shutdown())
  69. try:
  70. server.serve_forever()
  71. finally:
  72. server.server_close()
  73. return 0
  74. if __name__ == "__main__":
  75. import os
  76. raise SystemExit(main())
  77. """.strip()
  78. + "\n",
  79. encoding="utf-8",
  80. )
  81. return script_path
  82. def _build_gateway_command(script_path: Path, *, mode: str, port: int, pid_file: Path | None = None) -> str:
  83. command = [
  84. shlex.quote(sys.executable),
  85. shlex.quote(str(script_path)),
  86. "--mode",
  87. shlex.quote(mode),
  88. "--port",
  89. str(port),
  90. ]
  91. if pid_file is not None:
  92. command.extend(["--pid-file", shlex.quote(str(pid_file))])
  93. return " ".join(command)
  94. def _process_is_alive(pid: int) -> bool:
  95. try:
  96. os.kill(pid, 0)
  97. except OSError:
  98. return False
  99. return True
  100. def test_openclaw_runtime_start_and_stop_real_gateway_process(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
  101. port = _reserve_free_port()
  102. script_path = _write_gateway_script(tmp_path)
  103. monkeypatch.setenv("OPENCLAW_NODE_BIN", str(_write_fake_node_binary(tmp_path)))
  104. config = replace(
  105. _build_openclaw_config(tmp_path),
  106. port=port,
  107. gateway_command=_build_gateway_command(script_path, mode="serve", port=port),
  108. startup_timeout_seconds=5.0,
  109. )
  110. runtime = OpenClawRuntime(config)
  111. runtime.start()
  112. assert runtime.is_running is True
  113. assert runtime.health()["running"] is True
  114. runtime.stop()
  115. assert runtime.is_running is False
  116. assert runtime._process is None
  117. assert runtime._log_handle is None
  118. def test_openclaw_runtime_start_reports_early_exit_log_tail(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
  119. port = _reserve_free_port()
  120. script_path = _write_gateway_script(tmp_path)
  121. monkeypatch.setenv("OPENCLAW_NODE_BIN", str(_write_fake_node_binary(tmp_path)))
  122. runtime = OpenClawRuntime(
  123. replace(
  124. _build_openclaw_config(tmp_path),
  125. port=port,
  126. gateway_command=_build_gateway_command(script_path, mode="exit", port=port),
  127. startup_timeout_seconds=2.0,
  128. )
  129. )
  130. with pytest.raises(RuntimeError, match="OpenClaw exited early with code 1") as excinfo:
  131. runtime.start()
  132. assert "fatal gateway error" in str(excinfo.value)
  133. assert runtime._process is None
  134. assert runtime._log_handle is None
  135. def test_openclaw_runtime_timeout_cleans_up_real_process(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
  136. port = _reserve_free_port()
  137. script_path = _write_gateway_script(tmp_path)
  138. pid_file = tmp_path / "sleep.pid"
  139. monkeypatch.setenv("OPENCLAW_NODE_BIN", str(_write_fake_node_binary(tmp_path)))
  140. runtime = OpenClawRuntime(
  141. replace(
  142. _build_openclaw_config(tmp_path),
  143. port=port,
  144. gateway_command=_build_gateway_command(script_path, mode="sleep", port=port, pid_file=pid_file),
  145. startup_timeout_seconds=0.5,
  146. )
  147. )
  148. with pytest.raises(TimeoutError):
  149. runtime.start()
  150. pid = int(pid_file.read_text(encoding="utf-8"))
  151. deadline = time.time() + 2.0
  152. while time.time() < deadline and _process_is_alive(pid):
  153. time.sleep(0.05)
  154. assert _process_is_alive(pid) is False
  155. assert runtime._process is None
  156. assert runtime._log_handle is None