Skip to content

Commit 58b388d

Browse files
committed
feat: tighten headed browser lifecycle
1 parent d98f309 commit 58b388d

3 files changed

Lines changed: 68 additions & 0 deletions

File tree

src/core/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,14 @@ def browser_recaptcha_settle_seconds(self) -> float:
353353
except Exception:
354354
return 3.0
355355

356+
@property
357+
def browser_idle_ttl_seconds(self) -> int:
358+
value = self._config.get("captcha", {}).get("browser_idle_ttl_seconds", 600)
359+
try:
360+
return max(60, int(value))
361+
except Exception:
362+
return 600
363+
356364
@property
357365
def yescaptcha_api_key(self) -> str:
358366
"""Get YesCaptcha API key"""

src/services/browser_captcha.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ def __init__(self, token_id: int, user_data_dir: str, db=None):
391391
self._shared_reuse_count = 0
392392
self._consecutive_browser_failures = 0
393393
self._solve_inflight = 0
394+
self._last_idle_since = time.monotonic()
394395
self._refresh_browser_profile()
395396

396397
def _refresh_browser_profile(self):
@@ -752,6 +753,7 @@ async def _get_or_create_shared_browser(self, token_proxy_url: Optional[str] = N
752753
self._shared_proxy_url = (self._last_fingerprint or {}).get("proxy_url")
753754
self._shared_launch_count += 1
754755
self._shared_reuse_count = 0
756+
self.note_idle()
755757
return playwright, browser, context
756758

757759
async def _capture_page_fingerprint(self, page):
@@ -1431,6 +1433,18 @@ def handle_request_failed(request):
14311433
def is_busy(self) -> bool:
14321434
return self._solve_inflight > 0
14331435

1436+
def note_idle(self):
1437+
if self._solve_inflight <= 0:
1438+
self._last_idle_since = time.monotonic()
1439+
1440+
def idle_seconds(self) -> float:
1441+
if self.is_busy():
1442+
return 0.0
1443+
return max(0.0, time.monotonic() - self._last_idle_since)
1444+
1445+
def has_shared_browser(self) -> bool:
1446+
return bool(self._shared_browser or self._shared_context or self._shared_keepalive_page)
1447+
14341448
def get_last_fingerprint(self) -> Optional[Dict[str, Any]]:
14351449
"""返回最近一次打码浏览器的指纹快照。"""
14361450
if not self._last_fingerprint:
@@ -1495,6 +1509,7 @@ async def get_token(
14951509
return None, None
14961510
finally:
14971511
self._solve_inflight = max(0, self._solve_inflight - 1)
1512+
self.note_idle()
14981513

14991514
async def get_custom_token(
15001515
self,
@@ -1555,6 +1570,7 @@ async def get_custom_token(
15551570
return None
15561571
finally:
15571572
self._solve_inflight = max(0, self._solve_inflight - 1)
1573+
self.note_idle()
15581574

15591575
async def get_custom_score(
15601576
self,
@@ -1624,6 +1640,7 @@ async def get_custom_score(
16241640
}
16251641
finally:
16261642
self._solve_inflight = max(0, self._solve_inflight - 1)
1643+
self.note_idle()
16271644

16281645

16291646
class BrowserCaptchaService:
@@ -1658,7 +1675,36 @@ def __init__(self, db=None):
16581675

16591676
# ?????? _load_browser_count ???????
16601677
self._token_semaphore = None
1678+
self._idle_reaper_task: Optional[asyncio.Task] = None
16611679

1680+
async def _ensure_idle_reaper(self):
1681+
if self._idle_reaper_task is None or self._idle_reaper_task.done():
1682+
self._idle_reaper_task = asyncio.create_task(self._idle_reaper_loop())
1683+
1684+
async def _idle_reaper_loop(self):
1685+
while True:
1686+
try:
1687+
await asyncio.sleep(15)
1688+
idle_ttl = int(getattr(config, "browser_idle_ttl_seconds", 600) or 600)
1689+
browsers = []
1690+
async with self._browsers_lock:
1691+
browsers = list(self._browsers.values())
1692+
for browser in browsers:
1693+
try:
1694+
if browser.is_busy():
1695+
continue
1696+
if not browser.has_shared_browser():
1697+
continue
1698+
if browser.idle_seconds() < idle_ttl:
1699+
continue
1700+
await browser.recycle_browser(reason=f"idle_ttl_{idle_ttl}s", rotate_profile=False)
1701+
except Exception as e:
1702+
debug_logger.log_warning(f"[BrowserCaptcha] idle reaper failed: {e}")
1703+
except asyncio.CancelledError:
1704+
return
1705+
except Exception as e:
1706+
debug_logger.log_warning(f"[BrowserCaptcha] idle reaper loop error: {e}")
1707+
16621708
@classmethod
16631709
async def get_instance(cls, db=None) -> 'BrowserCaptchaService':
16641710
if cls._instance is None:
@@ -1667,6 +1713,7 @@ async def get_instance(cls, db=None) -> 'BrowserCaptchaService':
16671713
cls._instance = cls(db)
16681714
# 从数据库加载 browser_count 配置
16691715
await cls._instance._load_browser_count()
1716+
await cls._instance._ensure_idle_reaper()
16701717
return cls._instance
16711718

16721719
def _check_available(self):
@@ -1707,6 +1754,7 @@ async def reload_browser_count(self):
17071754
await self._load_browser_count()
17081755

17091756
browsers_to_close: List[TokenBrowser] = []
1757+
await self._ensure_idle_reaper()
17101758
if self._browser_count < old_count:
17111759
async with self._browsers_lock:
17121760
for browser_id in list(self._browsers.keys()):
@@ -2042,6 +2090,13 @@ async def close(self):
20422090
browsers = list(self._browsers.values())
20432091
self._browsers.clear()
20442092

2093+
if self._idle_reaper_task and not self._idle_reaper_task.done():
2094+
self._idle_reaper_task.cancel()
2095+
try:
2096+
await self._idle_reaper_task
2097+
except asyncio.CancelledError:
2098+
pass
2099+
20452100
for browser in browsers:
20462101
try:
20472102
await browser.force_close_pending_browser(close_all=True)

src/services/flow_client.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,9 @@ async def _acquire_image_launch_gate(
357357
deadline = wait_started + wait_timeout
358358
launch_limit = self._resolve_image_launch_soft_limit(token_image_concurrency)
359359
stagger_seconds = max(0, config.flow_image_launch_stagger_ms) / 1000.0
360+
if str(getattr(config, "captcha_method", "")).strip().lower() in {"browser", "remote_browser"}:
361+
# For headed browser token acquisition, avoid artificial staggering so the same batch can start closer together.
362+
stagger_seconds = 0.0
360363

361364
while True:
362365
now = time.monotonic()
@@ -433,6 +436,8 @@ async def _acquire_video_launch_gate(
433436
deadline = wait_started + wait_timeout
434437
launch_limit = self._resolve_video_launch_soft_limit(token_video_concurrency)
435438
stagger_seconds = max(0, config.flow_video_launch_stagger_ms) / 1000.0
439+
if str(getattr(config, "captcha_method", "")).strip().lower() in {"browser", "remote_browser"}:
440+
stagger_seconds = 0.0
436441

437442
while True:
438443
now = time.monotonic()

0 commit comments

Comments
 (0)