mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-15 12:50:20 +08:00
218 lines
7.0 KiB
Python
218 lines
7.0 KiB
Python
"""
|
|
进程内浏览器实例的公平准入与调用取消边界,不创建或关闭具体浏览器。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
from collections.abc import Callable, Iterator
|
|
from contextlib import contextmanager
|
|
from contextvars import ContextVar
|
|
from typing import Optional
|
|
|
|
from app.runtime.stop import runtime_stop_state
|
|
|
|
# 同步等待定期复核调用方取消和进程停止;异步等待不占用工作线程。
|
|
_CANCEL_CHECK_INTERVAL = 0.05
|
|
_cancel_check: ContextVar[Optional[Callable[[], bool]]] = ContextVar("browser_cancel_check", default=None)
|
|
|
|
|
|
class BrowserQueueCancelledError(RuntimeError):
|
|
"""
|
|
浏览器尚未启动时,所属调用或进程已经停止。
|
|
"""
|
|
|
|
|
|
class BrowserOperationTimeoutError(RuntimeError):
|
|
"""
|
|
浏览器启动、操作或关闭未在期限内收敛,动作的实际结果可能未知。
|
|
"""
|
|
|
|
|
|
def browser_cancel_check() -> Callable[[], bool]:
|
|
"""
|
|
捕获当前请求的取消检查,供专属监控线程读取,不跨线程进入调用方上下文。
|
|
"""
|
|
cancelled = _cancel_check.get()
|
|
return lambda: runtime_stop_state.is_system_stopped or (cancelled is not None and cancelled())
|
|
|
|
|
|
def check_browser_request() -> None:
|
|
"""
|
|
在等待和启动边界拒绝已经取消的调用,避免取消后继续创建浏览器。
|
|
"""
|
|
cancelled = _cancel_check.get()
|
|
if runtime_stop_state.is_system_stopped or (cancelled is not None and cancelled()):
|
|
raise BrowserQueueCancelledError("浏览器请求已取消或系统正在停止")
|
|
|
|
|
|
@contextmanager
|
|
def browser_request(cancelled: Callable[[], bool]) -> Iterator[None]:
|
|
"""
|
|
叠加当前调用的取消检查,并随执行上下文传播到浏览器所属线程。
|
|
|
|
:param cancelled: 返回当前调用是否已失效的线程安全检查函数
|
|
"""
|
|
parent = _cancel_check.get()
|
|
|
|
def is_cancelled() -> bool:
|
|
"""
|
|
任一祖先调用取消后,嵌套调用都不能再取得浏览器名额。
|
|
"""
|
|
return cancelled() or (parent is not None and parent())
|
|
|
|
token = _cancel_check.set(is_cancelled)
|
|
try:
|
|
yield
|
|
finally:
|
|
_cancel_check.reset(token)
|
|
|
|
|
|
class BrowserLease:
|
|
"""
|
|
表示一个已经获准启动的浏览器实例;重复归还不会扩大容量。
|
|
"""
|
|
|
|
def __init__(self, capacity: BrowserCapacity, ticket: object) -> None:
|
|
"""
|
|
保存准入来源及唯一票据,不持有具体浏览器对象。
|
|
|
|
:param capacity: 发放本名额的进程级队列
|
|
:param ticket: 该名额唯一的身份票据
|
|
"""
|
|
self._capacity = capacity
|
|
self._ticket = ticket
|
|
|
|
def release(self) -> None:
|
|
"""
|
|
在浏览器确认关闭或启动失败后幂等归还名额。
|
|
"""
|
|
self._capacity.release(self._ticket)
|
|
|
|
|
|
class BrowserCapacity:
|
|
"""
|
|
同步线程和异步调用共享的先到先得浏览器队列。
|
|
|
|
名额覆盖实例的完整存活期;等待超时和取消只移除自身票据,不影响持有者。
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
"""
|
|
创建默认单实例队列,不启动线程、任务或浏览器。
|
|
"""
|
|
self._condition = threading.Condition()
|
|
self._limit = 1
|
|
self._waiting: deque[object] = deque()
|
|
self._active: set[object] = set()
|
|
|
|
@property
|
|
def active(self) -> int:
|
|
"""
|
|
返回尚未归还的实例名额数。
|
|
"""
|
|
with self._condition:
|
|
return len(self._active)
|
|
|
|
@property
|
|
def waiting(self) -> int:
|
|
"""
|
|
返回正在等待准入的调用数。
|
|
"""
|
|
with self._condition:
|
|
return len(self._waiting)
|
|
|
|
def configure(self, limit: int) -> None:
|
|
"""
|
|
更新后续准入容量;调低容量不撤销已经运行的实例。
|
|
|
|
:param limit: 同时存活的浏览器实例上限,必须大于零
|
|
"""
|
|
if limit < 1:
|
|
raise ValueError("浏览器并发上限必须大于零")
|
|
with self._condition:
|
|
self._limit = limit
|
|
self._condition.notify_all()
|
|
|
|
def _enter(self) -> object:
|
|
"""
|
|
登记唯一等待票据,统一同步和异步调用的先后顺序。
|
|
"""
|
|
check_browser_request()
|
|
ticket = object()
|
|
with self._condition:
|
|
self._waiting.append(ticket)
|
|
return ticket
|
|
|
|
def _take(self, ticket: object, deadline: float) -> bool:
|
|
"""
|
|
在同一把锁内复核取消、超时、顺序和容量,再发放名额。
|
|
"""
|
|
check_browser_request()
|
|
if time.monotonic() >= deadline:
|
|
raise TimeoutError("等待浏览器名额超时,已有实例仍被占用,请稍后重试")
|
|
if self._waiting[0] is ticket and len(self._active) < self._limit:
|
|
self._waiting.popleft()
|
|
self._active.add(ticket)
|
|
self._condition.notify_all()
|
|
return True
|
|
return False
|
|
|
|
def _discard(self, ticket: object) -> None:
|
|
"""
|
|
撤销尚未准入的调用,不能归还其它调用仍持有的名额。
|
|
"""
|
|
with self._condition:
|
|
if ticket in self._waiting:
|
|
self._waiting.remove(ticket)
|
|
self._condition.notify_all()
|
|
|
|
def acquire(self, timeout: float) -> BrowserLease:
|
|
"""
|
|
在同步线程有限等待浏览器名额。
|
|
|
|
:param timeout: 最长排队秒数,必须大于零
|
|
:return: 由实例关闭流程负责归还的名额
|
|
"""
|
|
deadline = time.monotonic() + timeout
|
|
ticket = self._enter()
|
|
try:
|
|
with self._condition:
|
|
while not self._take(ticket, deadline):
|
|
self._condition.wait(min(_CANCEL_CHECK_INTERVAL, max(0.0, deadline - time.monotonic())))
|
|
return BrowserLease(self, ticket)
|
|
finally:
|
|
self._discard(ticket)
|
|
|
|
async def acquire_async(self, timeout: float) -> BrowserLease:
|
|
"""
|
|
在事件循环内等待同一队列,取消时立即撤销票据,不遗留后台取号线程。
|
|
|
|
:param timeout: 最长排队秒数,必须大于零
|
|
:return: 由实例关闭流程负责归还的名额
|
|
"""
|
|
deadline = time.monotonic() + timeout
|
|
ticket = self._enter()
|
|
try:
|
|
while True:
|
|
with self._condition:
|
|
if self._take(ticket, deadline):
|
|
return BrowserLease(self, ticket)
|
|
await asyncio.sleep(min(_CANCEL_CHECK_INTERVAL, max(0.0, deadline - time.monotonic())))
|
|
finally:
|
|
self._discard(ticket)
|
|
|
|
def release(self, ticket: object) -> None:
|
|
"""
|
|
只有对应实例的票据可以释放自己的名额,关闭重试保持幂等。
|
|
"""
|
|
with self._condition:
|
|
self._active.discard(ticket)
|
|
self._condition.notify_all()
|
|
|
|
|
|
browser_capacity = BrowserCapacity()
|