iflow-mcp_janspoerer-mcp_browser_use 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/METADATA +26 -0
- iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/RECORD +50 -0
- iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/WHEEL +5 -0
- iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/licenses/LICENSE +201 -0
- iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/top_level.txt +1 -0
- mcp_browser_use/__init__.py +2 -0
- mcp_browser_use/__main__.py +1347 -0
- mcp_browser_use/actions/__init__.py +1 -0
- mcp_browser_use/actions/elements.py +173 -0
- mcp_browser_use/actions/extraction.py +864 -0
- mcp_browser_use/actions/keyboard.py +43 -0
- mcp_browser_use/actions/navigation.py +73 -0
- mcp_browser_use/actions/screenshots.py +85 -0
- mcp_browser_use/browser/__init__.py +1 -0
- mcp_browser_use/browser/chrome.py +150 -0
- mcp_browser_use/browser/chrome_executable.py +204 -0
- mcp_browser_use/browser/chrome_launcher.py +330 -0
- mcp_browser_use/browser/chrome_process.py +104 -0
- mcp_browser_use/browser/devtools.py +230 -0
- mcp_browser_use/browser/driver.py +322 -0
- mcp_browser_use/browser/process.py +133 -0
- mcp_browser_use/cleaners.py +530 -0
- mcp_browser_use/config/__init__.py +30 -0
- mcp_browser_use/config/environment.py +155 -0
- mcp_browser_use/config/paths.py +97 -0
- mcp_browser_use/constants.py +68 -0
- mcp_browser_use/context.py +150 -0
- mcp_browser_use/context_pack.py +85 -0
- mcp_browser_use/decorators/__init__.py +13 -0
- mcp_browser_use/decorators/ensure.py +84 -0
- mcp_browser_use/decorators/envelope.py +83 -0
- mcp_browser_use/decorators/locking.py +172 -0
- mcp_browser_use/helpers.py +173 -0
- mcp_browser_use/helpers_context.py +261 -0
- mcp_browser_use/locking/__init__.py +1 -0
- mcp_browser_use/locking/action_lock.py +190 -0
- mcp_browser_use/locking/file_mutex.py +139 -0
- mcp_browser_use/locking/window_registry.py +178 -0
- mcp_browser_use/tools/__init__.py +59 -0
- mcp_browser_use/tools/browser_management.py +260 -0
- mcp_browser_use/tools/debugging.py +195 -0
- mcp_browser_use/tools/extraction.py +58 -0
- mcp_browser_use/tools/interaction.py +323 -0
- mcp_browser_use/tools/navigation.py +84 -0
- mcp_browser_use/tools/screenshots.py +116 -0
- mcp_browser_use/utils/__init__.py +1 -0
- mcp_browser_use/utils/diagnostics.py +85 -0
- mcp_browser_use/utils/html_utils.py +118 -0
- mcp_browser_use/utils/retry.py +57 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Action lock management for coordinating browser actions across processes."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import asyncio
|
|
7
|
+
from typing import Dict, Any, Optional
|
|
8
|
+
|
|
9
|
+
# Global intra-process lock
|
|
10
|
+
MCP_INTRA_PROCESS_LOCK: Optional[asyncio.Lock] = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_intra_process_lock() -> asyncio.Lock:
|
|
14
|
+
"""Get or create the intra-process asyncio lock."""
|
|
15
|
+
global MCP_INTRA_PROCESS_LOCK
|
|
16
|
+
if MCP_INTRA_PROCESS_LOCK is None:
|
|
17
|
+
MCP_INTRA_PROCESS_LOCK = asyncio.Lock()
|
|
18
|
+
return MCP_INTRA_PROCESS_LOCK
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _read_softlock(path: str) -> Dict[str, Any]:
|
|
22
|
+
"""Read softlock JSON file."""
|
|
23
|
+
try:
|
|
24
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
25
|
+
return json.load(f) or {}
|
|
26
|
+
except FileNotFoundError:
|
|
27
|
+
return {}
|
|
28
|
+
except Exception:
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _write_softlock(path: str, state: Dict[str, Any]):
|
|
33
|
+
"""Write softlock JSON file atomically."""
|
|
34
|
+
tmp = f"{path}.tmp"
|
|
35
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
36
|
+
json.dump(state, f)
|
|
37
|
+
os.replace(tmp, path)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _acquire_softlock(owner: str, ttl: int, wait: bool = True, wait_timeout: float = None) -> Dict[str, Any]:
|
|
41
|
+
"""
|
|
42
|
+
Acquire a softlock for the given owner.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
owner: Identifier of the lock owner
|
|
46
|
+
ttl: Time to live in seconds
|
|
47
|
+
wait: Whether to wait for the lock
|
|
48
|
+
wait_timeout: Maximum time to wait (defaults to ACTION_LOCK_WAIT_SECS)
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Dictionary with lock acquisition status
|
|
52
|
+
"""
|
|
53
|
+
from .file_mutex import _lock_paths, _file_mutex, _now
|
|
54
|
+
from ..constants import FILE_MUTEX_STALE_SECS, ACTION_LOCK_WAIT_SECS
|
|
55
|
+
|
|
56
|
+
if wait_timeout is None:
|
|
57
|
+
wait_timeout = ACTION_LOCK_WAIT_SECS
|
|
58
|
+
|
|
59
|
+
softlock_json, softlock_mutex, _ = _lock_paths()
|
|
60
|
+
deadline = _now() + max(0.0, wait_timeout)
|
|
61
|
+
|
|
62
|
+
while True:
|
|
63
|
+
try:
|
|
64
|
+
with _file_mutex(softlock_mutex, stale_secs=FILE_MUTEX_STALE_SECS, wait_timeout=min(5.0, max(0.1, deadline - _now()))):
|
|
65
|
+
state = _read_softlock(softlock_json)
|
|
66
|
+
cur_owner = state.get("owner")
|
|
67
|
+
expires_at = float(state.get("expires_at", 0.0))
|
|
68
|
+
|
|
69
|
+
if not cur_owner or expires_at <= _now() or cur_owner == owner:
|
|
70
|
+
new_exp = _now() + ttl
|
|
71
|
+
_write_softlock(softlock_json, {"owner": owner, "expires_at": new_exp})
|
|
72
|
+
return {"acquired": True, "owner": owner, "expires_at": new_exp}
|
|
73
|
+
|
|
74
|
+
result = {
|
|
75
|
+
"acquired": False,
|
|
76
|
+
"owner": cur_owner,
|
|
77
|
+
"expires_at": float(expires_at),
|
|
78
|
+
"message": "busy",
|
|
79
|
+
}
|
|
80
|
+
except TimeoutError:
|
|
81
|
+
if not wait or _now() >= deadline:
|
|
82
|
+
# Best-effort read without mutex for context
|
|
83
|
+
state = _read_softlock(softlock_json)
|
|
84
|
+
return {
|
|
85
|
+
"acquired": False,
|
|
86
|
+
"owner": state.get("owner"),
|
|
87
|
+
"expires_at": float(state.get("expires_at", 0.0)) if state.get("expires_at") else None,
|
|
88
|
+
"message": "mutex_timeout",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if not wait or _now() >= deadline:
|
|
92
|
+
return result
|
|
93
|
+
time.sleep(0.05)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _release_action_lock(owner: str) -> bool:
|
|
97
|
+
"""
|
|
98
|
+
Release the action lock if owned by the given owner.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
owner: Identifier of the lock owner
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
True if lock was released, False otherwise
|
|
105
|
+
"""
|
|
106
|
+
from .file_mutex import _lock_paths, _file_mutex
|
|
107
|
+
from ..constants import FILE_MUTEX_STALE_SECS
|
|
108
|
+
|
|
109
|
+
softlock_json, softlock_mutex, _ = _lock_paths()
|
|
110
|
+
with _file_mutex(softlock_mutex, stale_secs=FILE_MUTEX_STALE_SECS, wait_timeout=5.0):
|
|
111
|
+
state = _read_softlock(softlock_json)
|
|
112
|
+
if state.get("owner") == owner:
|
|
113
|
+
_write_softlock(softlock_json, {})
|
|
114
|
+
return True
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _renew_action_lock(owner: str, ttl: int) -> bool:
|
|
119
|
+
"""
|
|
120
|
+
Extend the action lock if owned by `owner`, or if stale. No-op if owned by someone else and not stale.
|
|
121
|
+
Also updates the window registry heartbeat as a piggyback optimization.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
owner: Identifier of the lock owner
|
|
125
|
+
ttl: Time to live in seconds
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
True if we wrote a new expiry, False otherwise
|
|
129
|
+
"""
|
|
130
|
+
from .file_mutex import _lock_paths, _file_mutex, _now
|
|
131
|
+
from .window_registry import _update_window_heartbeat
|
|
132
|
+
from ..constants import FILE_MUTEX_STALE_SECS
|
|
133
|
+
|
|
134
|
+
softlock_json, softlock_mutex, _ = _lock_paths()
|
|
135
|
+
try:
|
|
136
|
+
with _file_mutex(softlock_mutex, stale_secs=FILE_MUTEX_STALE_SECS, wait_timeout=1.0):
|
|
137
|
+
state = _read_softlock(softlock_json)
|
|
138
|
+
cur_owner = state.get("owner")
|
|
139
|
+
expires_at = float(state.get("expires_at", 0.0) or 0.0)
|
|
140
|
+
|
|
141
|
+
if cur_owner == owner or expires_at <= _now():
|
|
142
|
+
new_exp = _now() + int(ttl)
|
|
143
|
+
_write_softlock(softlock_json, {"owner": owner, "expires_at": new_exp})
|
|
144
|
+
|
|
145
|
+
# Piggyback: update window heartbeat while we're renewing the lock
|
|
146
|
+
try:
|
|
147
|
+
_update_window_heartbeat(owner)
|
|
148
|
+
except Exception:
|
|
149
|
+
pass # Non-critical
|
|
150
|
+
|
|
151
|
+
return True
|
|
152
|
+
return False
|
|
153
|
+
except Exception:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _acquire_action_lock_or_error(owner: str) -> Optional[str]:
|
|
158
|
+
"""
|
|
159
|
+
Acquire action lock or return error message.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
owner: Identifier of the lock owner
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
None if lock acquired, error JSON string otherwise
|
|
166
|
+
"""
|
|
167
|
+
from ..constants import ACTION_LOCK_TTL_SECS, ACTION_LOCK_WAIT_SECS
|
|
168
|
+
|
|
169
|
+
res = _acquire_softlock(owner=owner, ttl=ACTION_LOCK_TTL_SECS, wait=True, wait_timeout=ACTION_LOCK_WAIT_SECS)
|
|
170
|
+
if res.get("acquired"):
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
return json.dumps({
|
|
174
|
+
"ok": False,
|
|
175
|
+
"error": "locked",
|
|
176
|
+
"owner": res.get("owner"),
|
|
177
|
+
"expires_at": res.get("expires_at"),
|
|
178
|
+
"lock_ttl_seconds": ACTION_LOCK_TTL_SECS,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
__all__ = [
|
|
183
|
+
'get_intra_process_lock',
|
|
184
|
+
'_read_softlock',
|
|
185
|
+
'_write_softlock',
|
|
186
|
+
'_acquire_softlock',
|
|
187
|
+
'_release_action_lock',
|
|
188
|
+
'_renew_action_lock',
|
|
189
|
+
'_acquire_action_lock_or_error',
|
|
190
|
+
]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""File-based mutex and startup lock implementation."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
import contextlib
|
|
8
|
+
import psutil
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _now() -> float:
|
|
13
|
+
"""Return current time as float timestamp."""
|
|
14
|
+
return time.time()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _lock_paths():
|
|
18
|
+
"""
|
|
19
|
+
Get paths for lock files based on profile key.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Tuple of (softlock_json, softlock_mutex, startup_mutex) paths
|
|
23
|
+
"""
|
|
24
|
+
# Import here to avoid circular dependency
|
|
25
|
+
from ..config.environment import profile_key, get_env_config
|
|
26
|
+
from ..config.paths import get_lock_dir
|
|
27
|
+
|
|
28
|
+
key = profile_key(get_env_config()) # stable across processes; independent of port
|
|
29
|
+
base = Path(get_lock_dir())
|
|
30
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
softlock_json = base / f"{key}.softlock.json"
|
|
32
|
+
softlock_mutex = base / f"{key}.softlock.mutex"
|
|
33
|
+
startup_mutex = base / f"{key}.startup.mutex"
|
|
34
|
+
return str(softlock_json), str(softlock_mutex), str(startup_mutex)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextlib.contextmanager
|
|
38
|
+
def _file_mutex(path: str, stale_secs: int, wait_timeout: float):
|
|
39
|
+
"""
|
|
40
|
+
Simple cross-process mutex via an exclusive file create.
|
|
41
|
+
Removes stale mutex if older than stale_secs.
|
|
42
|
+
"""
|
|
43
|
+
start = _now()
|
|
44
|
+
p = Path(path)
|
|
45
|
+
while True:
|
|
46
|
+
try:
|
|
47
|
+
fd = os.open(str(p), os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
|
48
|
+
break
|
|
49
|
+
except FileExistsError:
|
|
50
|
+
try:
|
|
51
|
+
st = p.stat()
|
|
52
|
+
if _now() - st.st_mtime > stale_secs:
|
|
53
|
+
p.unlink(missing_ok=True)
|
|
54
|
+
continue
|
|
55
|
+
except FileNotFoundError:
|
|
56
|
+
continue
|
|
57
|
+
if _now() - start > wait_timeout:
|
|
58
|
+
raise TimeoutError(f"Timed out waiting for mutex {p}")
|
|
59
|
+
time.sleep(0.05)
|
|
60
|
+
try:
|
|
61
|
+
yield
|
|
62
|
+
finally:
|
|
63
|
+
try:
|
|
64
|
+
os.close(fd)
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
try:
|
|
68
|
+
p.unlink(missing_ok=True)
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def start_lock_dir(config: dict) -> str:
|
|
74
|
+
"""Get path to startup lock directory for the given profile."""
|
|
75
|
+
from ..config.environment import profile_key
|
|
76
|
+
return os.path.join(tempfile.gettempdir(), f"mcp_chrome_start_lock_{profile_key(config)}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def acquire_start_lock(config: dict, timeout_sec: float = None) -> bool:
|
|
80
|
+
"""
|
|
81
|
+
Acquire startup lock to ensure only one process starts Chrome.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
config: Configuration dictionary
|
|
85
|
+
timeout_sec: Timeout in seconds (defaults to START_LOCK_WAIT_SEC from helpers)
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
True if lock acquired, False on timeout
|
|
89
|
+
"""
|
|
90
|
+
if timeout_sec is None:
|
|
91
|
+
from ..constants import START_LOCK_WAIT_SEC
|
|
92
|
+
timeout_sec = START_LOCK_WAIT_SEC
|
|
93
|
+
|
|
94
|
+
lock_dir = start_lock_dir(config)
|
|
95
|
+
deadline = time.time() + timeout_sec
|
|
96
|
+
while time.time() < deadline:
|
|
97
|
+
try:
|
|
98
|
+
os.mkdir(lock_dir)
|
|
99
|
+
with open(os.path.join(lock_dir, "pid"), "w") as f:
|
|
100
|
+
f.write(str(os.getpid()))
|
|
101
|
+
return True
|
|
102
|
+
except FileExistsError:
|
|
103
|
+
# If owner died, break it
|
|
104
|
+
pid_file = os.path.join(lock_dir, "pid")
|
|
105
|
+
try:
|
|
106
|
+
if os.path.exists(pid_file):
|
|
107
|
+
with open(pid_file, "r") as f:
|
|
108
|
+
pid_txt = f.read().strip()
|
|
109
|
+
pid = int(pid_txt) if pid_txt.isdigit() else None
|
|
110
|
+
else:
|
|
111
|
+
pid = None
|
|
112
|
+
except Exception:
|
|
113
|
+
pid = None
|
|
114
|
+
if pid and not psutil.pid_exists(pid):
|
|
115
|
+
try:
|
|
116
|
+
shutil.rmtree(lock_dir, ignore_errors=True)
|
|
117
|
+
continue
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
time.sleep(0.05)
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def release_start_lock(config: dict) -> None:
|
|
125
|
+
"""Release startup lock."""
|
|
126
|
+
try:
|
|
127
|
+
shutil.rmtree(start_lock_dir(config), ignore_errors=True)
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
__all__ = [
|
|
133
|
+
'_now',
|
|
134
|
+
'_lock_paths',
|
|
135
|
+
'_file_mutex',
|
|
136
|
+
'start_lock_dir',
|
|
137
|
+
'acquire_start_lock',
|
|
138
|
+
'release_start_lock',
|
|
139
|
+
]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Window registry for tracking browser window ownership across processes."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import psutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, Any, Optional
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _window_registry_path() -> str:
|
|
15
|
+
"""Get path to window registry file for this profile."""
|
|
16
|
+
from ..config.environment import profile_key, get_env_config
|
|
17
|
+
from ..config.paths import get_lock_dir
|
|
18
|
+
|
|
19
|
+
key = profile_key(get_env_config())
|
|
20
|
+
return str(Path(get_lock_dir()) / f"{key}.window_registry.json")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _read_window_registry() -> Dict[str, Any]:
|
|
24
|
+
"""Read the window registry. Returns empty dict if not found or invalid."""
|
|
25
|
+
path = _window_registry_path()
|
|
26
|
+
try:
|
|
27
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
28
|
+
return json.load(f) or {}
|
|
29
|
+
except FileNotFoundError:
|
|
30
|
+
return {}
|
|
31
|
+
except Exception:
|
|
32
|
+
return {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _write_window_registry(registry: Dict[str, Any]):
|
|
36
|
+
"""Write window registry atomically using temp file + rename."""
|
|
37
|
+
path = _window_registry_path()
|
|
38
|
+
tmp = f"{path}.tmp"
|
|
39
|
+
try:
|
|
40
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
41
|
+
json.dump(registry, f, indent=2)
|
|
42
|
+
os.replace(tmp, path)
|
|
43
|
+
except Exception:
|
|
44
|
+
pass # Non-critical if write fails
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _register_window(agent_id: str, target_id: str, window_id: Optional[int]):
|
|
48
|
+
"""Register a window as owned by this agent."""
|
|
49
|
+
registry = _read_window_registry()
|
|
50
|
+
registry[agent_id] = {
|
|
51
|
+
"target_id": target_id,
|
|
52
|
+
"window_id": window_id,
|
|
53
|
+
"pid": os.getpid(),
|
|
54
|
+
"last_heartbeat": time.time(),
|
|
55
|
+
"created_at": time.time(),
|
|
56
|
+
}
|
|
57
|
+
_write_window_registry(registry)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _update_window_heartbeat(agent_id: str):
|
|
61
|
+
"""Update the heartbeat timestamp for this agent's window."""
|
|
62
|
+
registry = _read_window_registry()
|
|
63
|
+
if agent_id in registry:
|
|
64
|
+
registry[agent_id]["last_heartbeat"] = time.time()
|
|
65
|
+
_write_window_registry(registry)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _unregister_window(agent_id: str):
|
|
69
|
+
"""Remove this agent's window from the registry."""
|
|
70
|
+
registry = _read_window_registry()
|
|
71
|
+
if agent_id in registry:
|
|
72
|
+
del registry[agent_id]
|
|
73
|
+
_write_window_registry(registry)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cleanup_orphaned_windows(driver, *, close_on_stale: bool = False):
|
|
77
|
+
"""
|
|
78
|
+
Close windows owned by dead processes. Optionally close very stale windows if explicitly enabled.
|
|
79
|
+
|
|
80
|
+
- Default behavior: only close when the owning PID no longer exists.
|
|
81
|
+
- Stale heartbeats are logged but not closed by default to avoid killing idle sessions.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
driver: Selenium WebDriver instance
|
|
85
|
+
close_on_stale: If True, also close windows with stale heartbeats (default False)
|
|
86
|
+
"""
|
|
87
|
+
from ..constants import WINDOW_REGISTRY_STALE_THRESHOLD
|
|
88
|
+
|
|
89
|
+
# If you have a registry/file lock, acquire it here
|
|
90
|
+
# with _registry_lock():
|
|
91
|
+
registry = _read_window_registry()
|
|
92
|
+
now = time.time()
|
|
93
|
+
|
|
94
|
+
to_remove: list[str] = []
|
|
95
|
+
changed = False
|
|
96
|
+
|
|
97
|
+
# Optional: detect already-missing targets to avoid noisy close attempts
|
|
98
|
+
try:
|
|
99
|
+
targets_resp = driver.execute_cdp_cmd("Target.getTargets", {})
|
|
100
|
+
known_targets = {t.get("targetId") for t in targets_resp.get("targetInfos", [])}
|
|
101
|
+
except Exception:
|
|
102
|
+
known_targets = None # fall back to best-effort without pre-check
|
|
103
|
+
|
|
104
|
+
for agent_id, info in list(registry.items()):
|
|
105
|
+
pid = info.get("pid")
|
|
106
|
+
last_hb = info.get("last_heartbeat")
|
|
107
|
+
target_id = info.get("target_id")
|
|
108
|
+
|
|
109
|
+
# Robustness: skip weird records
|
|
110
|
+
if target_id is None:
|
|
111
|
+
logger.info(f"Removing registry entry with no target_id: agent={agent_id}, pid={pid}")
|
|
112
|
+
to_remove.append(agent_id)
|
|
113
|
+
changed = True
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
# Compute states safely
|
|
117
|
+
try:
|
|
118
|
+
is_dead = bool(pid) and not psutil.pid_exists(int(pid))
|
|
119
|
+
except Exception:
|
|
120
|
+
# If pid cannot be parsed, treat as unknown (do not close)
|
|
121
|
+
is_dead = False
|
|
122
|
+
|
|
123
|
+
is_stale = False
|
|
124
|
+
if isinstance(last_hb, (int, float)):
|
|
125
|
+
try:
|
|
126
|
+
is_stale = (now - float(last_hb)) > WINDOW_REGISTRY_STALE_THRESHOLD
|
|
127
|
+
except Exception:
|
|
128
|
+
is_stale = False
|
|
129
|
+
|
|
130
|
+
# If target is already gone, just drop the registry entry
|
|
131
|
+
if known_targets is not None and target_id not in known_targets:
|
|
132
|
+
logger.info(f"Target already gone; pruning registry entry: agent={agent_id}, target={target_id}")
|
|
133
|
+
to_remove.append(agent_id)
|
|
134
|
+
changed = True
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
# Decide whether to close
|
|
138
|
+
should_close = is_dead or (close_on_stale and is_stale)
|
|
139
|
+
if not should_close:
|
|
140
|
+
if is_stale:
|
|
141
|
+
logger.debug(f"Stale heartbeat but not closing (agent={agent_id}, pid={pid})")
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
# Try to close the target
|
|
145
|
+
try:
|
|
146
|
+
res = driver.execute_cdp_cmd("Target.closeTarget", {"targetId": target_id})
|
|
147
|
+
success = (res.get("success", True) if isinstance(res, dict) else True)
|
|
148
|
+
logger.info(
|
|
149
|
+
f"Closed orphaned window: agent={agent_id}, target={target_id}, "
|
|
150
|
+
f"dead={is_dead}, stale={is_stale}, success={success}"
|
|
151
|
+
)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
# Even if we fail to close a window of a dead process, remove the entry to avoid leaks
|
|
154
|
+
logger.debug(f"Could not close target {target_id} for agent {agent_id}: {e}")
|
|
155
|
+
|
|
156
|
+
to_remove.append(agent_id)
|
|
157
|
+
changed = True
|
|
158
|
+
|
|
159
|
+
# Clean up registry
|
|
160
|
+
if to_remove:
|
|
161
|
+
for agent_id in to_remove:
|
|
162
|
+
registry.pop(agent_id, None)
|
|
163
|
+
_write_window_registry(registry)
|
|
164
|
+
logger.info(f"Cleaned up {len(to_remove)} window registry entry(ies)")
|
|
165
|
+
elif changed:
|
|
166
|
+
# In case we changed something else
|
|
167
|
+
_write_window_registry(registry)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
__all__ = [
|
|
171
|
+
'_window_registry_path',
|
|
172
|
+
'_read_window_registry',
|
|
173
|
+
'_write_window_registry',
|
|
174
|
+
'_register_window',
|
|
175
|
+
'_update_window_heartbeat',
|
|
176
|
+
'_unregister_window',
|
|
177
|
+
'cleanup_orphaned_windows',
|
|
178
|
+
]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# mcp_browser_use/tools/__init__.py
|
|
2
|
+
"""
|
|
3
|
+
MCP tool implementations - async wrappers that return JSON responses.
|
|
4
|
+
|
|
5
|
+
This package contains high-level tool implementations that:
|
|
6
|
+
- Wrap lower-level helpers/actions
|
|
7
|
+
- Return JSON-serialized responses
|
|
8
|
+
- Include error handling and diagnostics
|
|
9
|
+
- Provide page snapshots
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .browser_management import (
|
|
13
|
+
start_browser,
|
|
14
|
+
unlock_browser,
|
|
15
|
+
close_browser,
|
|
16
|
+
force_close_all_chrome,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from .navigation import (
|
|
20
|
+
navigate_to_url,
|
|
21
|
+
scroll,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from .interaction import (
|
|
25
|
+
fill_text,
|
|
26
|
+
click_element,
|
|
27
|
+
send_keys,
|
|
28
|
+
wait_for_element,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from .debugging import (
|
|
32
|
+
get_debug_diagnostics_info,
|
|
33
|
+
debug_element,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from .screenshots import (
|
|
37
|
+
take_screenshot,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
# Browser management
|
|
42
|
+
'start_browser',
|
|
43
|
+
'unlock_browser',
|
|
44
|
+
'close_browser',
|
|
45
|
+
'force_close_all_chrome',
|
|
46
|
+
# Navigation
|
|
47
|
+
'navigate_to_url',
|
|
48
|
+
'scroll',
|
|
49
|
+
# Interaction
|
|
50
|
+
'fill_text',
|
|
51
|
+
'click_element',
|
|
52
|
+
'send_keys',
|
|
53
|
+
'wait_for_element',
|
|
54
|
+
# Debugging
|
|
55
|
+
'get_debug_diagnostics_info',
|
|
56
|
+
'debug_element',
|
|
57
|
+
# Screenshots
|
|
58
|
+
'take_screenshot',
|
|
59
|
+
]
|