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
Files changed (3) hide show
  1. Dockerfile +2 -1
  2. backend.py +88 -24
  3. 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(DATABASE_URL, ssl="require", min_size=1, max_size=5)
 
 
 
 
 
 
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
- if os.path.exists(archive_zip):
1214
- try:
1215
- os.unlink(archive_zip)
1216
- except Exception:
1217
- pass
1218
-
 
 
 
 
 
 
1219
  try:
1220
- shutil.make_archive(archive_base, 'zip', WORKSPACE_DIR)
 
 
 
 
 
 
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', 'create_time']):
1251
  try:
1252
  cmd = " ".join(proc.info['cmdline'] or [])
1253
- if "claude-code" in cmd.lower() or "anthropic" in cmd.lower():
1254
- elapsed = time.time() - proc.info['create_time']
1255
- if elapsed > 600: # 10 minutes limit
1256
- log_activity(f"[Watchdog SIGKILL] Reaping hung Claude Code process PID {proc.info['pid']} (Active for {elapsed:.1f}s)")
1257
- proc.kill()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1258
  except Exception:
1259
  continue
1260
  except ImportError:
1261
- # Fallback zero-dependency shell parser
1262
  try:
1263
- out = subprocess.check_output("ps -o pid,etime,args | grep -E 'claude-code|anthropic' | grep -v grep", shell=True, text=True)
 
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
- etime = parts[1]
1269
- # Check if running > 10 mins (format dd-hh:mm:ss or mm:ss)
1270
- is_stale = "-" in etime or len(etime.split(":")) > 2 or (len(etime.split(":")) == 2 and int(etime.split(":")[0]) > 10)
1271
- if is_stale:
1272
- log_activity(f"[Watchdog SIGKILL Fallback] Reaping hung process PID {pid} (etime: {etime})")
1273
- os.kill(pid, signal.SIGKILL)
 
 
 
 
 
 
 
 
 
 
 
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
+