diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index baa631b..66e8425 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,18 @@ jobs: uv run python -c "from pathlib import Path; import subprocess; out = Path('.tmp/agentseek.Dockerfile'); out.parent.mkdir(exist_ok=True); subprocess.run(['uv', 'run', 'agentseek-api', 'dockerfile', '--config', 'examples/external_graph/manifest.json', str(out)], check=True); text = out.read_text(encoding='utf-8'); assert 'ENV PYTHONPATH=/deps/agent' in text; assert 'ENV AGENTSEEK_GRAPHS=/deps/agent/examples/external_graph/manifest.json' in text" + - name: CLI config, host environment, and process tests + run: >- + uv run pytest + tests/unit/test_cli.py + tests/unit/test_graph_manifest.py + tests/unit/test_dotenv_adapter.py + tests/unit/test_runtime_environment.py + tests/unit/test_runtime_entrypoint.py + tests/unit/test_process_supervisor.py + tests/integration/test_cli_runtime_processes.py + -q + - name: Sync embedded SeekDB extra for serve smoke if: runner.os == 'Linux' || (runner.os == 'macOS' && runner.arch == 'ARM64') run: uv sync --dev --extra embedded @@ -90,8 +102,9 @@ jobs: if: runner.os == 'Linux' || (runner.os == 'macOS' && runner.arch == 'ARM64') run: uv run python scripts/test_cli_serve_smoke.py - - name: CLI config and Docker planning tests - run: uv run pytest tests/unit/test_cli.py tests/unit/test_graph_manifest.py -q + - name: Minimum direct dependency compatibility + if: runner.os == 'Linux' + run: uv run python scripts/test_minimum_cli_dependencies.py embedded-seekdb-smoke: name: Embedded SeekDB Smoke diff --git a/CHANGELOG.md b/CHANGELOG.md index 142937d..efabe93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ Notable changes to AgentSeek API are documented in this file. +## Unreleased + +### Fixed + +- Made inherited host environment keys, including explicit empty strings, + authoritative over config and CLI dotenv sources. +- Parse each dotenv source independently with strict malformed-file handling, + and distinguish valueless `KEY` from explicit empty `KEY=`. +- Start worker and scheduler roles in fresh child processes so their settings + are constructed after the resolved environment is installed. +- Kept version, help, and Dockerfile rendering independent of runtime settings + validation. + +### Upgrade notes + +- Dotenv values no longer interpolate from config mappings or other dotenv + files. Put dependent bindings in one physical file or pass the final literal + value. +- Malformed dotenv syntax now exits with status 2 instead of warning and + continuing with a partial runtime configuration. + ## 0.2.1 - 2026-07-14 ### Fixed diff --git a/README.md b/README.md index 5d2e7ee..e45a2ea 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,35 @@ When running from this repository, use `uv run agentseek-api ...`. - `-c, --config PATH`: explicit `agentseek.json`, `langgraph.json`, or manifest path -- `--env-file PATH`: dotenv-style file loaded into the runtime environment +- `--env-file PATH`: host-runtime dotenv source + +### Host runtime environment + +For `dev`, `serve`, `worker`, and `scheduler`, direct assignments are applied in +this order: + +1. the dotenv path named by config `env`; +2. the literal config `env` mapping and `auth.path`; +3. the CLI `--env-file`; +4. the environment inherited by `agentseek-api`. + +The inherited environment is authoritative by key presence. This includes an +explicit empty value. A lower source can fill an absent key, but cannot replace +an inherited `KEY=`. + +Each dotenv file is evaluated independently. It can reference the inherited +environment and earlier bindings in the same physical file; it cannot reference +a config mapping or another dotenv file. Later assignment does not recompute an +earlier interpolated value. + +In dotenv syntax, a bare `KEY` is valid but contributes no assignment, while +`KEY=` contributes an explicit empty string. Missing files, invalid UTF-8, and +malformed syntax stop the command before a runtime child starts. + +The CLI applies command-owned values after this merge: the selected config path +becomes `AGENTSEEK_GRAPHS`, and `dev` forces `STUDIO_AUTH_LOCAL_DEV=true`. +Host and port options are passed as child argv and do not rewrite environment +keys with similar names. ### Common usage diff --git a/pyproject.toml b/pyproject.toml index 188459c..961d4b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,15 +9,15 @@ dependencies = [ "uvicorn>=0.30.0", "pydantic>=2.8.0", "pydantic-settings>=2.4.0", - "sqlalchemy>=2.0.0", + "sqlalchemy>=2.0.12", "greenlet>=3.1.0", "asyncpg>=0.29.0", "aiomysql>=0.2.0", "aiosqlite>=0.20.0", "redis>=5.0.0", - "langgraph>=1.0.3", + "langgraph>=1.0.6", "langgraph-sdk>=0.3.5", - "langchain-core>=1.0.0", + "langchain-core>=1.2.5", "langchain-openai>=1.0.0", "langchain-anthropic>=1.0.0", "langchain-oceanbase==0.6.0", @@ -25,6 +25,7 @@ dependencies = [ "pymysql>=1.1.0", "langchain>=0.3.9", "mcp>=1.27.1,<2", + "python-dotenv>=1.0,<1.3", "scalar-fastapi>=1.0.3", ] @@ -39,7 +40,7 @@ agentseek-api = "agentseek_api.cli:main" [dependency-groups] dev = [ "pytest>=8.0.0", - "pytest-asyncio>=0.23.0", + "pytest-asyncio>=0.23.5", "pytest-cov>=5.0.0", "httpx>=0.27.0", "ruff>=0.6.0", diff --git a/scripts/test_minimum_cli_dependencies.py b/scripts/test_minimum_cli_dependencies.py new file mode 100644 index 0000000..206e618 --- /dev/null +++ b/scripts/test_minimum_cli_dependencies.py @@ -0,0 +1,109 @@ +"""Verify host environment resolution with all direct dependency floors.""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import tomllib +from pathlib import Path + + +def main() -> None: + repository = Path(__file__).resolve().parents[1] + project = tomllib.loads((repository / "pyproject.toml").read_text(encoding="utf-8")) + requirements = list(project["project"]["dependencies"]) + requirements.append("pytest>=8.0.0") + requirements.append("pytest-asyncio>=0.23.5") + + with tempfile.TemporaryDirectory(prefix="agentseek-minimum-") as directory: + environment = Path(directory) / ".venv" + subprocess.run( + [ + "uv", + "venv", + "--python", + "3.12", + str(environment), + ], + check=True, + ) + python = environment / ( + "Scripts/python.exe" if sys.platform == "win32" else "bin/python" + ) + subprocess.run( + [ + "uv", + "pip", + "install", + "--python", + str(python), + "--resolution", + "lowest-direct", + *requirements, + ], + check=True, + ) + subprocess.run( + [ + "uv", + "pip", + "install", + "--python", + str(python), + "--no-deps", + "-e", + str(repository), + ], + check=True, + ) + + version = subprocess.run( + [ + str(python), + "-c", + ( + "import importlib.metadata; " + "print(importlib.metadata.version('python-dotenv'))" + ), + ], + check=True, + capture_output=True, + text=True, + ) + assert version.stdout.strip() == "1.0.0" + + cli_env = dict(os.environ) + cli_env.pop("PYTHONPATH", None) + cli_env["PORT"] = "not-an-integer" + version_result = subprocess.run( + [str(python), "-m", "agentseek_api.cli", "version"], + cwd=repository, + env=cli_env, + check=True, + capture_output=True, + text=True, + ) + expected_version = project["project"]["version"] + assert version_result.stdout.strip() == f"agentseek-api {expected_version}" + + test_env = dict(os.environ) + test_env.pop("PYTHONPATH", None) + subprocess.run( + [ + str(python), + "-m", + "pytest", + "tests/unit/test_dotenv_adapter.py", + "tests/unit/test_runtime_environment.py", + "-q", + ], + cwd=repository, + env=test_env, + check=True, + ) + + +if __name__ == "__main__": + main() diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 0530d64..60971cf 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -15,7 +15,14 @@ from typing import TextIO from agentseek_api import __version__ -from agentseek_api.settings import DEFAULT_API_PORT +from agentseek_api.constants import DEFAULT_API_PORT +from agentseek_api.dotenv_adapter import DotenvFileError, parse_dotenv_file +from agentseek_api.process_supervisor import ( + ForegroundChildSupervisor, + ForwardingSignalGuard, + ProcessSupervisionError, + _ForwardedSignal, +) DEFAULT_CLI_NAME = "agentseek-api" @@ -30,6 +37,15 @@ " AgentSeek v{version}\n" ) +AGENTSEEK_ONBOARD_BANNER_ASCII = ( + "\n" + " Welcome to\n" + "\n" + "========================\n" + " AgentSeek v{version}\n" + "========================\n" +) + __all__ = [ "CliError", "build_container_env", @@ -81,6 +97,31 @@ class DevServerUrls: studio_url: str +def _write_banner( + stdout: TextIO, + *, + unicode_text: str, + ascii_text: str, +) -> None: + text = unicode_text + encoding = getattr(stdout, "encoding", None) + if isinstance(encoding, str) and encoding: + try: + unicode_text.encode(encoding, errors="strict") + except (UnicodeEncodeError, LookupError): + text = ascii_text.encode("ascii", errors="replace").decode("ascii") + stdout.write(text) + stdout.flush() + + +def _write_onboard_banner(stdout: TextIO) -> None: + _write_banner( + stdout, + unicode_text=AGENTSEEK_ONBOARD_BANNER.format(version=__version__) + "\n", + ascii_text=AGENTSEEK_ONBOARD_BANNER_ASCII.format(version=__version__) + "\n", + ) + + def _resolve_path(path_text: str, *, cwd: Path) -> Path: path = Path(path_text).expanduser() if not path.is_absolute(): @@ -146,19 +187,24 @@ def discover_config_path(*, explicit_path: str | None, cwd: Path) -> Path | None return None -def _parse_env_file(env_file: Path) -> dict[str, str]: - values: dict[str, str] = {} - for line_number, raw_line in enumerate(env_file.read_text(encoding="utf-8").splitlines(), start=1): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - if line.startswith("export "): - line = line[len("export ") :].strip() - if "=" not in line: - raise CliError(f"Env file '{env_file}' has an invalid line {line_number}: '{raw_line}'.") - key, value = line.split("=", maxsplit=1) - values[key.strip()] = value.strip().strip("\"'") - return values +def _apply_env_layer( + env: dict[str, str], + layer: dict[str, str | None], +) -> None: + for key, value in layer.items(): + if value is not None: + env[key] = value + + +def _read_env_layer( + path: Path, + *, + inherited: dict[str, str], +) -> dict[str, str | None]: + try: + return parse_dotenv_file(path, ambient=inherited) + except DotenvFileError as exc: + raise CliError(str(exc)) from exc def _resolve_path_from_config(path_text: str, *, config_path: Path) -> Path: @@ -277,42 +323,99 @@ def build_runtime_env( cwd: Path, base_env: dict[str, str] | None = None, ) -> dict[str, str]: - env = dict(os.environ if base_env is None else base_env) - config: CliConfig | None = _load_cli_config(config_path) if config_path is not None else None + inherited = dict(os.environ if base_env is None else base_env) + env: dict[str, str] = {} + config = _load_cli_config(config_path) if config_path is not None else None + if config is not None: if config.env_file is not None: - env.update(_parse_env_file(config.env_file)) + _apply_env_layer( + env, + _read_env_layer(config.env_file, inherited=inherited), + ) env.update(config.env_mapping) if config.auth_path: env["AUTH_MODULE_PATH"] = config.auth_path + if env_file: resolved_env_file = _resolve_path(env_file, cwd=cwd) - if not resolved_env_file.exists(): - raise CliError(f"Env file '{resolved_env_file}' does not exist.") - env.update(_parse_env_file(resolved_env_file)) + _apply_env_layer( + env, + _read_env_layer(resolved_env_file, inherited=inherited), + ) + + env.update(inherited) + env.pop("AGENTSEEK_GRAPHS", None) if config_path is not None: env["AGENTSEEK_GRAPHS"] = str(config_path) return env def build_uvicorn_command(*, host: str, port: int, reload_enabled: bool) -> list[str]: - command = [sys.executable, "-m", "uvicorn", "agentseek_api.main:app", "--host", host, "--port", str(port)] + command = [ + sys.executable, + "-m", + "agentseek_api.runtime_entrypoint", + "uvicorn", + "--", + "agentseek_api.main:app", + "--host", + host, + "--port", + str(port), + ] if reload_enabled: command.append("--reload") return command def build_worker_command() -> list[str]: - return [sys.executable, "-m", "agentseek_api.worker"] + return [ + sys.executable, + "-m", + "agentseek_api.runtime_entrypoint", + "worker", + ] def build_scheduler_command() -> list[str]: - return [sys.executable, "-m", "agentseek_api.scheduler"] + return [ + sys.executable, + "-m", + "agentseek_api.runtime_entrypoint", + "scheduler", + ] def _default_runner(command: list[str], *, env: dict[str, str], cwd: str | None = None) -> int: - completed = subprocess.run(command, env=env, cwd=cwd, check=False) - return completed.returncode + try: + with ForwardingSignalGuard() as signals: + child = ForegroundChildSupervisor.start(command, env=env, cwd=cwd) + try: + signals.attach(child) + exit_code = child.wait() + child.close_remaining_tree(timeout=5.0) + return exit_code + except KeyboardInterrupt: + signals.begin_cleanup() + child.forward_and_reap(signal.SIGINT, timeout=5.0) + return 130 + except _ForwardedSignal as exc: + signals.begin_cleanup() + child.forward_and_reap(exc.signum, timeout=5.0) + return 128 + exc.signum + except BaseException: + signals.begin_cleanup() + child.terminate_and_reap(timeout=5.0) + raise + finally: + signals.begin_cleanup() + try: + child.ensure_closed(timeout=5.0) + finally: + child.close() + except ProcessSupervisionError as exc: + raise CliError("Could not supervise the runtime child safely.") from exc def _format_http_host(host: str) -> str: @@ -347,6 +450,15 @@ def _render_dev_ready_banner(urls: DevServerUrls) -> str: ) +def _render_ascii_dev_ready_banner(urls: DevServerUrls) -> str: + return ( + f"- API: {urls.api_url}\n" + f"- Docs: {urls.docs_url}\n" + f"- Studio UI: {urls.studio_url}\n" + "\n\n" + ) + + def _wait_for_dev_server_ready( api_url: str, *, @@ -403,8 +515,11 @@ def _terminate_child(_signum, _frame) -> None: continue try: - stdout.write(_render_dev_ready_banner(urls)) - stdout.flush() + _write_banner( + stdout, + unicode_text=_render_dev_ready_banner(urls), + ascii_text=_render_ascii_dev_ready_banner(urls), + ) wait_for_ready(urls.api_url, process=process, sleep=sleep) if open_browser: if browser_opener is None: @@ -413,6 +528,10 @@ def _terminate_child(_signum, _frame) -> None: browser_opener = webbrowser.open browser_opener(urls.studio_url) return process.wait() + except CliError: + if process.poll() is not None: + return process.returncode + raise except KeyboardInterrupt: if process.poll() is None: process.terminate() @@ -446,8 +565,7 @@ def _execute_dev_command( cwd: Path, stdout: TextIO, ) -> int: - stdout.write(AGENTSEEK_ONBOARD_BANNER.format(version=__version__) + "\n") - stdout.flush() + _write_onboard_banner(stdout) args.reload = not args.no_reload config_path = discover_config_path(explicit_path=args.config, cwd=cwd) env = build_runtime_env(config_path=config_path, env_file=args.env_file, cwd=cwd) @@ -473,40 +591,12 @@ def _execute_dev_command( def _execute_worker_command(args: argparse.Namespace, *, runner: Callable[..., int], cwd: Path) -> int: config_path = discover_config_path(explicit_path=args.config, cwd=cwd) env = build_runtime_env(config_path=config_path, env_file=args.env_file, cwd=cwd) - if runner is _default_runner: - from agentseek_api import worker as worker_module - - previous_env = os.environ.copy() - previous_cwd = Path.cwd() - try: - os.environ.clear() - os.environ.update(env) - os.chdir(cwd) - return worker_module.main() - finally: - os.chdir(previous_cwd) - os.environ.clear() - os.environ.update(previous_env) return runner(build_worker_command(), env=env, cwd=str(cwd)) def _execute_scheduler_command(args: argparse.Namespace, *, runner: Callable[..., int], cwd: Path) -> int: config_path = discover_config_path(explicit_path=args.config, cwd=cwd) env = build_runtime_env(config_path=config_path, env_file=args.env_file, cwd=cwd) - if runner is _default_runner: - from agentseek_api import scheduler as scheduler_module - - previous_env = os.environ.copy() - previous_cwd = Path.cwd() - try: - os.environ.clear() - os.environ.update(env) - os.chdir(cwd) - return scheduler_module.main() - finally: - os.chdir(previous_cwd) - os.environ.clear() - os.environ.update(previous_env) return runner(build_scheduler_command(), env=env, cwd=str(cwd)) @@ -978,8 +1068,7 @@ def run_namespace( ) return _execute_dev_command(args, runner=run, cwd=workdir, stdout=out) if command == "serve": - out.write(AGENTSEEK_ONBOARD_BANNER.format(version=__version__) + "\n") - out.flush() + _write_onboard_banner(out) args.reload = False return _execute_runtime_command(args, runner=run, cwd=workdir) if command == "worker": diff --git a/src/agentseek_api/constants.py b/src/agentseek_api/constants.py new file mode 100644 index 0000000..43d7a82 --- /dev/null +++ b/src/agentseek_api/constants.py @@ -0,0 +1,3 @@ +"""Inert package constants safe to import from command-line tooling.""" + +DEFAULT_API_PORT = 2024 diff --git a/src/agentseek_api/dotenv_adapter.py b/src/agentseek_api/dotenv_adapter.py new file mode 100644 index 0000000..d9955c0 --- /dev/null +++ b/src/agentseek_api/dotenv_adapter.py @@ -0,0 +1,69 @@ +"""Strict adapter around the supported python-dotenv implementation APIs.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path + +from dotenv.parser import parse_stream +from dotenv.variables import parse_variables + + +class DotenvFileError(ValueError): + def __init__( + self, + path: Path, + message: str, + *, + line: int | None = None, + ) -> None: + self.path = path + self.line = line + location = f" at line {line}" if line is not None else "" + super().__init__(f"Env file '{path}' {message}{location}.") + + +def _resolve_value( + value: str, + *, + context: Mapping[str, str | None], +) -> str: + return "".join(atom.resolve(context) for atom in parse_variables(value)) + + +def parse_dotenv_file( + path: Path, + *, + ambient: Mapping[str, str], +) -> dict[str, str | None]: + try: + with path.open(encoding="utf-8") as stream: + bindings = list(parse_stream(stream)) + except FileNotFoundError as exc: + raise DotenvFileError(path, "does not exist") from exc + except UnicodeDecodeError as exc: + raise DotenvFileError(path, "is not valid UTF-8") from exc + except OSError as exc: + reason = exc.strerror or type(exc).__name__ + raise DotenvFileError(path, f"could not be read: {reason}") from exc + + malformed = next((binding for binding in bindings if binding.error), None) + if malformed is not None: + raise DotenvFileError( + path, + "has malformed dotenv syntax", + line=malformed.original.line, + ) + + context: dict[str, str | None] = dict(ambient) + values: dict[str, str | None] = {} + for binding in bindings: + if binding.key is None: + continue + if binding.value is None: + value = None + else: + value = _resolve_value(binding.value, context=context) + values[binding.key] = value + context[binding.key] = value + return values diff --git a/src/agentseek_api/process_supervisor.py b/src/agentseek_api/process_supervisor.py new file mode 100644 index 0000000..118c258 --- /dev/null +++ b/src/agentseek_api/process_supervisor.py @@ -0,0 +1,1531 @@ +from __future__ import annotations + +import ctypes +import errno +import math +import os +import signal +import subprocess +import sys +import threading +import time +from ctypes import wintypes +from types import FrameType +from typing import Protocol, Self + + +_IS_WINDOWS = os.name == "nt" +_MANAGED_SIGNALS = (signal.SIGINT, signal.SIGTERM) +_SUPERVISION_ERROR = "Runtime child supervision failed." +_WINDOWS_WAIT_POLL_SECONDS = 0.05 +_DARWIN_P_PID = 1 +_DARWIN_WNOHANG = 0x00000001 +_DARWIN_WEXITED = 0x00000004 +_DARWIN_WNOWAIT = 0x00000020 +_CLD_EXITED = 1 +_CLD_KILLED = 2 +_CLD_DUMPED = 3 +_DARWIN_WAITID_FUNCTION = None + + +class ProcessSupervisionError(RuntimeError): + """A value-free failure at the child-process ownership boundary.""" + + def __init__(self, _detail: object | None = None) -> None: + super().__init__(_SUPERVISION_ERROR) + + +class _ForwardedSignal(Exception): + def __init__(self, signum: int) -> None: + super().__init__() + self.signum = signum + + +class _SignalTarget(Protocol): + def forward_signal(self, signum: int) -> None: ... + + +class ForwardingSignalGuard: + """Own temporary foreground handlers without exposing an unguarded spawn gap.""" + + def __init__(self) -> None: + self._state = "new" + self._child: _SignalTarget | None = None + self._pending_signal: int | None = None + self._previous_handlers: dict[int, object] = {} + self._installed_signals: list[int] = [] + self._original_mask: set[signal.Signals] | None = None + self._mask_is_blocked = False + self._cleanup_forward_failed = False + self._installed_handler = self._handle_signal + + def __enter__(self) -> Self: + if threading.current_thread() is not threading.main_thread(): + raise ProcessSupervisionError() + self._state = "acquiring" + try: + self._block_for_handler_installation() + if self._original_mask is not None and any( + signum in self._original_mask for signum in _MANAGED_SIGNALS + ): + raise ProcessSupervisionError() + for signum in _MANAGED_SIGNALS: + previous_handler = signal.getsignal(signum) + if previous_handler is None: + raise ProcessSupervisionError() + self._previous_handlers[signum] = previous_handler + for signum in _MANAGED_SIGNALS: + signal.signal(signum, self._installed_handler) + self._installed_signals.append(signum) + for signum in _MANAGED_SIGNALS: + if signal.getsignal(signum) is not self._installed_handler: + raise ProcessSupervisionError() + self._restore_entry_mask() + return self + except BaseException as exc: + self._state = "cleanup" + self._restore_after_failed_entry() + if isinstance(exc, ProcessSupervisionError): + raise + raise ProcessSupervisionError() from exc + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback, + ) -> bool: + self.begin_cleanup() + restore_failed = False + try: + self._restore_handlers_and_mask() + except ProcessSupervisionError: + restore_failed = True + self._state = "closed" + if restore_failed or self._cleanup_forward_failed: + raise ProcessSupervisionError() + return False + + def attach(self, child: _SignalTarget) -> None: + if self._state != "acquiring" or self._child is not None: + raise ProcessSupervisionError() + self._child = child + self._state = "waiting" + pending_signal = self._pending_signal + if pending_signal is None: + return + self._state = "delivering" + self._pending_signal = None + raise _ForwardedSignal(pending_signal) + + def begin_cleanup(self) -> None: + if self._state != "closed": + self._state = "cleanup" + + def _handle_signal( + self, + signum: int, + _frame: FrameType | None, + ) -> None: + if self._state == "acquiring": + if self._pending_signal is None: + self._pending_signal = signum + return + if self._state == "waiting": + self._state = "delivering" + if self._pending_signal is not None: + signum = self._pending_signal + self._pending_signal = None + raise _ForwardedSignal(signum) + if self._state in {"delivering", "cleanup"}: + if self._child is None: + return + try: + self._child.forward_signal(signum) + except BaseException: + self._cleanup_forward_failed = True + + def _block_for_handler_installation(self) -> None: + if _IS_WINDOWS or not hasattr(signal, "pthread_sigmask"): + return + self._original_mask = signal.pthread_sigmask( + signal.SIG_BLOCK, + set(_MANAGED_SIGNALS), + ) + self._mask_is_blocked = True + + def _restore_entry_mask(self) -> None: + if not self._mask_is_blocked or self._original_mask is None: + return + signal.pthread_sigmask(signal.SIG_SETMASK, self._original_mask) + self._mask_is_blocked = False + + def _restore_after_failed_entry(self) -> None: + failed = False + for signum in reversed(self._installed_signals): + previous = self._previous_handlers.get(signum) + if previous is None: + continue + try: + signal.signal(signum, previous) + except BaseException: + failed = True + try: + self._restore_entry_mask() + except BaseException: + failed = True + if failed: + raise ProcessSupervisionError() + + def _restore_handlers_and_mask(self) -> None: + failed = False + mask_temporarily_blocked = False + if not _IS_WINDOWS and hasattr(signal, "pthread_sigmask"): + try: + signal.pthread_sigmask( + signal.SIG_BLOCK, + set(_MANAGED_SIGNALS), + ) + mask_temporarily_blocked = True + except BaseException: + failed = True + for signum in reversed(self._installed_signals): + previous = self._previous_handlers[signum] + try: + signal.signal(signum, previous) + except BaseException: + failed = True + if mask_temporarily_blocked and self._original_mask is not None: + try: + signal.pthread_sigmask(signal.SIG_SETMASK, self._original_mask) + except BaseException: + failed = True + if failed: + raise ProcessSupervisionError() + + +def _decode_waitid_exit(result, *, expected_pid: int) -> int: + if result is None or result.si_pid != expected_pid: + raise ProcessSupervisionError() + if result.si_code == _CLD_EXITED: + return int(result.si_status) + if result.si_code in (_CLD_KILLED, _CLD_DUMPED): + return -int(result.si_status) + raise ProcessSupervisionError() + + +class _DarwinSigval(ctypes.Union): + _fields_ = [ + ("sival_int", ctypes.c_int), + ("sival_ptr", ctypes.c_void_p), + ] + + +class _DarwinSiginfo(ctypes.Structure): + _fields_ = [ + ("si_signo", ctypes.c_int), + ("si_errno", ctypes.c_int), + ("si_code", ctypes.c_int), + ("si_pid", ctypes.c_int), + ("si_uid", ctypes.c_uint), + ("si_status", ctypes.c_int), + ("si_addr", ctypes.c_void_p), + ("si_value", _DarwinSigval), + ("si_band", ctypes.c_long), + ("reserved", ctypes.c_ulong * 7), + ] + + +def _darwin_libc_waitid(): + global _DARWIN_WAITID_FUNCTION + if _DARWIN_WAITID_FUNCTION is not None: + return _DARWIN_WAITID_FUNCTION + if ctypes.sizeof(_DarwinSiginfo) != 104: + raise ProcessSupervisionError() + try: + libc = ctypes.CDLL("/usr/lib/libSystem.B.dylib", use_errno=True) + waitid = libc.waitid + waitid.argtypes = [ + ctypes.c_int, + ctypes.c_uint, + ctypes.POINTER(_DarwinSiginfo), + ctypes.c_int, + ] + waitid.restype = ctypes.c_int + except BaseException as exc: + raise ProcessSupervisionError() from exc + _DARWIN_WAITID_FUNCTION = waitid + return waitid + + +def _darwin_waitid_no_reap(pid: int, *, nohang: bool) -> int | None: + options = _DARWIN_WEXITED | _DARWIN_WNOWAIT + if nohang: + options |= _DARWIN_WNOHANG + while True: + information = _DarwinSiginfo() + ctypes.set_errno(0) + try: + result = _darwin_libc_waitid()( + _DARWIN_P_PID, + pid, + ctypes.byref(information), + options, + ) + except (KeyboardInterrupt, _ForwardedSignal): + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc + if result == 0: + if information.si_pid == 0: + return None + return _decode_waitid_exit(information, expected_pid=pid) + if ctypes.get_errno() != errno.EINTR: + raise ProcessSupervisionError() + + +def _require_posix_supervision_support() -> None: + required_names = ( + "P_PID", + "WEXITED", + "WNOWAIT", + "WNOHANG", + "CLD_EXITED", + "CLD_KILLED", + "CLD_DUMPED", + ) + if sys.platform == "darwin" and not hasattr(os, "waitid"): + _darwin_libc_waitid() + return + if ( + not hasattr(os, "waitid") + or any(not hasattr(os, name) for name in required_names) + or not (sys.platform == "darwin" or sys.platform.startswith("linux")) + ): + raise ProcessSupervisionError() + + +def _waitid_no_reap(pid: int, *, nohang: bool) -> int | None: + if sys.platform == "darwin" and not hasattr(os, "waitid"): + return _darwin_waitid_no_reap(pid, nohang=nohang) + _require_posix_supervision_support() + options = os.WEXITED | os.WNOWAIT + if nohang: + options |= os.WNOHANG + try: + result = os.waitid(os.P_PID, pid, options) + except (KeyboardInterrupt, _ForwardedSignal): + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc + if result is None: + return None + return _decode_waitid_exit(result, expected_pid=pid) + + +def _linux_process_group_members(pgid: int) -> set[int]: + members: set[int] = set() + try: + entries = os.scandir("/proc") + except BaseException as exc: + raise ProcessSupervisionError() from exc + with entries: + for entry in entries: + if not entry.name.isdecimal(): + continue + try: + with open( + f"/proc/{entry.name}/stat", + encoding="utf-8", + ) as stat_file: + stat_text = stat_file.read() + except FileNotFoundError: + continue + except BaseException as exc: + raise ProcessSupervisionError() from exc + command_end = stat_text.rfind(")") + fields = stat_text[command_end + 1 :].split() + if command_end < 0 or len(fields) < 3: + raise ProcessSupervisionError() + try: + observed_pgid = int(fields[2]) + pid = int(entry.name) + except ValueError as exc: + raise ProcessSupervisionError() from exc + if observed_pgid == pgid: + members.add(pid) + return members + + +def _darwin_process_group_members(pgid: int) -> set[int]: + try: + libproc = ctypes.CDLL("/usr/lib/libproc.dylib", use_errno=True) + list_group_pids = libproc.proc_listpgrppids + list_group_pids.argtypes = [ + ctypes.c_int, + ctypes.c_void_p, + ctypes.c_int, + ] + list_group_pids.restype = ctypes.c_int + except BaseException as exc: + raise ProcessSupervisionError() from exc + + def list_pids(buffer, size: int) -> int: + ctypes.set_errno(0) + count = list_group_pids(pgid, buffer, size) + call_errno = ctypes.get_errno() + if count < 0 or (count == 0 and call_errno != 0): + raise ProcessSupervisionError() + return count + + try: + capacity = list_pids(None, 0) + if capacity == 0: + return set() + capacity = max(16, capacity) + for _attempt in range(3): + buffer = (ctypes.c_int * capacity)() + count = list_pids( + ctypes.cast(buffer, ctypes.c_void_p), + ctypes.sizeof(buffer), + ) + if count < capacity: + return {int(pid) for pid in buffer[:count] if pid > 0} + capacity *= 2 + except (KeyboardInterrupt, _ForwardedSignal): + raise + except ProcessSupervisionError: + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc + raise ProcessSupervisionError() + + +def _process_group_has_other_members(pgid: int, leader_pid: int) -> bool: + if pgid <= 0 or leader_pid <= 0 or pgid != leader_pid: + raise ProcessSupervisionError() + if sys.platform == "darwin": + members = _darwin_process_group_members(pgid) + elif sys.platform.startswith("linux"): + members = _linux_process_group_members(pgid) + else: + raise ProcessSupervisionError() + return any(pid != leader_pid for pid in members) + + +class _PosixChild: + def __init__(self, process: subprocess.Popen[bytes]) -> None: + self._process = process + self._pgid = process.pid + self._observed_exit_code: int | None = None + self._direct_reaped = False + self._group_signal_allowed = True + self._cleanup_error = False + + @classmethod + def start( + cls, + command: list[str], + *, + env: dict[str, str], + cwd: str | None, + ) -> Self: + _require_posix_supervision_support() + if sys.platform == "darwin": + _darwin_process_group_members(os.getpgrp()) + else: + _linux_process_group_members(os.getpgrp()) + try: + process = subprocess.Popen( + command, + env=env, + cwd=cwd, + start_new_session=True, + ) + except BaseException as exc: + raise ProcessSupervisionError() from exc + return cls(process) + + def wait(self) -> int: + try: + if not self._observe_exit(nohang=False): + raise ProcessSupervisionError() + assert self._observed_exit_code is not None + return self._observed_exit_code + except (KeyboardInterrupt, _ForwardedSignal): + raise + except ProcessSupervisionError: + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc + + def forward_signal(self, signum: int) -> None: + try: + self._signal_group(signum) + except ProcessSupervisionError: + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc + + def forward_and_reap(self, signum: int, *, timeout: float) -> None: + self._clean_and_reap( + signum, + timeout=timeout, + signal_only_if_members=False, + ) + + def terminate_and_reap(self, *, timeout: float) -> None: + self._clean_and_reap( + signal.SIGTERM, + timeout=timeout, + signal_only_if_members=False, + ) + + def close_remaining_tree(self, *, timeout: float) -> None: + if self._observed_exit_code is None and not self._direct_reaped: + raise ProcessSupervisionError() + self._clean_and_reap( + signal.SIGTERM, + timeout=timeout, + signal_only_if_members=True, + ) + + def ensure_closed(self, *, timeout: float) -> None: + if self._direct_reaped: + if self._cleanup_error: + raise ProcessSupervisionError() + return + if self._observed_exit_code is not None: + self.close_remaining_tree(timeout=timeout) + return + self.terminate_and_reap(timeout=timeout) + + def close(self) -> None: + return None + + def _validate_process_group(self) -> None: + if self._pgid <= 0 or self._pgid == os.getpgrp(): + raise ProcessSupervisionError() + try: + observed_pgid = os.getpgid(self._process.pid) + except ProcessLookupError: + if self._observed_exit_code is not None and not self._direct_reaped: + return + raise ProcessSupervisionError() from None + except BaseException as exc: + raise ProcessSupervisionError() from exc + if observed_pgid != self._pgid: + raise ProcessSupervisionError() + + def _signal_group(self, signum: int) -> None: + if not self._group_signal_allowed or self._direct_reaped: + return + self._validate_process_group() + try: + os.killpg(self._pgid, signum) + except ProcessLookupError: + return + except OSError as exc: + if exc.errno == errno.ESRCH: + return + raise ProcessSupervisionError() from exc + + def _observe_exit(self, *, nohang: bool) -> bool: + if self._observed_exit_code is not None: + return True + exit_code = _waitid_no_reap(self._process.pid, nohang=nohang) + if exit_code is None: + return False + self._observed_exit_code = exit_code + return True + + def _has_other_group_members(self) -> bool: + return _process_group_has_other_members( + self._pgid, + self._process.pid, + ) + + def _wait_for_owned_tree_exit(self, *, deadline: float) -> tuple[bool, bool]: + failure = False + while True: + try: + direct_exited = self._observe_exit(nohang=True) + other_members = self._has_other_group_members() + except ProcessSupervisionError: + direct_exited = False + other_members = True + failure = True + if direct_exited and not other_members: + return True, failure + remaining = deadline - time.monotonic() + if remaining <= 0: + return False, failure + time.sleep(min(0.02, remaining)) + + def _reap_observed_child(self) -> None: + if self._direct_reaped: + return + if self._observed_exit_code is None: + raise ProcessSupervisionError() + expected_exit_code = self._observed_exit_code + try: + observed_exit_code = self._wait_and_mark_direct_reaped() + except (KeyboardInterrupt, _ForwardedSignal): + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc + if observed_exit_code != expected_exit_code: + raise ProcessSupervisionError() + + def _wait_and_mark_direct_reaped(self) -> int: + previous_mask = signal.pthread_sigmask( + signal.SIG_BLOCK, + set(_MANAGED_SIGNALS), + ) + self._group_signal_allowed = False + try: + observed_exit_code = self._process.wait(timeout=0.0) + self._direct_reaped = True + return observed_exit_code + finally: + signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask) + + def _clean_and_reap( + self, + signum: int, + *, + timeout: float, + signal_only_if_members: bool, + ) -> None: + if self._direct_reaped: + if self._cleanup_error: + raise ProcessSupervisionError() + return + failure = False + + if self._observed_exit_code is None: + try: + self._observe_exit(nohang=True) + except ProcessSupervisionError: + failure = True + + should_signal = True + if signal_only_if_members: + try: + should_signal = self._has_other_group_members() + except ProcessSupervisionError: + failure = True + + if not should_signal and self._observed_exit_code is not None: + try: + self._reap_observed_child() + except ProcessSupervisionError: + failure = True + if failure: + self._cleanup_error = True + raise ProcessSupervisionError() + return + + try: + self._signal_group(signum) + except ProcessSupervisionError: + failure = True + soft_deadline = time.monotonic() + timeout + complete, wait_failed = self._wait_for_owned_tree_exit( + deadline=soft_deadline, + ) + failure = failure or wait_failed + + if not complete: + try: + self._signal_group(signal.SIGKILL) + except ProcessSupervisionError: + failure = True + hard_deadline = time.monotonic() + timeout + complete, hard_wait_failed = self._wait_for_owned_tree_exit( + deadline=hard_deadline, + ) + failure = failure or hard_wait_failed + + if self._observed_exit_code is not None: + try: + self._reap_observed_child() + except ProcessSupervisionError: + failure = True + else: + try: + self._wait_and_mark_direct_reaped() + except subprocess.TimeoutExpired: + pass + except (KeyboardInterrupt, _ForwardedSignal): + raise + except BaseException: + failure = True + else: + failure = True + + if failure or not complete or not self._direct_reaped: + if self._direct_reaped: + self._cleanup_error = True + raise ProcessSupervisionError() + + +class _Win32ApiProtocol(Protocol): + def create_job(self): ... + + def set_kill_on_close(self, job) -> None: ... + + def create_suspended_process( + self, + command: list[str], + *, + env: dict[str, str], + cwd: str | None, + job, + ): ... + + def resume_thread(self, thread) -> None: ... + + def terminate_job(self, job) -> None: ... + + def wait_process(self, process, timeout: float | None) -> bool: ... + + def process_exit_code(self, process) -> int: ... + + def wait_for_job_empty(self, job, timeout: float) -> bool: ... + + def send_ctrl_break(self, process_id: int) -> None: ... + + def close_handle(self, handle) -> None: ... + + +class _IO_COUNTERS(ctypes.Structure): + _fields_ = [ + ("ReadOperationCount", ctypes.c_ulonglong), + ("WriteOperationCount", ctypes.c_ulonglong), + ("OtherOperationCount", ctypes.c_ulonglong), + ("ReadTransferCount", ctypes.c_ulonglong), + ("WriteTransferCount", ctypes.c_ulonglong), + ("OtherTransferCount", ctypes.c_ulonglong), + ] + + +class _JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure): + _fields_ = [ + ("PerProcessUserTimeLimit", ctypes.c_longlong), + ("PerJobUserTimeLimit", ctypes.c_longlong), + ("LimitFlags", wintypes.DWORD), + ("MinimumWorkingSetSize", ctypes.c_size_t), + ("MaximumWorkingSetSize", ctypes.c_size_t), + ("ActiveProcessLimit", wintypes.DWORD), + ("Affinity", ctypes.c_size_t), + ("PriorityClass", wintypes.DWORD), + ("SchedulingClass", wintypes.DWORD), + ] + + +class _JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure): + _fields_ = [ + ("BasicLimitInformation", _JOBOBJECT_BASIC_LIMIT_INFORMATION), + ("IoInfo", _IO_COUNTERS), + ("ProcessMemoryLimit", ctypes.c_size_t), + ("JobMemoryLimit", ctypes.c_size_t), + ("PeakProcessMemoryUsed", ctypes.c_size_t), + ("PeakJobMemoryUsed", ctypes.c_size_t), + ] + + +class _JOBOBJECT_BASIC_ACCOUNTING_INFORMATION(ctypes.Structure): + _fields_ = [ + ("TotalUserTime", ctypes.c_longlong), + ("TotalKernelTime", ctypes.c_longlong), + ("ThisPeriodTotalUserTime", ctypes.c_longlong), + ("ThisPeriodTotalKernelTime", ctypes.c_longlong), + ("TotalPageFaultCount", wintypes.DWORD), + ("TotalProcesses", wintypes.DWORD), + ("ActiveProcesses", wintypes.DWORD), + ("TotalTerminatedProcesses", wintypes.DWORD), + ] + + +class _STARTUPINFOW(ctypes.Structure): + _fields_ = [ + ("cb", wintypes.DWORD), + ("lpReserved", wintypes.LPWSTR), + ("lpDesktop", wintypes.LPWSTR), + ("lpTitle", wintypes.LPWSTR), + ("dwX", wintypes.DWORD), + ("dwY", wintypes.DWORD), + ("dwXSize", wintypes.DWORD), + ("dwYSize", wintypes.DWORD), + ("dwXCountChars", wintypes.DWORD), + ("dwYCountChars", wintypes.DWORD), + ("dwFillAttribute", wintypes.DWORD), + ("dwFlags", wintypes.DWORD), + ("wShowWindow", wintypes.WORD), + ("cbReserved2", wintypes.WORD), + ("lpReserved2", ctypes.POINTER(wintypes.BYTE)), + ("hStdInput", wintypes.HANDLE), + ("hStdOutput", wintypes.HANDLE), + ("hStdError", wintypes.HANDLE), + ] + + +class _STARTUPINFOEXW(ctypes.Structure): + _fields_ = [ + ("StartupInfo", _STARTUPINFOW), + ("lpAttributeList", ctypes.c_void_p), + ] + + +class _PROCESS_INFORMATION(ctypes.Structure): + _fields_ = [ + ("hProcess", wintypes.HANDLE), + ("hThread", wintypes.HANDLE), + ("dwProcessId", wintypes.DWORD), + ("dwThreadId", wintypes.DWORD), + ] + + +class _Win32AttributeList: + def __init__(self, *, buffer, pointer, handle_array, job_array) -> None: + self.buffer = buffer + self.pointer = pointer + self.handle_array = handle_array + self.job_array = job_array + + +class _WindowsLaunchNativeProtocol(Protocol): + def get_standard_handle(self, stream: int): ... + + def open_null_handle(self, stream: int): ... + + def duplicate_inheritable_handle(self, handle): ... + + def create_attribute_list(self, handles, jobs): ... + + def create_suspended_process( + self, + command: list[str], + *, + env: dict[str, str], + cwd: str | None, + standard_handles, + attribute_list, + ): ... + + def delete_handle_list(self, attribute_list) -> None: ... + + def close_handle(self, handle) -> None: ... + + def abort_suspended_process(self, process, thread) -> None: ... + + +class _WindowsProcessLauncher: + def __init__(self, native: _WindowsLaunchNativeProtocol) -> None: + self._native = native + + def create( + self, + command: list[str], + *, + env: dict[str, str], + cwd: str | None, + job, + ): + duplicates: list[object] = [] + owned_sources: list[object] = [] + attribute_list = None + result = None + failure: BaseException | None = None + try: + standard_handles = [] + for stream in (-10, -11, -12): + handle = self._native.get_standard_handle(stream) + if handle is None: + handle = self._native.open_null_handle(stream) + owned_sources.append(handle) + standard_handles.append(handle) + for handle in standard_handles: + duplicates.append(self._native.duplicate_inheritable_handle(handle)) + attribute_list = self._native.create_attribute_list( + duplicates, + (job,), + ) + result = self._native.create_suspended_process( + command, + env=env, + cwd=cwd, + standard_handles=tuple(duplicates), + attribute_list=attribute_list, + ) + except BaseException as exc: + failure = exc + + try: + if attribute_list is not None: + self._native.delete_handle_list(attribute_list) + except BaseException as exc: + if failure is None: + failure = exc + for handle in duplicates: + try: + self._native.close_handle(handle) + except BaseException as exc: + if failure is None: + failure = exc + for handle in owned_sources: + try: + self._native.close_handle(handle) + except BaseException as exc: + if failure is None: + failure = exc + + if failure is not None: + if result is not None: + try: + process, thread, _process_id = result + self._native.abort_suspended_process(process, thread) + except BaseException: + pass + raise failure + return result + + +class _CtypesWindowsLaunchNative: + _CREATE_SUSPENDED = 0x00000004 + _CREATE_NEW_PROCESS_GROUP = 0x00000200 + _CREATE_UNICODE_ENVIRONMENT = 0x00000400 + _EXTENDED_STARTUPINFO_PRESENT = 0x00080000 + _STARTF_USESTDHANDLES = 0x00000100 + _PROC_THREAD_ATTRIBUTE_HANDLE_LIST = 0x00020002 + _PROC_THREAD_ATTRIBUTE_JOB_LIST = 0x0002000D + _DUPLICATE_SAME_ACCESS = 0x00000002 + _WAIT_OBJECT_0 = 0x00000000 + _GENERIC_READ = 0x80000000 + _GENERIC_WRITE = 0x40000000 + _FILE_SHARE_READ = 0x00000001 + _FILE_SHARE_WRITE = 0x00000002 + _OPEN_EXISTING = 3 + _FILE_ATTRIBUTE_NORMAL = 0x00000080 + + def __init__(self, kernel32) -> None: + self._kernel32 = kernel32 + + def get_standard_handle(self, stream: int): + handle = self._kernel32.GetStdHandle(wintypes.DWORD(stream & 0xFFFFFFFF)) + if handle in (None, 0, ctypes.c_void_p(-1).value): + return None + return handle + + def open_null_handle(self, stream: int): + desired_access = self._GENERIC_READ if stream == -10 else self._GENERIC_WRITE + handle = self._kernel32.CreateFileW( + "NUL", + desired_access, + self._FILE_SHARE_READ | self._FILE_SHARE_WRITE, + None, + self._OPEN_EXISTING, + self._FILE_ATTRIBUTE_NORMAL, + None, + ) + if handle in (None, ctypes.c_void_p(-1).value): + self._raise_error() + return handle + + def duplicate_inheritable_handle(self, handle): + current_process = self._kernel32.GetCurrentProcess() + duplicate = wintypes.HANDLE() + if not self._kernel32.DuplicateHandle( + current_process, + handle, + current_process, + ctypes.byref(duplicate), + 0, + True, + self._DUPLICATE_SAME_ACCESS, + ): + self._raise_error() + return duplicate.value + + def create_attribute_list(self, handles, jobs): + size = ctypes.c_size_t() + self._kernel32.InitializeProcThreadAttributeList( + None, + 2, + 0, + ctypes.byref(size), + ) + if size.value == 0: + self._raise_error() + buffer = ctypes.create_string_buffer(size.value) + pointer = ctypes.cast(buffer, ctypes.c_void_p) + if not self._kernel32.InitializeProcThreadAttributeList( + pointer, + 2, + 0, + ctypes.byref(size), + ): + self._raise_error() + handle_array = (wintypes.HANDLE * len(handles))(*handles) + if not self._kernel32.UpdateProcThreadAttribute( + pointer, + 0, + self._PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + ctypes.cast(handle_array, ctypes.c_void_p), + ctypes.sizeof(handle_array), + None, + None, + ): + self._kernel32.DeleteProcThreadAttributeList(pointer) + self._raise_error() + job_array = (wintypes.HANDLE * len(jobs))(*jobs) + if not self._kernel32.UpdateProcThreadAttribute( + pointer, + 0, + self._PROC_THREAD_ATTRIBUTE_JOB_LIST, + ctypes.cast(job_array, ctypes.c_void_p), + ctypes.sizeof(job_array), + None, + None, + ): + self._kernel32.DeleteProcThreadAttributeList(pointer) + self._raise_error() + return _Win32AttributeList( + buffer=buffer, + pointer=pointer, + handle_array=handle_array, + job_array=job_array, + ) + + def create_suspended_process( + self, + command: list[str], + *, + env: dict[str, str], + cwd: str | None, + standard_handles, + attribute_list, + ): + command_line = ctypes.create_unicode_buffer(subprocess.list2cmdline(command)) + environment_text = "\0".join( + f"{key}={value}" + for key, value in sorted( + env.items(), + key=lambda item: item[0].casefold(), + ) + ) + environment = ctypes.create_unicode_buffer(environment_text + "\0\0") + startup = _STARTUPINFOEXW() + startup.StartupInfo.cb = ctypes.sizeof(startup) + creation_flags = ( + self._CREATE_SUSPENDED + | self._CREATE_NEW_PROCESS_GROUP + | self._CREATE_UNICODE_ENVIRONMENT + ) + inherit_handles = bool(standard_handles) + if inherit_handles: + startup.StartupInfo.dwFlags |= self._STARTF_USESTDHANDLES + ( + startup.StartupInfo.hStdInput, + startup.StartupInfo.hStdOutput, + startup.StartupInfo.hStdError, + ) = standard_handles + startup.lpAttributeList = attribute_list.pointer + creation_flags |= self._EXTENDED_STARTUPINFO_PRESENT + process_information = _PROCESS_INFORMATION() + created = self._kernel32.CreateProcessW( + None, + command_line, + None, + None, + inherit_handles, + creation_flags, + environment, + cwd, + ctypes.cast(ctypes.byref(startup), ctypes.POINTER(_STARTUPINFOW)), + ctypes.byref(process_information), + ) + if not created: + self._raise_error() + return ( + process_information.hProcess, + process_information.hThread, + int(process_information.dwProcessId), + ) + + def delete_handle_list(self, attribute_list) -> None: + self._kernel32.DeleteProcThreadAttributeList(attribute_list.pointer) + + def close_handle(self, handle) -> None: + if handle and not self._kernel32.CloseHandle(handle): + self._raise_error() + + def abort_suspended_process(self, process, thread) -> None: + failed = False + try: + if not self._kernel32.TerminateProcess(process, 1): + failed = True + except BaseException: + failed = True + try: + if self._kernel32.WaitForSingleObject(process, 5000) != self._WAIT_OBJECT_0: + failed = True + except BaseException: + failed = True + for handle in (thread, process): + try: + if not self._kernel32.CloseHandle(handle): + failed = True + except BaseException: + failed = True + if failed: + self._raise_error() + + @staticmethod + def _raise_error() -> None: + get_last_error = getattr(ctypes, "get_last_error", None) + raise OSError(get_last_error() if get_last_error is not None else 0) + + +class _Win32Api: + _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 + _JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9 + _JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION = 1 + _WAIT_OBJECT_0 = 0x00000000 + _WAIT_TIMEOUT = 0x00000102 + _INFINITE = 0xFFFFFFFF + _CTRL_BREAK_EVENT = 1 + + def __init__(self) -> None: + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + self._kernel32 = kernel32 + kernel32.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR] + kernel32.CreateJobObjectW.restype = wintypes.HANDLE + kernel32.SetInformationJobObject.argtypes = [ + wintypes.HANDLE, + ctypes.c_int, + ctypes.c_void_p, + wintypes.DWORD, + ] + kernel32.SetInformationJobObject.restype = wintypes.BOOL + kernel32.CreateProcessW.argtypes = [ + wintypes.LPCWSTR, + wintypes.LPWSTR, + ctypes.c_void_p, + ctypes.c_void_p, + wintypes.BOOL, + wintypes.DWORD, + ctypes.c_void_p, + wintypes.LPCWSTR, + ctypes.POINTER(_STARTUPINFOW), + ctypes.POINTER(_PROCESS_INFORMATION), + ] + kernel32.CreateProcessW.restype = wintypes.BOOL + kernel32.ResumeThread.argtypes = [wintypes.HANDLE] + kernel32.ResumeThread.restype = wintypes.DWORD + kernel32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT] + kernel32.TerminateProcess.restype = wintypes.BOOL + kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT] + kernel32.TerminateJobObject.restype = wintypes.BOOL + kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] + kernel32.WaitForSingleObject.restype = wintypes.DWORD + kernel32.GetExitCodeProcess.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(wintypes.DWORD), + ] + kernel32.GetExitCodeProcess.restype = wintypes.BOOL + kernel32.QueryInformationJobObject.argtypes = [ + wintypes.HANDLE, + ctypes.c_int, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ] + kernel32.QueryInformationJobObject.restype = wintypes.BOOL + kernel32.GenerateConsoleCtrlEvent.argtypes = [wintypes.DWORD, wintypes.DWORD] + kernel32.GenerateConsoleCtrlEvent.restype = wintypes.BOOL + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + kernel32.GetStdHandle.argtypes = [wintypes.DWORD] + kernel32.GetStdHandle.restype = wintypes.HANDLE + kernel32.CreateFileW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + ctypes.c_void_p, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ] + kernel32.CreateFileW.restype = wintypes.HANDLE + kernel32.GetCurrentProcess.argtypes = [] + kernel32.GetCurrentProcess.restype = wintypes.HANDLE + kernel32.DuplicateHandle.argtypes = [ + wintypes.HANDLE, + wintypes.HANDLE, + wintypes.HANDLE, + ctypes.POINTER(wintypes.HANDLE), + wintypes.DWORD, + wintypes.BOOL, + wintypes.DWORD, + ] + kernel32.DuplicateHandle.restype = wintypes.BOOL + kernel32.InitializeProcThreadAttributeList.argtypes = [ + ctypes.c_void_p, + wintypes.DWORD, + wintypes.DWORD, + ctypes.POINTER(ctypes.c_size_t), + ] + kernel32.InitializeProcThreadAttributeList.restype = wintypes.BOOL + kernel32.UpdateProcThreadAttribute.argtypes = [ + ctypes.c_void_p, + wintypes.DWORD, + ctypes.c_size_t, + ctypes.c_void_p, + ctypes.c_size_t, + ctypes.c_void_p, + ctypes.c_void_p, + ] + kernel32.UpdateProcThreadAttribute.restype = wintypes.BOOL + kernel32.DeleteProcThreadAttributeList.argtypes = [ctypes.c_void_p] + kernel32.DeleteProcThreadAttributeList.restype = None + self._process_launcher = _WindowsProcessLauncher( + _CtypesWindowsLaunchNative(kernel32) + ) + + def create_job(self): + job = self._kernel32.CreateJobObjectW(None, None) + if not job: + self._raise_error() + return job + + def set_kill_on_close(self, job) -> None: + information = _JOBOBJECT_EXTENDED_LIMIT_INFORMATION() + information.BasicLimitInformation.LimitFlags = ( + self._JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + ) + if not self._kernel32.SetInformationJobObject( + job, + self._JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, + ctypes.byref(information), + ctypes.sizeof(information), + ): + self._raise_error() + + def create_suspended_process( + self, + command: list[str], + *, + env: dict[str, str], + cwd: str | None, + job, + ): + return self._process_launcher.create( + command, + env=env, + cwd=cwd, + job=job, + ) + + def resume_thread(self, thread) -> None: + previous_count = self._kernel32.ResumeThread(thread) + if previous_count != 1: + self._raise_error() + + def terminate_job(self, job) -> None: + if not self._kernel32.TerminateJobObject(job, 1): + self._raise_error() + + def wait_process(self, process, timeout: float | None) -> bool: + milliseconds = ( + self._INFINITE + if timeout is None + else min(self._INFINITE - 1, max(0, math.ceil(timeout * 1000))) + ) + result = self._kernel32.WaitForSingleObject(process, milliseconds) + if result == self._WAIT_OBJECT_0: + return True + if result == self._WAIT_TIMEOUT: + return False + self._raise_error() + + def process_exit_code(self, process) -> int: + exit_code = wintypes.DWORD() + if not self._kernel32.GetExitCodeProcess(process, ctypes.byref(exit_code)): + self._raise_error() + return int(exit_code.value) + + def wait_for_job_empty(self, job, timeout: float) -> bool: + deadline = time.monotonic() + timeout + while True: + information = _JOBOBJECT_BASIC_ACCOUNTING_INFORMATION() + if not self._kernel32.QueryInformationJobObject( + job, + self._JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION, + ctypes.byref(information), + ctypes.sizeof(information), + None, + ): + self._raise_error() + if information.ActiveProcesses == 0: + return True + remaining = deadline - time.monotonic() + if remaining <= 0: + return False + time.sleep(min(0.02, remaining)) + + def send_ctrl_break(self, process_id: int) -> None: + if not self._kernel32.GenerateConsoleCtrlEvent( + self._CTRL_BREAK_EVENT, + process_id, + ): + self._raise_error() + + def close_handle(self, handle) -> None: + if handle and not self._kernel32.CloseHandle(handle): + self._raise_error() + + @staticmethod + def _raise_error() -> None: + raise OSError(ctypes.get_last_error()) + + +class _WindowsChild: + def __init__( + self, + *, + api: _Win32ApiProtocol, + job, + process, + process_id: int, + ) -> None: + self._api = api + self._job = job + self._process = process + self._process_id = process_id + + @classmethod + def start( + cls, + command: list[str], + *, + env: dict[str, str], + cwd: str | None, + api: _Win32ApiProtocol | None = None, + ) -> Self: + native = api or _Win32Api() + job = None + process = None + thread = None + try: + job = native.create_job() + native.set_kill_on_close(job) + process, thread, process_id = native.create_suspended_process( + command, + env=env, + cwd=cwd, + job=job, + ) + native.resume_thread(thread) + native.close_handle(thread) + thread = None + return cls( + api=native, + job=job, + process=process, + process_id=process_id, + ) + except BaseException as exc: + cls._rollback_start( + native, + job=job, + process=process, + thread=thread, + ) + raise ProcessSupervisionError() from exc + + @staticmethod + def _rollback_start( + api: _Win32ApiProtocol, + *, + job, + process, + thread, + ) -> None: + if process is not None: + if job is not None: + try: + api.terminate_job(job) + except BaseException: + pass + try: + api.wait_for_job_empty(job, 5.0) + except BaseException: + pass + for handle in (thread, process, job): + if handle is None: + continue + try: + api.close_handle(handle) + except BaseException: + pass + + def wait(self) -> int: + try: + while not self._api.wait_process( + self._process, + _WINDOWS_WAIT_POLL_SECONDS, + ): + pass + return self._api.process_exit_code(self._process) + except (KeyboardInterrupt, _ForwardedSignal): + raise + except ProcessSupervisionError: + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc + + def forward_signal(self, signum: int) -> None: + try: + if signum == signal.SIGINT: + try: + self._api.send_ctrl_break(self._process_id) + except BaseException: + self._api.terminate_job(self._job) + else: + self._api.terminate_job(self._job) + except BaseException as exc: + raise ProcessSupervisionError() from exc + + def forward_and_reap(self, signum: int, *, timeout: float) -> None: + try: + if signum == signal.SIGINT: + try: + self._api.send_ctrl_break(self._process_id) + except BaseException: + self._api.terminate_job(self._job) + deadline = time.monotonic() + timeout + job_empty = self._api.wait_for_job_empty(self._job, timeout) + else: + deadline = time.monotonic() + timeout + job_empty = self._api.wait_for_job_empty(self._job, timeout) + if not job_empty: + self._api.terminate_job(self._job) + deadline = time.monotonic() + timeout + job_empty = self._api.wait_for_job_empty( + self._job, + timeout, + ) + else: + self._api.terminate_job(self._job) + deadline = time.monotonic() + timeout + job_empty = self._api.wait_for_job_empty(self._job, timeout) + if not job_empty: + raise ProcessSupervisionError() + if not self._wait_process_until(deadline): + raise ProcessSupervisionError() + except ProcessSupervisionError: + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc + + def terminate_and_reap(self, *, timeout: float) -> None: + try: + self._api.terminate_job(self._job) + deadline = time.monotonic() + timeout + if not self._api.wait_for_job_empty(self._job, timeout): + raise ProcessSupervisionError() + if not self._wait_process_until(deadline): + raise ProcessSupervisionError() + except ProcessSupervisionError: + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc + + def close_remaining_tree(self, *, timeout: float) -> None: + try: + if self._api.wait_for_job_empty(self._job, 0.0): + return + self._api.terminate_job(self._job) + if not self._api.wait_for_job_empty(self._job, timeout): + raise ProcessSupervisionError() + except ProcessSupervisionError: + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc + + def ensure_closed(self, *, timeout: float) -> None: + self.close_remaining_tree(timeout=timeout) + + def close(self) -> None: + failed = False + process, self._process = self._process, None + job, self._job = self._job, None + for handle in (process, job): + if handle is None: + continue + try: + self._api.close_handle(handle) + except BaseException: + failed = True + if failed: + raise ProcessSupervisionError() + + def _wait_process_until(self, deadline: float) -> bool: + while True: + remaining = deadline - time.monotonic() + wait_seconds = max( + 0.0, + min(_WINDOWS_WAIT_POLL_SECONDS, remaining), + ) + if self._api.wait_process(self._process, wait_seconds): + return True + if remaining <= 0: + return False + + +class ForegroundChildSupervisor: + def __init__(self, child: _PosixChild | _WindowsChild) -> None: + self._child = child + + @classmethod + def start( + cls, + command: list[str], + *, + env: dict[str, str], + cwd: str | None = None, + ) -> Self: + try: + child = ( + _WindowsChild.start(command, env=env, cwd=cwd) + if _IS_WINDOWS + else _PosixChild.start(command, env=env, cwd=cwd) + ) + except ProcessSupervisionError: + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc + return cls(child) + + def wait(self) -> int: + return self._child.wait() + + def forward_signal(self, signum: int) -> None: + self._child.forward_signal(signum) + + def forward_and_reap(self, signum: int, *, timeout: float) -> None: + self._child.forward_and_reap(signum, timeout=timeout) + + def terminate_and_reap(self, *, timeout: float) -> None: + self._child.terminate_and_reap(timeout=timeout) + + def close_remaining_tree(self, *, timeout: float) -> None: + self._child.close_remaining_tree(timeout=timeout) + + def ensure_closed(self, *, timeout: float) -> None: + self._child.ensure_closed(timeout=timeout) + + def close(self) -> None: + self._child.close() diff --git a/src/agentseek_api/runtime_entrypoint.py b/src/agentseek_api/runtime_entrypoint.py new file mode 100644 index 0000000..f9ae427 --- /dev/null +++ b/src/agentseek_api/runtime_entrypoint.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import importlib +import runpy +import sys +from collections.abc import Sequence + +from pydantic import ValidationError + + +TARGET_MODULES = { + "uvicorn": "uvicorn.__main__", + "worker": "agentseek_api.worker", + "scheduler": "agentseek_api.scheduler", +} + + +def _format_settings_validation_error(exc: ValidationError) -> str: + fields = sorted( + { + ".".join(str(part) for part in error["loc"]) + f" ({error['type']})" + for error in exc.errors(include_input=False, include_url=False) + } + ) + return f"Invalid runtime setting(s): {', '.join(fields)}." + + +def main(argv: Sequence[str] | None = None) -> int: + arguments = list(sys.argv[1:] if argv is None else argv) + if not arguments or arguments[0] not in TARGET_MODULES: + sys.stderr.write("Invalid internal runtime target.\n") + return 2 + target_name, *target_argv = arguments + if target_argv[:1] == ["--"]: + target_argv = target_argv[1:] + target_module = TARGET_MODULES[target_name] + previous_argv = sys.argv + sys.argv = [target_module, *target_argv] + try: + try: + importlib.import_module("agentseek_api.settings") + except ValidationError as exc: + sys.stderr.write(_format_settings_validation_error(exc) + "\n") + return 2 + try: + runpy.run_module(target_module, run_name="__main__") + except SystemExit as exc: + return ( + exc.code + if isinstance(exc.code, int) + else (0 if exc.code is None else 1) + ) + return 0 + finally: + sys.argv = previous_argv + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/agentseek_api/settings.py b/src/agentseek_api/settings.py index 11e50ec..5aabd7c 100644 --- a/src/agentseek_api/settings.py +++ b/src/agentseek_api/settings.py @@ -1,6 +1,6 @@ from pydantic_settings import BaseSettings, SettingsConfigDict -DEFAULT_API_PORT = 2024 +from agentseek_api.constants import DEFAULT_API_PORT class Settings(BaseSettings): diff --git a/tests/fixtures/runtime_settings_probe/sitecustomize.py b/tests/fixtures/runtime_settings_probe/sitecustomize.py new file mode 100644 index 0000000..394fc10 --- /dev/null +++ b/tests/fixtures/runtime_settings_probe/sitecustomize.py @@ -0,0 +1,69 @@ +"""Test-only probe loaded by runtime child interpreters.""" + +from __future__ import annotations + +import asyncio +import json +import os +import subprocess +import sys +import time +from pathlib import Path + + +validation_child_pid_path = os.environ.get("AGENTSEEK_VALIDATION_CHILD_PID_PATH") +if validation_child_pid_path: + Path(validation_child_pid_path).write_text( + str(os.getpid()), + encoding="utf-8", + ) + + +termination_probe_path = os.environ.get("AGENTSEEK_TERMINATION_PROBE_PATH") +probe_path = os.environ.get("AGENTSEEK_SETTINGS_PROBE_PATH") +if termination_probe_path: + + def _block_runtime_role(awaitable) -> int: + awaitable.close() + termination_fixture = ( + Path(__file__).resolve().parents[1] / "termination_tree.py" + ) + grandchild = subprocess.Popen( + [sys.executable, str(termination_fixture), "--grandchild"] + ) + Path(termination_probe_path).write_text( + json.dumps( + { + "parent": os.getpid(), + "grandchild": grandchild.pid, + } + ), + encoding="utf-8", + ) + while True: + time.sleep(60) + + asyncio.run = _block_runtime_role +elif probe_path: + probe_fields = tuple( + field + for field in os.environ["AGENTSEEK_SETTINGS_PROBE_FIELDS"].split(",") + if field + ) + probe_exit_code = int(os.environ.get("AGENTSEEK_SETTINGS_PROBE_EXIT_CODE", "0")) + + def _record_settings(awaitable) -> int: + awaitable.close() + from agentseek_api.settings import settings + + observed = { + "pid": os.getpid(), + "settings": {field: getattr(settings, field) for field in probe_fields}, + } + Path(probe_path).write_text( + json.dumps(observed, sort_keys=True), + encoding="utf-8", + ) + return probe_exit_code + + asyncio.run = _record_settings diff --git a/tests/fixtures/termination_tree.py b/tests/fixtures/termination_tree.py new file mode 100644 index 0000000..1365f94 --- /dev/null +++ b/tests/fixtures/termination_tree.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import argparse +import json +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + + +def _use_native_interrupt_termination() -> None: + signal.signal(signal.SIGINT, signal.SIG_DFL) + if hasattr(signal, "SIGBREAK"): + signal.signal(signal.SIGBREAK, signal.SIG_DFL) + + +def _block() -> int: + _use_native_interrupt_termination() + while True: + time.sleep(60) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("result_path", nargs="?") + parser.add_argument("--grandchild", action="store_true") + parser.add_argument("--parent-exit", type=int) + parser.add_argument("--output-marker") + args = parser.parse_args() + if args.grandchild: + return _block() + if args.result_path is None: + parser.error("result_path is required") + + _use_native_interrupt_termination() + if args.output_marker is not None: + print(args.output_marker, flush=True) + blocked_signals: list[int] = [] + if hasattr(signal, "pthread_sigmask"): + current_mask = signal.pthread_sigmask(signal.SIG_BLOCK, set()) + blocked_signals = sorted( + int(signum) + for signum in (signal.SIGINT, signal.SIGTERM) + if signum in current_mask + ) + grandchild = subprocess.Popen( + [sys.executable, str(Path(__file__).resolve()), "--grandchild"] + ) + Path(args.result_path).write_text( + json.dumps( + { + "parent": os.getpid(), + "grandchild": grandchild.pid, + "blocked_signals": blocked_signals, + } + ), + encoding="utf-8", + ) + if args.parent_exit is not None: + os._exit(args.parent_exit) + return _block() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/integration/test_cli_runtime_processes.py b/tests/integration/test_cli_runtime_processes.py new file mode 100644 index 0000000..568bcc4 --- /dev/null +++ b/tests/integration/test_cli_runtime_processes.py @@ -0,0 +1,812 @@ +from __future__ import annotations + +import json +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +from agentseek_api import __version__ + + +PROBE_SITE_DIR = ( + Path(__file__).resolve().parents[1] / "fixtures" / "runtime_settings_probe" +) +TERMINATION_TREE_FIXTURE = ( + Path(__file__).resolve().parents[1] / "fixtures" / "termination_tree.py" +) +VALIDATION_CHILD_PID_PATH_ENV = "AGENTSEEK_VALIDATION_CHILD_PID_PATH" +TERMINATION_PROBE_PATH_ENV = "AGENTSEEK_TERMINATION_PROBE_PATH" + + +def _probe_pythonpath() -> str: + existing_pythonpath = os.environ.get("PYTHONPATH") + pythonpath = str(PROBE_SITE_DIR) + if existing_pythonpath: + pythonpath = os.pathsep.join((pythonpath, existing_pythonpath)) + return pythonpath + + +def _pid_is_alive(pid: int) -> bool: + if os.name == "nt": + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + open_process = kernel32.OpenProcess + open_process.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + open_process.restype = wintypes.HANDLE + get_exit_code = kernel32.GetExitCodeProcess + get_exit_code.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] + get_exit_code.restype = wintypes.BOOL + close_handle = kernel32.CloseHandle + close_handle.argtypes = [wintypes.HANDLE] + close_handle.restype = wintypes.BOOL + handle = open_process(0x1000, False, pid) + if not handle: + return False + try: + exit_code = wintypes.DWORD() + if not get_exit_code(handle, ctypes.byref(exit_code)): + return False + return exit_code.value == 259 + finally: + close_handle(handle) + + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _read_observed_pid(path: Path, *, timeout_seconds: float = 2.0) -> int: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + try: + return int(path.read_text(encoding="utf-8")) + except FileNotFoundError: + time.sleep(0.01) + raise AssertionError("Runtime validation child PID was not observed.") + + +def _terminate_observed_pid(pid: int, *, timeout_seconds: float = 2.0) -> None: + if not _pid_is_alive(pid): + return + os.kill(pid, signal.SIGTERM) + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if not _pid_is_alive(pid): + return + time.sleep(0.01) + kill_signal = getattr(signal, "SIGKILL", signal.SIGTERM) + os.kill(pid, kill_signal) + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if not _pid_is_alive(pid): + return + time.sleep(0.01) + raise AssertionError(f"Runtime validation child PID {pid} did not exit.") + + +def _read_tree_pids( + path: Path, + *, + timeout_seconds: float = 5.0, +) -> tuple[int, int]: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + return int(payload["parent"]), int(payload["grandchild"]) + except (FileNotFoundError, KeyError, TypeError, ValueError): + time.sleep(0.01) + raise AssertionError("The supervised parent/grandchild PIDs were not observed.") + + +def _wait_for_pids_gone( + pids: tuple[int, ...], + *, + timeout_seconds: float = 8.0, +) -> None: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if not any(_pid_is_alive(pid) for pid in pids): + return + time.sleep(0.02) + live_pids = [pid for pid in pids if _pid_is_alive(pid)] + raise AssertionError(f"Supervised process IDs remained alive: {live_pids}") + + +def _stop_test_process(process: subprocess.Popen[str]) -> None: + if process.poll() is not None: + return + process.terminate() + try: + process.communicate(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.communicate(timeout=3) + + +def _cleanup_recorded_pids(pids: tuple[int, ...]) -> None: + for pid in pids: + _terminate_observed_pid(pid, timeout_seconds=3.0) + + +def _run_python( + *arguments: str, + cwd: Path, + extra_env: dict[str, str] | None = None, + removed_env: tuple[str, ...] = (), +) -> subprocess.CompletedProcess[str]: + env = dict(os.environ) + env.update(extra_env or {}) + for field in removed_env: + env.pop(field, None) + return subprocess.run( + [sys.executable, *arguments], + cwd=cwd, + env=env, + check=False, + capture_output=True, + text=True, + timeout=20, + ) + + +def _write_runtime_config( + root: Path, + name: str, + env_mapping: dict[str, object], +) -> Path: + config_path = root / f"{name}.json" + config_path.write_text( + json.dumps( + { + "graphs": {"chat": "chat.graph:graph"}, + "env": env_mapping, + } + ), + encoding="utf-8", + ) + return config_path + + +def _settings_probe_environment( + *, + output_path: Path, + fields: tuple[str, ...], + exit_code: int, +) -> dict[str, str]: + environment = os.environ.copy() + environment.update( + { + "PYTHONPATH": _probe_pythonpath(), + "AGENTSEEK_SETTINGS_PROBE_PATH": str(output_path), + "AGENTSEEK_SETTINGS_PROBE_FIELDS": ",".join(fields), + "AGENTSEEK_SETTINGS_PROBE_EXIT_CODE": str(exit_code), + } + ) + for field in fields: + environment.pop(field, None) + return environment + + +def _run_role_probe( + *, + role: str, + config_path: Path, + environment: dict[str, str], +) -> tuple[int, int, str]: + process = subprocess.Popen( + [ + sys.executable, + "-m", + "agentseek_api.cli", + role, + "--config", + str(config_path), + ], + cwd=config_path.parent, + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + _stdout, stderr = process.communicate(timeout=20) + except subprocess.TimeoutExpired: + process.terminate() + try: + process.communicate(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.communicate() + raise + return process.returncode, process.pid, stderr + + +def test_cli_import_does_not_import_runtime_settings(tmp_path: Path) -> None: + result = _run_python( + "-c", + ( + "import sys; " + "import agentseek_api.cli; " + "assert 'agentseek_api.settings' not in sys.modules" + ), + cwd=tmp_path, + extra_env={"PORT": "not-an-integer"}, + ) + + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + "arguments", + [ + ("-m", "agentseek_api.cli", "version"), + ("-m", "agentseek_api.cli", "--help"), + ], + ids=["version", "help"], +) +def test_non_runtime_commands_ignore_invalid_runtime_settings( + tmp_path: Path, + arguments: tuple[str, ...], +) -> None: + result = _run_python( + *arguments, + cwd=tmp_path, + extra_env={"PORT": "not-an-integer"}, + ) + + assert result.returncode == 0, result.stderr + assert "ValidationError" not in result.stderr + + +def test_dockerfile_rendering_ignores_invalid_runtime_settings( + tmp_path: Path, +) -> None: + config_path = tmp_path / "langgraph.json" + config_path.write_text( + '{"graphs":{"chat":"chat.graph:graph"}}', + encoding="utf-8", + ) + output_path = tmp_path / "Dockerfile" + + result = _run_python( + "-m", + "agentseek_api.cli", + "dockerfile", + "--config", + str(config_path), + str(output_path), + cwd=tmp_path, + extra_env={"PORT": "not-an-integer"}, + ) + + assert result.returncode == 0, result.stderr + assert output_path.exists() + assert "ValidationError" not in result.stderr + + +@pytest.mark.parametrize( + ("role", "invalid_field", "invalid_value", "error_type"), + [ + ("dev", "PORT", "invalid-port-canary", "int_parsing"), + ("serve", "PORT", "invalid-port-canary", "int_parsing"), + ( + "worker", + "WORKER_CONCURRENT_JOBS", + "invalid-jobs-canary", + "int_parsing", + ), + ( + "scheduler", + "WORKER_CONCURRENT_JOBS", + "invalid-jobs-canary", + "int_parsing", + ), + ], +) +def test_invalid_runtime_setting_is_redacted_and_fresh_child_exits( + tmp_path: Path, + role: str, + invalid_field: str, + invalid_value: str, + error_type: str, +) -> None: + config_path = _write_runtime_config( + tmp_path, + f"invalid-{role}", + {invalid_field: invalid_value}, + ) + arguments = [ + "-m", + "agentseek_api.cli", + role, + "--config", + str(config_path), + ] + if role == "dev": + arguments.append("--no-reload") + + result = _run_python( + *arguments, + cwd=tmp_path, + removed_env=(invalid_field, VALIDATION_CHILD_PID_PATH_ENV), + ) + + assert result.returncode == 2 + assert result.stderr == ( + f"Invalid runtime setting(s): {invalid_field} ({error_type}).\n" + ) + assert invalid_value not in result.stderr + assert "ValidationError" not in result.stderr + assert "input_value" not in result.stderr + assert "Traceback" not in result.stderr + + +@pytest.mark.parametrize("role", ["dev", "serve"]) +def test_invalid_port_reaches_runtime_child_with_cp1252_stdout( + tmp_path: Path, + role: str, +) -> None: + invalid_value = "invalid-port-canary" + config_path = _write_runtime_config( + tmp_path, + f"invalid-cp1252-{role}", + {"PORT": invalid_value}, + ) + arguments = [ + "-m", + "agentseek_api.cli", + role, + "--config", + str(config_path), + ] + if role == "dev": + arguments.append("--no-reload") + + result = _run_python( + *arguments, + cwd=tmp_path, + extra_env={"PYTHONIOENCODING": "cp1252:strict"}, + removed_env=("PORT", VALIDATION_CHILD_PID_PATH_ENV), + ) + + assert result.returncode == 2 + assert result.stderr == "Invalid runtime setting(s): PORT (int_parsing).\n" + assert invalid_value not in result.stderr + assert "UnicodeEncodeError" not in result.stderr + assert "Traceback" not in result.stderr + assert f"AgentSeek v{__version__}" in result.stdout + result.stdout.encode("ascii", errors="strict") + + +def test_invalid_uvicorn_runtime_setting_is_redacted_and_process_exits( + tmp_path: Path, +) -> None: + invalid_value = "invalid-port-canary" + environment = dict(os.environ) + environment["PORT"] = invalid_value + environment.pop("PYTHONPATH", None) + environment.pop(VALIDATION_CHILD_PID_PATH_ENV, None) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "agentseek_api.runtime_entrypoint", + "uvicorn", + "--", + "agentseek_api.main:app", + "--host", + "127.0.0.1", + "--port", + "2024", + ], + cwd=tmp_path, + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + runtime_pid = process.pid + try: + stdout, stderr = process.communicate(timeout=20) + assert _pid_is_alive(runtime_pid) is False + finally: + _stop_test_process(process) + + assert process.returncode == 2 + assert stdout == "" + assert stderr == "Invalid runtime setting(s): PORT (int_parsing).\n" + assert invalid_value not in stderr + assert "ValidationError" not in stderr + assert "input_value" not in stderr + assert "Traceback" not in stderr + + +def test_invalid_internal_runtime_target_returns_fixed_error( + tmp_path: Path, +) -> None: + result = _run_python( + "-m", + "agentseek_api.runtime_entrypoint", + "invalid-target", + cwd=tmp_path, + ) + + assert result.returncode == 2 + assert result.stderr == "Invalid internal runtime target.\n" + + +@pytest.mark.parametrize( + ("role", "env_mapping", "fields", "expected", "exit_code"), + [ + ( + "worker", + { + "EXECUTOR_BACKEND": "redis", + "WORKER_CONCURRENT_JOBS": 3, + "REDIS_URL": "redis://worker.example:6379/1", + }, + ( + "EXECUTOR_BACKEND", + "WORKER_CONCURRENT_JOBS", + "REDIS_URL", + ), + { + "EXECUTOR_BACKEND": "redis", + "WORKER_CONCURRENT_JOBS": 3, + "REDIS_URL": "redis://worker.example:6379/1", + }, + 17, + ), + ( + "scheduler", + { + "SCHEDULER_CLAIM_LIMIT": 23, + "SCHEDULER_POLL_INTERVAL_SECONDS": 0.25, + "REDIS_URL": "redis://scheduler.example:6379/2", + }, + ( + "SCHEDULER_CLAIM_LIMIT", + "SCHEDULER_POLL_INTERVAL_SECONDS", + "REDIS_URL", + ), + { + "SCHEDULER_CLAIM_LIMIT": 23, + "SCHEDULER_POLL_INTERVAL_SECONDS": 0.25, + "REDIS_URL": "redis://scheduler.example:6379/2", + }, + 19, + ), + ], + ids=["worker", "scheduler"], +) +def test_runtime_role_default_path_observes_settings_in_fresh_child( + tmp_path: Path, + role: str, + env_mapping: dict[str, object], + fields: tuple[str, ...], + expected: dict[str, object], + exit_code: int, +) -> None: + config_path = _write_runtime_config( + tmp_path, + f"{role}-config", + env_mapping, + ) + probe_output = tmp_path / f"{role}-settings.json" + environment = _settings_probe_environment( + output_path=probe_output, + fields=fields, + exit_code=exit_code, + ) + + actual_exit_code, cli_pid, stderr = _run_role_probe( + role=role, + config_path=config_path, + environment=environment, + ) + + assert actual_exit_code == exit_code + assert stderr == "" + observation = json.loads(probe_output.read_text(encoding="utf-8")) + assert observation["pid"] != cli_pid + assert observation["settings"] == expected + + +def test_sequential_worker_invocations_do_not_reuse_settings_singleton( + tmp_path: Path, +) -> None: + observed: list[int] = [] + for index, concurrent_jobs in enumerate((2, 7), start=1): + config_path = _write_runtime_config( + tmp_path, + f"worker-{index}", + { + "EXECUTOR_BACKEND": "redis", + "WORKER_CONCURRENT_JOBS": concurrent_jobs, + }, + ) + probe_output = tmp_path / f"worker-{index}.json" + environment = _settings_probe_environment( + output_path=probe_output, + fields=("WORKER_CONCURRENT_JOBS",), + exit_code=0, + ) + + exit_code, cli_pid, stderr = _run_role_probe( + role="worker", + config_path=config_path, + environment=environment, + ) + assert exit_code == 0 + assert stderr == "" + observation = json.loads(probe_output.read_text(encoding="utf-8")) + assert observation["pid"] != cli_pid + observed.append(observation["settings"]["WORKER_CONCURRENT_JOBS"]) + + assert observed == [2, 7] + + +def _start_supervisor_wrapper( + *, + command: list[str], + cwd: Path, +) -> subprocess.Popen[str]: + wrapper = ( + "import os, sys; " + "from agentseek_api.cli import _default_runner; " + "raise SystemExit(_default_runner(sys.argv[1:], " + "env=dict(os.environ), cwd=None))" + ) + return subprocess.Popen( + [sys.executable, "-c", wrapper, *command], + cwd=cwd, + env=dict(os.environ), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + +@pytest.mark.skipif(os.name != "nt", reason="Windows Job Object acquisition only") +def test_windows_hard_termination_during_creation_leaves_no_unassigned_child( + tmp_path: Path, +) -> None: + child_pid_path = tmp_path / "atomic-job-child.pid" + helper = """ +import os +import sys +import time +from pathlib import Path +from agentseek_api import process_supervisor as supervisor + +api = supervisor._Win32Api() +native = api._process_launcher._native +create_suspended_process = native.create_suspended_process + +def pause_after_create(*args, **kwargs): + result = create_suspended_process(*args, **kwargs) + Path(sys.argv[1]).write_text(str(result[2]), encoding="utf-8") + time.sleep(60) + return result + +native.create_suspended_process = pause_after_create +supervisor._WindowsChild.start( + [sys.executable, "-c", "import time; time.sleep(60)"], + env=dict(os.environ), + cwd=None, + api=api, +) +""" + process = subprocess.Popen( + [sys.executable, "-c", helper, str(child_pid_path)], + cwd=tmp_path, + env=dict(os.environ), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + child_pid = 0 + try: + child_pid = _read_observed_pid(child_pid_path, timeout_seconds=10.0) + assert _pid_is_alive(child_pid) + + process.kill() + process.communicate(timeout=5) + + _wait_for_pids_gone((child_pid,), timeout_seconds=8.0) + finally: + _stop_test_process(process) + if child_pid: + _cleanup_recorded_pids((child_pid,)) + + +def test_external_sigterm_forwards_and_leaves_no_runtime_child( + tmp_path: Path, +) -> None: + result_path = tmp_path / "termination-tree.json" + process = _start_supervisor_wrapper( + command=[ + sys.executable, + str(TERMINATION_TREE_FIXTURE), + str(result_path), + ], + cwd=tmp_path, + ) + recorded_pids: tuple[int, ...] = () + try: + parent_pid, grandchild_pid = _read_tree_pids(result_path) + recorded_pids = (parent_pid, grandchild_pid) + if os.name == "nt": + process.terminate() + else: + os.kill(process.pid, signal.SIGTERM) + stdout, stderr = process.communicate(timeout=15) + + if os.name != "nt": + assert process.returncode == 128 + signal.SIGTERM + assert stdout == "" + assert stderr == "" + _wait_for_pids_gone(recorded_pids) + finally: + _stop_test_process(process) + _cleanup_recorded_pids(recorded_pids) + + +def test_normal_child_return_reaps_remaining_grandchild( + tmp_path: Path, +) -> None: + result_path = tmp_path / "normal-return-tree.json" + sentinel_exit_code = 37 + process = _start_supervisor_wrapper( + command=[ + sys.executable, + str(TERMINATION_TREE_FIXTURE), + str(result_path), + "--parent-exit", + str(sentinel_exit_code), + ], + cwd=tmp_path, + ) + recorded_pids: tuple[int, ...] = () + try: + recorded_pids = _read_tree_pids(result_path) + stdout, stderr = process.communicate(timeout=15) + + assert process.returncode == sentinel_exit_code + assert stdout == "" + assert stderr == "" + _wait_for_pids_gone(recorded_pids) + finally: + _stop_test_process(process) + _cleanup_recorded_pids(recorded_pids) + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX signal masks only") +def test_supervised_posix_child_starts_with_forwarded_signals_unblocked( + tmp_path: Path, +) -> None: + result_path = tmp_path / "startup-mask-tree.json" + process = _start_supervisor_wrapper( + command=[ + sys.executable, + str(TERMINATION_TREE_FIXTURE), + str(result_path), + "--parent-exit", + "0", + ], + cwd=tmp_path, + ) + recorded_pids: tuple[int, ...] = () + try: + recorded_pids = _read_tree_pids(result_path) + observation = json.loads(result_path.read_text(encoding="utf-8")) + stdout, stderr = process.communicate(timeout=15) + + assert process.returncode == 0 + assert observation["blocked_signals"] == [] + assert stdout == "" + assert stderr == "" + _wait_for_pids_gone(recorded_pids) + finally: + _stop_test_process(process) + _cleanup_recorded_pids(recorded_pids) + + +def test_supervised_child_preserves_captured_stdout( + tmp_path: Path, +) -> None: + result_path = tmp_path / "captured-output-tree.json" + output_marker = "captured-child-output" + process = _start_supervisor_wrapper( + command=[ + sys.executable, + str(TERMINATION_TREE_FIXTURE), + str(result_path), + "--parent-exit", + "0", + "--output-marker", + output_marker, + ], + cwd=tmp_path, + ) + recorded_pids: tuple[int, ...] = () + try: + recorded_pids = _read_tree_pids(result_path) + stdout, stderr = process.communicate(timeout=15) + + assert process.returncode == 0 + assert stdout == f"{output_marker}\n" + assert stderr == "" + _wait_for_pids_gone(recorded_pids) + finally: + _stop_test_process(process) + _cleanup_recorded_pids(recorded_pids) + + +@pytest.mark.parametrize("role", ["worker", "scheduler"]) +def test_public_runtime_role_sigterm_reaps_role_tree( + tmp_path: Path, + role: str, +) -> None: + config_path = _write_runtime_config(tmp_path, f"{role}-termination", {}) + result_path = tmp_path / f"{role}-termination-tree.json" + environment = dict(os.environ) + environment.update( + { + "PYTHONPATH": _probe_pythonpath(), + TERMINATION_PROBE_PATH_ENV: str(result_path), + } + ) + for field in ( + "AGENTSEEK_SETTINGS_PROBE_PATH", + "AGENTSEEK_SETTINGS_PROBE_FIELDS", + "AGENTSEEK_SETTINGS_PROBE_EXIT_CODE", + VALIDATION_CHILD_PID_PATH_ENV, + ): + environment.pop(field, None) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "agentseek_api.cli", + role, + "--config", + str(config_path), + ], + cwd=tmp_path, + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + recorded_pids: tuple[int, ...] = () + try: + parent_pid, grandchild_pid = _read_tree_pids(result_path) + recorded_pids = (parent_pid, grandchild_pid) + assert parent_pid != process.pid + if os.name == "nt": + process.terminate() + else: + os.kill(process.pid, signal.SIGTERM) + stdout, stderr = process.communicate(timeout=15) + + if os.name != "nt": + assert process.returncode == 128 + signal.SIGTERM + assert stdout == "" + assert stderr == "" + _wait_for_pids_gone(recorded_pids) + finally: + _stop_test_process(process) + _cleanup_recorded_pids(recorded_pids) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 71a5de9..519bf84 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -3,15 +3,24 @@ import argparse import importlib import io +import signal import tomllib from dataclasses import dataclass from pathlib import Path import pytest +from pydantic import ValidationError +from agentseek_api import __version__ from agentseek_api.services.langgraph_service import LangGraphService +def test_python_dotenv_dependency_is_available() -> None: + from dotenv import dotenv_values + + assert callable(dotenv_values) + + @dataclass class _RunCapture: calls: list[list[str]] | None = None @@ -31,6 +40,291 @@ def __call__(self, command: list[str], *, env: dict[str, str], cwd: str | None = return 0 +class _EncodingTextStream: + def __init__(self, encoding: str) -> None: + self.encoding = encoding + self.writes: list[str] = [] + self.flush_count = 0 + + def write(self, value: str) -> int: + value.encode(self.encoding, errors="strict") + self.writes.append(value) + return len(value) + + def flush(self) -> None: + self.flush_count += 1 + + +class _RecordingTextStream: + def __init__(self, encoding: str) -> None: + self.encoding = encoding + self.writes: list[str] = [] + self.flush_count = 0 + + def write(self, value: str) -> int: + self.writes.append(value) + return len(value) + + def flush(self) -> None: + self.flush_count += 1 + + +class _PartialWriteFailureStream: + encoding = "utf-8" + + def __init__(self) -> None: + self.write_calls = 0 + self.value = "" + self.flush_count = 0 + + def write(self, value: str) -> int: + self.write_calls += 1 + self.value += value[:8] + raise UnicodeEncodeError("utf-8", value, 8, 9, "write-canary") + + def flush(self) -> None: + self.flush_count += 1 + + +class _FakeForegroundSupervisor: + def __init__( + self, + *, + wait_result: int | BaseException, + escalates: bool = False, + ) -> None: + self.wait_result = wait_result + self.escalates = escalates + self.terminated = False + self.killed = False + self.wait_calls = 0 + self.close_remaining_tree_calls: list[float] = [] + self.forward_and_reap_calls: list[tuple[int, float]] = [] + self.terminate_and_reap_calls: list[float] = [] + self.ensure_closed_calls: list[float] = [] + self.close_calls = 0 + + def wait(self) -> int: + self.wait_calls += 1 + if isinstance(self.wait_result, BaseException): + raise self.wait_result + return self.wait_result + + def close_remaining_tree(self, *, timeout: float) -> None: + self.close_remaining_tree_calls.append(timeout) + + def forward_signal(self, signum: int) -> None: + self.forward_and_reap_calls.append((signum, 0.0)) + + def forward_and_reap(self, signum: int, *, timeout: float) -> None: + self.forward_and_reap_calls.append((signum, timeout)) + self.terminated = True + self.killed = self.escalates + + def terminate_and_reap(self, *, timeout: float) -> None: + self.terminate_and_reap_calls.append(timeout) + self.terminated = True + + def ensure_closed(self, *, timeout: float) -> None: + self.ensure_closed_calls.append(timeout) + + def close(self) -> None: + self.close_calls += 1 + + +def test_default_runner_propagates_child_exit_code( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import cli as cli_module + + child = _FakeForegroundSupervisor(wait_result=23) + observed: dict[str, object] = {} + + def fake_start(command, *, env, cwd): + observed.update(command=command, env=env, cwd=cwd) + return child + + monkeypatch.setattr( + cli_module.ForegroundChildSupervisor, + "start", + fake_start, + ) + + exit_code = cli_module._default_runner( + ["python", "-m", "agentseek_api.worker"], + env={"TOKEN": "value"}, + cwd="/runtime", + ) + + assert exit_code == 23 + assert child.terminated is False + assert child.close_remaining_tree_calls == [5.0] + assert child.ensure_closed_calls == [5.0] + assert child.close_calls == 1 + assert observed == { + "command": ["python", "-m", "agentseek_api.worker"], + "env": {"TOKEN": "value"}, + "cwd": "/runtime", + } + + +def test_default_runner_terminates_and_reaps_child_on_keyboard_interrupt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import cli as cli_module + + child = _FakeForegroundSupervisor(wait_result=KeyboardInterrupt()) + monkeypatch.setattr( + cli_module.ForegroundChildSupervisor, + "start", + lambda command, *, env, cwd: child, + ) + + exit_code = cli_module._default_runner( + ["python", "-m", "agentseek_api.scheduler"], + env={}, + cwd="/runtime", + ) + + assert exit_code == 130 + assert child.terminated is True + assert child.killed is False + assert child.wait_calls == 1 + assert child.forward_and_reap_calls == [(signal.SIGINT, 5.0)] + assert child.ensure_closed_calls == [5.0] + assert child.close_calls == 1 + + +def test_default_runner_delegates_bounded_escalation_for_interrupt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import cli as cli_module + + child = _FakeForegroundSupervisor( + wait_result=KeyboardInterrupt(), + escalates=True, + ) + monkeypatch.setattr( + cli_module.ForegroundChildSupervisor, + "start", + lambda command, *, env, cwd: child, + ) + + assert cli_module._default_runner(["child"], env={}, cwd=None) == 130 + assert child.terminated is True + assert child.killed is True + assert child.forward_and_reap_calls == [(signal.SIGINT, 5.0)] + assert child.ensure_closed_calls == [5.0] + assert child.close_calls == 1 + + +@pytest.mark.parametrize( + "failure_point", + ["guard-entry", "child-start", "guard-attach", "native-cleanup"], +) +def test_public_worker_redacts_process_supervision_failures( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure_point: str, +) -> None: + from agentseek_api import cli as cli_module + from agentseek_api.process_supervisor import ProcessSupervisionError + + setup_canary = "setup-canary" + command_canary = "command-canary" + environment_canary = "environment-canary" + config_path = tmp_path / "langgraph.json" + config_path.write_text( + '{"graphs":{"chat":"chat.graph:graph"},' + f'"env":{{"SUPERVISION_SECRET":"{environment_canary}"}}}}', + encoding="utf-8", + ) + monkeypatch.delenv("SUPERVISION_SECRET", raising=False) + + child = _FakeForegroundSupervisor(wait_result=0) + child.live = True + + def fail_native_cleanup(*, timeout: float) -> None: + raise ProcessSupervisionError(setup_canary) + + if failure_point == "native-cleanup": + child.close_remaining_tree = fail_native_cleanup # type: ignore[method-assign] + + original_terminate_and_reap = child.terminate_and_reap + + def terminate_and_reap(*, timeout: float) -> None: + original_terminate_and_reap(timeout=timeout) + child.live = False + + child.terminate_and_reap = terminate_and_reap # type: ignore[method-assign] + + original_close = child.close + + def close() -> None: + original_close() + child.live = False + + child.close = close # type: ignore[method-assign] + + class _FakeGuard: + def __enter__(self): + if failure_point == "guard-entry": + raise ProcessSupervisionError(setup_canary) + return self + + def __exit__(self, exc_type, exc, traceback) -> bool: + return False + + def attach(self, attached_child) -> None: + assert attached_child is child + if failure_point == "guard-attach": + raise ProcessSupervisionError(setup_canary) + + def begin_cleanup(self) -> None: + return None + + def start(command, *, env, cwd): + assert command == [command_canary] + assert env["SUPERVISION_SECRET"] == environment_canary + assert cwd == str(tmp_path) + if failure_point == "child-start": + raise ProcessSupervisionError(setup_canary) + return child + + monkeypatch.setattr(cli_module, "ForwardingSignalGuard", _FakeGuard) + monkeypatch.setattr( + cli_module.ForegroundChildSupervisor, + "start", + start, + ) + monkeypatch.setattr( + cli_module, + "build_worker_command", + lambda: [command_canary], + ) + stdout = io.StringIO() + stderr = io.StringIO() + + exit_code = cli_module.main( + ["worker", "--config", str(config_path)], + cwd=tmp_path, + stdout=stdout, + stderr=stderr, + ) + + combined_output = stdout.getvalue() + stderr.getvalue() + assert exit_code == 2 + assert stderr.getvalue() == "Could not supervise the runtime child safely.\n" + assert "Traceback" not in combined_output + assert setup_canary not in combined_output + assert command_canary not in combined_output + assert environment_canary not in combined_output + if failure_point in {"guard-attach", "native-cleanup"}: + assert child.live is False + assert child.ensure_closed_calls == [5.0] + assert child.close_calls == 1 + + def _docker_env_from_run_command(command: list[str]) -> dict[str, str]: values: dict[str, str] = {} for index, token in enumerate(command): @@ -81,6 +375,137 @@ def _write_basic_manifest_config(root: Path) -> Path: return manifest_path +def test_onboard_banner_preserves_unicode_for_stringio(tmp_path: Path) -> None: + from agentseek_api.cli import main + + _write_basic_langgraph_config(tmp_path) + stdout = io.StringIO() + + exit_code = main( + ["serve"], + runner=_RunCapture(), + stdout=stdout, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert stdout.getvalue() == ( + "\n" + " Welcome to\n" + "\n" + "╔═╗┌─┐┌─┐┌┐┌┌┬┐╔═╗┌─┐┌─┐┬┌─\n" + "╠═╣│ ┬├┤ │││ │ ╚═╗├┤ ├┤ ├┴┐\n" + "╩ ╩└─┘└─┘┘└┘ ┴ ╚═╝└─┘└─┘┴ ┴\n" + "\n" + f" AgentSeek v{__version__}\n" + "\n" + ) + + +def test_onboard_banner_uses_one_write_and_flush_for_utf8_stream( + tmp_path: Path, +) -> None: + from agentseek_api.cli import main + + _write_basic_langgraph_config(tmp_path) + stdout = _EncodingTextStream("utf-8") + + exit_code = main( + ["serve"], + runner=_RunCapture(), + stdout=stdout, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert stdout.writes == [ + "\n" + " Welcome to\n" + "\n" + "╔═╗┌─┐┌─┐┌┐┌┌┬┐╔═╗┌─┐┌─┐┬┌─\n" + "╠═╣│ ┬├┤ │││ │ ╚═╗├┤ ├┤ ├┴┐\n" + "╩ ╩└─┘└─┘┘└┘ ┴ ╚═╝└─┘└─┘┴ ┴\n" + "\n" + f" AgentSeek v{__version__}\n" + "\n" + ] + assert stdout.flush_count == 1 + + +@pytest.mark.parametrize("role", ["dev", "serve"]) +def test_onboard_banner_falls_back_before_writing_to_cp1252_stream( + tmp_path: Path, + role: str, +) -> None: + from agentseek_api.cli import main + + _write_basic_langgraph_config(tmp_path) + stdout = _EncodingTextStream("cp1252") + arguments = [role] + if role == "dev": + arguments.append("--no-reload") + + exit_code = main( + arguments, + runner=_RunCapture(), + stdout=stdout, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert stdout.writes == [ + "\n" + " Welcome to\n" + "\n" + "========================\n" + f" AgentSeek v{__version__}\n" + "========================\n" + "\n" + ] + assert stdout.flush_count == 1 + + +def test_onboard_banner_uses_ascii_fallback_for_unknown_named_encoding( + tmp_path: Path, +) -> None: + from agentseek_api.cli import main + + _write_basic_langgraph_config(tmp_path) + stdout = _RecordingTextStream("unknown-codec-canary") + + exit_code = main( + ["serve"], + runner=_RunCapture(), + stdout=stdout, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert stdout.writes == [ + "\n" + " Welcome to\n" + "\n" + "========================\n" + f" AgentSeek v{__version__}\n" + "========================\n" + "\n" + ] + assert stdout.flush_count == 1 + + +def test_onboard_banner_does_not_retry_or_flush_after_partial_write() -> None: + from agentseek_api import cli as cli_module + + stdout = _PartialWriteFailureStream() + + with pytest.raises(UnicodeEncodeError, match="write-canary"): + cli_module._write_onboard_banner(stdout) + + assert stdout.write_calls == 1 + assert stdout.value == "\n " + assert stdout.flush_count == 0 + + def test_dev_command_prefers_agentseek_json_over_langgraph_json(tmp_path: Path) -> None: from agentseek_api.cli import main @@ -92,7 +517,17 @@ def test_dev_command_prefers_agentseek_json_over_langgraph_json(tmp_path: Path) exit_code = main(["dev", "--no-reload"], runner=capture, cwd=tmp_path) assert exit_code == 0 - assert capture.command[2:] == ["uvicorn", "agentseek_api.main:app", "--host", "127.0.0.1", "--port", "2024"] + assert capture.command[1:] == [ + "-m", + "agentseek_api.runtime_entrypoint", + "uvicorn", + "--", + "agentseek_api.main:app", + "--host", + "127.0.0.1", + "--port", + "2024", + ] assert capture.env is not None assert capture.env["AGENTSEEK_GRAPHS"] == str(config_path.resolve()) @@ -106,7 +541,17 @@ def test_serve_command_falls_back_to_langgraph_json_and_runs_graph(tmp_path: Pat exit_code = main(["serve", "--host", "0.0.0.0", "--port", "3030"], runner=capture, cwd=tmp_path) assert exit_code == 0 - assert capture.command[2:] == ["uvicorn", "agentseek_api.main:app", "--host", "0.0.0.0", "--port", "3030"] + assert capture.command[1:] == [ + "-m", + "agentseek_api.runtime_entrypoint", + "uvicorn", + "--", + "agentseek_api.main:app", + "--host", + "0.0.0.0", + "--port", + "3030", + ] assert capture.env is not None assert capture.env["AGENTSEEK_GRAPHS"] == str(config_path.resolve()) @@ -128,7 +573,17 @@ def test_serve_command_uses_agentseek_graphs_env_for_manifest_named_config( exit_code = main(["serve", "--host", "0.0.0.0", "--port", "3030"], runner=capture, cwd=tmp_path) assert exit_code == 0 - assert capture.command[2:] == ["uvicorn", "agentseek_api.main:app", "--host", "0.0.0.0", "--port", "3030"] + assert capture.command[1:] == [ + "-m", + "agentseek_api.runtime_entrypoint", + "uvicorn", + "--", + "agentseek_api.main:app", + "--host", + "0.0.0.0", + "--port", + "3030", + ] assert capture.env is not None assert capture.env["AGENTSEEK_GRAPHS"] == str(config_path.resolve()) @@ -143,41 +598,15 @@ def test_worker_command_uses_runtime_env_and_worker_module(tmp_path: Path) -> No assert exit_code == 0 assert capture.command is not None - assert capture.command[1:] == ["-m", "agentseek_api.worker"] + assert capture.command[1:] == [ + "-m", + "agentseek_api.runtime_entrypoint", + "worker", + ] assert capture.env is not None assert capture.env["AGENTSEEK_GRAPHS"] == str(config_path.resolve()) -def test_worker_command_runs_in_process_with_default_runner( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - from agentseek_api import cli as cli_module - - config_path = _write_basic_langgraph_config(tmp_path) - observed: dict[str, object] = {} - previous_cwd = Path.cwd() - sentinel_key = "AGENTSEEK_WORKER_TEST_SENTINEL" - - def fake_worker_main() -> int: - observed["graphs"] = cli_module.os.environ["AGENTSEEK_GRAPHS"] - observed["cwd"] = str(Path.cwd()) - return 7 - - monkeypatch.setattr("agentseek_api.worker.main", fake_worker_main) - monkeypatch.setenv(sentinel_key, "before") - - exit_code = cli_module.main(["worker", "--config", str(config_path)], cwd=tmp_path) - - assert exit_code == 7 - assert observed == { - "graphs": str(config_path.resolve()), - "cwd": str(tmp_path.resolve()), - } - assert Path.cwd() == previous_cwd - assert cli_module.os.environ.get(sentinel_key) == "before" - - def test_scheduler_command_uses_runtime_env_and_scheduler_module(tmp_path: Path) -> None: from agentseek_api.cli import main @@ -188,44 +617,36 @@ def test_scheduler_command_uses_runtime_env_and_scheduler_module(tmp_path: Path) assert exit_code == 0 assert capture.command is not None - assert capture.command[1:] == ["-m", "agentseek_api.scheduler"] + assert capture.command[1:] == [ + "-m", + "agentseek_api.runtime_entrypoint", + "scheduler", + ] assert capture.env is not None assert capture.env["AGENTSEEK_GRAPHS"] == str(config_path.resolve()) -def test_scheduler_command_runs_in_process_with_default_runner( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - from agentseek_api import cli as cli_module - - config_path = _write_basic_langgraph_config(tmp_path) - observed: dict[str, object] = {} - previous_cwd = Path.cwd() - sentinel_key = "AGENTSEEK_SCHEDULER_TEST_SENTINEL" - - def fake_scheduler_main() -> int: - observed["graphs"] = cli_module.os.environ["AGENTSEEK_GRAPHS"] - observed["cwd"] = str(Path.cwd()) - return 11 +def test_settings_validation_formatter_omits_input_values() -> None: + from agentseek_api.runtime_entrypoint import ( + _format_settings_validation_error, + ) + from agentseek_api.settings import Settings - monkeypatch.setattr("agentseek_api.scheduler.main", fake_scheduler_main) - monkeypatch.setenv(sentinel_key, "before") + with pytest.raises(ValidationError) as captured: + Settings.model_validate({"PORT": "invalid-port-canary"}) - exit_code = cli_module.main(["scheduler", "--config", str(config_path)], cwd=tmp_path) + message = _format_settings_validation_error(captured.value) - assert exit_code == 11 - assert observed == { - "graphs": str(config_path.resolve()), - "cwd": str(tmp_path.resolve()), - } - assert Path.cwd() == previous_cwd - assert cli_module.os.environ.get(sentinel_key) == "before" + assert message == "Invalid runtime setting(s): PORT (int_parsing)." + assert "invalid-port-canary" not in message -def test_dev_command_accepts_langgraph_cli_flags_and_env_file(tmp_path: Path) -> None: +def test_dev_command_accepts_langgraph_cli_flags_and_env_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: from agentseek_api.cli import main + monkeypatch.delenv("AUTH_MODULE_PATH", raising=False) config_path = _write_basic_langgraph_config(tmp_path) env_file = tmp_path / ".env" env_file.write_text("AUTH_MODULE_PATH=test.module:backend\n", encoding="utf-8") @@ -249,15 +670,29 @@ def test_dev_command_accepts_langgraph_cli_flags_and_env_file(tmp_path: Path) -> ) assert exit_code == 0 - assert capture.command[2:] == ["uvicorn", "agentseek_api.main:app", "--host", "0.0.0.0", "--port", "9999"] + assert capture.command[1:] == [ + "-m", + "agentseek_api.runtime_entrypoint", + "uvicorn", + "--", + "agentseek_api.main:app", + "--host", + "0.0.0.0", + "--port", + "9999", + ] assert capture.env is not None assert capture.env["AGENTSEEK_GRAPHS"] == str(config_path.resolve()) assert capture.env["AUTH_MODULE_PATH"] == "test.module:backend" -def test_dev_command_loads_config_env_mapping_and_auth_path(tmp_path: Path) -> None: +def test_dev_command_loads_config_env_mapping_and_auth_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: from agentseek_api.cli import main + for key in ("OPENAI_API_KEY", "FEATURE_FLAG", "AUTH_MODULE_PATH"): + monkeypatch.delenv(key, raising=False) package_dir = tmp_path / "chat" package_dir.mkdir() (package_dir / "__init__.py").write_text("", encoding="utf-8") @@ -292,9 +727,13 @@ def test_dev_command_loads_config_env_mapping_and_auth_path(tmp_path: Path) -> N assert capture.env["AUTH_MODULE_PATH"] == f"{(tmp_path / 'auth.py').resolve()}:auth" -def test_dev_command_merges_config_env_file_before_cli_env_file(tmp_path: Path) -> None: +def test_dev_command_merges_config_env_file_before_cli_env_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: from agentseek_api.cli import main + for key in ("TOKEN", "SHARED"): + monkeypatch.delenv(key, raising=False) config_path = _write_basic_langgraph_config(tmp_path) config_env = tmp_path / "config.env" config_env.write_text("TOKEN=from-config\nSHARED=config\n", encoding="utf-8") @@ -327,6 +766,33 @@ def test_dev_command_merges_config_env_file_before_cli_env_file(tmp_path: Path) assert capture.env["SHARED"] == "override" +def test_dev_command_preserves_dotenv_default_and_bare_variable_syntax( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from agentseek_api.cli import main + + monkeypatch.delenv("API_ORIGIN", raising=False) + config_path = _write_basic_langgraph_config(tmp_path) + env_file = tmp_path / "defaults.env" + env_file.write_text( + "OPENAI_BASE_URL=${API_ORIGIN:-https://default.example.test}/v1\n" + "BARE_REFERENCE=$API_ORIGIN\n", + encoding="utf-8", + ) + capture = _RunCapture() + + exit_code = main( + ["dev", "--config", str(config_path), "--env-file", str(env_file), "--no-reload"], + runner=capture, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert capture.env is not None + assert capture.env["OPENAI_BASE_URL"] == "https://default.example.test/v1" + assert capture.env["BARE_REFERENCE"] == "$API_ORIGIN" + + def test_dev_command_rejects_unsupported_langgraph_flags(tmp_path: Path) -> None: from agentseek_api.cli import main @@ -340,10 +806,14 @@ def test_dev_command_rejects_unsupported_langgraph_flags(tmp_path: Path) -> None assert "Use 'langgraph dev' for mocked or tunneled local workflows." in stderr.getvalue() -def test_dev_command_marks_runtime_as_local_dev_for_studio_auth(tmp_path: Path) -> None: +def test_dev_command_forces_local_studio_auth_after_inherited_env( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: from agentseek_api.cli import main _write_basic_langgraph_config(tmp_path) + monkeypatch.setenv("STUDIO_AUTH_LOCAL_DEV", "false") capture = _RunCapture() exit_code = main(["dev", "--no-reload"], runner=capture, cwd=tmp_path) @@ -353,6 +823,29 @@ def test_dev_command_marks_runtime_as_local_dev_for_studio_auth(tmp_path: Path) assert capture.env["STUDIO_AUTH_LOCAL_DEV"] == "true" +def test_serve_port_flag_does_not_rewrite_inherited_port_env( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api.cli import main + + _write_basic_langgraph_config(tmp_path) + monkeypatch.setenv("PORT", "7777") + capture = _RunCapture() + + exit_code = main( + ["serve", "--port", "3030"], + runner=capture, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert capture.command is not None + assert capture.command[-2:] == ["--port", "3030"] + assert capture.env is not None + assert capture.env["PORT"] == "7777" + + def test_resolve_dev_urls_use_localhost_display_and_loopback_base_url() -> None: from agentseek_api.cli import _resolve_dev_urls @@ -416,6 +909,60 @@ def terminate(self) -> None: assert opened == ["https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024"] +def test_managed_dev_ascii_fallback_normalizes_non_ascii_urls_before_write( + tmp_path: Path, +) -> None: + from agentseek_api import cli as cli_module + + class FakeProcess: + def __init__(self) -> None: + self.returncode: int | None = None + self.wait_calls = 0 + self.terminate_calls = 0 + + def poll(self) -> int | None: + return self.returncode + + def wait(self) -> int: + self.wait_calls += 1 + self.returncode = 23 + return self.returncode + + def terminate(self) -> None: + self.terminate_calls += 1 + self.returncode = -1 + + process = FakeProcess() + stdout = _EncodingTextStream("cp1252") + + exit_code = cli_module._run_managed_dev_server( + command=["uvicorn", "agentseek_api.main:app"], + env={}, + cwd=tmp_path, + urls=cli_module._resolve_dev_urls( + host="例子", + port=2024, + studio_url="https://例子.test", + ), + stdout=stdout, + process_factory=lambda command, *, env, cwd: process, + wait_for_ready=lambda *_args, **_kwargs: None, + open_browser=False, + sleep=lambda _seconds: None, + ) + + assert exit_code == 23 + assert process.wait_calls == 1 + assert process.terminate_calls == 0 + assert stdout.writes == [ + "- API: http://??:2024\n" + "- Docs: http://??:2024/docs\n" + "- Studio UI: https://??.test/studio/?baseUrl=http://??:2024\n" + "\n\n" + ] + assert stdout.flush_count == 1 + + def test_run_managed_dev_server_honors_no_browser(tmp_path: Path) -> None: from agentseek_api import cli as cli_module @@ -556,7 +1103,17 @@ def test_run_namespace_allows_parent_cli_dispatch(tmp_path: Path) -> None: exit_code = cli_module.run_namespace(parsed, runner=capture, cwd=tmp_path) assert exit_code == 0 - assert capture.command[2:] == ["uvicorn", "agentseek_api.main:app", "--host", "0.0.0.0", "--port", "3030"] + assert capture.command[1:] == [ + "-m", + "agentseek_api.runtime_entrypoint", + "uvicorn", + "--", + "agentseek_api.main:app", + "--host", + "0.0.0.0", + "--port", + "3030", + ] def test_dockerfile_command_writes_langgraph_compatible_runtime_file(tmp_path: Path) -> None: @@ -836,31 +1393,81 @@ def test_build_command_plans_docker_build_from_generated_dockerfile(tmp_path: Pa assert 'CMD ["python", "-m", "agentseek_api.cli", "serve", "--host", "0.0.0.0", "--port", "2024"]' in generated -def test_build_runtime_env_rejects_invalid_env_lines(tmp_path: Path) -> None: +def test_build_runtime_env_parses_exported_values(tmp_path: Path) -> None: from agentseek_api.cli import build_runtime_env + config_path = _write_basic_langgraph_config(tmp_path) env_file = tmp_path / ".env" - env_file.write_text("BROKEN_LINE\n", encoding="utf-8") + env_file.write_text( + '# comment\nexport TOKEN="quoted # value\nnext"\nPLAIN=value # inline comment\n', + encoding="utf-8", + ) - with pytest.raises(RuntimeError, match="invalid line 1"): - build_runtime_env(config_path=None, env_file=str(env_file), cwd=tmp_path, base_env={}) + env = build_runtime_env(config_path=config_path, env_file=str(env_file), cwd=tmp_path, base_env={}) + assert env["TOKEN"] == "quoted # value\nnext" + assert env["PLAIN"] == "value" + assert env["AGENTSEEK_GRAPHS"] == str(config_path.resolve()) -def test_build_runtime_env_parses_exported_values(tmp_path: Path) -> None: + +def test_build_runtime_env_ignores_dotenv_entries_without_values(tmp_path: Path) -> None: from agentseek_api.cli import build_runtime_env config_path = _write_basic_langgraph_config(tmp_path) env_file = tmp_path / ".env" - env_file.write_text( - "# comment\nexport TOKEN='quoted-value'\nPLAIN=value\n", + env_file.write_text("MALFORMED_LINE\nTOKEN=present\n", encoding="utf-8") + + env = build_runtime_env(config_path=config_path, env_file=str(env_file), cwd=tmp_path, base_env={}) + + assert "MALFORMED_LINE" not in env + assert env["TOKEN"] == "present" + + +def test_build_runtime_env_shell_values_override_config_and_cli_dotenv(tmp_path: Path) -> None: + from agentseek_api.cli import build_runtime_env + + config_path = _write_basic_langgraph_config(tmp_path) + config_env = tmp_path / "config.env" + config_env.write_text("TOKEN=from-config\n", encoding="utf-8") + config_path.write_text( + """ +{ + "graphs": {"chat": "chat.graph:graph"}, + "env": "./config.env" +} +""".strip(), encoding="utf-8", ) + cli_env = tmp_path / "override.env" + cli_env.write_text("TOKEN=from-cli-file\n", encoding="utf-8") - env = build_runtime_env(config_path=config_path, env_file=str(env_file), cwd=tmp_path, base_env={}) + env = build_runtime_env( + config_path=config_path, + env_file=str(cli_env), + cwd=tmp_path, + base_env={"TOKEN": "from-shell"}, + ) - assert env["TOKEN"] == "quoted-value" - assert env["PLAIN"] == "value" - assert env["AGENTSEEK_GRAPHS"] == str(config_path.resolve()) + assert env["TOKEN"] == "from-shell" + + +def test_higher_precedence_valueless_binding_keeps_lower_export(tmp_path: Path) -> None: + from agentseek_api.cli import build_runtime_env + + config_env = tmp_path / "config.env" + config_env.write_text("TOKEN=from-config\n", encoding="utf-8") + config_path = tmp_path / "langgraph.json" + config_path.write_text( + '{"graphs":{"chat":"chat.graph:graph"},"env":"./config.env"}', + encoding="utf-8", + ) + cli_env = tmp_path / "cli.env" + cli_env.write_text("TOKEN\nRESULT=${TOKEN:-fallback}\n", encoding="utf-8") + + env = build_runtime_env(config_path=config_path, env_file=str(cli_env), cwd=tmp_path, base_env={}) + + assert env["TOKEN"] == "from-config" + assert env["RESULT"] == "" def test_build_runtime_env_rejects_invalid_config_env_shape(tmp_path: Path) -> None: @@ -1071,7 +1678,10 @@ def test_up_command_plans_docker_run_with_recreate_and_env_file(tmp_path: Path) config_path = _write_basic_langgraph_config(tmp_path) env_file = tmp_path / "docker.env" env_file.write_text( - "METADATA_DB_URL=sqlite+aiosqlite:////tmp/agentseek.db\nOCEANBASE_HOST=host.docker.internal\n", + "METADATA_DB_URL=sqlite+aiosqlite:////tmp/agentseek.db\n" + "OCEANBASE_HOST=host.docker.internal\n" + "API_ORIGIN=https://api.example.test\n" + "OPENAI_BASE_URL=${API_ORIGIN}/v1\n", encoding="utf-8", ) capture = _RunCapture() @@ -1112,6 +1722,7 @@ def test_up_command_plans_docker_run_with_recreate_and_env_file(tmp_path: Path) assert container_env["AGENTSEEK_GRAPHS"] == "/deps/agent/langgraph.json" assert container_env["METADATA_DB_URL"] == "sqlite+aiosqlite:////tmp/agentseek.db" assert container_env["OCEANBASE_HOST"] == "host.docker.internal" + assert container_env["OPENAI_BASE_URL"] == "https://api.example.test/v1" def test_up_command_supports_docker_compose_sidecars(tmp_path: Path) -> None: @@ -1338,8 +1949,6 @@ def test_up_command_passes_ambient_env_into_container(tmp_path: Path, monkeypatc assert container_env["OPENAI_API_KEY"] == "ambient-key" - - def test_up_command_prefers_agentseek_json_without_explicit_flag(tmp_path: Path) -> None: from agentseek_api.cli import main diff --git a/tests/unit/test_dotenv_adapter.py b/tests/unit/test_dotenv_adapter.py new file mode 100644 index 0000000..ea55c34 --- /dev/null +++ b/tests/unit/test_dotenv_adapter.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + + +def test_parse_dotenv_file_preserves_file_local_physical_order( + tmp_path: Path, +) -> None: + from agentseek_api.dotenv_adapter import parse_dotenv_file + + env_file = tmp_path / "runtime.env" + env_file.write_text( + "export FIRST=one\n" + "DUPLICATE=first\n" + "FROM_DUPLICATE=${DUPLICATE}/v1\n" + "DUPLICATE=second\n" + 'MULTILINE="line one\nline two"\n' + "FROM_AMBIENT=${AMBIENT}/v2\n" + "BARE_REFERENCE=$AMBIENT\n" + "MISSING_REFERENCE=${UNSET}\n" + "MISSING_DEFAULT=${UNSET:-fallback}\n" + "EMPTY=\n" + "VALUELESS\n" + "FROM_VALUELESS=${VALUELESS:-fallback}\n", + encoding="utf-8", + ) + ambient = {"AMBIENT": "from-shell"} + + values = parse_dotenv_file(env_file, ambient=ambient) + + assert values == { + "FIRST": "one", + "DUPLICATE": "second", + "FROM_DUPLICATE": "first/v1", + "MULTILINE": "line one\nline two", + "FROM_AMBIENT": "from-shell/v2", + "BARE_REFERENCE": "$AMBIENT", + "MISSING_REFERENCE": "", + "MISSING_DEFAULT": "fallback", + "EMPTY": "", + "VALUELESS": None, + "FROM_VALUELESS": "", + } + assert ambient == {"AMBIENT": "from-shell"} + + +@pytest.mark.parametrize( + "contents", + [ + 'BROKEN "value"\n', + 'UNTERMINATED="value\n', + ], +) +def test_parse_dotenv_file_rejects_genuinely_malformed_syntax( + tmp_path: Path, + contents: str, +) -> None: + from agentseek_api.dotenv_adapter import DotenvFileError, parse_dotenv_file + + env_file = tmp_path / "broken.env" + env_file.write_text("SECRET=must-not-leak\n" + contents, encoding="utf-8") + + with pytest.raises(DotenvFileError) as raised: + parse_dotenv_file(env_file, ambient={}) + + assert raised.value.path == env_file + assert raised.value.line == 2 + assert "must-not-leak" not in str(raised.value) + + +def test_parse_dotenv_file_reports_missing_source(tmp_path: Path) -> None: + from agentseek_api.dotenv_adapter import DotenvFileError, parse_dotenv_file + + env_file = tmp_path / "missing.env" + + with pytest.raises(DotenvFileError, match="does not exist"): + parse_dotenv_file(env_file, ambient={}) + + +def test_parse_dotenv_file_reports_utf8_decode_failure(tmp_path: Path) -> None: + from agentseek_api.dotenv_adapter import DotenvFileError, parse_dotenv_file + + env_file = tmp_path / "invalid.env" + env_file.write_bytes(b"TOKEN=\xff\n") + + with pytest.raises(DotenvFileError, match="not valid UTF-8"): + parse_dotenv_file(env_file, ambient={}) diff --git a/tests/unit/test_process_supervisor.py b/tests/unit/test_process_supervisor.py new file mode 100644 index 0000000..3b01bfc --- /dev/null +++ b/tests/unit/test_process_supervisor.py @@ -0,0 +1,2860 @@ +from __future__ import annotations + +import errno +import os +import signal +import subprocess +import sys +from types import SimpleNamespace + +import pytest + + +_POSIX_ONLY = pytest.mark.skipif(os.name == "nt", reason="POSIX process groups only") + + +class _FakePopen: + def __init__( + self, + *, + pid: int = 4312, + wait_results: list[int | BaseException] | None = None, + ) -> None: + self.pid = pid + self.returncode: int | None = None + self.wait_results = list(wait_results or [0]) + self.wait_timeouts: list[float | None] = [] + + def wait(self, timeout: float | None = None) -> int: + self.wait_timeouts.append(timeout) + if not self.wait_results: + assert self.returncode is not None + return self.returncode + result = self.wait_results.pop(0) + if isinstance(result, BaseException): + raise result + self.returncode = result + return result + + def poll(self) -> int | None: + return self.returncode + + +class _SignalHarness: + def __init__( + self, + *, + deliver_on_restore: int | None = None, + deliver_on_install: int | None = None, + old_mask: frozenset[int] | None = None, + fail_restore_attempts: int = 0, + ) -> None: + self.previous = { + signal.SIGINT: object(), + signal.SIGTERM: object(), + } + self.handlers = dict(self.previous) + self.old_mask = ( + old_mask + if old_mask is not None + else ( + frozenset({signal.SIGUSR1}) + if hasattr(signal, "SIGUSR1") + else frozenset() + ) + ) + self.deliver_on_restore = deliver_on_restore + self.deliver_on_install = deliver_on_install + self.fail_restore_attempts = fail_restore_attempts + self.current_mask = self.old_mask + self.events: list[tuple[str, object]] = [] + + def getsignal(self, signum: int): + return self.handlers[signum] + + def install(self, signum: int, handler): + previous = self.handlers[signum] + self.handlers[signum] = handler + self.events.append(("handler", signum)) + if self.deliver_on_install == signum: + self.deliver_on_install = None + handler(signum, None) + return previous + + def pthread_sigmask(self, operation: int, mask): + frozen_mask = frozenset(mask) + self.events.append(("mask", (operation, frozen_mask))) + previous_mask = self.current_mask + if operation == signal.SIG_BLOCK: + self.current_mask = previous_mask | frozen_mask + return previous_mask + assert operation == signal.SIG_SETMASK + assert frozen_mask == self.old_mask + if self.fail_restore_attempts: + self.fail_restore_attempts -= 1 + raise OSError("mask-restore-canary") + self.current_mask = frozen_mask + if self.deliver_on_restore is not None: + signum = self.deliver_on_restore + self.deliver_on_restore = None + self.handlers[signum](signum, None) + return previous_mask + + +class _AttachedChild: + def __init__(self) -> None: + self.forwarded: list[int] = [] + + def forward_signal(self, signum: int) -> None: + self.forwarded.append(signum) + + +def _install_signal_harness( + monkeypatch: pytest.MonkeyPatch, + harness: _SignalHarness, + *, + is_windows: bool = False, +): + from agentseek_api import process_supervisor as supervisor_module + + monkeypatch.setattr(supervisor_module, "_IS_WINDOWS", is_windows) + monkeypatch.setattr(supervisor_module.signal, "getsignal", harness.getsignal) + monkeypatch.setattr(supervisor_module.signal, "signal", harness.install) + monkeypatch.setattr( + supervisor_module.signal, + "SIG_BLOCK", + getattr(supervisor_module.signal, "SIG_BLOCK", 0), + raising=False, + ) + monkeypatch.setattr( + supervisor_module.signal, + "SIG_SETMASK", + getattr(supervisor_module.signal, "SIG_SETMASK", 2), + raising=False, + ) + monkeypatch.setattr( + supervisor_module.signal, + "pthread_sigmask", + harness.pthread_sigmask, + raising=False, + ) + return supervisor_module + + +def test_forwarding_signal_guard_restores_exact_handlers_and_mask( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness() + supervisor_module = _install_signal_harness(monkeypatch, harness) + + with pytest.raises(RuntimeError, match="body-canary"): + with supervisor_module.ForwardingSignalGuard(): + raise RuntimeError("body-canary") + + assert harness.handlers == harness.previous + assert harness.events[0] == ( + "mask", + ( + signal.SIG_BLOCK, + frozenset({signal.SIGINT, signal.SIGTERM}), + ), + ) + assert harness.events[-1] == ( + "mask", + (signal.SIG_SETMASK, harness.old_mask), + ) + + +def test_fake_posix_signal_guard_supports_windows_signal_module( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness() + from agentseek_api import process_supervisor as supervisor_module + + monkeypatch.delattr(supervisor_module.signal, "SIG_BLOCK", raising=False) + monkeypatch.delattr(supervisor_module.signal, "SIG_SETMASK", raising=False) + _install_signal_harness(monkeypatch, harness) + + with pytest.raises(RuntimeError, match="body-canary"): + with supervisor_module.ForwardingSignalGuard(): + raise RuntimeError("body-canary") + + assert harness.handlers == harness.previous + + +def test_pending_signal_is_delivered_only_after_child_attachment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness(deliver_on_restore=signal.SIGTERM) + supervisor_module = _install_signal_harness(monkeypatch, harness) + child = _AttachedChild() + + with pytest.raises(supervisor_module._ForwardedSignal) as captured: + with supervisor_module.ForwardingSignalGuard() as guard: + assert child.forwarded == [] + guard.attach(child) + + assert captured.value.signum == signal.SIGTERM + assert harness.handlers == harness.previous + + +def test_second_signal_during_cleanup_is_non_throwing_and_reforwarded( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness() + supervisor_module = _install_signal_harness(monkeypatch, harness) + child = _AttachedChild() + + with supervisor_module.ForwardingSignalGuard() as guard: + guard.attach(child) + guard.begin_cleanup() + harness.handlers[signal.SIGTERM](signal.SIGTERM, None) + harness.handlers[signal.SIGTERM](signal.SIGTERM, None) + + assert child.forwarded == [signal.SIGTERM, signal.SIGTERM] + assert harness.handlers == harness.previous + + +def test_guard_rejects_unverified_handler_installation_and_restores_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness() + supervisor_module = _install_signal_harness(monkeypatch, harness) + + def ignore_sigterm_install(signum: int, handler): + previous = harness.handlers[signum] + if signum == signal.SIGINT or handler in harness.previous.values(): + harness.handlers[signum] = handler + return previous + + monkeypatch.setattr( + supervisor_module.signal, + "signal", + ignore_sigterm_install, + ) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + with supervisor_module.ForwardingSignalGuard(): + pass + + assert harness.handlers == harness.previous + + +def test_guard_rejects_unknown_native_handler_before_any_installation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness() + harness.previous[signal.SIGTERM] = None + harness.handlers = dict(harness.previous) + supervisor_module = _install_signal_harness(monkeypatch, harness) + guard = supervisor_module.ForwardingSignalGuard() + entered = False + + try: + with pytest.raises(supervisor_module.ProcessSupervisionError) as captured: + guard.__enter__() + entered = True + finally: + if entered: + guard.__exit__(None, None, None) + + assert str(captured.value) == "Runtime child supervision failed." + assert harness.handlers == harness.previous + assert [event for event in harness.events if event[0] == "handler"] == [] + assert harness.current_mask == harness.old_mask + + +def test_default_runner_redacts_unknown_native_handler_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import cli as cli_module + + harness = _SignalHarness() + harness.previous[signal.SIGTERM] = None + harness.handlers = dict(harness.previous) + _install_signal_harness(monkeypatch, harness) + child_started = False + + def start_child(command, *, env, cwd): + nonlocal child_started + child_started = True + raise AssertionError(f"{command!r} {env!r} {cwd!r}") + + monkeypatch.setattr( + cli_module.ForegroundChildSupervisor, + "start", + start_child, + ) + + with pytest.raises(cli_module.CliError) as captured: + cli_module._default_runner( + ["command-canary"], + env={"SECRET": "environment-canary"}, + cwd="cwd-canary", + ) + + assert str(captured.value) == "Could not supervise the runtime child safely." + assert child_started is False + assert "canary" not in str(captured.value) + assert [event for event in harness.events if event[0] == "handler"] == [] + + +def test_guard_fails_closed_when_callers_mask_blocks_forwarded_signal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness(old_mask=frozenset({signal.SIGTERM})) + supervisor_module = _install_signal_harness(monkeypatch, harness) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + with supervisor_module.ForwardingSignalGuard(): + pass + + assert harness.handlers == harness.previous + assert ( + "mask", + (signal.SIG_SETMASK, harness.old_mask), + ) in harness.events + + +def test_failed_guard_entry_retries_exact_mask_restore( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness(fail_restore_attempts=1) + supervisor_module = _install_signal_harness(monkeypatch, harness) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + with supervisor_module.ForwardingSignalGuard(): + pass + + restore_events = [ + event + for event in harness.events + if event + == ( + "mask", + (signal.SIG_SETMASK, harness.old_mask), + ) + ] + assert len(restore_events) == 2 + assert harness.current_mask == harness.old_mask + assert harness.handlers == harness.previous + + +@_POSIX_ONLY +def test_signal_arriving_during_popen_is_pending_until_attachment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness() + supervisor_module = _install_signal_harness(monkeypatch, harness) + process = _FakePopen(wait_results=[0]) + + def fake_popen(command, **kwargs): + harness.handlers[signal.SIGTERM](signal.SIGTERM, None) + return process + + monkeypatch.setattr(supervisor_module.subprocess, "Popen", fake_popen) + + with pytest.raises(supervisor_module._ForwardedSignal) as captured: + with supervisor_module.ForwardingSignalGuard() as guard: + child = supervisor_module.ForegroundChildSupervisor.start( + ["child"], + env={}, + cwd=None, + ) + guard.attach(child) + + assert captured.value.signum == signal.SIGTERM + + +def test_windows_signal_during_first_handler_install_is_not_lost( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness(deliver_on_install=signal.SIGINT) + supervisor_module = _install_signal_harness( + monkeypatch, + harness, + is_windows=True, + ) + + with pytest.raises(supervisor_module._ForwardedSignal) as captured: + with supervisor_module.ForwardingSignalGuard() as guard: + guard.attach(_AttachedChild()) + + assert captured.value.signum == signal.SIGINT + + +def test_signal_during_pending_consumption_is_delivered_from_attach( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness() + supervisor_module = _install_signal_harness(monkeypatch, harness) + + class _AttachRaceGuard(supervisor_module.ForwardingSignalGuard): + def __init__(self) -> None: + self.arm_pending_read = False + super().__init__() + + def __getattribute__(self, name: str): + value = object.__getattribute__(self, name) + if name == "_pending_signal" and object.__getattribute__( + self, "arm_pending_read" + ): + object.__setattr__(self, "arm_pending_read", False) + object.__getattribute__(self, "_installed_handler")( + signal.SIGTERM, + None, + ) + return value + + guard = _AttachRaceGuard() + with pytest.raises(supervisor_module._ForwardedSignal) as captured: + with guard: + harness.handlers[signal.SIGINT](signal.SIGINT, None) + guard.arm_pending_read = True + guard.attach(_AttachedChild()) + + assert captured.value.signum == signal.SIGINT + + +def test_signal_after_pending_clear_cannot_displace_first_attach_signal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness() + supervisor_module = _install_signal_harness(monkeypatch, harness) + + class _AfterClearRaceGuard(supervisor_module.ForwardingSignalGuard): + def __init__(self) -> None: + self.arm_after_clear = False + super().__init__() + + def __setattr__(self, name: str, value: object) -> None: + object.__setattr__(self, name, value) + if ( + name == "_pending_signal" + and value is None + and object.__getattribute__(self, "arm_after_clear") + ): + object.__setattr__(self, "arm_after_clear", False) + object.__getattribute__(self, "_installed_handler")( + signal.SIGTERM, + None, + ) + + child = _AttachedChild() + guard = _AfterClearRaceGuard() + with pytest.raises(supervisor_module._ForwardedSignal) as captured: + with guard: + harness.handlers[signal.SIGINT](signal.SIGINT, None) + guard.arm_after_clear = True + guard.attach(child) + + assert captured.value.signum == signal.SIGINT + assert child.forwarded == [signal.SIGTERM] + + +def test_reentrant_signal_after_handler_clear_cannot_displace_pending_signal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness() + supervisor_module = _install_signal_harness(monkeypatch, harness) + + class _HandlerClearRaceGuard(supervisor_module.ForwardingSignalGuard): + def __init__(self) -> None: + self.arm_handler_clear = False + super().__init__() + + def __setattr__(self, name: str, value: object) -> None: + object.__setattr__(self, name, value) + if ( + name == "_pending_signal" + and value is None + and object.__getattribute__(self, "arm_handler_clear") + ): + object.__setattr__(self, "arm_handler_clear", False) + object.__getattribute__(self, "_installed_handler")( + signal.SIGTERM, + None, + ) + + child = _AttachedChild() + guard = _HandlerClearRaceGuard() + with pytest.raises(supervisor_module._ForwardedSignal) as captured: + with guard: + guard.attach(child) + guard._pending_signal = signal.SIGINT + guard.arm_handler_clear = True + harness.handlers[signal.SIGTERM](signal.SIGTERM, None) + + assert captured.value.signum == signal.SIGINT + assert child.forwarded == [signal.SIGTERM] + + +@_POSIX_ONLY +def test_posix_start_uses_new_session_without_shell_and_preserves_inputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = _FakePopen(wait_results=[23]) + observed: dict[str, object] = {} + + def fake_popen(command, **kwargs): + observed.update(command=command, kwargs=kwargs) + return process + + monkeypatch.setattr(supervisor_module, "_IS_WINDOWS", False) + monkeypatch.setattr(supervisor_module.subprocess, "Popen", fake_popen) + monkeypatch.setattr( + supervisor_module, + "_waitid_no_reap", + lambda _pid, *, nohang: 23, + ) + monkeypatch.setattr( + supervisor_module, + "_process_group_has_other_members", + lambda _pgid, _leader_pid: False, + ) + command = ["python", "command-canary"] + environment = {"SECRET": "environment-canary"} + + child = supervisor_module.ForegroundChildSupervisor.start( + command, + env=environment, + cwd="/runtime", + ) + + assert child.wait() == 23 + child.close_remaining_tree(timeout=5.0) + child.ensure_closed(timeout=5.0) + child.close() + assert process.wait_timeouts == [0.0] + assert observed == { + "command": command, + "kwargs": { + "env": environment, + "cwd": "/runtime", + "start_new_session": True, + }, + } + + +@_POSIX_ONLY +def test_posix_start_failure_is_value_free( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + monkeypatch.setattr(supervisor_module, "_IS_WINDOWS", False) + + def fail_popen(command, **kwargs): + raise OSError("setup-canary command-canary environment-canary") + + monkeypatch.setattr(supervisor_module.subprocess, "Popen", fail_popen) + + with pytest.raises(supervisor_module.ProcessSupervisionError) as captured: + supervisor_module.ForegroundChildSupervisor.start( + ["command-canary"], + env={"SECRET": "environment-canary"}, + cwd=None, + ) + + assert "setup-canary" not in str(captured.value) + assert "command-canary" not in str(captured.value) + assert "environment-canary" not in str(captured.value) + + +@_POSIX_ONLY +def test_posix_start_fails_before_popen_without_nonreaping_wait_support( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + popen_called = False + + def fake_popen(command, **kwargs): + nonlocal popen_called + popen_called = True + return _FakePopen() + + monkeypatch.setattr(supervisor_module.sys, "platform", "linux") + monkeypatch.delattr(supervisor_module.os, "WNOWAIT") + monkeypatch.setattr(supervisor_module.subprocess, "Popen", fake_popen) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + supervisor_module._PosixChild.start(["child"], env={}, cwd=None) + + assert popen_called is False + + +@_POSIX_ONLY +def test_darwin_without_os_waitid_uses_native_nonreaping_observer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + observed: list[tuple[int, bool]] = [] + monkeypatch.setattr(supervisor_module.sys, "platform", "darwin") + monkeypatch.delattr(supervisor_module.os, "waitid", raising=False) + monkeypatch.setattr( + supervisor_module, + "_darwin_waitid_no_reap", + lambda pid, *, nohang: observed.append((pid, nohang)) or 27, + raising=False, + ) + + assert supervisor_module._waitid_no_reap(4312, nohang=True) == 27 + assert observed == [(4312, True)] + + +@pytest.mark.skipif(sys.platform != "darwin", reason="Darwin libc waitid only") +def test_darwin_native_waitid_observes_exit_without_reaping() -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = subprocess.Popen( + [sys.executable, "-c", "raise SystemExit(27)"], + ) + try: + assert ( + supervisor_module._darwin_waitid_no_reap( + process.pid, + nohang=False, + ) + == 27 + ) + assert process.returncode is None + assert process.wait(timeout=1.0) == 27 + finally: + if process.returncode is None: + process.kill() + process.wait(timeout=1.0) + + +@pytest.mark.parametrize( + ("native_errno", "expected_members", "raises"), + [ + (0, set(), False), + (errno.EPERM, None, True), + ], +) +@_POSIX_ONLY +def test_darwin_group_enumeration_distinguishes_empty_from_native_error( + monkeypatch: pytest.MonkeyPatch, + native_errno: int, + expected_members: set[int] | None, + raises: bool, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + class _ListGroupPids: + def __init__(self) -> None: + self.calls: list[tuple[int, object, int]] = [] + + def __call__(self, pgid: int, buffer, size: int) -> int: + self.calls.append((pgid, buffer, size)) + if len(self.calls) == 1: + supervisor_module.ctypes.set_errno(0) + return 16 + supervisor_module.ctypes.set_errno(native_errno) + return 0 + + list_group_pids = _ListGroupPids() + monkeypatch.setattr( + supervisor_module.ctypes, + "CDLL", + lambda path, *, use_errno: SimpleNamespace( + proc_listpgrppids=list_group_pids, + ), + ) + + if raises: + with pytest.raises(supervisor_module.ProcessSupervisionError): + supervisor_module._darwin_process_group_members(4312) + else: + assert supervisor_module._darwin_process_group_members(4312) == expected_members + + assert len(list_group_pids.calls) == 2 + + +@_POSIX_ONLY +def test_darwin_group_enumeration_wraps_native_callable_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + class _FailingListGroupPids: + def __call__(self, pgid: int, buffer, size: int) -> int: + raise OSError("libproc-canary") + + monkeypatch.setattr( + supervisor_module.ctypes, + "CDLL", + lambda path, *, use_errno: SimpleNamespace( + proc_listpgrppids=_FailingListGroupPids(), + ), + ) + + with pytest.raises(supervisor_module.ProcessSupervisionError) as captured: + supervisor_module._darwin_process_group_members(4312) + + assert "libproc-canary" not in str(captured.value) + + +@_POSIX_ONLY +def test_linux_group_enumeration_reads_only_live_numeric_processes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + class _ProcEntries: + def __init__(self) -> None: + self.entries = [ + SimpleNamespace(name="self"), + SimpleNamespace(name="100"), + SimpleNamespace(name="101"), + SimpleNamespace(name="102"), + SimpleNamespace(name="103"), + ] + + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + return None + + def __iter__(self): + return iter(self.entries) + + class _StatFile: + def __init__(self, text: str) -> None: + self.text = text + + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + return None + + def read(self) -> str: + return self.text + + stat_text = { + "100": "100 (leader process) S 1 4312 0", + "102": "102 (worker with ) character) S 100 4312 0", + "103": "103 (other group) S 1 9000 0", + } + + def open_stat(path: str, *, encoding: str): + assert encoding == "utf-8" + pid = path.split("/")[2] + if pid == "101": + raise FileNotFoundError(path) + return _StatFile(stat_text[pid]) + + monkeypatch.setattr( + supervisor_module.os, + "scandir", + lambda path: _ProcEntries() if path == "/proc" else None, + ) + monkeypatch.setattr(supervisor_module, "open", open_stat, raising=False) + + assert supervisor_module._linux_process_group_members(4312) == {100, 102} + + +@pytest.mark.parametrize( + ("stat_result", "failure"), + [ + ("malformed", None), + ("123 (process) S parent invalid-pgid 0", None), + (None, OSError("stat-read-canary")), + ], +) +@_POSIX_ONLY +def test_linux_group_enumeration_fails_closed_on_untrusted_proc_data( + monkeypatch: pytest.MonkeyPatch, + stat_result: str | None, + failure: OSError | None, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + class _ProcEntries: + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + return None + + def __iter__(self): + return iter([SimpleNamespace(name="123")]) + + class _StatFile: + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + return None + + def read(self) -> str: + if failure is not None: + raise failure + assert stat_result is not None + return stat_result + + monkeypatch.setattr(supervisor_module.os, "scandir", lambda _path: _ProcEntries()) + monkeypatch.setattr( + supervisor_module, + "open", + lambda *_args, **_kwargs: _StatFile(), + raising=False, + ) + + with pytest.raises(supervisor_module.ProcessSupervisionError) as captured: + supervisor_module._linux_process_group_members(4312) + + assert "canary" not in str(captured.value) + + +@_POSIX_ONLY +def test_linux_group_enumeration_wraps_proc_scan_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + monkeypatch.setattr( + supervisor_module.os, + "scandir", + lambda _path: (_ for _ in ()).throw(OSError("proc-scan-canary")), + ) + + with pytest.raises(supervisor_module.ProcessSupervisionError) as captured: + supervisor_module._linux_process_group_members(4312) + + assert "proc-scan-canary" not in str(captured.value) + + +@_POSIX_ONLY +def test_darwin_group_enumeration_accepts_zero_capacity_as_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + class _EmptyGroup: + def __call__(self, _pgid: int, _buffer, _size: int) -> int: + supervisor_module.ctypes.set_errno(0) + return 0 + + monkeypatch.setattr( + supervisor_module.ctypes, + "CDLL", + lambda *_args, **_kwargs: SimpleNamespace(proc_listpgrppids=_EmptyGroup()), + ) + + assert supervisor_module._darwin_process_group_members(4312) == set() + + +@_POSIX_ONLY +def test_darwin_group_enumeration_fails_closed_when_members_keep_growing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + class _GrowingGroup: + def __call__(self, _pgid: int, _buffer, size: int) -> int: + supervisor_module.ctypes.set_errno(0) + if size == 0: + return 16 + return size // supervisor_module.ctypes.sizeof( + supervisor_module.ctypes.c_int + ) + + monkeypatch.setattr( + supervisor_module.ctypes, + "CDLL", + lambda *_args, **_kwargs: SimpleNamespace(proc_listpgrppids=_GrowingGroup()), + ) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + supervisor_module._darwin_process_group_members(4312) + + +@pytest.mark.parametrize( + "failure", [OSError("libproc-load-canary"), KeyboardInterrupt()] +) +@_POSIX_ONLY +def test_darwin_group_enumeration_preserves_control_flow_and_redacts_native_errors( + monkeypatch: pytest.MonkeyPatch, + failure: BaseException, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + if isinstance(failure, KeyboardInterrupt): + + class _InterruptedGroup: + def __call__(self, _pgid: int, _buffer, _size: int) -> int: + raise failure + + def loader(*_args, **_kwargs): + return SimpleNamespace(proc_listpgrppids=_InterruptedGroup()) + + expected = KeyboardInterrupt + else: + + def loader(*_args, **_kwargs): + raise failure + + expected = supervisor_module.ProcessSupervisionError + monkeypatch.setattr(supervisor_module.ctypes, "CDLL", loader) + + with pytest.raises(expected) as captured: + supervisor_module._darwin_process_group_members(4312) + + assert "canary" not in str(captured.value) + + +@_POSIX_ONLY +def test_posix_waitid_preserves_forwarded_signal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + forwarded = supervisor_module._ForwardedSignal(signal.SIGTERM) + monkeypatch.setattr(supervisor_module.sys, "platform", "linux") + for name, value in { + "P_PID": 1, + "WEXITED": 4, + "WNOWAIT": 0x01000000, + "WNOHANG": 1, + "CLD_EXITED": 1, + "CLD_KILLED": 2, + "CLD_DUMPED": 3, + }.items(): + monkeypatch.setattr(supervisor_module.os, name, value, raising=False) + monkeypatch.setattr( + supervisor_module.os, + "waitid", + lambda id_type, pid, options: (_ for _ in ()).throw(forwarded), + raising=False, + ) + + with pytest.raises(supervisor_module._ForwardedSignal) as captured: + supervisor_module._waitid_no_reap(4312, nohang=False) + + assert captured.value is forwarded + + +@pytest.mark.parametrize( + ("wait_result", "expected"), + [ + (SimpleNamespace(si_pid=4312, si_code=1, si_status=27), 27), + (SimpleNamespace(si_pid=4312, si_code=2, si_status=9), -9), + (SimpleNamespace(si_pid=4312, si_code=3, si_status=6), -6), + ], +) +def test_waitid_exit_decoder_preserves_direct_child_status( + wait_result: SimpleNamespace, + expected: int, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + assert ( + supervisor_module._decode_waitid_exit(wait_result, expected_pid=4312) + == expected + ) + + +@pytest.mark.parametrize( + "wait_result", + [ + None, + SimpleNamespace(si_pid=9999, si_code=1, si_status=0), + SimpleNamespace(si_pid=4312, si_code=99, si_status=0), + ], +) +def test_waitid_exit_decoder_rejects_ambiguous_child_status( + wait_result: SimpleNamespace | None, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + with pytest.raises(supervisor_module.ProcessSupervisionError): + supervisor_module._decode_waitid_exit(wait_result, expected_pid=4312) + + +@_POSIX_ONLY +def test_generic_waitid_adapter_supports_polling_and_signal_exit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + constants = { + "P_PID": 1, + "WEXITED": 4, + "WNOWAIT": 0x01000000, + "WNOHANG": 1, + "CLD_EXITED": 1, + "CLD_KILLED": 2, + "CLD_DUMPED": 3, + } + calls: list[tuple[int, int, int]] = [] + results = iter( + [ + None, + SimpleNamespace(si_pid=4312, si_code=2, si_status=signal.SIGTERM), + ] + ) + monkeypatch.setattr(supervisor_module.sys, "platform", "linux") + for name, value in constants.items(): + monkeypatch.setattr(supervisor_module.os, name, value, raising=False) + + def waitid(id_type: int, pid: int, options: int): + calls.append((id_type, pid, options)) + return next(results) + + monkeypatch.setattr(supervisor_module.os, "waitid", waitid, raising=False) + + assert supervisor_module._waitid_no_reap(4312, nohang=True) is None + assert supervisor_module._waitid_no_reap(4312, nohang=False) == -signal.SIGTERM + assert calls == [ + (1, 4312, 0x01000005), + (1, 4312, 0x01000004), + ] + + +@_POSIX_ONLY +def test_generic_waitid_adapter_redacts_native_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + monkeypatch.setattr(supervisor_module.sys, "platform", "linux") + for name, value in { + "P_PID": 1, + "WEXITED": 4, + "WNOWAIT": 0x01000000, + "WNOHANG": 1, + "CLD_EXITED": 1, + "CLD_KILLED": 2, + "CLD_DUMPED": 3, + }.items(): + monkeypatch.setattr(supervisor_module.os, name, value, raising=False) + monkeypatch.setattr( + supervisor_module.os, + "waitid", + lambda *_args: (_ for _ in ()).throw(OSError("waitid-canary")), + raising=False, + ) + + with pytest.raises(supervisor_module.ProcessSupervisionError) as captured: + supervisor_module._waitid_no_reap(4312, nohang=False) + + assert "waitid-canary" not in str(captured.value) + + +@pytest.mark.parametrize( + ("platform", "members", "expected"), + [ + ("darwin", {4312, 4313}, True), + ("linux", {4312}, False), + ], +) +@_POSIX_ONLY +def test_process_group_member_routing_uses_platform_enumerator( + monkeypatch: pytest.MonkeyPatch, + platform: str, + members: set[int], + expected: bool, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + calls: list[tuple[str, int]] = [] + monkeypatch.setattr(supervisor_module.sys, "platform", platform) + monkeypatch.setattr( + supervisor_module, + "_darwin_process_group_members", + lambda pgid: calls.append(("darwin", pgid)) or members, + ) + monkeypatch.setattr( + supervisor_module, + "_linux_process_group_members", + lambda pgid: calls.append(("linux", pgid)) or members, + ) + + assert supervisor_module._process_group_has_other_members(4312, 4312) is expected + assert calls == [(platform, 4312)] + + +@pytest.mark.parametrize( + ("pgid", "leader_pid", "platform"), + [ + (0, 4312, "linux"), + (4312, 0, "linux"), + (4312, 9999, "linux"), + (4312, 4312, "aix"), + ], +) +@_POSIX_ONLY +def test_process_group_member_routing_rejects_unsafe_identity_or_platform( + monkeypatch: pytest.MonkeyPatch, + pgid: int, + leader_pid: int, + platform: str, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + monkeypatch.setattr(supervisor_module.sys, "platform", platform) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + supervisor_module._process_group_has_other_members(pgid, leader_pid) + + +@_POSIX_ONLY +def test_posix_persistent_observer_failure_still_attempts_final_direct_reap( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = _FakePopen(wait_results=[-signal.SIGKILL]) + signals: list[int] = [] + deadlines: list[float] = [] + monkeypatch.setattr( + supervisor_module.subprocess, + "Popen", + lambda command, **kwargs: process, + ) + monkeypatch.setattr( + supervisor_module, + "_waitid_no_reap", + lambda pid, *, nohang: (_ for _ in ()).throw( + supervisor_module.ProcessSupervisionError() + ), + ) + monkeypatch.setattr(supervisor_module.os, "getpgid", lambda _pid: process.pid) + monkeypatch.setattr( + supervisor_module.os, + "killpg", + lambda _pgid, signum: signals.append(signum), + ) + child = supervisor_module.ForegroundChildSupervisor.start( + ["child"], + env={}, + cwd=None, + ) + + def wait_for_tree(*, deadline: float) -> tuple[bool, bool]: + deadlines.append(deadline) + return False, True + + monkeypatch.setattr(child._child, "_wait_for_owned_tree_exit", wait_for_tree) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + child.forward_and_reap(signal.SIGTERM, timeout=5.0) + + assert signals == [signal.SIGTERM, signal.SIGKILL] + assert len(deadlines) == 2 + assert process.wait_timeouts == [0.0] + with pytest.raises(supervisor_module.ProcessSupervisionError): + child.ensure_closed(timeout=5.0) + assert signals == [signal.SIGTERM, signal.SIGKILL] + + +@_POSIX_ONLY +def test_posix_group_forwarding_escalates_and_reaps_direct_child( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = _FakePopen(wait_results=[-signal.SIGKILL]) + sent: list[tuple[int, int]] = [] + monkeypatch.setattr(supervisor_module, "_IS_WINDOWS", False) + monkeypatch.setattr( + supervisor_module.subprocess, + "Popen", + lambda command, **kwargs: process, + ) + monkeypatch.setattr( + supervisor_module.os, + "killpg", + lambda pgid, signum: sent.append((pgid, signum)), + ) + monkeypatch.setattr( + supervisor_module, + "_waitid_no_reap", + lambda _pid, *, nohang: None, + ) + monkeypatch.setattr(supervisor_module.os, "getpgid", lambda _pid: process.pid) + + child = supervisor_module.ForegroundChildSupervisor.start( + ["child"], + env={}, + cwd=None, + ) + outcomes = iter([(False, False), (True, False)]) + + def wait_for_tree(*, deadline: float) -> tuple[bool, bool]: + outcome = next(outcomes) + if outcome[0]: + child._child._observed_exit_code = -signal.SIGKILL + return outcome + + monkeypatch.setattr(child._child, "_wait_for_owned_tree_exit", wait_for_tree) + child.forward_and_reap(signal.SIGTERM, timeout=5.0) + + assert sent == [ + (process.pid, signal.SIGTERM), + (process.pid, signal.SIGKILL), + ] + assert process.wait_timeouts == [0.0] + + +@_POSIX_ONLY +def test_posix_hard_kill_wait_is_bounded_when_direct_child_does_not_reap( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = _FakePopen(wait_results=[subprocess.TimeoutExpired("child", 0.0)]) + monkeypatch.setattr(supervisor_module, "_IS_WINDOWS", False) + monkeypatch.setattr( + supervisor_module.subprocess, + "Popen", + lambda command, **kwargs: process, + ) + monkeypatch.setattr(supervisor_module.os, "killpg", lambda pgid, signum: None) + monkeypatch.setattr( + supervisor_module, + "_waitid_no_reap", + lambda _pid, *, nohang: None, + ) + monkeypatch.setattr(supervisor_module.os, "getpgid", lambda _pid: process.pid) + child = supervisor_module.ForegroundChildSupervisor.start( + ["child"], + env={}, + cwd=None, + ) + deadlines: list[float] = [] + + def wait_for_tree(*, deadline: float) -> tuple[bool, bool]: + deadlines.append(deadline) + return False, False + + monkeypatch.setattr(child._child, "_wait_for_owned_tree_exit", wait_for_tree) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + child.forward_and_reap(signal.SIGTERM, timeout=5.0) + + assert len(deadlines) == 2 + assert all(deadline < float("inf") for deadline in deadlines) + assert process.wait_timeouts == [0.0] + + +@_POSIX_ONLY +def test_posix_hard_cleanup_uses_a_separate_finite_deadline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = _FakePopen(wait_results=[-signal.SIGKILL]) + monotonic_values = iter([0.0, 10.0]) + deadlines: list[float] = [] + monkeypatch.setattr( + supervisor_module.subprocess, + "Popen", + lambda command, **kwargs: process, + ) + monkeypatch.setattr(supervisor_module.os, "killpg", lambda pgid, signum: None) + monkeypatch.setattr(supervisor_module.os, "getpgid", lambda _pid: process.pid) + monkeypatch.setattr( + supervisor_module, + "_waitid_no_reap", + lambda _pid, *, nohang: None, + ) + monkeypatch.setattr( + supervisor_module.time, + "monotonic", + lambda: next(monotonic_values), + ) + + child = supervisor_module.ForegroundChildSupervisor.start( + ["child"], + env={}, + cwd=None, + ) + + def wait_for_tree(*, deadline: float) -> tuple[bool, bool]: + deadlines.append(deadline) + if len(deadlines) == 2: + child._child._observed_exit_code = -signal.SIGKILL + return True, False + return False, False + + monkeypatch.setattr(child._child, "_wait_for_owned_tree_exit", wait_for_tree) + + child.forward_and_reap(signal.SIGTERM, timeout=5.0) + + assert deadlines == [5.0, 15.0] + assert process.wait_timeouts == [0.0] + + +@_POSIX_ONLY +def test_posix_normal_return_terminates_remaining_process_group( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = _FakePopen(wait_results=[29]) + group_states = iter([True, False]) + sent: list[tuple[int, int]] = [] + monkeypatch.setattr(supervisor_module, "_IS_WINDOWS", False) + monkeypatch.setattr( + supervisor_module.subprocess, + "Popen", + lambda command, **kwargs: process, + ) + monkeypatch.setattr( + supervisor_module, + "_waitid_no_reap", + lambda _pid, *, nohang: 29, + ) + monkeypatch.setattr( + supervisor_module, + "_process_group_has_other_members", + lambda _pgid, _leader_pid: next(group_states), + ) + monkeypatch.setattr(supervisor_module.os, "getpgid", lambda _pid: process.pid) + monkeypatch.setattr( + supervisor_module.os, + "killpg", + lambda pgid, signum: sent.append((pgid, signum)), + ) + + child = supervisor_module.ForegroundChildSupervisor.start( + ["child"], + env={}, + cwd=None, + ) + + assert child.wait() == 29 + child.close_remaining_tree(timeout=5.0) + assert sent == [(process.pid, signal.SIGTERM)] + assert process.wait_timeouts == [0.0] + + +@_POSIX_ONLY +def test_posix_normal_return_retains_leader_until_group_cleanup_then_reaps( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = _FakePopen(wait_results=[41]) + events: list[tuple[str, object]] = [] + other_member_states = iter([True, False]) + monkeypatch.setattr( + supervisor_module.subprocess, + "Popen", + lambda command, **kwargs: process, + ) + + monkeypatch.setattr( + supervisor_module, + "_waitid_no_reap", + lambda pid, *, nohang: events.append(("observe", (pid, nohang))) or 41, + ) + monkeypatch.setattr( + supervisor_module.os, + "getpgid", + lambda _pid: (_ for _ in ()).throw(ProcessLookupError()), + ) + + def has_other_members(pgid: int, leader_pid: int) -> bool: + events.append(("members", (pgid, leader_pid))) + return next(other_member_states) + + monkeypatch.setattr( + supervisor_module, + "_process_group_has_other_members", + has_other_members, + raising=False, + ) + monkeypatch.setattr( + supervisor_module.os, + "killpg", + lambda pgid, signum: events.append(("signal", (pgid, signum))), + ) + child = supervisor_module.ForegroundChildSupervisor.start( + ["child"], + env={}, + cwd=None, + ) + + assert child.wait() == 41 + assert process.wait_timeouts == [] + child.close_remaining_tree(timeout=5.0) + + assert process.wait_timeouts == [0.0] + assert [name for name, _value in events] == [ + "observe", + "members", + "signal", + "members", + ] + assert events[2] == ("signal", (process.pid, signal.SIGTERM)) + + +@_POSIX_ONLY +@pytest.mark.parametrize("signum", [signal.SIGINT, signal.SIGTERM]) +def test_posix_signal_during_final_reap_preserves_signal_exit_without_reused_pgid( + monkeypatch: pytest.MonkeyPatch, + signum: int, +) -> None: + from agentseek_api import cli as cli_module + from agentseek_api import process_supervisor as supervisor_module + + harness = _SignalHarness() + _install_signal_harness(monkeypatch, harness) + process = _FakePopen(wait_results=[41]) + child = supervisor_module.ForegroundChildSupervisor( + supervisor_module._PosixChild(process) + ) + monkeypatch.setattr( + supervisor_module, + "_waitid_no_reap", + lambda _pid, *, nohang: 41, + ) + + def no_other_members(_pgid: int, _leader_pid: int) -> bool: + harness.deliver_on_restore = signum + return False + + monkeypatch.setattr( + supervisor_module, + "_process_group_has_other_members", + no_other_members, + ) + reused_group_signals: list[tuple[int, int]] = [] + monkeypatch.setattr( + supervisor_module.os, + "getpgid", + lambda _pid: process.pid, + ) + monkeypatch.setattr( + supervisor_module.os, + "killpg", + lambda pgid, delivered: reused_group_signals.append((pgid, delivered)), + ) + monkeypatch.setattr( + cli_module.ForegroundChildSupervisor, + "start", + lambda command, *, env, cwd: child, + ) + + assert cli_module._default_runner(["child"], env={}, cwd=None) == 128 + signum + assert process.wait_timeouts == [0.0] + assert reused_group_signals == [] + + +@_POSIX_ONLY +def test_posix_forward_signal_after_reap_never_targets_reused_process_group( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = _FakePopen(pid=4312) + child = supervisor_module._PosixChild(process) + child._observed_exit_code = 0 + child._direct_reaped = True + monkeypatch.setattr( + supervisor_module.os, + "getpgid", + lambda _pid: process.pid, + ) + reused_group_signals: list[tuple[int, int]] = [] + monkeypatch.setattr( + supervisor_module.os, + "killpg", + lambda pgid, delivered: reused_group_signals.append((pgid, delivered)), + ) + + child.forward_signal(signal.SIGTERM) + + assert reused_group_signals == [] + + +@_POSIX_ONLY +def test_posix_reap_revokes_group_signaling_before_wait_returns( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + reused_group_signals: list[tuple[int, int]] = [] + + class _SignalDuringWaitPopen(_FakePopen): + def wait(self, timeout: float | None = None) -> int: + self.wait_timeouts.append(timeout) + self.returncode = 41 + child.forward_signal(signal.SIGTERM) + return 41 + + process = _SignalDuringWaitPopen(pid=4312) + child = supervisor_module._PosixChild(process) + child._observed_exit_code = 41 + monkeypatch.setattr( + supervisor_module.os, + "getpgid", + lambda _pid: process.pid, + ) + monkeypatch.setattr( + supervisor_module.os, + "killpg", + lambda pgid, delivered: reused_group_signals.append((pgid, delivered)), + ) + + child._reap_observed_child() + + assert process.wait_timeouts == [0.0] + assert reused_group_signals == [] + + +@_POSIX_ONLY +def test_posix_failed_final_reap_never_reauthorizes_numeric_process_group( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = _FakePopen( + pid=4312, + wait_results=[subprocess.TimeoutExpired("child", 0.0)], + ) + child = supervisor_module._PosixChild(process) + child._observed_exit_code = 41 + monkeypatch.setattr( + supervisor_module.os, + "getpgid", + lambda _pid: process.pid, + ) + reused_group_signals: list[tuple[int, int]] = [] + monkeypatch.setattr( + supervisor_module.os, + "killpg", + lambda pgid, delivered: reused_group_signals.append((pgid, delivered)), + ) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + child._reap_observed_child() + child.forward_signal(signal.SIGTERM) + + assert reused_group_signals == [] + + +@_POSIX_ONLY +def test_posix_reap_mask_block_failure_preserves_tree_cleanup_authority( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = _FakePopen(pid=4312, wait_results=[41]) + child = supervisor_module._PosixChild(process) + child._observed_exit_code = 41 + monkeypatch.setattr( + supervisor_module.signal, + "pthread_sigmask", + lambda operation, mask: (_ for _ in ()).throw(OSError("mask-canary")), + ) + monkeypatch.setattr( + supervisor_module.os, + "getpgid", + lambda _pid: process.pid, + ) + delivered: list[tuple[int, int]] = [] + monkeypatch.setattr( + supervisor_module.os, + "killpg", + lambda pgid, signum: delivered.append((pgid, signum)), + ) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + child._reap_observed_child() + child.forward_signal(signal.SIGTERM) + + assert process.wait_timeouts == [] + assert delivered == [(process.pid, signal.SIGTERM)] + + +class _FakeWin32Api: + def __init__( + self, + *, + fail_at: str | None = None, + exit_code: int = 0, + job_empty_results: list[bool] | None = None, + wait_process_results: list[bool | BaseException] | None = None, + ) -> None: + self.fail_at = fail_at + self.failed = False + self.exit_code = exit_code + self.job_empty_results = list(job_empty_results or [True]) + self.wait_process_results = list(wait_process_results or [True]) + self.events: list[tuple[str, object]] = [] + + def _record(self, name: str, value: object = None) -> None: + self.events.append((name, value)) + if self.fail_at == name and not self.failed: + self.failed = True + raise OSError(f"{name}-setup-canary") + + def create_job(self): + self._record("create-job") + return "job-handle" + + def set_kill_on_close(self, job) -> None: + self._record("set-kill-on-close", job) + + def create_suspended_process(self, command, *, env, cwd, job=None): + self._record( + "create-suspended", + (list(command), dict(env), cwd, job), + ) + return "process-handle", "thread-handle", 8128 + + def resume_thread(self, thread) -> None: + self._record("resume-thread", thread) + + def terminate_job(self, job) -> None: + self._record("terminate-job", job) + + def wait_process(self, process, timeout: float | None) -> bool: + self._record("wait-process", (process, timeout)) + result = self.wait_process_results.pop(0) + if isinstance(result, BaseException): + raise result + return result + + def process_exit_code(self, process) -> int: + self._record("exit-code", process) + return self.exit_code + + def wait_for_job_empty(self, job, timeout: float) -> bool: + self._record("wait-job-empty", (job, timeout)) + return self.job_empty_results.pop(0) + + def send_ctrl_break(self, process_id: int) -> None: + self._record("ctrl-break", process_id) + + def close_handle(self, handle) -> None: + self._record(f"close-{handle}", handle) + + +def test_windows_signal_during_process_creation_is_pending_until_attachment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness() + supervisor_module = _install_signal_harness( + monkeypatch, + harness, + is_windows=True, + ) + api = _FakeWin32Api() + create_suspended_process = api.create_suspended_process + + def create_with_sigterm(command, *, env, cwd, job=None): + result = create_suspended_process(command, env=env, cwd=cwd, job=job) + harness.handlers[signal.SIGTERM](signal.SIGTERM, None) + return result + + monkeypatch.setattr(api, "create_suspended_process", create_with_sigterm) + child = None + try: + with supervisor_module.ForwardingSignalGuard() as guard: + child = supervisor_module._WindowsChild.start( + ["child"], + env={}, + cwd=None, + api=api, + ) + assert "terminate-job" not in [name for name, _value in api.events] + with pytest.raises(supervisor_module._ForwardedSignal) as captured: + guard.attach(child) + finally: + if child is not None: + child.close() + + assert captured.value.signum == signal.SIGTERM + + +class _FakeWindowsLaunchNative: + def __init__( + self, + *, + missing_streams: frozenset[int] = frozenset(), + fail_duplicate_at: int | None = None, + fail_delete: bool = False, + fail_attribute_list: bool = False, + fail_close: frozenset[str] = frozenset(), + fail_abort: bool = False, + ) -> None: + self.unrelated_inheritable_handle = "sentinel-handle" + self.missing_streams = missing_streams + self.fail_duplicate_at = fail_duplicate_at + self.fail_delete = fail_delete + self.fail_attribute_list = fail_attribute_list + self.fail_close = fail_close + self.fail_abort = fail_abort + self.duplicate_calls = 0 + self.events: list[tuple[str, object]] = [] + + def get_standard_handle(self, stream: int): + if stream in self.missing_streams: + self.events.append(("get-standard", (stream, None))) + return None + handle = { + -10: "stdin-handle", + -11: "stdout-handle", + -12: "stderr-handle", + }[stream] + self.events.append(("get-standard", (stream, handle))) + return handle + + def open_null_handle(self, stream: int): + handle = f"null-handle-{stream}" + self.events.append(("open-null", (stream, handle))) + return handle + + def duplicate_inheritable_handle(self, handle): + duplicate_index = self.duplicate_calls + self.duplicate_calls += 1 + if duplicate_index == self.fail_duplicate_at: + self.events.append(("duplicate-failed", handle)) + raise OSError("duplicate-canary") + duplicate = f"duplicate-{handle}" + self.events.append(("duplicate", (handle, duplicate))) + return duplicate + + def create_attribute_list(self, handles, jobs): + self.events.append( + ( + "create-attribute-list", + (tuple(handles), tuple(jobs)), + ) + ) + if self.fail_attribute_list: + raise OSError("attribute-list-canary") + return "attribute-list" + + def create_suspended_process( + self, + command, + *, + env, + cwd, + standard_handles, + attribute_list, + ): + self.events.append( + ( + "create-process", + { + "command": list(command), + "env": dict(env), + "cwd": cwd, + "standard_handles": tuple(standard_handles), + "attribute_list": attribute_list, + }, + ) + ) + return "process-handle", "thread-handle", 9127 + + def delete_handle_list(self, attribute_list) -> None: + self.events.append(("delete-handle-list", attribute_list)) + if self.fail_delete: + raise OSError("delete-canary") + + def close_handle(self, handle) -> None: + self.events.append(("close-duplicate", handle)) + if handle in self.fail_close: + raise OSError("close-canary") + + def abort_suspended_process(self, process, thread) -> None: + self.events.append(("abort-process", (process, thread))) + if self.fail_abort: + raise OSError("abort-canary") + + +class _FakeAttributeKernel32: + def __init__( + self, + *, + fail_attribute: int | None = None, + zero_size: bool = False, + fail_initialize: bool = False, + ) -> None: + self.fail_attribute = fail_attribute + self.zero_size = zero_size + self.fail_initialize = fail_initialize + self.initialize_counts: list[int] = [] + self.updated_attributes: list[tuple[int, int]] = [] + self.deleted: list[object] = [] + + def InitializeProcThreadAttributeList( + self, + pointer, + count: int, + flags: int, + size, + ) -> bool: + self.initialize_counts.append(count) + assert flags == 0 + if pointer is None: + size._obj.value = 0 if self.zero_size else 128 + return False + return not self.fail_initialize + + def UpdateProcThreadAttribute( + self, + pointer, + flags: int, + attribute: int, + value, + size: int, + previous, + return_size, + ) -> bool: + assert pointer + assert flags == 0 + assert value + assert previous is None + assert return_size is None + self.updated_attributes.append((attribute, size)) + return attribute != self.fail_attribute + + def DeleteProcThreadAttributeList(self, pointer) -> None: + self.deleted.append(pointer) + + +class _ConfiguredWin32Function: + def __init__(self, name: str, kernel) -> None: + self.name = name + self.kernel = kernel + self.argtypes: object = "unset" + self.restype: object = "unset" + + def __call__(self, *args): + self.kernel.calls.append((self.name, args)) + result = self.kernel.results.get(self.name, True) + if isinstance(result, list): + result = result.pop(0) + return result(*args) if callable(result) else result + + +class _ConfiguredWin32Kernel: + def __init__(self) -> None: + self.functions: dict[str, _ConfiguredWin32Function] = {} + self.results: dict[str, object] = {} + self.calls: list[tuple[str, tuple[object, ...]]] = [] + + def __getattr__(self, name: str) -> _ConfiguredWin32Function: + if name in self.functions: + return self.functions[name] + function = _ConfiguredWin32Function(name, self) + self.functions[name] = function + return function + + +def _make_configured_win32_api( + monkeypatch: pytest.MonkeyPatch, +): + from agentseek_api import process_supervisor as supervisor_module + + kernel32 = _ConfiguredWin32Kernel() + monkeypatch.setattr( + supervisor_module.ctypes, + "WinDLL", + lambda name, *, use_last_error: kernel32, + raising=False, + ) + api = supervisor_module._Win32Api() + return supervisor_module, kernel32, api + + +def test_windows_native_attribute_list_includes_stdio_and_atomic_job() -> None: + from agentseek_api import process_supervisor as supervisor_module + + kernel32 = _FakeAttributeKernel32() + native = supervisor_module._CtypesWindowsLaunchNative(kernel32) + + attribute_list = native.create_attribute_list( + (11, 12, 13), + (99,), + ) + + handle_size = supervisor_module.ctypes.sizeof(supervisor_module.wintypes.HANDLE) + assert kernel32.initialize_counts == [2, 2] + assert kernel32.updated_attributes == [ + (0x00020002, 3 * handle_size), + (0x0002000D, handle_size), + ] + assert list(attribute_list.handle_array) == [11, 12, 13] + assert list(attribute_list.job_array) == [99] + + +def test_windows_native_job_attribute_failure_deletes_attribute_list() -> None: + from agentseek_api import process_supervisor as supervisor_module + + kernel32 = _FakeAttributeKernel32(fail_attribute=0x0002000D) + native = supervisor_module._CtypesWindowsLaunchNative(kernel32) + + with pytest.raises(OSError): + native.create_attribute_list((11, 12, 13), (99,)) + + assert [attribute for attribute, _size in kernel32.updated_attributes] == [ + 0x00020002, + 0x0002000D, + ] + assert len(kernel32.deleted) == 1 + + +@pytest.mark.parametrize( + ("kernel_options", "deleted_count"), + [ + ({"zero_size": True}, 0), + ({"fail_initialize": True}, 0), + ({"fail_attribute": 0x00020002}, 1), + ], +) +def test_windows_native_attribute_setup_failures_stop_before_process_creation( + kernel_options: dict[str, object], + deleted_count: int, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + kernel32 = _FakeAttributeKernel32(**kernel_options) + native = supervisor_module._CtypesWindowsLaunchNative(kernel32) + + with pytest.raises(OSError): + native.create_attribute_list((11, 12, 13), (99,)) + + assert len(kernel32.deleted) == deleted_count + + +def test_win32_api_configures_native_ownership_functions_and_key_signatures( + monkeypatch: pytest.MonkeyPatch, +) -> None: + supervisor_module, kernel32, api = _make_configured_win32_api(monkeypatch) + + expected_functions = { + "CloseHandle", + "CreateFileW", + "CreateJobObjectW", + "CreateProcessW", + "DeleteProcThreadAttributeList", + "DuplicateHandle", + "GenerateConsoleCtrlEvent", + "GetCurrentProcess", + "GetExitCodeProcess", + "GetStdHandle", + "InitializeProcThreadAttributeList", + "QueryInformationJobObject", + "ResumeThread", + "SetInformationJobObject", + "TerminateJobObject", + "TerminateProcess", + "UpdateProcThreadAttribute", + "WaitForSingleObject", + } + + assert set(kernel32.functions) == expected_functions + assert all( + function.argtypes != "unset" and function.restype != "unset" + for function in kernel32.functions.values() + ) + expected_signatures = { + "CreateProcessW": ( + [ + supervisor_module.wintypes.LPCWSTR, + supervisor_module.wintypes.LPWSTR, + supervisor_module.ctypes.c_void_p, + supervisor_module.ctypes.c_void_p, + supervisor_module.wintypes.BOOL, + supervisor_module.wintypes.DWORD, + supervisor_module.ctypes.c_void_p, + supervisor_module.wintypes.LPCWSTR, + supervisor_module.ctypes.POINTER(supervisor_module._STARTUPINFOW), + supervisor_module.ctypes.POINTER( + supervisor_module._PROCESS_INFORMATION + ), + ], + supervisor_module.wintypes.BOOL, + ), + "InitializeProcThreadAttributeList": ( + [ + supervisor_module.ctypes.c_void_p, + supervisor_module.wintypes.DWORD, + supervisor_module.wintypes.DWORD, + supervisor_module.ctypes.POINTER(supervisor_module.ctypes.c_size_t), + ], + supervisor_module.wintypes.BOOL, + ), + "UpdateProcThreadAttribute": ( + [ + supervisor_module.ctypes.c_void_p, + supervisor_module.wintypes.DWORD, + supervisor_module.ctypes.c_size_t, + supervisor_module.ctypes.c_void_p, + supervisor_module.ctypes.c_size_t, + supervisor_module.ctypes.c_void_p, + supervisor_module.ctypes.c_void_p, + ], + supervisor_module.wintypes.BOOL, + ), + "QueryInformationJobObject": ( + [ + supervisor_module.wintypes.HANDLE, + supervisor_module.ctypes.c_int, + supervisor_module.ctypes.c_void_p, + supervisor_module.wintypes.DWORD, + supervisor_module.ctypes.POINTER(supervisor_module.wintypes.DWORD), + ], + supervisor_module.wintypes.BOOL, + ), + } + for name, (argtypes, restype) in expected_signatures.items(): + assert kernel32.functions[name].argtypes == argtypes + assert kernel32.functions[name].restype is restype + assert api._process_launcher._native._kernel32 is kernel32 + + +def test_win32_api_performs_job_wait_and_exit_operations( + monkeypatch: pytest.MonkeyPatch, +) -> None: + supervisor_module, kernel32, api = _make_configured_win32_api(monkeypatch) + observed_limit_flags: list[int] = [] + query_results = [1, 0] + + kernel32.results.update( + { + "CreateJobObjectW": 41, + "SetInformationJobObject": lambda _job, _kind, information, _size: ( + observed_limit_flags.append( + information._obj.BasicLimitInformation.LimitFlags + ) + or True + ), + "ResumeThread": 1, + "TerminateJobObject": True, + "WaitForSingleObject": [ + supervisor_module._Win32Api._WAIT_TIMEOUT, + supervisor_module._Win32Api._WAIT_OBJECT_0, + ], + "GetExitCodeProcess": lambda _process, result: ( + setattr(result._obj, "value", 73) or True + ), + "QueryInformationJobObject": lambda _job, _kind, information, _size, _used: ( + setattr(information._obj, "ActiveProcesses", query_results.pop(0)) + or True + ), + "GenerateConsoleCtrlEvent": True, + "CloseHandle": True, + } + ) + launcher_calls: list[tuple[object, ...]] = [] + api._process_launcher = SimpleNamespace( + create=lambda command, *, env, cwd, job: ( + launcher_calls.append((command, env, cwd, job)) or ("process", "thread", 55) + ) + ) + + job = api.create_job() + api.set_kill_on_close(job) + assert api.create_suspended_process( + ["python", "child.py"], + env={"A": "1"}, + cwd="C:\\runtime", + job=job, + ) == ("process", "thread", 55) + api.resume_thread("thread") + api.terminate_job(job) + assert api.wait_process("process", 0.0001) is False + assert api.wait_process("process", None) is True + assert api.process_exit_code("process") == 73 + assert api.wait_for_job_empty(job, 0.0) is False + assert api.wait_for_job_empty(job, 0.0) is True + api.send_ctrl_break(55) + api.close_handle("process") + api.close_handle(None) + + assert observed_limit_flags == [0x00002000] + assert launcher_calls == [(["python", "child.py"], {"A": "1"}, "C:\\runtime", 41)] + wait_calls = [ + args for name, args in kernel32.calls if name == "WaitForSingleObject" + ] + assert [args[1] for args in wait_calls] == [1, 0xFFFFFFFF] + assert [ + args for name, args in kernel32.calls if name == "GenerateConsoleCtrlEvent" + ] == [(1, 55)] + + +@pytest.mark.parametrize( + "operation", + [ + "create-job", + "set-kill-on-close", + "resume-thread", + "terminate-job", + "wait-process", + "exit-code", + "query-job", + "ctrl-break", + "close-handle", + ], +) +def test_win32_api_native_failures_raise_os_error( + monkeypatch: pytest.MonkeyPatch, + operation: str, +) -> None: + supervisor_module, kernel32, api = _make_configured_win32_api(monkeypatch) + monkeypatch.setattr( + supervisor_module.ctypes, + "get_last_error", + lambda: 5, + raising=False, + ) + actions = { + "create-job": ("CreateJobObjectW", 0, lambda: api.create_job()), + "set-kill-on-close": ( + "SetInformationJobObject", + False, + lambda: api.set_kill_on_close(41), + ), + "resume-thread": ("ResumeThread", 2, lambda: api.resume_thread(42)), + "terminate-job": ( + "TerminateJobObject", + False, + lambda: api.terminate_job(41), + ), + "wait-process": ( + "WaitForSingleObject", + 0xFFFFFFFF, + lambda: api.wait_process(43, 0.0), + ), + "exit-code": ( + "GetExitCodeProcess", + False, + lambda: api.process_exit_code(43), + ), + "query-job": ( + "QueryInformationJobObject", + False, + lambda: api.wait_for_job_empty(41, 0.0), + ), + "ctrl-break": ( + "GenerateConsoleCtrlEvent", + False, + lambda: api.send_ctrl_break(55), + ), + "close-handle": ("CloseHandle", False, lambda: api.close_handle(43)), + } + function_name, result, action = actions[operation] + kernel32.results[function_name] = result + + with pytest.raises(OSError): + action() + + +def test_windows_native_stdio_and_process_creation_preserve_explicit_inputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + kernel32 = _ConfiguredWin32Kernel() + create_file_calls: list[tuple[object, ...]] = [] + duplicate_calls: list[tuple[object, ...]] = [] + process_call: dict[str, object] = {} + standard_handles = iter([0, 77]) + + def create_file(*args): + create_file_calls.append(args) + return 88 + + def duplicate_handle(*args): + duplicate_calls.append(args) + args[3]._obj.value = 99 + return True + + def create_process( + _application, + command_line, + _process_attributes, + _thread_attributes, + inherit_handles, + creation_flags, + environment, + cwd, + startup_pointer, + process_information, + ) -> bool: + startup = supervisor_module.ctypes.cast( + startup_pointer, + supervisor_module.ctypes.POINTER(supervisor_module._STARTUPINFOEXW), + ).contents + environment_text = environment[:] + process_call.update( + command=command_line.value, + environment_entries=environment_text.rstrip("\0").split("\0"), + environment_terminated=environment_text.endswith("\0\0"), + cwd=cwd, + inherit_handles=inherit_handles, + creation_flags=creation_flags, + stdio=( + startup.StartupInfo.hStdInput, + startup.StartupInfo.hStdOutput, + startup.StartupInfo.hStdError, + ), + attribute_pointer=startup.lpAttributeList, + ) + process_information._obj.hProcess = 501 + process_information._obj.hThread = 502 + process_information._obj.dwProcessId = 503 + return True + + kernel32.results.update( + { + "GetStdHandle": lambda _stream: next(standard_handles), + "CreateFileW": create_file, + "GetCurrentProcess": 17, + "DuplicateHandle": duplicate_handle, + "CreateProcessW": create_process, + "CloseHandle": True, + "DeleteProcThreadAttributeList": None, + } + ) + native = supervisor_module._CtypesWindowsLaunchNative(kernel32) + + assert native.get_standard_handle(-10) is None + assert native.get_standard_handle(-11) == 77 + assert native.open_null_handle(-10) == 88 + assert native.open_null_handle(-11) == 88 + assert native.duplicate_inheritable_handle(88) == 99 + attribute_list = supervisor_module._Win32AttributeList( + buffer=object(), + pointer=1234, + handle_array=(11, 12, 13), + job_array=(41,), + ) + + assert native.create_suspended_process( + ["python", "child canary.py"], + env={"z": "last", "A": "first"}, + cwd="C:\\runtime", + standard_handles=(11, 12, 13), + attribute_list=attribute_list, + ) == (501, 502, 503) + native.delete_handle_list(attribute_list) + native.close_handle(501) + native.close_handle(None) + + assert create_file_calls[0][1] == 0x80000000 + assert create_file_calls[1][1] == 0x40000000 + assert duplicate_calls[0][0:3] == (17, 88, 17) + assert process_call == { + "command": 'python "child canary.py"', + "environment_entries": ["A=first", "z=last"], + "environment_terminated": True, + "cwd": "C:\\runtime", + "inherit_handles": True, + "creation_flags": 0x00080604, + "stdio": (11, 12, 13), + "attribute_pointer": 1234, + } + + +@pytest.mark.parametrize( + ("function_name", "native_call"), + [ + ("CreateFileW", "open-null"), + ("DuplicateHandle", "duplicate"), + ("CreateProcessW", "create-process"), + ("CloseHandle", "close"), + ], +) +def test_windows_native_launch_failures_remain_internal_os_errors( + monkeypatch: pytest.MonkeyPatch, + function_name: str, + native_call: str, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + kernel32 = _ConfiguredWin32Kernel() + kernel32.results.update( + { + "CreateFileW": supervisor_module.ctypes.c_void_p(-1).value, + "GetCurrentProcess": 17, + "DuplicateHandle": False, + "CreateProcessW": False, + "CloseHandle": False, + } + ) + monkeypatch.setattr( + supervisor_module.ctypes, + "get_last_error", + lambda: 5, + raising=False, + ) + native = supervisor_module._CtypesWindowsLaunchNative(kernel32) + attribute_list = supervisor_module._Win32AttributeList( + buffer=object(), + pointer=1234, + handle_array=(11, 12, 13), + job_array=(41,), + ) + calls = { + "open-null": lambda: native.open_null_handle(-10), + "duplicate": lambda: native.duplicate_inheritable_handle(88), + "create-process": lambda: native.create_suspended_process( + ["child"], + env={}, + cwd=None, + standard_handles=(11, 12, 13), + attribute_list=attribute_list, + ), + "close": lambda: native.close_handle(501), + } + + with pytest.raises(OSError): + calls[native_call]() + + expected_calls = ( + ["GetCurrentProcess", "DuplicateHandle"] + if native_call == "duplicate" + else [function_name] + ) + assert [name for name, _args in kernel32.calls] == expected_calls + + +def test_windows_child_has_no_unassigned_post_creation_window() -> None: + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api() + child = supervisor_module._WindowsChild.start( + ["child"], + env={}, + cwd=None, + api=api, + ) + child.close() + + create_event = next( + value for name, value in api.events if name == "create-suspended" + ) + assert create_event[-1] == "job-handle" + assert "assign-job" not in [name for name, _value in api.events] + + +def test_windows_launcher_inherits_only_inheritable_stdio_duplicates() -> None: + from agentseek_api import process_supervisor as supervisor_module + + native = _FakeWindowsLaunchNative() + launcher = supervisor_module._WindowsProcessLauncher(native) + + result = launcher.create( + ["python", "child.py"], + env={"TOKEN": "value"}, + cwd="C:\\runtime", + job="job-handle", + ) + + assert result == ("process-handle", "thread-handle", 9127) + create_event = next( + value for name, value in native.events if name == "create-process" + ) + expected_duplicates = ( + "duplicate-stdin-handle", + "duplicate-stdout-handle", + "duplicate-stderr-handle", + ) + assert create_event["standard_handles"] == expected_duplicates + assert native.unrelated_inheritable_handle not in create_event["standard_handles"] + assert ( + "create-attribute-list", + (expected_duplicates, ("job-handle",)), + ) in native.events + assert native.events[-4:] == [ + ("delete-handle-list", "attribute-list"), + ("close-duplicate", "duplicate-stdin-handle"), + ("close-duplicate", "duplicate-stdout-handle"), + ("close-duplicate", "duplicate-stderr-handle"), + ] + + +@pytest.mark.parametrize("failure_index", [0, 1, 2]) +def test_windows_launcher_closes_partial_standard_handle_duplicates( + failure_index: int, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + native = _FakeWindowsLaunchNative(fail_duplicate_at=failure_index) + launcher = supervisor_module._WindowsProcessLauncher(native) + + with pytest.raises(OSError, match="duplicate-canary"): + launcher.create(["child"], env={}, cwd=None, job="job-handle") + + closed_duplicates = [ + value + for name, value in native.events + if name == "close-duplicate" and str(value).startswith("duplicate-") + ] + assert ( + closed_duplicates + == [ + "duplicate-stdin-handle", + "duplicate-stdout-handle", + ][:failure_index] + ) + + +def test_windows_launcher_substitutes_null_for_missing_stdin() -> None: + from agentseek_api import process_supervisor as supervisor_module + + native = _FakeWindowsLaunchNative(missing_streams=frozenset({-10})) + launcher = supervisor_module._WindowsProcessLauncher(native) + + launcher.create(["child"], env={}, cwd=None, job="job-handle") + + create_event = next( + value for name, value in native.events if name == "create-process" + ) + assert create_event["standard_handles"] == ( + "duplicate-null-handle--10", + "duplicate-stdout-handle", + "duplicate-stderr-handle", + ) + assert ( + "create-attribute-list", + (create_event["standard_handles"], ("job-handle",)), + ) in native.events + assert ("close-duplicate", "null-handle--10") in native.events + + +def test_windows_launcher_attempts_all_cleanup_before_aborting_created_process() -> ( + None +): + from agentseek_api import process_supervisor as supervisor_module + + native = _FakeWindowsLaunchNative( + fail_delete=True, + fail_close=frozenset({"duplicate-stdout-handle"}), + fail_abort=True, + ) + launcher = supervisor_module._WindowsProcessLauncher(native) + + with pytest.raises(OSError, match="delete-canary"): + launcher.create(["child"], env={}, cwd=None, job="job-handle") + + assert ("abort-process", ("process-handle", "thread-handle")) in native.events + assert [value for name, value in native.events if name == "close-duplicate"] == [ + "duplicate-stdin-handle", + "duplicate-stdout-handle", + "duplicate-stderr-handle", + ] + + +def test_windows_launcher_job_attribute_failure_closes_stdio_without_creation() -> None: + from agentseek_api import process_supervisor as supervisor_module + + native = _FakeWindowsLaunchNative(fail_attribute_list=True) + launcher = supervisor_module._WindowsProcessLauncher(native) + + with pytest.raises(OSError, match="attribute-list-canary"): + launcher.create(["child"], env={}, cwd=None, job="job-handle") + + assert "create-process" not in [name for name, _value in native.events] + assert [value for name, value in native.events if name == "close-duplicate"] == [ + "duplicate-stdin-handle", + "duplicate-stdout-handle", + "duplicate-stderr-handle", + ] + + +class _FakeAbortKernel32: + def __init__(self, failure_point: str) -> None: + self.failure_point = failure_point + self.events: list[tuple[str, object]] = [] + + def TerminateProcess(self, process, exit_code: int) -> bool: + self.events.append(("terminate", (process, exit_code))) + return self.failure_point != "terminate" + + def WaitForSingleObject(self, process, timeout: int) -> int: + self.events.append(("wait", (process, timeout))) + if self.failure_point == "wait-timeout": + return 258 + if self.failure_point == "wait-failed": + return 0xFFFFFFFF + return 0 + + def CloseHandle(self, handle) -> bool: + self.events.append(("close", handle)) + return self.failure_point != f"close-{handle}" + + +@pytest.mark.parametrize( + "failure_point", + [ + "terminate", + "wait-timeout", + "wait-failed", + "close-thread-handle", + "close-process-handle", + ], +) +def test_windows_native_abort_reports_failures_after_attempting_all_cleanup( + failure_point: str, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + kernel32 = _FakeAbortKernel32(failure_point) + native = supervisor_module._CtypesWindowsLaunchNative(kernel32) + + with pytest.raises(OSError): + native.abort_suspended_process("process-handle", "thread-handle") + + assert kernel32.events == [ + ("terminate", ("process-handle", 1)), + ("wait", ("process-handle", 5000)), + ("close", "thread-handle"), + ("close", "process-handle"), + ] + + +def test_windows_child_is_created_in_kill_on_close_job_before_resume() -> None: + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api() + child = supervisor_module._WindowsChild.start( + ["python", "command-canary"], + env={"SECRET": "environment-canary"}, + cwd="C:\\runtime", + api=api, + ) + child.close() + + names = [name for name, _value in api.events] + assert names[:5] == [ + "create-job", + "set-kill-on-close", + "create-suspended", + "resume-thread", + "close-thread-handle", + ] + create_event = next( + value for name, value in api.events if name == "create-suspended" + ) + assert create_event[-1] == "job-handle" + assert "assign-job" not in names + assert names.index("create-suspended") < names.index("resume-thread") + assert names[-2:] == ["close-process-handle", "close-job-handle"] + + +@pytest.mark.parametrize( + "failure_point", + [ + "set-kill-on-close", + "create-suspended", + "resume-thread", + "close-thread-handle", + ], +) +def test_windows_setup_failure_terminates_and_closes_every_acquired_handle( + failure_point: str, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api(fail_at=failure_point) + + with pytest.raises(supervisor_module.ProcessSupervisionError) as captured: + supervisor_module._WindowsChild.start( + ["command-canary"], + env={"SECRET": "environment-canary"}, + cwd=None, + api=api, + ) + + names = [name for name, _value in api.events] + assert "job-handle" not in str(captured.value) + assert "setup-canary" not in str(captured.value) + assert "close-job-handle" in names + if failure_point in {"resume-thread", "close-thread-handle"}: + assert "close-process-handle" in names + assert "close-thread-handle" in names + if failure_point in {"resume-thread", "close-thread-handle"}: + assert "terminate-job" in names + assert "terminate-process" not in names + + +def test_windows_interrupt_timeout_terminates_job_and_closes_handles() -> None: + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api(job_empty_results=[False, True]) + child = supervisor_module._WindowsChild.start( + ["child"], + env={}, + cwd=None, + api=api, + ) + + child.forward_and_reap(signal.SIGINT, timeout=5.0) + child.close() + + names = [name for name, _value in api.events] + assert "ctrl-break" in names + assert "terminate-job" in names + assert names[-2:] == ["close-process-handle", "close-job-handle"] + wait_timeouts = [value[1] for name, value in api.events if name == "wait-process"] + assert wait_timeouts + assert all(timeout is not None for timeout in wait_timeouts) + + +def test_windows_unsupported_ctrl_break_falls_back_to_job_termination() -> None: + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api( + fail_at="ctrl-break", + job_empty_results=[True, True], + ) + child = supervisor_module._WindowsChild.start( + ["child"], + env={}, + cwd=None, + api=api, + ) + + child.forward_signal(signal.SIGINT) + api.failed = False + child.forward_and_reap(signal.SIGINT, timeout=5.0) + child.close() + + names = [name for name, _value in api.events] + assert names.count("ctrl-break") == 2 + assert names.count("terminate-job") == 2 + assert names[-2:] == ["close-process-handle", "close-job-handle"] + + +def test_windows_ctrl_break_fallback_preserves_default_runner_exit_130( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import cli as cli_module + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api( + fail_at="ctrl-break", + job_empty_results=[True, True], + wait_process_results=[KeyboardInterrupt(), True], + ) + child = supervisor_module._WindowsChild.start( + ["command-canary"], + env={"SECRET": "environment-canary"}, + cwd=None, + api=api, + ) + foreground = supervisor_module.ForegroundChildSupervisor(child) + monkeypatch.setattr( + cli_module.ForegroundChildSupervisor, + "start", + lambda command, *, env, cwd: foreground, + ) + + assert ( + cli_module._default_runner( + ["command-canary"], + env={"SECRET": "environment-canary"}, + cwd=None, + ) + == 130 + ) + + names = [name for name, _value in api.events] + assert "ctrl-break" in names + assert "terminate-job" in names + assert all( + value[1] is not None for name, value in api.events if name == "wait-process" + ) + assert names[-2:] == ["close-process-handle", "close-job-handle"] + + +def test_windows_normal_wait_polls_with_finite_intervals() -> None: + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api( + exit_code=47, + wait_process_results=[False, False, True], + ) + child = supervisor_module._WindowsChild.start( + ["child"], + env={}, + cwd=None, + api=api, + ) + + assert child.wait() == 47 + wait_timeouts = [value[1] for name, value in api.events if name == "wait-process"] + assert len(wait_timeouts) == 3 + assert all(timeout is not None and 0 < timeout <= 0.1 for timeout in wait_timeouts) + child.close() + + +def test_windows_normal_return_terminates_remaining_job_members() -> None: + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api(exit_code=31, job_empty_results=[False, True]) + child = supervisor_module._WindowsChild.start( + ["child"], + env={}, + cwd=None, + api=api, + ) + + assert child.wait() == 31 + child.close_remaining_tree(timeout=5.0) + child.close() + + names = [name for name, _value in api.events] + assert names.count("terminate-job") == 1 + assert names.count("wait-job-empty") == 2 + + +def test_windows_native_cleanup_failure_still_closes_every_handle() -> None: + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api( + fail_at="terminate-job", + job_empty_results=[False], + ) + child = supervisor_module._WindowsChild.start( + ["child"], + env={}, + cwd=None, + api=api, + ) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + try: + child.ensure_closed(timeout=5.0) + finally: + child.close() + + names = [name for name, _value in api.events] + assert names[-2:] == ["close-process-handle", "close-job-handle"] + + +def test_windows_sigterm_and_forced_cleanup_wait_for_job_and_direct_process() -> None: + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api( + job_empty_results=[True, True], + wait_process_results=[True, True], + ) + child = supervisor_module._WindowsChild.start( + ["child"], + env={}, + cwd=None, + api=api, + ) + + child.forward_signal(signal.SIGTERM) + child.forward_and_reap(signal.SIGTERM, timeout=5.0) + child.terminate_and_reap(timeout=5.0) + child.close() + + names = [name for name, _value in api.events] + assert names.count("terminate-job") == 3 + assert names.count("wait-job-empty") == 2 + assert names.count("wait-process") == 2 + assert names[-2:] == ["close-process-handle", "close-job-handle"] + + +@pytest.mark.parametrize( + "failure_point", + [ + "forward-job-timeout", + "forward-process-timeout", + "terminate-job-timeout", + "terminate-process-timeout", + "remaining-tree-timeout", + ], +) +def test_windows_cleanup_timeouts_fail_closed_after_bounded_wait( + failure_point: str, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + if failure_point == "remaining-tree-timeout": + job_empty_results = [False, False] + else: + job_empty_results = [failure_point.endswith("process-timeout")] + wait_process_results = [False] + api = _FakeWin32Api( + job_empty_results=job_empty_results, + wait_process_results=wait_process_results, + ) + child = supervisor_module._WindowsChild.start( + ["child"], + env={}, + cwd=None, + api=api, + ) + operations = { + "forward-job-timeout": lambda: child.forward_and_reap( + signal.SIGTERM, + timeout=0.0, + ), + "forward-process-timeout": lambda: child.forward_and_reap( + signal.SIGTERM, + timeout=0.0, + ), + "terminate-job-timeout": lambda: child.terminate_and_reap(timeout=0.0), + "terminate-process-timeout": lambda: child.terminate_and_reap(timeout=0.0), + "remaining-tree-timeout": lambda: child.close_remaining_tree(timeout=0.0), + } + + with pytest.raises(supervisor_module.ProcessSupervisionError): + operations[failure_point]() + + child.close() + job_waits = [value for name, value in api.events if name == "wait-job-empty"] + wait_timeouts = [value[1] for name, value in api.events if name == "wait-process"] + assert len(job_waits) == (2 if failure_point == "remaining-tree-timeout" else 1) + if failure_point.endswith("process-timeout"): + assert wait_timeouts == [0.0] + else: + assert wait_timeouts == [] + + +@pytest.mark.parametrize("failure_point", ["wait", "forward", "close"]) +def test_windows_child_native_failures_are_value_free_and_attempt_handle_cleanup( + failure_point: str, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api( + fail_at={ + "wait": "wait-process", + "forward": "terminate-job", + "close": "close-process-handle", + }[failure_point], + wait_process_results=[True], + ) + child = supervisor_module._WindowsChild.start( + ["child"], + env={}, + cwd=None, + api=api, + ) + actions = { + "wait": child.wait, + "forward": lambda: child.forward_signal(signal.SIGTERM), + "close": child.close, + } + + with pytest.raises(supervisor_module.ProcessSupervisionError) as captured: + actions[failure_point]() + + if failure_point != "close": + child.close() + names = [name for name, _value in api.events] + assert "setup-canary" not in str(captured.value) + assert names[-2:] == ["close-process-handle", "close-job-handle"] diff --git a/tests/unit/test_runtime_entrypoint.py b/tests/unit/test_runtime_entrypoint.py new file mode 100644 index 0000000..58d5c09 --- /dev/null +++ b/tests/unit/test_runtime_entrypoint.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import runpy +import sys + +import pytest +from pydantic import ValidationError + + +@pytest.mark.parametrize("arguments", [[], ["unknown-target"]]) +def test_runtime_entrypoint_rejects_unknown_internal_targets( + arguments: list[str], + capsys: pytest.CaptureFixture[str], +) -> None: + from agentseek_api.runtime_entrypoint import main + + assert main(arguments) == 2 + assert capsys.readouterr().err == "Invalid internal runtime target.\n" + + +def test_runtime_entrypoint_dispatches_target_with_isolated_argv( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import runtime_entrypoint + + original_argv = ["parent", "parent-canary"] + observed: list[tuple[str, str, list[str]]] = [] + monkeypatch.setattr(sys, "argv", original_argv) + monkeypatch.setattr( + runtime_entrypoint.importlib, + "import_module", + lambda name: observed.append(("import", name, list(sys.argv))), + ) + monkeypatch.setattr( + runtime_entrypoint.runpy, + "run_module", + lambda name, *, run_name: observed.append((name, run_name, list(sys.argv))), + ) + + assert runtime_entrypoint.main(["uvicorn", "--", "app:api", "--port", "8080"]) == 0 + + assert observed == [ + ( + "import", + "agentseek_api.settings", + ["uvicorn.__main__", "app:api", "--port", "8080"], + ), + ( + "uvicorn.__main__", + "__main__", + ["uvicorn.__main__", "app:api", "--port", "8080"], + ), + ] + assert sys.argv is original_argv + + +def test_runtime_entrypoint_uses_process_argv_when_arguments_are_omitted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import runtime_entrypoint + + observed: list[tuple[str, list[str]]] = [] + monkeypatch.setattr(sys, "argv", ["entrypoint", "scheduler", "--once"]) + monkeypatch.setattr( + runtime_entrypoint.importlib, "import_module", lambda _name: None + ) + monkeypatch.setattr( + runtime_entrypoint.runpy, + "run_module", + lambda name, *, run_name: observed.append((name, list(sys.argv))), + ) + + assert runtime_entrypoint.main() == 0 + assert observed == [ + ("agentseek_api.scheduler", ["agentseek_api.scheduler", "--once"]) + ] + assert sys.argv == ["entrypoint", "scheduler", "--once"] + + +def test_runtime_entrypoint_redacts_settings_validation_input( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + from agentseek_api import runtime_entrypoint + from agentseek_api.settings import Settings + + with pytest.raises(ValidationError) as captured: + Settings.model_validate({"PORT": "settings-input-canary"}) + + run_called = False + + def fail_settings_import(_name: str) -> None: + raise captured.value + + def record_run(*_args, **_kwargs) -> None: + nonlocal run_called + run_called = True + + parent_argv = ["parent"] + monkeypatch.setattr(sys, "argv", parent_argv) + monkeypatch.setattr( + runtime_entrypoint.importlib, + "import_module", + fail_settings_import, + ) + monkeypatch.setattr(runtime_entrypoint.runpy, "run_module", record_run) + + assert runtime_entrypoint.main(["worker"]) == 2 + + stderr = capsys.readouterr().err + assert stderr == "Invalid runtime setting(s): PORT (int_parsing).\n" + assert "settings-input-canary" not in stderr + assert run_called is False + assert sys.argv is parent_argv + + +@pytest.mark.parametrize( + ("system_exit_code", "expected"), + [(37, 37), (None, 0), ("non-integer-canary", 1)], +) +def test_runtime_entrypoint_normalizes_target_system_exit( + system_exit_code: object, + expected: int, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import runtime_entrypoint + + monkeypatch.setattr( + runtime_entrypoint.importlib, "import_module", lambda _name: None + ) + monkeypatch.setattr( + runtime_entrypoint.runpy, + "run_module", + lambda *_args, **_kwargs: (_ for _ in ()).throw(SystemExit(system_exit_code)), + ) + + assert runtime_entrypoint.main(["worker"]) == expected + + +def test_runtime_entrypoint_restores_argv_when_target_crashes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import runtime_entrypoint + + parent_argv = ["parent", "argv-canary"] + monkeypatch.setattr(sys, "argv", parent_argv) + monkeypatch.setattr( + runtime_entrypoint.importlib, "import_module", lambda _name: None + ) + monkeypatch.setattr( + runtime_entrypoint.runpy, + "run_module", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("target-canary")), + ) + + with pytest.raises(RuntimeError, match="target-canary"): + runtime_entrypoint.main(["scheduler"]) + + assert sys.argv is parent_argv + + +def test_runtime_entrypoint_module_execution_uses_cli_exit_code( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(sys, "argv", ["runtime-entrypoint", "invalid-target"]) + monkeypatch.delitem(sys.modules, "agentseek_api.runtime_entrypoint", raising=False) + + with pytest.raises(SystemExit) as captured: + runpy.run_module("agentseek_api.runtime_entrypoint", run_name="__main__") + + assert captured.value.code == 2 diff --git a/tests/unit/test_runtime_environment.py b/tests/unit/test_runtime_environment.py new file mode 100644 index 0000000..d4d1286 --- /dev/null +++ b/tests/unit/test_runtime_environment.py @@ -0,0 +1,603 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +def _write_config( + root: Path, + *, + env: str | dict[str, object] | None, + auth_path: str | None = None, +) -> Path: + payload: dict[str, object] = { + "graphs": {"chat": "chat.graph:graph"}, + } + if env is not None: + payload["env"] = env + if auth_path is not None: + payload["auth"] = {"path": auth_path} + config_path = root / "langgraph.json" + config_path.write_text(json.dumps(payload), encoding="utf-8") + return config_path + + +@pytest.mark.parametrize( + ("cli_binding", "inherited", "expected"), + [ + ("TOKEN=from-cli\n", {}, "from-cli"), + ("TOKEN\n", {}, "from-config"), + ("TOKEN=\n", {}, ""), + ("TOKEN=from-cli\n", {"TOKEN": ""}, ""), + ("TOKEN=from-cli\n", {"TOKEN": "from-shell"}, "from-shell"), + ], + ids=[ + "cli-over-config", + "valueless-does-not-assign", + "explicit-empty-assigns", + "inherited-empty-is-final", + "inherited-nonempty-is-final", + ], +) +def test_host_runtime_assignment_matrix( + tmp_path: Path, + cli_binding: str, + inherited: dict[str, str], + expected: str, +) -> None: + from agentseek_api.cli import build_runtime_env + + config_env = tmp_path / "config.env" + config_env.write_text("TOKEN=from-config\n", encoding="utf-8") + config_path = _write_config(tmp_path, env="./config.env") + cli_env = tmp_path / "cli.env" + cli_env.write_text(cli_binding, encoding="utf-8") + + actual = build_runtime_env( + config_path=config_path, + env_file=str(cli_env), + cwd=tmp_path, + base_env=inherited, + ) + + assert actual["TOKEN"] == expected + + +def test_config_mapping_and_auth_are_below_cli_and_inherited( + tmp_path: Path, +) -> None: + from agentseek_api.cli import build_runtime_env + + config_path = _write_config( + tmp_path, + env={"TOKEN": "from-mapping", "AUTH_MODULE_PATH": "from-env-mapping"}, + auth_path="auth.module:backend", + ) + cli_env = tmp_path / "cli.env" + cli_env.write_text( + "TOKEN=from-cli\nAUTH_MODULE_PATH=from-cli-auth\n", + encoding="utf-8", + ) + + actual = build_runtime_env( + config_path=config_path, + env_file=str(cli_env), + cwd=tmp_path, + base_env={ + "TOKEN": "from-shell", + "AUTH_MODULE_PATH": "", + }, + ) + + assert actual["TOKEN"] == "from-shell" + assert actual["AUTH_MODULE_PATH"] == "" + + +def test_config_dotenv_valueless_is_absent_and_empty_is_present( + tmp_path: Path, +) -> None: + from agentseek_api.cli import build_runtime_env + + config_env = tmp_path / "config.env" + config_env.write_text("VALUELESS\nEMPTY=\n", encoding="utf-8") + config_path = _write_config(tmp_path, env="./config.env") + + actual = build_runtime_env( + config_path=config_path, + env_file=None, + cwd=tmp_path, + base_env={}, + ) + + assert "VALUELESS" not in actual + assert actual["EMPTY"] == "" + + +@pytest.mark.parametrize( + "case", + [ + { + "id": "config-bare", + "config_env": ("dotenv", "KEY\n"), + "auth_path": None, + "cli_dotenv": None, + "inherited": {}, + "key": "KEY", + "present": False, + "value": None, + }, + { + "id": "config-empty", + "config_env": ("dotenv", "KEY=\n"), + "auth_path": None, + "cli_dotenv": None, + "inherited": {}, + "key": "KEY", + "present": True, + "value": "", + }, + { + "id": "config-value", + "config_env": ("dotenv", "KEY=config\n"), + "auth_path": None, + "cli_dotenv": None, + "inherited": {}, + "key": "KEY", + "present": True, + "value": "config", + }, + { + "id": "mapping-empty", + "config_env": {"KEY": ""}, + "auth_path": None, + "cli_dotenv": None, + "inherited": {}, + "key": "KEY", + "present": True, + "value": "", + }, + { + "id": "mapping-value", + "config_env": {"KEY": "mapping"}, + "auth_path": None, + "cli_dotenv": None, + "inherited": {}, + "key": "KEY", + "present": True, + "value": "mapping", + }, + { + "id": "auth-over-dotenv", + "config_env": ("dotenv", "AUTH_MODULE_PATH=dotenv\n"), + "auth_path": "auth.module:backend", + "cli_dotenv": None, + "inherited": {}, + "key": "AUTH_MODULE_PATH", + "present": True, + "value": "auth.module:backend", + }, + { + "id": "cli-bare", + "config_env": {"KEY": "mapping"}, + "auth_path": None, + "cli_dotenv": "KEY\n", + "inherited": {}, + "key": "KEY", + "present": True, + "value": "mapping", + }, + { + "id": "cli-empty", + "config_env": {"KEY": "mapping"}, + "auth_path": None, + "cli_dotenv": "KEY=\n", + "inherited": {}, + "key": "KEY", + "present": True, + "value": "", + }, + { + "id": "cli-value", + "config_env": {"KEY": "mapping"}, + "auth_path": None, + "cli_dotenv": "KEY=cli\n", + "inherited": {}, + "key": "KEY", + "present": True, + "value": "cli", + }, + { + "id": "inherited-empty", + "config_env": {"KEY": "mapping"}, + "auth_path": None, + "cli_dotenv": "KEY=cli\n", + "inherited": {"KEY": ""}, + "key": "KEY", + "present": True, + "value": "", + }, + { + "id": "inherited-value", + "config_env": {"KEY": "mapping"}, + "auth_path": None, + "cli_dotenv": "KEY=cli\n", + "inherited": {"KEY": "shell"}, + "key": "KEY", + "present": True, + "value": "shell", + }, + { + "id": "inherited-empty-auth", + "config_env": ("dotenv", "AUTH_MODULE_PATH=dotenv\n"), + "auth_path": "auth.module:backend", + "cli_dotenv": "AUTH_MODULE_PATH=cli\n", + "inherited": {"AUTH_MODULE_PATH": ""}, + "key": "AUTH_MODULE_PATH", + "present": True, + "value": "", + }, + { + "id": "inherited-value-auth", + "config_env": ("dotenv", "AUTH_MODULE_PATH=dotenv\n"), + "auth_path": "auth.module:backend", + "cli_dotenv": "AUTH_MODULE_PATH=cli\n", + "inherited": {"AUTH_MODULE_PATH": "shell"}, + "key": "AUTH_MODULE_PATH", + "present": True, + "value": "shell", + }, + ], + ids=lambda case: case["id"], +) +def test_complete_host_assignment_collision_matrix( + tmp_path: Path, + case: dict[str, object], +) -> None: + from agentseek_api.cli import build_runtime_env + + config_source = case["config_env"] + if isinstance(config_source, tuple): + _, contents = config_source + assert isinstance(contents, str) + (tmp_path / "config.env").write_text(contents, encoding="utf-8") + config_env: str | dict[str, object] = "./config.env" + else: + assert isinstance(config_source, dict) + config_env = config_source + auth_path = case["auth_path"] + assert auth_path is None or isinstance(auth_path, str) + config_path = _write_config( + tmp_path, + env=config_env, + auth_path=auth_path, + ) + cli_dotenv = case["cli_dotenv"] + cli_env: Path | None = None + if cli_dotenv is not None: + assert isinstance(cli_dotenv, str) + cli_env = tmp_path / "cli.env" + cli_env.write_text(cli_dotenv, encoding="utf-8") + inherited = case["inherited"] + assert isinstance(inherited, dict) + + actual = build_runtime_env( + config_path=config_path, + env_file=str(cli_env) if cli_env is not None else None, + cwd=tmp_path, + base_env=inherited, + ) + + key = case["key"] + assert isinstance(key, str) + assert (key in actual) is case["present"] + if case["present"]: + assert actual[key] == case["value"] + + +def test_each_dotenv_file_uses_an_independent_interpolation_context( + tmp_path: Path, +) -> None: + from agentseek_api.cli import build_runtime_env + + config_env = tmp_path / "config.env" + config_env.write_text( + "ORIGIN=https://config.example\nCONFIG_RESULT=${ORIGIN}/v1\n", + encoding="utf-8", + ) + config_path = _write_config(tmp_path, env="./config.env") + cli_env = tmp_path / "cli.env" + cli_env.write_text("CLI_RESULT=${ORIGIN}/v2\n", encoding="utf-8") + + actual = build_runtime_env( + config_path=config_path, + env_file=str(cli_env), + cwd=tmp_path, + base_env={"ORIGIN": "https://shell.example"}, + ) + + assert actual == { + "CONFIG_RESULT": "https://config.example/v1", + "CLI_RESULT": "https://shell.example/v2", + "ORIGIN": "https://shell.example", + "AGENTSEEK_GRAPHS": str(config_path), + } + + +def test_cli_dotenv_does_not_interpolate_literal_config_mapping( + tmp_path: Path, +) -> None: + from agentseek_api.cli import build_runtime_env + + config_path = _write_config( + tmp_path, + env={"ORIGIN": "https://mapping.example"}, + ) + cli_env = tmp_path / "cli.env" + cli_env.write_text( + "RESULT=${ORIGIN:-https://fallback.example}/v1\n", + encoding="utf-8", + ) + + actual = build_runtime_env( + config_path=config_path, + env_file=str(cli_env), + cwd=tmp_path, + base_env={}, + ) + + assert actual["ORIGIN"] == "https://mapping.example" + assert actual["RESULT"] == "https://fallback.example/v1" + + +def test_inherited_override_does_not_recompute_earlier_file_value( + tmp_path: Path, +) -> None: + from agentseek_api.cli import build_runtime_env + + config_env = tmp_path / "config.env" + config_env.write_text( + "ORIGIN=https://config.example\nBASE_URL=${ORIGIN}/v1\n", + encoding="utf-8", + ) + config_path = _write_config(tmp_path, env="./config.env") + + actual = build_runtime_env( + config_path=config_path, + env_file=None, + cwd=tmp_path, + base_env={"ORIGIN": "https://shell.example"}, + ) + + assert actual["ORIGIN"] == "https://shell.example" + assert actual["BASE_URL"] == "https://config.example/v1" + + +def test_malformed_dotenv_returns_exit_2_without_starting_child( + tmp_path: Path, +) -> None: + from agentseek_api.cli import main + + config_path = _write_config(tmp_path, env=None) + env_file = tmp_path / "broken.env" + env_file.write_text('SECRET=must-not-leak\nBROKEN "value"\n', encoding="utf-8") + calls: list[list[str]] = [] + + def runner( + command: list[str], + *, + env: dict[str, str], + cwd: str | None = None, + ) -> int: + calls.append(command) + return 0 + + import io + + stderr = io.StringIO() + exit_code = main( + [ + "serve", + "--config", + str(config_path), + "--env-file", + str(env_file), + ], + runner=runner, + cwd=tmp_path, + stderr=stderr, + ) + + assert exit_code == 2 + assert calls == [] + assert "line 2" in stderr.getvalue() + assert "must-not-leak" not in stderr.getvalue() + + +@pytest.mark.parametrize( + "failure", + ["missing", "decode", "read"], +) +def test_unreadable_dotenv_returns_exit_2_without_starting_child( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure: str, +) -> None: + import io + + from agentseek_api.cli import main + + config_path = _write_config(tmp_path, env=None) + env_file = tmp_path / f"{failure}.env" + if failure == "decode": + env_file.write_bytes(b"TOKEN=\xff\n") + elif failure == "read": + env_file.write_text("TOKEN=hidden\n", encoding="utf-8") + original_open = Path.open + + def fail_selected_open(path: Path, *args: object, **kwargs: object): + if path == env_file: + raise PermissionError(13, "Permission denied", str(path)) + return original_open(path, *args, **kwargs) + + monkeypatch.setattr(Path, "open", fail_selected_open) + calls: list[list[str]] = [] + + def runner( + command: list[str], + *, + env: dict[str, str], + cwd: str | None = None, + ) -> int: + calls.append(command) + return 0 + + stderr = io.StringIO() + exit_code = main( + [ + "serve", + "--config", + str(config_path), + "--env-file", + str(env_file), + ], + runner=runner, + cwd=tmp_path, + stderr=stderr, + ) + + assert exit_code == 2 + assert calls == [] + if failure == "missing": + assert "does not exist" in stderr.getvalue() + elif failure == "decode": + assert "not valid UTF-8" in stderr.getvalue() + else: + assert "could not be read" in stderr.getvalue() + assert "hidden" not in stderr.getvalue() + + +def test_command_owned_graph_path_overrides_inherited_value( + tmp_path: Path, +) -> None: + from agentseek_api.cli import build_runtime_env + + config_path = _write_config(tmp_path, env=None) + + actual = build_runtime_env( + config_path=config_path, + env_file=None, + cwd=tmp_path, + base_env={"AGENTSEEK_GRAPHS": "/stale/manifest.json"}, + ) + + assert actual["AGENTSEEK_GRAPHS"] == str(config_path) + + +def test_command_owned_graph_path_is_absent_without_selected_config( + tmp_path: Path, +) -> None: + from agentseek_api.cli import build_runtime_env + + actual = build_runtime_env( + config_path=None, + env_file=None, + cwd=tmp_path, + base_env={"AGENTSEEK_GRAPHS": "/stale/manifest.json"}, + ) + + assert "AGENTSEEK_GRAPHS" not in actual + + +def test_shared_lifecycle_dotenv_mutation_cannot_replace_inherited_present_values( + tmp_path: Path, +) -> None: + from agentseek_api.cli import build_runtime_env + + shared_env = tmp_path / ".env" + shared_env.write_text( + "PRESENT=initial\nEMPTY=\nCHILD_ONLY=initial\n", + encoding="utf-8", + ) + snapshot = {"PRESENT": "initial", "EMPTY": ""} + shared_env.write_text( + "PRESENT=mutated\nEMPTY=mutated\nCHILD_ONLY=added-later\n", + encoding="utf-8", + ) + config_path = _write_config(tmp_path, env="./.env") + + actual = build_runtime_env( + config_path=config_path, + env_file=None, + cwd=tmp_path, + base_env=snapshot, + ) + + assert actual["PRESENT"] == "initial" + assert "EMPTY" in actual + assert actual["EMPTY"] == "" + assert actual["CHILD_ONLY"] == "added-later" + + +@pytest.mark.parametrize("role", ["dev", "serve", "worker", "scheduler"]) +@pytest.mark.parametrize( + ("source", "source_value"), + [ + ("config-dotenv", "false"), + ("config-mapping", "false"), + ("cli-dotenv", "false"), + ("inherited-empty", ""), + ("inherited-nonempty", "false"), + ], +) +def test_studio_auth_local_dev_is_command_owned_only_for_dev( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + role: str, + source: str, + source_value: str, +) -> None: + from agentseek_api.cli import main + + monkeypatch.delenv("STUDIO_AUTH_LOCAL_DEV", raising=False) + config_env: str | dict[str, object] | None = None + env_file: Path | None = None + if source == "config-dotenv": + (tmp_path / "config.env").write_text( + "STUDIO_AUTH_LOCAL_DEV=false\n", + encoding="utf-8", + ) + config_env = "./config.env" + elif source == "config-mapping": + config_env = {"STUDIO_AUTH_LOCAL_DEV": "false"} + elif source == "cli-dotenv": + env_file = tmp_path / "cli.env" + env_file.write_text("STUDIO_AUTH_LOCAL_DEV=false\n", encoding="utf-8") + else: + monkeypatch.setenv("STUDIO_AUTH_LOCAL_DEV", source_value) + config_path = _write_config(tmp_path, env=config_env) + captured_env: dict[str, str] | None = None + + def runner( + command: list[str], + *, + env: dict[str, str], + cwd: str | None = None, + ) -> int: + nonlocal captured_env + captured_env = env + return 0 + + argv = [role, "--config", str(config_path)] + if role == "dev": + argv.append("--no-reload") + if env_file is not None: + argv.extend(["--env-file", str(env_file)]) + + exit_code = main(argv, runner=runner, cwd=tmp_path) + + assert exit_code == 0 + assert captured_env is not None + expected = "true" if role == "dev" else source_value + assert captured_env["STUDIO_AUTH_LOCAL_DEV"] == expected diff --git a/uv.lock b/uv.lock index 5d02a21..eae8bb9 100644 --- a/uv.lock +++ b/uv.lock @@ -49,6 +49,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pymysql" }, + { name = "python-dotenv" }, { name = "redis" }, { name = "scalar-fastapi" }, { name = "sqlalchemy" }, @@ -82,19 +83,20 @@ requires-dist = [ { name = "greenlet", specifier = ">=3.1.0" }, { name = "langchain", specifier = ">=0.3.9" }, { name = "langchain-anthropic", specifier = ">=1.0.0" }, - { name = "langchain-core", specifier = ">=1.0.0" }, + { name = "langchain-core", specifier = ">=1.2.5" }, { name = "langchain-oceanbase", specifier = "==0.6.0" }, { name = "langchain-oceanbase", extras = ["pyseekdb"], marker = "extra == 'embedded'", specifier = "==0.6.0" }, { name = "langchain-openai", specifier = ">=1.0.0" }, - { name = "langgraph", specifier = ">=1.0.3" }, + { name = "langgraph", specifier = ">=1.0.6" }, { name = "langgraph-sdk", specifier = ">=0.3.5" }, { name = "mcp", specifier = ">=1.27.1,<2" }, { name = "pydantic", specifier = ">=2.8.0" }, { name = "pydantic-settings", specifier = ">=2.4.0" }, { name = "pymysql", specifier = ">=1.1.0" }, + { name = "python-dotenv", specifier = ">=1.0,<1.3" }, { name = "redis", specifier = ">=5.0.0" }, { name = "scalar-fastapi", specifier = ">=1.0.3" }, - { name = "sqlalchemy", specifier = ">=2.0.0" }, + { name = "sqlalchemy", specifier = ">=2.0.12" }, { name = "uvicorn", specifier = ">=0.30.0" }, ] provides-extras = ["embedded"] @@ -106,7 +108,7 @@ dev = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "langgraph-cli", extras = ["inmem"] }, { name = "pytest", specifier = ">=8.0.0" }, - { name = "pytest-asyncio", specifier = ">=0.23.0" }, + { name = "pytest-asyncio", specifier = ">=0.23.5" }, { name = "pytest-cov", specifier = ">=5.0.0" }, { name = "ruff", specifier = ">=0.6.0" }, ]