| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Sorry, something went wrong.
|
good idea, the code moved into src/_custom recently but we can incorporate these changes @monadoid |
Sorry, something went wrong.
|
gotcha, ty |
Sorry, something went wrong.
There was a problem hiding this comment.
1 issue found across 1 file
Confidence score: 3/5
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/stagehand/_custom/sea_server.py">
<violation number="1" location="src/stagehand/_custom/sea_server.py:220">
P1: Sync/async lifecycle state is bifurcated (`_proc` vs `_async_proc`), allowing duplicate SEA process starts and incomplete cleanup when both APIs are used on one manager.</violation>
</file>
sequenceDiagram
participant App as Caller
participant SEA as SeaServer
participant Proc as Async Subprocess
participant Health as Health Check (HTTP)
participant OS as OS / Process Group
Note over App,OS: Startup Flow (ensure_running_async)
App->>SEA: ensure_running_async()
SEA->>SEA: Acquire async lock
alt Process not running
SEA->>Proc: NEW: asyncio.create_subprocess_exec()
Note right of Proc: start_new_session=True (New PGID)
SEA->>SEA: CHANGED: Register async-compatible atexit
loop Until Ready or Timeout
SEA->>Health: _wait_ready_async()
Health-->>SEA: 200 OK
end
end
SEA-->>App: base_url
Note over App,OS: Shutdown Flow (aclose)
App->>SEA: aclose()
SEA->>SEA: Acquire async lock
alt Process exists
SEA->>OS: NEW: Send SIGTERM to PGID (or proc.terminate)
SEA->>Proc: NEW: await asyncio.wait_for(proc.wait(), 3s)
alt Timeout (Process still alive)
Proc-->>SEA: TimeoutError
SEA->>OS: NEW: Send SIGKILL to PGID (or proc.kill)
SEA->>Proc: NEW: await asyncio.wait_for(proc.wait(), 3s)
else Success
Proc-->>SEA: Process exited
end
end
SEA-->>App: Done
Note over SEA,OS: Emergency Cleanup (atexit)
OS->>SEA: Process exiting (atexit)
SEA->>OS: NEW: Non-blocking SIGTERM to PGID
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.
Sorry, something went wrong.
| async def ensure_running_async(self) -> str: | ||
| async with self._async_lock: | ||
| if self._proc is not None and self._proc.poll() is None and self._base_url is not None: | ||
| if self._async_proc is not None and self._async_proc.returncode is None and self._base_url is not None: |
There was a problem hiding this comment.
P1: Sync/async lifecycle state is bifurcated (_proc vs _async_proc), allowing duplicate SEA process starts and incomplete cleanup when both APIs are used on one manager.
Prompt for AI agentsCheck if this issue is valid — if so, understand the root cause and fix it. At src/stagehand/_custom/sea_server.py, line 220:
<comment>Sync/async lifecycle state is bifurcated (`_proc` vs `_async_proc`), allowing duplicate SEA process starts and incomplete cleanup when both APIs are used on one manager.</comment>
<file context>
@@ -177,12 +217,12 @@ def ensure_running_sync(self) -> str:
async def ensure_running_async(self) -> str:
async with self._async_lock:
- if self._proc is not None and self._proc.poll() is None and self._base_url is not None:
+ if self._async_proc is not None and self._async_proc.returncode is None and self._base_url is not None:
return self._base_url
</file context>
Sorry, something went wrong.
There was a problem hiding this comment.
I've fixed the bifurcated sync/async lifecycle state in SeaServerManager that allowed duplicate SEA process starts and incomplete cleanup.
Root cause: ensure_running_sync() only checked _proc and ensure_running_async() only checked _async_proc, so calling both APIs on the same manager would start two separate SEA processes. Similarly, close() only terminated _proc and aclose() only terminated _async_proc, leaving the other process orphaned.
Changes:
✅ All tests passing (12/12 in test_local_server.py)
✅ Lint (ruff) passing
✅ Type checking (pyright) passing
diff --git a/src/stagehand/_custom/sea_server.py b/src/stagehand/_custom/sea_server.py
index 3c8107a..1254296 100644
--- a/src/stagehand/_custom/sea_server.py
+++ b/src/stagehand/_custom/sea_server.py
@@ -181,6 +181,13 @@ class SeaServerManager:
self._base_url: str | None = None
self._atexit_registered: bool = False
+ def _has_running_process(self) -> bool:
+ if self._proc is not None and self._proc.poll() is None:
+ return True
+ if self._async_proc is not None and self._async_proc.returncode is None:
+ return True
+ return False
+
@property
def base_url(self) -> str | None:
return self._base_url
@@ -207,7 +214,7 @@ class SeaServerManager:
def ensure_running_sync(self) -> str:
with self._lock:
- if self._proc is not None and self._proc.poll() is None and self._base_url is not None:
+ if self._has_running_process() and self._base_url is not None:
return self._base_url
base_url, proc = self._start_sync()
@@ -217,7 +224,7 @@ class SeaServerManager:
async def ensure_running_async(self) -> str:
async with self._async_lock:
- if self._async_proc is not None and self._async_proc.returncode is None and self._base_url is not None:
+ if self._has_running_process() and self._base_url is not None:
return self._base_url
base_url, proc = await self._start_async()
@@ -230,10 +237,12 @@ class SeaServerManager:
return
with self._lock:
- if self._proc is None:
- return
- _terminate_process(self._proc)
- self._proc = None
+ if self._proc is not None:
+ _terminate_process(self._proc)
+ self._proc = None
+ if self._async_proc is not None:
+ _terminate_process_async_atexit(self._async_proc)
+ self._async_proc = None
self._base_url = None
async def aclose(self) -> None:
@@ -241,10 +250,12 @@ class SeaServerManager:
return
async with self._async_lock:
- if self._async_proc is None:
- return
- await _terminate_process_async(self._async_proc)
- self._async_proc = None
+ if self._async_proc is not None:
+ await _terminate_process_async(self._async_proc)
+ self._async_proc = None
+ if self._proc is not None:
+ _terminate_process(self._proc)
+ self._proc = None
self._base_url = None
def _start_sync(self) -> tuple[str, subprocess.Popen[bytes]]:
diff --git a/tests/test_local_server.py b/tests/test_local_server.py
index 20f7ab5..b551e5b 100644
--- a/tests/test_local_server.py
+++ b/tests/test_local_server.py
@@ -332,3 +332,128 @@ def test_local_mode_forwards_flow_log_and_config_dir_env_to_sea_binary(
assert captured_env["BROWSERBASE_FLOW_LOGS"] == "1"
assert captured_env["BROWSERBASE_CONFIG_DIR"] == "./tmp"
client.close()
+
+
+def test_unified_lifecycle_prevents_duplicate_process_starts() -> None:
+ manager = sea_server.SeaServerManager(
+ config=sea_server.SeaServerConfig(
+ host="127.0.0.1",
+ port=9999,
+ headless=True,
+ ready_timeout_s=5.0,
+ model_api_key=None,
+ chrome_path=None,
+ shutdown_on_close=True,
+ ),
+ _local_stagehand_binary_path="/fake/path",
+ )
+
+ class FakeAsyncProc:
+ pid = 99999
+ returncode: int | None = None
+
+ def terminate(self) -> None:
+ self.returncode = 0
+
+ fake_async_proc = FakeAsyncProc()
+ manager._async_proc = fake_async_proc # type: ignore[assignment]
+ manager._base_url = "http://127.0.0.1:9999"
+
+ assert manager._has_running_process() is True
+ url = manager.ensure_running_sync()
+ assert url == "http://127.0.0.1:9999"
+ assert manager._proc is None
+
+
+@pytest.mark.asyncio
+async def test_unified_lifecycle_async_reuses_sync_process() -> None:
+ manager = sea_server.SeaServerManager(
+ config=sea_server.SeaServerConfig(
+ host="127.0.0.1",
+ port=9999,
+ headless=True,
+ ready_timeout_s=5.0,
+ model_api_key=None,
+ chrome_path=None,
+ shutdown_on_close=True,
+ ),
+ _local_stagehand_binary_path="/fake/path",
+ )
+
+ fake_sync_proc = _DummyProcess()
+ manager._proc = fake_sync_proc # type: ignore[assignment]
+ manager._base_url = "http://127.0.0.1:9999"
+
+ assert manager._has_running_process() is True
+ url = await manager.ensure_running_async()
+ assert url == "http://127.0.0.1:9999"
+ assert manager._async_proc is None
+
+
+def test_close_terminates_both_process_types(monkeypatch: pytest.MonkeyPatch) -> None:
+ terminated_pids: list[int] = []
+
+ def _fake_terminate(proc: object) -> None:
+ terminated_pids.append(getattr(proc, "pid", 0))
+
+ monkeypatch.setattr(sea_server, "_terminate_process", _fake_terminate)
+ monkeypatch.setattr(sea_server, "_terminate_process_async_atexit", _fake_terminate)
+
+ manager = sea_server.SeaServerManager(
+ config=sea_server.SeaServerConfig(
+ host="127.0.0.1",
+ port=9999,
+ headless=True,
+ ready_timeout_s=5.0,
+ model_api_key=None,
+ chrome_path=None,
+ shutdown_on_close=True,
+ ),
+ _local_stagehand_binary_path="/fake/path",
+ )
+
+ fake_sync_proc = _DummyProcess()
+ manager._proc = fake_sync_proc # type: ignore[assignment]
+ manager._base_url = "http://127.0.0.1:9999"
+
+ manager.close()
+ assert manager._proc is None
+ assert manager._base_url is None
+ assert fake_sync_proc.pid in terminated_pids
+
+
+@pytest.mark.asyncio
+async def test_aclose_terminates_both_process_types(monkeypatch: pytest.MonkeyPatch) -> None:
+ terminated_pids: list[int] = []
+
+ def _fake_terminate(proc: object) -> None:
+ terminated_pids.append(getattr(proc, "pid", 0))
+
+ async def _fake_terminate_async(proc: object) -> None:
+ terminated_pids.append(getattr(proc, "pid", 0))
+
+ monkeypatch.setattr(sea_server, "_terminate_process", _fake_terminate)
+ monkeypatch.setattr(sea_server, "_terminate_process_async", _fake_terminate_async)
+
+ manager = sea_server.SeaServerManager(
+ config=sea_server.SeaServerConfig(
+ host="127.0.0.1",
+ port=9999,
+ headless=True,
+ ready_timeout_s=5.0,
+ model_api_key=None,
+ chrome_path=None,
+ shutdown_on_close=True,
+ ),
+ _local_stagehand_binary_path="/fake/path",
+ )
+
+ fake_sync_proc = _DummyProcess()
+ manager._proc = fake_sync_proc # type: ignore[assignment]
+ manager._base_url = "http://127.0.0.1:9999"
+
+ await manager.aclose()
+ assert manager._proc is None
+ assert manager._async_proc is None
+ assert manager._base_url is None
+ assert fake_sync_proc.pid in terminated_pids
Files changed:
src/stagehand/_custom/sea_server.py — Added _has_running_process() method, updated ensure_running_sync/async to use unified check, updated close/aclose to terminate both process types
tests/test_local_server.py — Added 4 tests verifying unified lifecycle prevents duplicate starts and complete cleanup
Sorry, something went wrong.
|
will this be patched soon? still looking for a fix in a new version of stagehand |
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
Summary by cubic
Switch SEA process start/stop to fully async to prevent blocking the event loop during startup and shutdown. Improves reliability with timeouts and cross‑platform, process‑group aware termination.
Written for commit c58d9f7. Summary will update on new commits. Review in cubic