mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 12:47:55 -10:00
Merge pull request #6212 from MervinPraison/claude/issue-6208-20261103
fix: gateway single-instance guard is state-scoped, not only port-scoped
This commit is contained in:
commit
feae4f936d
3 files changed
+260
-21
No files matched your search
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
@@ -86,19 +87,21 @@ def _process_create_time(pid: int) -> Optional[float]:
|
||||
class GatewayPIDLock:
|
||||
"""Manages PID lock file for single-instance gateway enforcement."""
|
||||
|
||||
def __init__(self, lock_dir: Optional[Path] = None, host: str = "127.0.0.1", port: int = 8765):
|
||||
def __init__(self, lock_dir: Optional[Path] = None, host: str = "127.0.0.1", port: int = 8765,
|
||||
lock_name: Optional[str] = None):
|
||||
"""Initialize PID lock manager.
|
||||
|
||||
Args:
|
||||
lock_dir: Directory for lock file (default: ~/.praisonai/)
|
||||
host: Gateway host
|
||||
port: Gateway port
|
||||
lock_name: Override the lock file name (used by the state lock)
|
||||
"""
|
||||
self.lock_dir = lock_dir or Path.home() / ".praisonai"
|
||||
# Make lock file specific to host and port to allow multiple instances
|
||||
safe_host = host.replace(":", "_").replace(".", "_")
|
||||
self.lock_file = self.lock_dir / f"gateway-{safe_host}-{port}.pid"
|
||||
self.lock_dir.mkdir(exist_ok=True)
|
||||
self.lock_file = self.lock_dir / (lock_name or f"gateway-{safe_host}-{port}.pid")
|
||||
self.lock_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def acquire_lock(self, host: str, port: int) -> bool:
|
||||
"""Acquire the PID lock for the gateway.
|
||||
@@ -110,6 +113,27 @@ class GatewayPIDLock:
|
||||
Returns:
|
||||
True if lock acquired successfully, False if conflict
|
||||
"""
|
||||
# Serialise check-then-claim across processes so two simultaneous
|
||||
# starts can never both observe "no live holder" and both win.
|
||||
with self._claim_guard():
|
||||
return self._acquire_locked(host, port)
|
||||
|
||||
@contextmanager
|
||||
def _claim_guard(self):
|
||||
"""Hold an OS advisory lock on a sidecar file for the claim (POSIX)."""
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # Windows: exclusive link() below still applies
|
||||
yield
|
||||
return
|
||||
guard = open(self.lock_file.with_name(self.lock_file.name + ".guard"), "a")
|
||||
try:
|
||||
fcntl.flock(guard, fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
guard.close() # closing drops the flock
|
||||
|
||||
def _acquire_locked(self, host: str, port: int) -> bool:
|
||||
current_pid = os.getpid()
|
||||
|
||||
# Check if lock file exists
|
||||
@@ -120,6 +144,9 @@ class GatewayPIDLock:
|
||||
lines = lock_content.split('\n')
|
||||
if lines:
|
||||
existing_pid = int(lines[0])
|
||||
if existing_pid == current_pid:
|
||||
# Re-acquire by the same process (in-process restart).
|
||||
return True
|
||||
existing_create_time = None
|
||||
if len(lines) >= 5 and lines[4]:
|
||||
try:
|
||||
@@ -145,14 +172,29 @@ class GatewayPIDLock:
|
||||
create_time = _process_create_time(current_pid)
|
||||
create_time_line = "" if create_time is None else repr(create_time)
|
||||
lock_content = f"{current_pid}\n{host}\n{port}\n{int(time.time())}\n{create_time_line}\n"
|
||||
# Exclusive claim: write a private temp file, then hard-link it into
|
||||
# place. ``link`` fails if the lock already exists, so a racing claimant
|
||||
# can never overwrite a live holder (unlike rename/replace).
|
||||
temp_lock_file = self.lock_file.with_name(f"{self.lock_file.name}.{current_pid}.tmp")
|
||||
try:
|
||||
# Use a temporary file and atomic rename to prevent race conditions
|
||||
temp_lock_file = self.lock_file.with_suffix(".tmp")
|
||||
temp_lock_file.write_text(lock_content)
|
||||
temp_lock_file.replace(self.lock_file)
|
||||
try:
|
||||
os.link(temp_lock_file, self.lock_file)
|
||||
except FileExistsError:
|
||||
return False
|
||||
except OSError:
|
||||
# Filesystem without hard links: fall back to O_EXCL create.
|
||||
fd = os.open(self.lock_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(lock_content)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
temp_lock_file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def release_lock(self) -> None:
|
||||
"""Release the PID lock."""
|
||||
@@ -253,6 +295,43 @@ class GatewayPIDLock:
|
||||
pass
|
||||
|
||||
|
||||
def resolve_state_dir() -> Path:
|
||||
"""Return the gateway's shared mutable state root (``~/.praisonai``).
|
||||
|
||||
The durable outbox, DLQ, pairing and hook stores are hard-wired under
|
||||
``~/.praisonai`` (they do not honour ``PRAISONAI_HOME``), so the home root -
|
||||
not the TCP port or ``PRAISONAI_HOME`` - is what identifies a gateway.
|
||||
"""
|
||||
return Path.home() / ".praisonai"
|
||||
|
||||
|
||||
def state_lock(state_dir: Optional[Path] = None, host: str = "127.0.0.1",
|
||||
port: int = 8765) -> GatewayPIDLock:
|
||||
"""Single-instance lock keyed on the shared state directory, regardless of port.
|
||||
|
||||
Two gateways sharing one state root would poll the same bot tokens and write
|
||||
the same SQLite stores concurrently (split-brain), so only one may own it.
|
||||
"""
|
||||
return GatewayPIDLock(lock_dir=state_dir or resolve_state_dir(), host=host,
|
||||
port=port, lock_name="gateway.state.pid")
|
||||
|
||||
|
||||
def format_state_collision_error(state_dir: Path, lock_info: Optional[dict] = None) -> str:
|
||||
"""Error shown when another live gateway already owns the shared state."""
|
||||
holder = ""
|
||||
if lock_info:
|
||||
holder = f" (PID {lock_info['pid']} on {lock_info['host']}:{lock_info['port']})"
|
||||
return "\n".join([
|
||||
f"Error: another gateway already owns the state at {state_dir}{holder}.",
|
||||
"",
|
||||
" Two gateways sharing state poll the same bot tokens and write the same",
|
||||
" queues, causing 409 Conflict errors and duplicate delivery.",
|
||||
" Stop it: praisonai gateway stop",
|
||||
" Or run an isolated gateway with its own state and bot tokens:",
|
||||
" HOME=/srv/gateway-b praisonai gateway start",
|
||||
])
|
||||
|
||||
|
||||
def format_collision_error(host: str, port: int, lock_info: Optional[dict] = None) -> str:
|
||||
"""Format a user-friendly error message for port collisions.
|
||||
|
||||
@@ -275,10 +354,11 @@ def format_collision_error(host: str, port: int, lock_info: Optional[dict] = Non
|
||||
pid = lock_info['pid']
|
||||
lines.extend([
|
||||
f" Another gateway may be running (PID {pid}).",
|
||||
f" Stop it: praisonai gateway stop",
|
||||
f" Or use a different port: GATEWAY_PORT={port + 1} praisonai gateway start",
|
||||
" Stop it: praisonai gateway stop",
|
||||
"",
|
||||
" Only ONE gateway process should poll each Telegram bot token.",
|
||||
" Only ONE gateway process may poll each bot token. To run a second,",
|
||||
" isolated gateway give it its own state, bot tokens and port:",
|
||||
f" HOME=/srv/gateway-b GATEWAY_PORT={port + 1} praisonai gateway start",
|
||||
])
|
||||
else:
|
||||
lines.extend([
|
||||
|
||||
@@ -1412,6 +1412,7 @@ class WebSocketGateway:
|
||||
|
||||
# PID lock for single-instance enforcement
|
||||
self._pid_lock: Optional[Any] = None
|
||||
self._state_lock: Optional[Any] = None
|
||||
|
||||
# Channel supervisor for resilient bot management. Share the gateway's
|
||||
# degraded-capability registry so the fleet crash-loop breaker (Issue
|
||||
@@ -1544,9 +1545,46 @@ class WebSocketGateway:
|
||||
logger.error("Gateway startup failed - another instance is running")
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
# Store PID lock for cleanup
|
||||
self._pid_lock = pid_lock
|
||||
|
||||
# When ``start_with_config`` already claimed the state root, its channel
|
||||
# bots are still polling if we fail here; only it may release that lock
|
||||
# (after channels stop), or a second gateway could claim the state.
|
||||
owns_state_lock = self._state_lock is None
|
||||
try:
|
||||
self._acquire_state_lock()
|
||||
await self._start_locked()
|
||||
except BaseException:
|
||||
# Any failure after claiming (bad config, bind error, cancel) must
|
||||
# not leave a live process holding the locks and blocking restarts.
|
||||
self._release_locks(state=owns_state_lock)
|
||||
raise
|
||||
|
||||
def _acquire_state_lock(self) -> None:
|
||||
"""Claim the shared state root (idempotent).
|
||||
|
||||
The port lock alone admits a second gateway on another port sharing the
|
||||
same state + bot tokens (split-brain), so the state root is locked too.
|
||||
"""
|
||||
if self._state_lock is not None:
|
||||
return
|
||||
from .port_utils import state_lock, resolve_state_dir, format_state_collision_error
|
||||
state_dir = resolve_state_dir()
|
||||
st_lock = state_lock(state_dir, host=self._host, port=self._port)
|
||||
if not st_lock.acquire_lock(self._host, self._port):
|
||||
logger.error("Gateway startup failed - another instance owns the shared state")
|
||||
raise RuntimeError(format_state_collision_error(state_dir, st_lock.get_lock_info()))
|
||||
self._state_lock = st_lock
|
||||
|
||||
def _release_locks(self, state: bool = True) -> None:
|
||||
"""Release the port lock and (if ``state``) the state lock (idempotent)."""
|
||||
for attr in ("_pid_lock", "_state_lock") if state else ("_pid_lock",):
|
||||
lock = getattr(self, attr, None)
|
||||
if lock:
|
||||
lock.release_lock()
|
||||
setattr(self, attr, None)
|
||||
|
||||
async def _start_locked(self) -> None:
|
||||
"""Body of :meth:`start` once the single-instance locks are held."""
|
||||
# Validate bind-aware auth configuration before starting
|
||||
from .auth import assert_external_bind_safe
|
||||
self.config.host = self._host
|
||||
@@ -2768,14 +2806,8 @@ class WebSocketGateway:
|
||||
self._ledger_heartbeat_loop(), name="gateway-lifecycle-ledger"
|
||||
)
|
||||
try:
|
||||
# Lock cleanup on failure is handled by ``start()``.
|
||||
await self._server.serve()
|
||||
except Exception as e:
|
||||
# Clean up PID lock on any startup failure
|
||||
if hasattr(self, '_pid_lock') and self._pid_lock:
|
||||
self._pid_lock.release_lock()
|
||||
self._pid_lock = None
|
||||
# Re-raise the original exception
|
||||
raise
|
||||
finally:
|
||||
self._disarm_watchdog()
|
||||
await self._cancel_liveness_task()
|
||||
@@ -3993,9 +4025,7 @@ class WebSocketGateway:
|
||||
logger.debug("Failed to shut down pooled MCP client")
|
||||
self._mcp_pool.clear()
|
||||
|
||||
# Release PID lock
|
||||
if hasattr(self, '_pid_lock') and self._pid_lock:
|
||||
self._pid_lock.release_lock()
|
||||
self._release_locks()
|
||||
|
||||
logger.info("Gateway stopped")
|
||||
|
||||
@@ -12436,6 +12466,9 @@ class WebSocketGateway:
|
||||
except (NotImplementedError, OSError, ValueError):
|
||||
pass
|
||||
|
||||
# Claim the state root BEFORE channels start: channel setup replays the
|
||||
# inbound journal, which a rejected second gateway must never touch.
|
||||
self._acquire_state_lock()
|
||||
try:
|
||||
await _run_all()
|
||||
finally:
|
||||
@@ -12486,3 +12519,4 @@ class WebSocketGateway:
|
||||
logger.warning("Error draining websocket sessions: %s", e)
|
||||
finally:
|
||||
self._cancel_shutdown_deadline()
|
||||
self._release_locks()
|
||||
@@ -0,0 +1,125 @@
|
||||
"""A gateway's single-instance identity is its shared state, not its port (#6208)."""
|
||||
|
||||
import asyncio
|
||||
import multiprocessing
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonai_bot.gateway.port_utils import (
|
||||
format_collision_error,
|
||||
format_state_collision_error,
|
||||
resolve_state_dir,
|
||||
state_lock,
|
||||
)
|
||||
|
||||
|
||||
def _claim(d, start, done, results):
|
||||
start.wait() # release every worker at once so the claims genuinely race
|
||||
results.put(state_lock(Path(d)).acquire_lock("127.0.0.1", 8765))
|
||||
done.wait() # stay alive until all claimed, so no lock is seen as stale
|
||||
|
||||
|
||||
def _seed_foreign_holder(lock, pid):
|
||||
lock.lock_file.write_text(f"{pid}\n127.0.0.1\n8765\n0\n\n")
|
||||
|
||||
|
||||
def test_second_gateway_on_another_port_is_refused(tmp_path, monkeypatch):
|
||||
first = state_lock(tmp_path, port=8765)
|
||||
_seed_foreign_holder(first, os.getppid()) # a live process other than us
|
||||
second = state_lock(tmp_path, port=8766)
|
||||
assert second.lock_file == first.lock_file
|
||||
assert second.acquire_lock("127.0.0.1", 8766) is False
|
||||
msg = format_state_collision_error(tmp_path, second.get_lock_info())
|
||||
assert str(tmp_path) in msg and "HOME=" in msg and "PRAISONAI_HOME" not in msg
|
||||
|
||||
|
||||
def test_distinct_state_dirs_are_independent(tmp_path):
|
||||
a = state_lock(tmp_path / "a")
|
||||
_seed_foreign_holder(a, os.getppid())
|
||||
assert state_lock(tmp_path / "b").acquire_lock("127.0.0.1", 8766) is True
|
||||
|
||||
|
||||
def test_stale_state_lock_is_reclaimed(tmp_path, monkeypatch):
|
||||
lock = state_lock(tmp_path)
|
||||
_seed_foreign_holder(lock, 999999)
|
||||
monkeypatch.setattr(lock, "_is_process_running", lambda pid: False)
|
||||
assert lock.acquire_lock("127.0.0.1", 8765) is True
|
||||
|
||||
|
||||
def test_state_dir_ignores_praisonai_home(tmp_path, monkeypatch):
|
||||
# The stores live under ~/.praisonai regardless of PRAISONAI_HOME, so a
|
||||
# different PRAISONAI_HOME must not escape the lock.
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path))
|
||||
assert resolve_state_dir() == Path.home() / ".praisonai"
|
||||
|
||||
|
||||
def test_concurrent_claims_have_exactly_one_winner(tmp_path):
|
||||
ctx = multiprocessing.get_context("spawn")
|
||||
n = 8
|
||||
start, done, results = ctx.Barrier(n), ctx.Barrier(n), ctx.Queue()
|
||||
procs = [ctx.Process(target=_claim, args=(str(tmp_path), start, done, results)) for _ in range(n)]
|
||||
for p in procs:
|
||||
p.start()
|
||||
outcomes = [results.get(timeout=60) for _ in range(n)]
|
||||
for p in procs:
|
||||
p.join(timeout=60)
|
||||
assert sum(outcomes) == 1
|
||||
|
||||
|
||||
def test_claim_never_overwrites_existing_lock(tmp_path):
|
||||
lock = state_lock(tmp_path)
|
||||
_seed_foreign_holder(lock, os.getppid())
|
||||
assert lock.acquire_lock("127.0.0.1", 8766) is False
|
||||
assert lock.lock_file.read_text().startswith(str(os.getppid()))
|
||||
|
||||
|
||||
def test_failed_startup_releases_both_locks(tmp_path, monkeypatch):
|
||||
from praisonai_bot.gateway import server as srv
|
||||
|
||||
monkeypatch.setenv("HOME", str(tmp_path)) # isolate both lock files
|
||||
gw = srv.WebSocketGateway.__new__(srv.WebSocketGateway)
|
||||
gw._is_running, gw._host, gw._port = False, "127.0.0.1", 0
|
||||
gw._pid_lock = gw._state_lock = None
|
||||
|
||||
async def boom():
|
||||
raise RuntimeError("bad config")
|
||||
|
||||
gw._start_locked = boom
|
||||
monkeypatch.setattr("praisonai_bot.gateway.port_utils.check_port_available", lambda h, p: (True, None))
|
||||
with pytest.raises(RuntimeError, match="bad config"):
|
||||
asyncio.run(gw.start())
|
||||
assert gw._pid_lock is None and gw._state_lock is None
|
||||
assert not list((tmp_path / ".praisonai").glob("*.pid"))
|
||||
|
||||
|
||||
def test_start_failure_keeps_state_lock_owned_by_start_with_config(tmp_path, monkeypatch):
|
||||
# start_with_config claims the state lock before channels start; if start()
|
||||
# then fails, channels are still polling, so start() must not drop it.
|
||||
from praisonai_bot.gateway import server as srv
|
||||
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
gw = srv.WebSocketGateway.__new__(srv.WebSocketGateway)
|
||||
gw._is_running, gw._host, gw._port = False, "127.0.0.1", 0
|
||||
gw._pid_lock = gw._state_lock = None
|
||||
gw._acquire_state_lock()
|
||||
held = gw._state_lock
|
||||
|
||||
async def boom():
|
||||
raise RuntimeError("bind error")
|
||||
|
||||
gw._start_locked = boom
|
||||
monkeypatch.setattr("praisonai_bot.gateway.port_utils.check_port_available", lambda h, p: (True, None))
|
||||
with pytest.raises(RuntimeError, match="bind error"):
|
||||
asyncio.run(gw.start())
|
||||
assert gw._pid_lock is None
|
||||
assert gw._state_lock is held and held.lock_file.exists()
|
||||
gw._release_locks() # start_with_config's finally, after channels stop
|
||||
assert gw._state_lock is None and not held.lock_file.exists()
|
||||
|
||||
|
||||
def test_collision_error_no_longer_suggests_bare_port_change():
|
||||
msg = format_collision_error("127.0.0.1", 8765, {"pid": 1, "is_running": True})
|
||||
assert "Or use a different port" not in msg
|
||||
assert "HOME=" in msg and "PRAISONAI_HOME" not in msg
|
||||
Reference in new issue
Block a user