Repository navigation
fix(docker): restart_browser on the permanent browser deadlocks the pool - #2230
talelboussetta wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
restart_permanent() can leave PERMANENT unset indefinitely if close() hangs (or the task is cancelled mid-close), preventing the permanent browser from being recreated.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes a deadlock in the Docker monitor endpoint’s permanent-browser restart flow by moving permanent browser lifecycle operations into the pool and ensuring browser close() operations are not awaited while holding the pool lock.
Changes:
- Add
restart_permanent()to safely detach the permanent browser underLOCK, then close and recreate it outside the lock. - Update
restart_browserto callrestart_permanent()and buildBrowserConfigthe same way startup does (via_browser_extra_args()). - Add regression tests that exercise the real
deploy/docker/crawler_pool.pywith stubbed dependencies.
File summaries
| File | Description |
|---|---|
| deploy/docker/crawler_pool.py | Adds restart_permanent() to avoid deadlocking on non-reentrant asyncio.Lock. |
| deploy/docker/monitor_routes.py | Uses restart_permanent() for permanent restart; moves browser closes outside LOCK. |
| tests/docker/test_pool_restart_permanent.py | Adds regression tests for the permanent restart deadlock and pool usability. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
a32b24a to
e8cb918
Compare
|
Rebased onto current
@ntohidi, would you have time to review? @SohamKukreti, the restart path now goes through the builder from #2237, so a look from you would be welcome too. |
|
Thanks for this, @talelboussetta. The deadlock diagnosis is correct, and so is the move to I tested this live: an image built from this branch, real Chromium, a local test page, Problem 1: a duplicate default-sig browser that the janitor does not reap while traffic continuesWhile the old browser closes and the new one starts, Live, one restart under 6 parallel That is one extra Chromium that stays as long as default traffic continues. When traffic stops, the janitor reaps it, and it also pops the permanent browser's Problem 2: a failed start leaves no working browser, and then leaks browsers
Other live results on this branch: 3 concurrent restarts OK, Two ways to fix themBoth ways fix both problems. They differ in what happens during a restart. @talelboussetta and @ntohidi, please pick the one you think fits better. Option A: keep detach → close → re-create, and add guards
Option B: start new → swap under
|
| Scenario | This branch | Option B |
|---|---|---|
Restart under load (6 parallel /html, 4 min) |
duplicate hot browser, 1 failed request | no duplicate, 1901/1901 OK |
Restart while an 8s /html is running |
in-flight request → 500 | in-flight request → 200 (restart takes 5.4s) |
| 3 concurrent restarts | OK | OK |
kill_browser on a cold browser |
OK | OK |
| Restart with an unlaunchable browser | all /html 500, then 3 orphaned browsers |
restart 500, /html keeps working, no orphans |
Old browser's browser.close() raises or hangs |
not tested (same leak: this branch also skips playwright.stop()) |
old Chromium and driver killed, no leftovers |
Your 4 tests in tests/docker/test_pool_restart_permanent.py pass unchanged with Option B.
Applies to both options
- Drain before close. Without it, the restart closes the old browser under requests that are still running on it. Live with Option B, that was 5 failed requests in one restart under load without the drain, and 0 with it.
- Close fallback.
BrowserManager.close()runsplaywright.stop()only afterbrowser.close()succeeds, so a failing close leaves Chromium and its driver running. The fallback in_retire_browserstops the old browser's own driver. Each browser has its own driver (browser_manager.py:838), so this does not affect the new permanent browser. Live, with the old browser'sbrowser.close()forced to raise or to hang: without the fallback, the old Chromium (5 processes) and its driver stayed alive; with it, none were left, and/htmlworked on the new browser in all cases. - Tests. Please add tests for: a default-config
get_crawler()during a slow restart must not add toCOLD_POOL; a failingstart()must not leave an unstartedPERMANENT; a request holding the old browser must complete before it is closed; a failingclose()must still stop the old browser's driver. init_permanent(). AssignPERMANENTonly afterstart()succeeds, so startup cannot leave a half-built global either.
Smaller points
- Silent
except Exception: passon the close: please log a warning. - Route test:
test_restart_rebuilds_permanent_browser_from_startup_configreplacesrestart_permanentwith a fake. That is fine for the config check, but no test shows that the route itself does not holdLOCKwhile it restarts. Optional. - Not blocking, separate issue: the same unguarded
browser.close()inBrowserManager.close()is used when the janitor andkill_browserclose hot/cold browsers. The fallback above covers only the permanent restart. Atry/finallyaroundbrowser.close()in the library would cover all of them.
@ntohidi, it would be good to have your input on the option before @talelboussetta makes changes. The main question: on a restart, is a short time with two browsers (Option B) better or worse than making default requests wait and accepting an outage if the new start fails (Option A)? Also, are the timeouts good? Happy to re-review after we agree on the direction. The rest looks good to merge.
POST /monitor/actions/restart_browser {"sig": "permanent"} took the pool LOCK and then called init_permanent(), which acquires the same lock. asyncio.Lock is not reentrant, so the route never returned and every later get_crawler() blocked behind it for the life of the process. GET /health kept answering OK throughout, because health never opens a page.
Two smaller faults in the same branch: the module-level PERMANENT name is a stale binding that cannot be written back, and the global was never cleared, so init_permanent()'s "already initialized" guard would have made the re-create a no-op even without the deadlock. The rebuilt config also dropped the CRAWL4AI_CHROMIUM_SANDBOX handling that startup applies, so a restart could hand back a browser with --no-sandbox an operator had turned off.
The pool lifecycle now lives in the pool: restart_permanent() detaches under LOCK, then closes and re-creates outside it. Browser closes in kill_browser and the hot/cold branch move out of LOCK for the same reason -- the browser is already detached, so a close that hangs no longer freezes get_crawler().
The old browser's close is bounded at 60 s, so a close that hangs cannot stall the restart request itself.
… does Since unclecode#2237 the permanent browser is created from get_default_browser_config(), which applies the egress hardening, and requests reach it by pool signature. The restart path assembled its own BrowserConfig without that hardening, so the restarted browser got a signature no request carries (and ran with TLS verification off and no pinning proxy): it sat unused while every default-config request created a cold-pool browser instead. Restart now calls get_default_browser_config(), so the restarted browser is the one startup would build.
Wraps the two new lines over black's limit and adds the blank lines black expects before the new function and after the local import. No behaviour change.
Detaching the permanent browser, closing it and only then creating the replacement left two failure modes in the restart path, found by live testing: - While PERMANENT was None, a default-config request created a cold-pool browser with DEFAULT_CONFIG_SIG. It kept sharing LAST_USED with the new permanent browser, so the janitor never saw it idle while default traffic continued, and reaping it later popped the permanent browser's counters. - init_permanent() assigned PERMANENT before start(). A failed start left an unstarted global that arun() would start again on every concurrent request, launching browsers nothing tracked. restart_permanent() now starts the replacement outside LOCK, swaps it in under LOCK, and retires the old browser afterwards, so PERMANENT is never None and a failed or cancelled start leaves the old browser serving. Retiring waits up to 30s for requests already running on the old browser, bounds the close at 60s, and stops the old browser's own Playwright driver if the close fails or hangs, since BrowserManager.close() only stops it after browser.close() succeeds. The failure is logged instead of swallowed. init_permanent() publishes PERMANENT only after start() succeeds. Tests cover a default-config request during a slow restart, a failing start (restart and startup), a restart cancelled mid-start, a request holding the old browser, a failing and a hanging close, and concurrent restarts.
6bacb3c to
b399396
Compare
|
Thanks @SohamKukreti. I went with Option B, pushed as b399396 and rebased on current develop.
I checked the first problem against a real browser, one restart under 4 parallel request loops (Edge through Playwright on a Windows host, not your Docker setup). Before: 4 of 25 requests failed and a second default-config browser was left in the cold pool. After: 0 of 34 failed and there was no duplicate. A 4s request in flight during the restart failed before and returns 200 now. I did not reproduce your unlaunchable-binary case live; the unit tests cover that path. On Windows the docker suites show the same 72 failures as develop, with and without this change. Left alone: a restart peaks at two browsers and does not check the memory limit. The unguarded |
Summary
POST /monitor/actions/restart_browser {"sig": "permanent"}deadlocks the browser pool permanently. Still the case on 0.9.4; this branch is rebased onto currentdevelop.The route takes the pool
LOCKand then, while still holding it, callsinit_permanent(), which acquires the same lock.asyncio.Lockis not reentrant, so the route never returns and every subsequentget_crawler()blocks behind it for the life of the process.Observed first on a self-hosted 0.9.2 container: the call hung for 120 s, and from that moment every
/crawlon that container timed out at 90 s whileGET /healthstill returned{"status": "ok"}and Docker reported the container(healthy). Only a container restart recovered it. Health never opens a page, so nothing an orchestrator watches goes red. Reproduced end to end on a container built fromdevelop, below.Two more faults in the same lines, either of which breaks the restart even without the deadlock:
from crawler_pool import PERMANENTbinds a copy of the reference. The route cannot write the global back, and nothing clears it, soinit_permanent()'sif PERMANENT: returnguard would make the re-create a silent no-op, leaving a closed browser in the pool.lifespan()builds the permanent browser withget_default_browser_config():_browser_extra_args()(which drops--no-sandboxwhenCRAWL4AI_CHROMIUM_SANDBOX=true) plus the egress hardening. The route readsconfig["crawler"]["browser"]raw. A restart would hand back a browser with--no-sandboxan operator had turned off, without TLS verification or the pinning proxy, and under a pool signature no request carries, so it would sit unused while the endpoints that fetch through the default config (/html,/screenshot,/pdf,/execute_js) build cold-pool browsers.The fix
Pool lifecycle moves into the pool.
restart_permanent()starts the replacement outsideLOCK, swaps it in underLOCK, then retires the old browser:PERMANENTis neverNonewhile the server is up, so a default-config request during a restart is served by the old browser and cannot create a second default-signature browser in the cold pool.LOCK: up to 30s for requests already running on it to finish, the close bounded at 60s, and its own Playwright driver stopped (10s bound) if the close fails or hangs, becauseBrowserManager.close()only stops the driver afterbrowser.close()succeeds. Failures are logged.init_permanent()assignsPERMANENTonly afterstart()succeeds, so a failed start cannot leave an unstarted global thatarun()would start again on every concurrent request.The cost is a short memory peak with two browsers running, and a restart request that can take up to the drain plus close timeouts when requests are still running. This is Option B from the review below.
The route calls that with
get_default_browser_config(), the builderlifespan()uses, so the restarted browser is the one startup would build.Secondary, same file, same class of problem:
kill_browserand the hot/cold branch ofrestart_browserbothawait browser.close()while holdingLOCK. The browser is already popped from the pool at that point, so nothing else can hand it out and the close does not need the lock, but a close that hangs freezesget_crawler()for every caller. Both moved outside. This is themonitor_routes.pyhalf of what #2214 recorded as out of scope; the two touch different functions.List of files changed and why
deploy/docker/crawler_pool.py: addsrestart_permanent(), the start, swap and retire ordering, and the comment explaining why the ordering is the fix.deploy/docker/monitor_routes.py:restart_browsercallsrestart_permanent(get_default_browser_config()). Browser closes inkill_browserand in the hot/cold branch move out ofLOCK. Drops the now-unused imports.tests/docker/test_pool_restart_permanent.py(new): regression tests against the realcrawler_poolmodule.deploy/docker/tests/test_restart_permanent_config.py(new): the restarted browser's pool signature equals the oneget_default_browser_config()gives, and TLS verification is on.How Has This Been Tested?
Unit:
get_crawler()still returns after a restart.PERMANENT, and the old browser keeps serving. A restart cancelled mid-start tears the new browser down.test_init_permanent_under_the_lock_deadlocksdocuments the original bug and guards the assumption the fix rests on (thatLOCKis a plain non-reentrantasyncio.Lock).test_restart_rebuilds_permanent_browser_from_startup_config: the restarted browser's pool signature equals the oneget_default_browser_config()gives. It failed on the earlier version of this branch.The offline suites (
deploy/docker/testswithout the live-servertest_1–test_7scripts,tests/unit, the pool tests) show no new failures againstdevelop.End to end, on an image built from
develop@ 1f68e5b (docker build -t c4ai-dev .), run withCRAWL4AI_API_TOKENset against a local test site, soCRAWL4AI_ALLOW_INTERNAL_URLS=true. The second and third runs mount this branch'scrawler_pool.pyandmonitor_routes.pyover/app, the second as it was before this update./htmlis used because it builds its crawler fromget_default_browser_config(). Signatures differ between containers, so compare them within a run:After the start-first change (b399396), checked against a real browser on a Windows host (Edge through Playwright, not the Docker image), one restart under 4 parallel request loops. Before: 4 of 25 requests failed and a second default-config browser stayed in the cold pool. After: 0 of 34 failed and there was no duplicate. A 4s request in flight during the restart failed before and returns 200 now. The Docker-image runs above predate this change.
Checklist: