mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-30 04:15:45 +08:00
137 lines
4.8 KiB
Python
137 lines
4.8 KiB
Python
"""Installation-local writable defaults and awaited native process lifecycle."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import functools
|
|
import hashlib
|
|
import os
|
|
import signal
|
|
from pathlib import Path
|
|
|
|
SHARED_DATA_ROOT = Path("/data")
|
|
|
|
|
|
class ProcessCleanupUnconfirmed(BaseException):
|
|
"""Do not translate an unconfirmed cleanup into a successful daemon ack."""
|
|
|
|
|
|
def local_workspace(configured: str = "") -> str:
|
|
if os.environ.get("LANGBOT_PLUGIN_RUNTIME_PROFILE") != "shared":
|
|
return configured or os.getcwd()
|
|
path = Path(configured).expanduser() if configured else SHARED_DATA_ROOT / "workspace"
|
|
try:
|
|
if not configured:
|
|
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
if not path.is_dir() or not os.access(path, os.W_OK | os.X_OK):
|
|
raise OSError("directory is missing or not writable")
|
|
except OSError as exc:
|
|
raise ValueError(
|
|
f"Native workspace {path} must be a writable jail-visible directory; default is /data/workspace"
|
|
) from exc
|
|
return str(path.resolve())
|
|
|
|
|
|
def _group_alive(pgid: int) -> bool:
|
|
try:
|
|
os.killpg(pgid, 0)
|
|
except ProcessLookupError:
|
|
return False
|
|
# Linux may retain orphan zombies until namespace init reaps them. Zombies
|
|
# cannot execute; do not confuse them with a still-running child.
|
|
if Path("/proc").is_dir():
|
|
for entry in Path("/proc").iterdir():
|
|
if not entry.name.isdigit():
|
|
continue
|
|
try:
|
|
fields = (entry / "stat").read_text().rsplit(") ", 1)[1].split()
|
|
if int(fields[2]) == pgid and fields[0] not in ("Z", "X"):
|
|
return True
|
|
except (FileNotFoundError, ProcessLookupError):
|
|
continue
|
|
return False
|
|
return True
|
|
|
|
|
|
async def _terminate(process):
|
|
if os.name == "nt":
|
|
if process.returncode is None:
|
|
process.kill()
|
|
await asyncio.wait_for(process.wait(), timeout=2)
|
|
return
|
|
for sig, grace in ((signal.SIGTERM, 1.0), (signal.SIGKILL, 2.0)):
|
|
with contextlib.suppress(ProcessLookupError):
|
|
os.killpg(process.pid, sig)
|
|
deadline = asyncio.get_running_loop().time() + grace
|
|
while _group_alive(process.pid):
|
|
if asyncio.get_running_loop().time() >= deadline:
|
|
break
|
|
await asyncio.sleep(0.02)
|
|
if not _group_alive(process.pid):
|
|
await asyncio.wait_for(process.wait(), timeout=2)
|
|
return
|
|
raise ProcessCleanupUnconfirmed(f"process group {process.pid} did not stop; target must remain fenced")
|
|
|
|
|
|
async def terminate_process_group(process):
|
|
"""Shield reap/kill through repeated cancellation, then restore cancellation."""
|
|
task = asyncio.create_task(_terminate(process))
|
|
interrupted = False
|
|
while not task.done():
|
|
try:
|
|
await asyncio.shield(task)
|
|
except asyncio.CancelledError:
|
|
interrupted = True
|
|
except Exception:
|
|
break # classify cleanup errors below, never report a terminal ack
|
|
try:
|
|
task.result()
|
|
except Exception as exc:
|
|
raise ProcessCleanupUnconfirmed("native cleanup could not be confirmed") from exc
|
|
if interrupted:
|
|
raise asyncio.CancelledError
|
|
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def session_guard(home: str, session_id: str):
|
|
"""Serialize writes to the same vendor session, including separate daemons."""
|
|
if not session_id:
|
|
yield
|
|
return
|
|
# fcntl is available in the supported Linux shared workers. Dedicated Windows
|
|
# keeps its existing behavior; no POSIX process-tree guarantee is asserted.
|
|
if os.name == "nt":
|
|
yield
|
|
return
|
|
import fcntl
|
|
|
|
lockdir = Path(home).expanduser().resolve() / ".langbot-session-locks"
|
|
lockdir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
name = hashlib.sha256(session_id.encode()).hexdigest()
|
|
fd = os.open(lockdir / name, os.O_CREAT | os.O_RDWR, 0o600)
|
|
try:
|
|
while True:
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
break
|
|
except BlockingIOError:
|
|
await asyncio.sleep(0.05)
|
|
yield
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def serialize_codex_session(function):
|
|
@functools.wraps(function)
|
|
async def wrapped(*args, **kwargs):
|
|
env = kwargs.get("env") or {}
|
|
home = Path(env.get("CODEX_HOME") or os.environ.get("CODEX_HOME") or Path.home() / ".codex")
|
|
# Per-run homes link to the installation's common sessions directory.
|
|
async with session_guard(str((home / "sessions").resolve()), kwargs.get("resume_session_id") or ""):
|
|
async with contextlib.aclosing(function(*args, **kwargs)) as stream:
|
|
async for event in stream:
|
|
yield event
|
|
|
|
return wrapped
|