Skip to content

Commit 5dd5e41

Browse files
authored
Merge pull request #67 from Serverless-Devs/copilot/fix-playwright-cache-issue
fix(BrowserToolSet): Playwright greenlet thread-binding crash on cross-thread cache reuse
2 parents 3b8ea78 + 213fc6c commit 5dd5e41

File tree

9 files changed

+316
-53
lines changed

9 files changed

+316
-53
lines changed

agentrun/integration/builtin/sandbox.py

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ class PlaywrightError(Exception): # type: ignore[no-redef]
2626
pass
2727

2828

29+
try:
30+
from greenlet import error as GreenletError
31+
except ImportError:
32+
33+
class GreenletError(Exception): # type: ignore[no-redef]
34+
"""Fallback greenlet error used when greenlet is not installed."""
35+
36+
pass
37+
38+
2939
class SandboxToolSet(CommonToolSet):
3040
"""沙箱工具集基类
3141
@@ -727,24 +737,47 @@ def __init__(
727737
polar_fs_config=polar_fs_config,
728738
)
729739
self._playwright_sync: Optional["BrowserPlaywrightSync"] = None
740+
self._playwright_thread: Optional[threading.Thread] = None
730741

731742
def _get_playwright(self, sb: BrowserSandbox) -> "BrowserPlaywrightSync":
732743
"""获取或创建 Playwright 连接 / Get or create Playwright connection
733744
734745
复用已有连接以减少连接建立开销和瞬态错误。
735746
使用双重检查锁定避免并发调用时创建多个连接导致资源泄漏。
747+
当创建连接的线程已退出时,自动重建连接(Playwright greenlet 绑定到创建它的线程)。
748+
736749
Reuses existing connection to reduce connection overhead and transient errors.
737750
Uses double-checked locking to avoid leaking connections under concurrent calls.
751+
Automatically recreates the connection when the thread that created it has exited,
752+
because Playwright's internal greenlet is bound to the thread that created it.
738753
"""
739-
if self._playwright_sync is not None:
740-
return self._playwright_sync
754+
if self._playwright_sync is not None and self._playwright_thread is not None:
755+
current_thread = threading.current_thread()
756+
creator_thread = self._playwright_thread
757+
if not creator_thread.is_alive() or current_thread is not creator_thread:
758+
if not creator_thread.is_alive():
759+
logger.debug(
760+
"Playwright creating thread (id=%s) has exited, recreating"
761+
" connection",
762+
creator_thread.ident,
763+
)
764+
else:
765+
logger.debug(
766+
"Playwright creating thread (id=%s) differs from current"
767+
" thread (id=%s), recreating connection",
768+
creator_thread.ident,
769+
current_thread.ident,
770+
)
771+
self._reset_playwright()
741772

742-
with self.lock:
743-
if self._playwright_sync is None:
744-
playwright_sync = sb.sync_playwright()
745-
playwright_sync.open()
746-
self._playwright_sync = playwright_sync
747-
return self._playwright_sync
773+
if self._playwright_sync is None:
774+
with self.lock:
775+
if self._playwright_sync is None:
776+
playwright_sync = sb.sync_playwright()
777+
playwright_sync.open()
778+
self._playwright_sync = playwright_sync
779+
self._playwright_thread = threading.current_thread()
780+
return self._playwright_sync
748781

749782
def _reset_playwright(self) -> None:
750783
"""重置 Playwright 连接 / Reset Playwright connection
@@ -763,6 +796,7 @@ def _reset_playwright(self) -> None:
763796
exc_info=True,
764797
)
765798
self._playwright_sync = None
799+
self._playwright_thread = None
766800

767801
def _run_in_sandbox(self, callback: Callable[[Sandbox], Any]) -> Any:
768802
"""在沙箱中执行操作,智能区分错误类型 / Execute in sandbox with smart error handling
@@ -812,6 +846,22 @@ def _run_in_sandbox(self, callback: Callable[[Sandbox], Any]) -> Any:
812846
"Browser tool-level error (no sandbox rebuild): %s", e
813847
)
814848
return {"error": f"{e!s}"}
849+
except GreenletError as e:
850+
logger.debug(
851+
"Greenlet thread-binding error, resetting Playwright: %s",
852+
e,
853+
)
854+
# Keep the existing sandbox (it is still healthy); only the
855+
# Playwright connection needs to be recreated on this thread.
856+
try:
857+
self._reset_playwright()
858+
return callback(sb)
859+
except Exception as e2:
860+
logger.debug(
861+
"Retry after Playwright reset failed: %s",
862+
e2,
863+
)
864+
return {"error": f"{e!s}"}
815865
except Exception as e:
816866
logger.debug("Unexpected error in browser sandbox: %s", e)
817867
return {"error": f"{e!s}"}
@@ -881,7 +931,7 @@ def inner(sb: Sandbox):
881931
def browser_navigate(
882932
self,
883933
url: str,
884-
wait_until: str = "load",
934+
wait_until: str = "domcontentloaded",
885935
timeout: Optional[float] = None,
886936
) -> Dict[str, Any]:
887937
"""导航到 URL / Navigate to URL"""

tests/unittests/integration/langchain/test_agent_invoke_methods.py renamed to tests/unittests/integration/langchain/test_agent_invoke_methods_unittests.py

File renamed without changes.

tests/unittests/integration/test_agentscope.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from .scenarios import Scenarios
2222

2323

24-
class TestToolSet(CommonToolSet):
24+
class SampleToolSet(CommonToolSet):
2525
"""测试用工具集"""
2626

2727
def __init__(self, timezone: str = "UTC"):
@@ -150,9 +150,9 @@ def mocked_model(
150150
return model("mock-model")
151151

152152
@pytest.fixture
153-
def mocked_toolset(self) -> TestToolSet:
153+
def mocked_toolset(self) -> SampleToolSet:
154154
"""创建 mock 的工具集"""
155-
return TestToolSet(timezone="UTC")
155+
return SampleToolSet(timezone="UTC")
156156

157157
# =========================================================================
158158
# 测试:简单对话(无工具调用)
@@ -194,7 +194,7 @@ async def test_multi_tool_calls(
194194
self,
195195
mock_server: MockLLMServer,
196196
mocked_model: CommonModel,
197-
mocked_toolset: TestToolSet,
197+
mocked_toolset: SampleToolSet,
198198
):
199199
"""测试多工具同时调用"""
200200
# 使用默认的多工具场景
@@ -223,7 +223,7 @@ async def test_stream_options_validation(
223223
self,
224224
mock_server: MockLLMServer,
225225
mocked_model: CommonModel,
226-
mocked_toolset: TestToolSet,
226+
mocked_toolset: SampleToolSet,
227227
):
228228
"""测试 stream_options 在请求中的正确性"""
229229
# 使用默认场景

0 commit comments

Comments
 (0)