Skip to content

Commit f6ab207

Browse files
committed
fix: remove shared LOCK contention in monitor to prevent pod deadlock (#1754)
The monitor's update_timeline(), get_health_summary(), and get_browser_list() all acquired the crawler pool's global LOCK to read pool stats. That same lock is held during slow browser start/close operations (get_crawler, janitor, close_all), causing the monitor to block indefinitely and the pod to become unresponsive after sustained crawling. Replaced all three lock acquisitions in monitor.py with a lock-free get_pool_snapshot() in crawler_pool.py that returns shallow dict copies. Under CPython's GIL, dict.copy() and len() are atomic — safe for read-only monitoring with at most slightly stale counts.
1 parent 648f36b commit f6ab207

2 files changed

Lines changed: 83 additions & 54 deletions

File tree

deploy/docker/crawler_pool.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,27 @@
2222
BASE_IDLE_TTL = CONFIG.get("crawler", {}).get("pool", {}).get("idle_ttl_sec", 300)
2323
DEFAULT_CONFIG_SIG = None # Cached sig for default config
2424

25+
26+
def get_pool_snapshot() -> dict:
27+
"""Return a point-in-time snapshot of pool state for monitoring.
28+
29+
This is intentionally lock-free. Under CPython's GIL, reading
30+
``len(dict)``, ``dict.copy()``, and ``x is not None`` are atomic
31+
operations, so the monitor can safely call this without contending
32+
on the pool LOCK that is held during slow browser start/close ops.
33+
The worst case is a slightly stale count, which is acceptable for
34+
dashboard display purposes.
35+
"""
36+
return {
37+
"permanent": PERMANENT,
38+
"permanent_sig": DEFAULT_CONFIG_SIG,
39+
"hot_pool": HOT_POOL.copy(),
40+
"cold_pool": COLD_POOL.copy(),
41+
"last_used": LAST_USED.copy(),
42+
"usage_count": USAGE_COUNT.copy(),
43+
}
44+
45+
2546
def _sig(cfg: BrowserConfig) -> str:
2647
"""Generate config signature."""
2748
payload = json.dumps(cfg.to_dict(), sort_keys=True, separators=(",",":"))

deploy/docker/monitor.py

Lines changed: 62 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -149,14 +149,15 @@ async def update_timeline(self):
149149
recent_reqs = sum(1 for req in self.completed_requests
150150
if now - req.get("end_time", 0) < 5)
151151

152-
# Browser counts (acquire lock to prevent race conditions)
153-
from crawler_pool import PERMANENT, HOT_POOL, COLD_POOL, LOCK
154-
async with LOCK:
155-
browser_count = {
156-
"permanent": 1 if PERMANENT else 0,
157-
"hot": len(HOT_POOL),
158-
"cold": len(COLD_POOL)
159-
}
152+
# Browser counts — lock-free snapshot to avoid contending on
153+
# the pool LOCK held during slow browser start/close (issue #1754)
154+
from crawler_pool import get_pool_snapshot
155+
snap = get_pool_snapshot()
156+
browser_count = {
157+
"permanent": 1 if snap["permanent"] else 0,
158+
"hot": len(snap["hot_pool"]),
159+
"cold": len(snap["cold_pool"]),
160+
}
160161

161162
self.memory_timeline.append({"time": now, "value": mem_pct})
162163
self.requests_timeline.append({"time": now, "value": recent_reqs})
@@ -232,17 +233,17 @@ async def get_health_summary(self) -> Dict:
232233
# Network I/O (delta since last call)
233234
net = psutil.net_io_counters()
234235

235-
# Pool status (acquire lock to prevent race conditions)
236-
from crawler_pool import PERMANENT, HOT_POOL, COLD_POOL, LOCK
237-
async with LOCK:
238-
# TODO: Track actual browser process memory instead of estimates
239-
# These are conservative estimates based on typical Chromium usage
240-
permanent_mem = 270 if PERMANENT else 0 # Estimate: ~270MB for permanent browser
241-
hot_mem = len(HOT_POOL) * 180 # Estimate: ~180MB per hot pool browser
242-
cold_mem = len(COLD_POOL) * 180 # Estimate: ~180MB per cold pool browser
243-
permanent_active = PERMANENT is not None
244-
hot_count = len(HOT_POOL)
245-
cold_count = len(COLD_POOL)
236+
# Pool status lock-free snapshot (issue #1754)
237+
from crawler_pool import get_pool_snapshot
238+
snap = get_pool_snapshot()
239+
# TODO: Track actual browser process memory instead of estimates
240+
# These are conservative estimates based on typical Chromium usage
241+
permanent_mem = 270 if snap["permanent"] else 0 # Estimate: ~270MB for permanent browser
242+
hot_mem = len(snap["hot_pool"]) * 180 # Estimate: ~180MB per hot pool browser
243+
cold_mem = len(snap["cold_pool"]) * 180 # Estimate: ~180MB per cold pool browser
244+
permanent_active = snap["permanent"] is not None
245+
hot_count = len(snap["hot_pool"])
246+
cold_count = len(snap["cold_pool"])
246247

247248
return {
248249
"container": {
@@ -287,45 +288,52 @@ def get_completed_requests(self, limit: int = 50, filter_status: str = "all") ->
287288

288289
async def get_browser_list(self) -> List[Dict]:
289290
"""Get detailed browser pool information."""
290-
from crawler_pool import PERMANENT, HOT_POOL, COLD_POOL, LAST_USED, USAGE_COUNT, DEFAULT_CONFIG_SIG, LOCK
291+
from crawler_pool import get_pool_snapshot
291292

292293
browsers = []
293294
now = time.time()
294295

295-
# Acquire lock to prevent race conditions during iteration
296-
async with LOCK:
297-
if PERMANENT:
298-
browsers.append({
299-
"type": "permanent",
300-
"sig": DEFAULT_CONFIG_SIG[:8] if DEFAULT_CONFIG_SIG else "unknown",
301-
"age_seconds": int(now - self.start_time),
302-
"last_used_seconds": int(now - LAST_USED.get(DEFAULT_CONFIG_SIG, now)),
303-
"memory_mb": 270,
304-
"hits": USAGE_COUNT.get(DEFAULT_CONFIG_SIG, 0),
305-
"killable": False
306-
})
307-
308-
for sig, crawler in HOT_POOL.items():
309-
browsers.append({
310-
"type": "hot",
311-
"sig": sig[:8],
312-
"age_seconds": int(now - self.start_time), # Approximation
313-
"last_used_seconds": int(now - LAST_USED.get(sig, now)),
314-
"memory_mb": 180, # Estimate
315-
"hits": USAGE_COUNT.get(sig, 0),
316-
"killable": True
317-
})
318-
319-
for sig, crawler in COLD_POOL.items():
320-
browsers.append({
321-
"type": "cold",
322-
"sig": sig[:8],
323-
"age_seconds": int(now - self.start_time),
324-
"last_used_seconds": int(now - LAST_USED.get(sig, now)),
325-
"memory_mb": 180,
326-
"hits": USAGE_COUNT.get(sig, 0),
327-
"killable": True
328-
})
296+
# Lock-free snapshot — iterates over copies (issue #1754)
297+
snap = get_pool_snapshot()
298+
permanent = snap["permanent"]
299+
permanent_sig = snap["permanent_sig"]
300+
hot_pool = snap["hot_pool"]
301+
cold_pool = snap["cold_pool"]
302+
last_used = snap["last_used"]
303+
usage_count = snap["usage_count"]
304+
305+
if permanent:
306+
browsers.append({
307+
"type": "permanent",
308+
"sig": permanent_sig[:8] if permanent_sig else "unknown",
309+
"age_seconds": int(now - self.start_time),
310+
"last_used_seconds": int(now - last_used.get(permanent_sig, now)),
311+
"memory_mb": 270,
312+
"hits": usage_count.get(permanent_sig, 0),
313+
"killable": False
314+
})
315+
316+
for sig, crawler in hot_pool.items():
317+
browsers.append({
318+
"type": "hot",
319+
"sig": sig[:8],
320+
"age_seconds": int(now - self.start_time), # Approximation
321+
"last_used_seconds": int(now - last_used.get(sig, now)),
322+
"memory_mb": 180, # Estimate
323+
"hits": usage_count.get(sig, 0),
324+
"killable": True
325+
})
326+
327+
for sig, crawler in cold_pool.items():
328+
browsers.append({
329+
"type": "cold",
330+
"sig": sig[:8],
331+
"age_seconds": int(now - self.start_time),
332+
"last_used_seconds": int(now - last_used.get(sig, now)),
333+
"memory_mb": 180,
334+
"hits": usage_count.get(sig, 0),
335+
"killable": True
336+
})
329337

330338
return browsers
331339

0 commit comments

Comments
 (0)