Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions src/yoke/process.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""Cross-platform process helpers for provider CLIs."""

from __future__ import annotations

import asyncio
import contextlib
import os
import shutil
import signal
import subprocess
from pathlib import Path

IS_WINDOWS = os.name == "nt"


def path_for_provider(path: str | Path) -> str:
"""Serialize a filesystem path for provider APIs with stable separators."""
return Path(path).as_posix()


def resolve_executable(command: str) -> str | None:
"""Resolve a command name to an executable path on the current platform.

On Windows this finds npm ``.CMD``/``.BAT`` shims that
``asyncio.create_subprocess_exec("codex", ...)`` cannot launch by bare
name.

A bare name always goes through ``PATH``. Only a command written as a path
is read from the filesystem, so a file named ``codex`` in the working
directory cannot shadow the real executable.
"""
if _is_path_like(command):
# Returned verbatim: normalising through Path would strip a leading
# ``./``, turning an explicit relative path back into a bare name that
# the exec call would then look up on PATH.
return command if Path(command).is_file() else None
return shutil.which(command)


def _is_path_like(command: str) -> bool:
"""Return whether a command names a filesystem path rather than a bare name."""
separators = (os.sep, os.altsep) if os.altsep else (os.sep,)
return any(separator in command for separator in separators)


def popen_start_new_session() -> bool:
"""Return whether new process groups are safe for this platform."""
# Windows process groups do not match POSIX killpg semantics and can leave
# npm shim trees behind when only the wrapper pid is terminated.
return not IS_WINDOWS


def process_is_alive(pid: int) -> bool:
"""Return whether a PID currently refers to a live process."""
if pid <= 0:
return False
if pid == os.getpid():
return True
if IS_WINDOWS:
return _windows_process_is_alive(pid)
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True


def _windows_process_is_alive(pid: int) -> bool:
import ctypes
from ctypes import wintypes

kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
process_query_limited_information = 0x1000
still_active = 259
handle = kernel32.OpenProcess(process_query_limited_information, 0, pid)
if not handle:
return False
try:
exit_code = wintypes.DWORD()
if kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)) == 0:
return False
return int(exit_code.value) == still_active
finally:
kernel32.CloseHandle(handle)


def kill_process_tree(pid: int) -> None:
"""Force-terminate a process and its descendants."""
if pid <= 0:
return
if IS_WINDOWS:
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
check=False,
capture_output=True,
creationflags=creationflags,
)
return
if _kill_process_group(pid):
return
with contextlib.suppress(ProcessLookupError, PermissionError):
os.kill(pid, signal.SIGKILL)


def _kill_process_group(pid: int) -> bool:
"""Kill the process group led by ``pid``, if it is safe to do so.

Children started with ``start_new_session=True`` lead their own group, so
signalling the group reaches descendants that a bare ``os.kill`` would
orphan. A child sharing this process's group is skipped: signalling that
group would kill the caller too.
"""
try:
group = os.getpgid(pid)
except (ProcessLookupError, PermissionError, OSError):
return False
if group in (os.getpgid(0), 0):
return False
try:
os.killpg(group, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
return False
return True


async def terminate_asyncio_process(process: asyncio.subprocess.Process) -> None:
"""Terminate an asyncio subprocess, including Windows npm shim trees."""
if process.returncode is not None or process.pid is None:
return
if IS_WINDOWS:
await asyncio.to_thread(kill_process_tree, process.pid)
try:
await asyncio.wait_for(process.wait(), timeout=2)
except TimeoutError:
process.kill()
await process.wait()
return
process.kill()
await process.wait()


def terminate_popen(process: subprocess.Popen[str]) -> None:
"""Terminate a ``subprocess.Popen`` process tree."""
if process.poll() is not None or process.pid is None:
return
kill_process_tree(process.pid)
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
with contextlib.suppress(subprocess.TimeoutExpired):
process.wait(timeout=0.5)
16 changes: 14 additions & 2 deletions src/yoke/providers/codex_app/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
from pydantic import JsonValue, TypeAdapter, ValidationError

from yoke.errors import YokeError
from yoke.process import (
IS_WINDOWS,
popen_start_new_session,
resolve_executable,
terminate_popen,
)
from yoke.providers.codex_app.fields import JsonObject, as_record, string_field

JSON_VALUE = TypeAdapter(JsonValue)
Expand All @@ -39,20 +45,23 @@ def start(
cwd: Path,
env: dict[str, str] | None,
) -> JsonRpcLineProcess:
executable = resolve_executable(command)
if executable is None:
raise FileNotFoundError(command)
process_env = dict(os.environ)
process_env.setdefault("YOKE_INTERNAL_SESSION", "1")
if env is not None:
process_env.update(env)
child = subprocess.Popen(
(command, *args),
(executable, *args),
cwd=cwd,
env=process_env,
text=True,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=1,
start_new_session=True,
start_new_session=popen_start_new_session(),
)
return cls(child)

Expand Down Expand Up @@ -87,6 +96,9 @@ def read_until(self, deadline: float, timeout_label: str) -> JsonObject:
def terminate(self) -> None:
if self.child.poll() is not None:
return
if IS_WINDOWS:
terminate_popen(self.child)
return
try:
os.killpg(os.getpgid(self.child.pid), signal.SIGTERM)
except (AttributeError, ProcessLookupError, PermissionError, OSError):
Expand Down
6 changes: 5 additions & 1 deletion src/yoke/providers/codex_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from typing import Any

from yoke.errors import YokeError
from yoke.process import resolve_executable

ORIGINATOR_ENV = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"
YOKE_ORIGINATOR = "yoke_python"
Expand Down Expand Up @@ -47,7 +48,10 @@ async def run(
) -> AsyncIterator[dict[str, Any]]:
schema_path: Path | None = None
try:
args = [self.executable, "exec", "--json", "--cd", str(cwd)]
executable = resolve_executable(self.executable)
if executable is None:
raise FileNotFoundError(self.executable)
args = [executable, "exec", "--json", "--cd", str(cwd)]
if model:
args.extend(["--model", model])
if sandbox:
Expand Down
13 changes: 1 addition & 12 deletions src/yoke/providers/runtime_deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from yoke.errors import YokeError
from yoke.models import Agent, Provider, Skill
from yoke.process import process_is_alive
from yoke.providers.codex_agents import (
codex_agent_name,
codex_agent_toml,
Expand Down Expand Up @@ -117,18 +118,6 @@ def runtime_owner_pid(name: str) -> int | None:
return pid if pid > 0 else None


def process_is_alive(pid: int) -> bool:
if pid == os.getpid():
return True
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True


def _write_codex(agent: Agent, deployment: RuntimeDeployment) -> None:
agents_dir = deployment.root / "agents"
entries, role_maps = _codex_roles(agent)
Expand Down
22 changes: 19 additions & 3 deletions src/yoke/readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import os
from dataclasses import dataclass

from yoke.process import resolve_executable, terminate_asyncio_process


@dataclass(frozen=True)
class CommandCheck:
Expand All @@ -29,11 +31,17 @@ async def run_command(
) -> CommandCheck:
"""Run one local readiness command."""

if not args:
raise ValueError("run_command requires at least one argument")
executable = resolve_executable(args[0])
if executable is None:
raise FileNotFoundError(args[0])
process_env = dict(os.environ)
if env is not None:
process_env.update(env)
process = await asyncio.create_subprocess_exec(
*args,
executable,
*args[1:],
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=process_env,
Expand All @@ -44,8 +52,7 @@ async def run_command(
timeout=timeout_seconds,
)
except TimeoutError:
process.kill()
await process.wait()
await terminate_asyncio_process(process)
raise
return CommandCheck(
code=process.returncode or 0,
Expand All @@ -59,3 +66,12 @@ def first_line(value: str) -> str:

lines = value.splitlines()
return lines[0] if lines else ""


__all__ = [
"CommandCheck",
"first_line",
"resolve_executable",
"run_command",
"terminate_asyncio_process",
]
Loading