Spaces:
Runtime error
Runtime error
Cyber Catalyst Team commited on
Commit ·
8d7c6d3
1
Parent(s): 4571bf6
Implement PPID orphan watchdog, tini zombie reaping, connection pooling caps, point-in-time zip snapshots, and db keep-alive heartbeats
Browse files- Dockerfile +2 -1
- backend.py +88 -24
- requirements.txt +2 -0
Dockerfile
CHANGED
|
@@ -2,7 +2,7 @@ FROM python:3.11-slim
|
|
| 2 |
|
| 3 |
# Install system dependencies
|
| 4 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
-
git bash curl && \
|
| 6 |
rm -rf /var/lib/apt/lists/*
|
| 7 |
|
| 8 |
# Create non-root user (HF requirement: uid 1000)
|
|
@@ -25,4 +25,5 @@ RUN mkdir -p /tmp/workspace
|
|
| 25 |
|
| 26 |
EXPOSE 7860
|
| 27 |
|
|
|
|
| 28 |
CMD ["python", "backend.py"]
|
|
|
|
| 2 |
|
| 3 |
# Install system dependencies
|
| 4 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
git bash curl tini && \
|
| 6 |
rm -rf /var/lib/apt/lists/*
|
| 7 |
|
| 8 |
# Create non-root user (HF requirement: uid 1000)
|
|
|
|
| 25 |
|
| 26 |
EXPOSE 7860
|
| 27 |
|
| 28 |
+
ENTRYPOINT ["tini", "--"]
|
| 29 |
CMD ["python", "backend.py"]
|
backend.py
CHANGED
|
@@ -355,7 +355,13 @@ async def init_db():
|
|
| 355 |
if not DATABASE_URL:
|
| 356 |
return
|
| 357 |
try:
|
| 358 |
-
db_pool = await asyncpg.create_pool(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
async with db_pool.acquire() as conn:
|
| 360 |
await conn.execute("""
|
| 361 |
CREATE TABLE IF NOT EXISTS agent_sessions (
|
|
@@ -1208,20 +1214,35 @@ from fastapi.responses import FileResponse
|
|
| 1208 |
@app.get("/api/backup/download")
|
| 1209 |
async def download_backup(authorization: str = Header(None)):
|
| 1210 |
auth(authorization)
|
|
|
|
| 1211 |
archive_base = "/tmp/workspace_backup_download"
|
| 1212 |
archive_zip = archive_base + ".zip"
|
| 1213 |
-
|
| 1214 |
-
|
| 1215 |
-
|
| 1216 |
-
|
| 1217 |
-
|
| 1218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1219 |
try:
|
| 1220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1221 |
if not os.path.exists(archive_zip):
|
| 1222 |
raise HTTPException(status_code=500, detail="Failed to create zip archive")
|
| 1223 |
return FileResponse(archive_zip, media_type="application/zip", filename="workspace_backup.zip")
|
| 1224 |
except Exception as e:
|
|
|
|
|
|
|
| 1225 |
raise HTTPException(status_code=500, detail=str(e))
|
| 1226 |
|
| 1227 |
|
|
@@ -1239,38 +1260,66 @@ async def health():
|
|
| 1239 |
|
| 1240 |
|
| 1241 |
# ---------------------------------------------------------------------------
|
| 1242 |
-
# Watchdog Daemon for Claude Code Subprocesses
|
| 1243 |
# ---------------------------------------------------------------------------
|
| 1244 |
|
| 1245 |
def run_watchdog():
|
| 1246 |
-
log_activity("System Watchdog Daemon started")
|
| 1247 |
while True:
|
| 1248 |
try:
|
| 1249 |
import psutil
|
| 1250 |
-
for proc in psutil.process_iter(['pid', 'name', 'cmdline', '
|
| 1251 |
try:
|
| 1252 |
cmd = " ".join(proc.info['cmdline'] or [])
|
| 1253 |
-
|
| 1254 |
-
|
| 1255 |
-
|
| 1256 |
-
|
| 1257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1258 |
except Exception:
|
| 1259 |
continue
|
| 1260 |
except ImportError:
|
| 1261 |
-
# Fallback zero-dependency shell parser
|
| 1262 |
try:
|
| 1263 |
-
|
|
|
|
| 1264 |
for line in out.strip().split("\n"):
|
| 1265 |
parts = line.strip().split(None, 2)
|
| 1266 |
if len(parts) >= 2:
|
| 1267 |
pid = int(parts[0])
|
| 1268 |
-
|
| 1269 |
-
|
| 1270 |
-
|
| 1271 |
-
|
| 1272 |
-
|
| 1273 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1274 |
except Exception:
|
| 1275 |
pass
|
| 1276 |
except Exception as e:
|
|
@@ -1278,10 +1327,25 @@ def run_watchdog():
|
|
| 1278 |
time.sleep(60)
|
| 1279 |
|
| 1280 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1281 |
@app.on_event("startup")
|
| 1282 |
async def startup_event():
|
| 1283 |
# Start the watchdog thread on startup
|
| 1284 |
threading.Thread(target=run_watchdog, daemon=True).start()
|
|
|
|
|
|
|
| 1285 |
|
| 1286 |
|
| 1287 |
# ---------------------------------------------------------------------------
|
|
|
|
| 355 |
if not DATABASE_URL:
|
| 356 |
return
|
| 357 |
try:
|
| 358 |
+
db_pool = await asyncpg.create_pool(
|
| 359 |
+
DATABASE_URL,
|
| 360 |
+
ssl="require",
|
| 361 |
+
min_size=1,
|
| 362 |
+
max_size=3,
|
| 363 |
+
max_inactive_connection_lifetime=300
|
| 364 |
+
)
|
| 365 |
async with db_pool.acquire() as conn:
|
| 366 |
await conn.execute("""
|
| 367 |
CREATE TABLE IF NOT EXISTS agent_sessions (
|
|
|
|
| 1214 |
@app.get("/api/backup/download")
|
| 1215 |
async def download_backup(authorization: str = Header(None)):
|
| 1216 |
auth(authorization)
|
| 1217 |
+
snapshot_dir = "/tmp/workspace_snapshot"
|
| 1218 |
archive_base = "/tmp/workspace_backup_download"
|
| 1219 |
archive_zip = archive_base + ".zip"
|
| 1220 |
+
|
| 1221 |
+
# Clean up old files/folders
|
| 1222 |
+
for path in [snapshot_dir, archive_zip]:
|
| 1223 |
+
if os.path.exists(path):
|
| 1224 |
+
try:
|
| 1225 |
+
if os.path.isdir(path):
|
| 1226 |
+
shutil.rmtree(path)
|
| 1227 |
+
else:
|
| 1228 |
+
os.unlink(path)
|
| 1229 |
+
except Exception:
|
| 1230 |
+
pass
|
| 1231 |
+
|
| 1232 |
try:
|
| 1233 |
+
# 1. Atomic-like snapshot copy (ignoring temporary files)
|
| 1234 |
+
shutil.copytree(WORKSPACE_DIR, snapshot_dir, symlinks=True, ignore=shutil.ignore_patterns('.git', 'node_modules', '.next'))
|
| 1235 |
+
# 2. Archive the snapshot folder to disk to prevent OOM memory spike
|
| 1236 |
+
shutil.make_archive(archive_base, 'zip', snapshot_dir)
|
| 1237 |
+
# 3. Clean up the snapshot directory immediately
|
| 1238 |
+
shutil.rmtree(snapshot_dir)
|
| 1239 |
+
|
| 1240 |
if not os.path.exists(archive_zip):
|
| 1241 |
raise HTTPException(status_code=500, detail="Failed to create zip archive")
|
| 1242 |
return FileResponse(archive_zip, media_type="application/zip", filename="workspace_backup.zip")
|
| 1243 |
except Exception as e:
|
| 1244 |
+
if os.path.exists(snapshot_dir):
|
| 1245 |
+
shutil.rmtree(snapshot_dir)
|
| 1246 |
raise HTTPException(status_code=500, detail=str(e))
|
| 1247 |
|
| 1248 |
|
|
|
|
| 1260 |
|
| 1261 |
|
| 1262 |
# ---------------------------------------------------------------------------
|
| 1263 |
+
# Watchdog Daemon for Claude Code Subprocesses (Orphan Reaper)
|
| 1264 |
# ---------------------------------------------------------------------------
|
| 1265 |
|
| 1266 |
def run_watchdog():
|
| 1267 |
+
log_activity("System Watchdog Daemon started (PPID-based Orphan detection)")
|
| 1268 |
while True:
|
| 1269 |
try:
|
| 1270 |
import psutil
|
| 1271 |
+
for proc in psutil.process_iter(['pid', 'ppid', 'name', 'cmdline', 'status']):
|
| 1272 |
try:
|
| 1273 |
cmd = " ".join(proc.info['cmdline'] or [])
|
| 1274 |
+
# Match the CLI binary (looks like claude-code or anthropic CLI wrapper)
|
| 1275 |
+
if "claude" in cmd.lower() or "anthropic" in cmd.lower():
|
| 1276 |
+
ppid = proc.info['ppid']
|
| 1277 |
+
pid = proc.info['pid']
|
| 1278 |
+
|
| 1279 |
+
# Is the parent still alive and not a zombie?
|
| 1280 |
+
parent_exists = False
|
| 1281 |
+
if ppid != 1: # Orphaned processes get reparented to PID 1 in Linux
|
| 1282 |
+
try:
|
| 1283 |
+
parent_proc = psutil.Process(ppid)
|
| 1284 |
+
if parent_proc.is_running() and parent_proc.status() != psutil.STATUS_ZOMBIE:
|
| 1285 |
+
parent_exists = True
|
| 1286 |
+
except psutil.NoSuchProcess:
|
| 1287 |
+
pass
|
| 1288 |
+
|
| 1289 |
+
if not parent_exists:
|
| 1290 |
+
log_activity(f"[Watchdog SIGKILL] Reaping orphaned Claude Code process PID {pid} (PPID {ppid})")
|
| 1291 |
+
proc.terminate()
|
| 1292 |
+
time.sleep(2)
|
| 1293 |
+
if proc.is_running():
|
| 1294 |
+
proc.kill()
|
| 1295 |
except Exception:
|
| 1296 |
continue
|
| 1297 |
except ImportError:
|
| 1298 |
+
# Fallback zero-dependency shell parser using /proc
|
| 1299 |
try:
|
| 1300 |
+
# Find all processes and examine their parent PID
|
| 1301 |
+
out = subprocess.check_output("ps -o pid,ppid,args | grep -E 'claude|anthropic' | grep -v grep", shell=True, text=True)
|
| 1302 |
for line in out.strip().split("\n"):
|
| 1303 |
parts = line.strip().split(None, 2)
|
| 1304 |
if len(parts) >= 2:
|
| 1305 |
pid = int(parts[0])
|
| 1306 |
+
ppid = int(parts[1])
|
| 1307 |
+
|
| 1308 |
+
# Check if parent pid exists/is alive
|
| 1309 |
+
parent_exists = False
|
| 1310 |
+
if ppid != 1:
|
| 1311 |
+
# Check /proc/[ppid] directory
|
| 1312 |
+
if os.path.exists(f"/proc/{ppid}"):
|
| 1313 |
+
parent_exists = True
|
| 1314 |
+
|
| 1315 |
+
if not parent_exists:
|
| 1316 |
+
log_activity(f"[Watchdog SIGKILL Fallback] Reaping orphaned process PID {pid} (PPID {ppid})")
|
| 1317 |
+
try:
|
| 1318 |
+
os.kill(pid, signal.SIGTERM)
|
| 1319 |
+
time.sleep(2)
|
| 1320 |
+
os.kill(pid, signal.SIGKILL)
|
| 1321 |
+
except Exception:
|
| 1322 |
+
pass
|
| 1323 |
except Exception:
|
| 1324 |
pass
|
| 1325 |
except Exception as e:
|
|
|
|
| 1327 |
time.sleep(60)
|
| 1328 |
|
| 1329 |
|
| 1330 |
+
async def db_heartbeat_loop():
|
| 1331 |
+
log_activity("Database Heartbeat task started")
|
| 1332 |
+
while True:
|
| 1333 |
+
try:
|
| 1334 |
+
if db_pool:
|
| 1335 |
+
async with db_pool.acquire() as conn:
|
| 1336 |
+
await conn.execute("SELECT 1")
|
| 1337 |
+
log_activity("[Heartbeat] Pinged Aiven PostgreSQL successfully")
|
| 1338 |
+
except Exception as e:
|
| 1339 |
+
log_activity(f"[Heartbeat Warning] Failed to ping database: {e}")
|
| 1340 |
+
await asyncio.sleep(240) # Every 4 minutes
|
| 1341 |
+
|
| 1342 |
+
|
| 1343 |
@app.on_event("startup")
|
| 1344 |
async def startup_event():
|
| 1345 |
# Start the watchdog thread on startup
|
| 1346 |
threading.Thread(target=run_watchdog, daemon=True).start()
|
| 1347 |
+
# Start the db keep-alive loop on FastAPI event loop
|
| 1348 |
+
asyncio.create_task(db_heartbeat_loop())
|
| 1349 |
|
| 1350 |
|
| 1351 |
# ---------------------------------------------------------------------------
|
requirements.txt
CHANGED
|
@@ -3,3 +3,5 @@ uvicorn[standard]==0.34.2
|
|
| 3 |
openai==1.86.0
|
| 4 |
asyncpg==0.30.0
|
| 5 |
anyio==4.9.0
|
|
|
|
|
|
|
|
|
| 3 |
openai==1.86.0
|
| 4 |
asyncpg==0.30.0
|
| 5 |
anyio==4.9.0
|
| 6 |
+
psutil==5.9.8
|
| 7 |
+
|