Leon4gr45 commited on
Commit
d2a62c8
·
verified ·
1 Parent(s): 920b02b

Delete browser_use/browser_use/browser/session_manager.py with huggingface_hub

Browse files
browser_use/browser_use/browser/session_manager.py DELETED
@@ -1,895 +0,0 @@
1
- """Event-driven CDP session management.
2
-
3
- Manages CDP sessions by listening to Target.attachedToTarget and Target.detachedFromTarget
4
- events, ensuring the session pool always reflects the current browser state.
5
- """
6
-
7
- import asyncio
8
- from typing import TYPE_CHECKING
9
-
10
- from cdp_use.cdp.target import AttachedToTargetEvent, DetachedFromTargetEvent, SessionID, TargetID
11
-
12
- from browser_use.utils import create_task_with_error_handling
13
-
14
- if TYPE_CHECKING:
15
- from browser_use.browser.session import BrowserSession, CDPSession, Target
16
-
17
-
18
- class SessionManager:
19
- """Event-driven CDP session manager.
20
-
21
- Automatically synchronizes the CDP session pool with browser state via CDP events.
22
-
23
- Key features:
24
- - Sessions added/removed automatically via Target attach/detach events
25
- - Multiple sessions can attach to the same target
26
- - Targets only removed when ALL sessions detach
27
- - No stale sessions - pool always reflects browser reality
28
-
29
- SessionManager is the SINGLE SOURCE OF TRUTH for all targets and sessions.
30
- """
31
-
32
- def __init__(self, browser_session: 'BrowserSession'):
33
- self.browser_session = browser_session
34
- self.logger = browser_session.logger
35
-
36
- # All targets (entities: pages, iframes, workers)
37
- self._targets: dict[TargetID, 'Target'] = {}
38
-
39
- # All sessions (communication channels)
40
- self._sessions: dict[SessionID, 'CDPSession'] = {}
41
-
42
- # Mapping: target -> sessions attached to it
43
- self._target_sessions: dict[TargetID, set[SessionID]] = {}
44
-
45
- # Reverse mapping: session -> target it belongs to
46
- self._session_to_target: dict[SessionID, TargetID] = {}
47
-
48
- self._lock = asyncio.Lock()
49
- self._recovery_lock = asyncio.Lock()
50
-
51
- # Focus recovery coordination - event-driven instead of polling
52
- self._recovery_in_progress: bool = False
53
- self._recovery_complete_event: asyncio.Event | None = None
54
- self._recovery_task: asyncio.Task | None = None
55
-
56
- async def start_monitoring(self) -> None:
57
- """Start monitoring Target attach/detach events.
58
-
59
- Registers CDP event handlers to keep the session pool synchronized with browser state.
60
- Also discovers and initializes all existing targets on startup.
61
- """
62
- if not self.browser_session._cdp_client_root:
63
- raise RuntimeError('CDP client not initialized')
64
-
65
- # Capture cdp_client_root in closure to avoid type errors
66
- cdp_client = self.browser_session._cdp_client_root
67
-
68
- # Enable target discovery to receive targetInfoChanged events automatically
69
- # This eliminates the need for getTargetInfo() polling calls
70
- await cdp_client.send.Target.setDiscoverTargets(
71
- params={'discover': True, 'filter': [{'type': 'page'}, {'type': 'iframe'}]}
72
- )
73
-
74
- # Register synchronous event handlers (CDP requirement)
75
- def on_attached(event: AttachedToTargetEvent, session_id: SessionID | None = None):
76
- # _handle_target_attached() handles:
77
- # - setAutoAttach for children
78
- # - Create CDPSession
79
- # - Enable monitoring (for pages/tabs)
80
- # - Add to pool
81
- create_task_with_error_handling(
82
- self._handle_target_attached(event),
83
- name='handle_target_attached',
84
- logger_instance=self.logger,
85
- suppress_exceptions=True,
86
- )
87
-
88
- def on_detached(event: DetachedFromTargetEvent, session_id: SessionID | None = None):
89
- create_task_with_error_handling(
90
- self._handle_target_detached(event),
91
- name='handle_target_detached',
92
- logger_instance=self.logger,
93
- suppress_exceptions=True,
94
- )
95
-
96
- def on_target_info_changed(event, session_id: SessionID | None = None):
97
- # Update session info from targetInfoChanged events (no polling needed!)
98
- create_task_with_error_handling(
99
- self._handle_target_info_changed(event),
100
- name='handle_target_info_changed',
101
- logger_instance=self.logger,
102
- suppress_exceptions=True,
103
- )
104
-
105
- cdp_client.register.Target.attachedToTarget(on_attached)
106
- cdp_client.register.Target.detachedFromTarget(on_detached)
107
- cdp_client.register.Target.targetInfoChanged(on_target_info_changed)
108
-
109
- self.logger.debug('[SessionManager] Event monitoring started')
110
-
111
- # Discover and initialize ALL existing targets
112
- await self._initialize_existing_targets()
113
-
114
- def _get_session_for_target(self, target_id: TargetID) -> 'CDPSession | None':
115
- """Internal: Get ANY valid session for a target (picks first available).
116
-
117
- ⚠️ INTERNAL API - Use browser_session.get_or_create_cdp_session() instead!
118
- This method has no validation, no focus management, no recovery.
119
-
120
- Args:
121
- target_id: Target ID to get session for
122
-
123
- Returns:
124
- CDPSession if exists, None if target has detached
125
- """
126
- session_ids = self._target_sessions.get(target_id, set())
127
- if not session_ids:
128
- # Check if this is the focused target - indicates stale focus that needs cleanup
129
- if self.browser_session.agent_focus_target_id == target_id:
130
- self.logger.warning(
131
- f'[SessionManager] ⚠️ Attempted to get session for stale focused target {target_id[:8]}... '
132
- f'Clearing stale focus and triggering recovery.'
133
- )
134
-
135
- # Clear stale focus immediately (defense in depth)
136
- self.browser_session.agent_focus_target_id = None
137
-
138
- # Trigger recovery if not already in progress
139
- if not self._recovery_in_progress:
140
- self.logger.warning('[SessionManager] Recovery was not in progress! Triggering now.')
141
- self._recovery_task = create_task_with_error_handling(
142
- self._recover_agent_focus(target_id),
143
- name='recover_agent_focus_from_stale_get',
144
- logger_instance=self.logger,
145
- suppress_exceptions=False,
146
- )
147
- return None
148
- return self._sessions.get(next(iter(session_ids)))
149
-
150
- def get_all_page_targets(self) -> list:
151
- """Get all page/tab targets using owned data.
152
-
153
- Returns:
154
- List of Target objects for all page/tab targets
155
- """
156
- page_targets = []
157
- for target in self._targets.values():
158
- if target.target_type in ('page', 'tab'):
159
- page_targets.append(target)
160
- return page_targets
161
-
162
- async def validate_session(self, target_id: TargetID) -> bool:
163
- """Check if a target still has active sessions.
164
-
165
- Args:
166
- target_id: Target ID to validate
167
-
168
- Returns:
169
- True if target has active sessions, False if it should be removed
170
- """
171
- if target_id not in self._target_sessions:
172
- return False
173
- return len(self._target_sessions[target_id]) > 0
174
-
175
- async def clear(self) -> None:
176
- """Clear all owned data structures for cleanup."""
177
- async with self._lock:
178
- # Clear owned data (single source of truth)
179
- self._targets.clear()
180
- self._sessions.clear()
181
- self._target_sessions.clear()
182
- self._session_to_target.clear()
183
-
184
- self.logger.info('[SessionManager] Cleared all owned data (targets, sessions, mappings)')
185
-
186
- async def is_target_valid(self, target_id: TargetID) -> bool:
187
- """Check if a target is still valid and has active sessions.
188
-
189
- Args:
190
- target_id: Target ID to validate
191
-
192
- Returns:
193
- True if target is valid and has active sessions, False otherwise
194
- """
195
- if target_id not in self._target_sessions:
196
- return False
197
- return len(self._target_sessions[target_id]) > 0
198
-
199
- def get_target_id_from_session_id(self, session_id: SessionID) -> TargetID | None:
200
- """Look up which target a session belongs to.
201
-
202
- Args:
203
- session_id: The session ID to look up
204
-
205
- Returns:
206
- Target ID if found, None otherwise
207
- """
208
- return self._session_to_target.get(session_id)
209
-
210
- def get_target(self, target_id: TargetID) -> 'Target | None':
211
- """Get target from owned data.
212
-
213
- Args:
214
- target_id: Target ID to get
215
-
216
- Returns:
217
- Target object if found, None otherwise
218
- """
219
- return self._targets.get(target_id)
220
-
221
- def get_all_targets(self) -> dict[TargetID, 'Target']:
222
- """Get all targets (read-only access to owned data).
223
-
224
- Returns:
225
- Dict mapping target_id to Target objects
226
- """
227
- return self._targets
228
-
229
- def get_all_target_ids(self) -> list[TargetID]:
230
- """Get all target IDs from owned data.
231
-
232
- Returns:
233
- List of all target IDs
234
- """
235
- return list(self._targets.keys())
236
-
237
- def get_all_sessions(self) -> dict[SessionID, 'CDPSession']:
238
- """Get all sessions (read-only access to owned data).
239
-
240
- Returns:
241
- Dict mapping session_id to CDPSession objects
242
- """
243
- return self._sessions
244
-
245
- def get_session(self, session_id: SessionID) -> 'CDPSession | None':
246
- """Get session from owned data.
247
-
248
- Args:
249
- session_id: Session ID to get
250
-
251
- Returns:
252
- CDPSession object if found, None otherwise
253
- """
254
- return self._sessions.get(session_id)
255
-
256
- def get_all_sessions_for_target(self, target_id: TargetID) -> list['CDPSession']:
257
- """Get ALL sessions attached to a target from owned data.
258
-
259
- Args:
260
- target_id: Target ID to get sessions for
261
-
262
- Returns:
263
- List of all CDPSession objects for this target
264
- """
265
- session_ids = self._target_sessions.get(target_id, set())
266
- return [self._sessions[sid] for sid in session_ids if sid in self._sessions]
267
-
268
- def get_target_sessions_mapping(self) -> dict[TargetID, set[SessionID]]:
269
- """Get target->sessions mapping (read-only access).
270
-
271
- Returns:
272
- Dict mapping target_id to set of session_ids
273
- """
274
- return self._target_sessions
275
-
276
- def get_focused_target(self) -> 'Target | None':
277
- """Get the target that currently has agent focus.
278
-
279
- Convenience method that uses browser_session.agent_focus_target_id.
280
-
281
- Returns:
282
- Target object if agent has focus, None otherwise
283
- """
284
- if not self.browser_session.agent_focus_target_id:
285
- return None
286
- return self.get_target(self.browser_session.agent_focus_target_id)
287
-
288
- async def ensure_valid_focus(self, timeout: float = 3.0) -> bool:
289
- """Ensure agent_focus_target_id points to a valid, attached CDP session.
290
-
291
- If the focus target is stale (detached), this method waits for automatic recovery.
292
- Uses event-driven coordination instead of polling for efficiency.
293
-
294
- Args:
295
- timeout: Maximum time to wait for recovery in seconds (default: 3.0)
296
-
297
- Returns:
298
- True if focus is valid or successfully recovered, False if no focus or recovery failed
299
- """
300
- if not self.browser_session.agent_focus_target_id:
301
- # No focus at all - might be initial state or complete failure
302
- if self._recovery_in_progress and self._recovery_complete_event:
303
- # Recovery is happening, wait for it
304
- try:
305
- await asyncio.wait_for(self._recovery_complete_event.wait(), timeout=timeout)
306
- # Check again after recovery - simple existence check
307
- focus_id = self.browser_session.agent_focus_target_id
308
- return bool(focus_id and self._get_session_for_target(focus_id))
309
- except TimeoutError:
310
- self.logger.error(f'[SessionManager] ❌ Timed out waiting for recovery after {timeout}s')
311
- return False
312
- return False
313
-
314
- # Simple existence check - does the focused target have a session?
315
- cdp_session = self._get_session_for_target(self.browser_session.agent_focus_target_id)
316
- if cdp_session:
317
- # Session exists - validate it's still active
318
- is_valid = await self.validate_session(self.browser_session.agent_focus_target_id)
319
- if is_valid:
320
- return True
321
-
322
- # Focus is stale - wait for recovery using event instead of polling
323
- stale_target_id = self.browser_session.agent_focus_target_id
324
- self.logger.warning(
325
- f'[SessionManager] ⚠️ Stale agent_focus detected (target {stale_target_id[:8] if stale_target_id else "None"}... detached), '
326
- f'waiting for recovery...'
327
- )
328
-
329
- # Check if recovery is already in progress
330
- if not self._recovery_in_progress:
331
- self.logger.warning(
332
- '[SessionManager] ⚠️ Recovery not in progress for stale focus! '
333
- 'This indicates a bug - recovery should have been triggered.'
334
- )
335
- return False
336
-
337
- # Wait for recovery complete event (event-driven, not polling!)
338
- if self._recovery_complete_event:
339
- try:
340
- start_time = asyncio.get_event_loop().time()
341
- await asyncio.wait_for(self._recovery_complete_event.wait(), timeout=timeout)
342
- elapsed = asyncio.get_event_loop().time() - start_time
343
-
344
- # Verify recovery succeeded - simple existence check
345
- focus_id = self.browser_session.agent_focus_target_id
346
- if focus_id and self._get_session_for_target(focus_id):
347
- self.logger.info(
348
- f'[SessionManager] ✅ Agent focus recovered to {self.browser_session.agent_focus_target_id[:8]}... '
349
- f'after {elapsed * 1000:.0f}ms'
350
- )
351
- return True
352
- else:
353
- self.logger.error(
354
- f'[SessionManager] ❌ Recovery completed but focus still invalid after {elapsed * 1000:.0f}ms'
355
- )
356
- return False
357
-
358
- except TimeoutError:
359
- self.logger.error(
360
- f'[SessionManager] ❌ Recovery timed out after {timeout}s '
361
- f'(was: {stale_target_id[:8] if stale_target_id else "None"}..., '
362
- f'now: {self.browser_session.agent_focus_target_id[:8] if self.browser_session.agent_focus_target_id else "None"})'
363
- )
364
- return False
365
- else:
366
- self.logger.error('[SessionManager] ❌ Recovery event not initialized')
367
- return False
368
-
369
- async def _handle_target_attached(self, event: AttachedToTargetEvent) -> None:
370
- """Handle Target.attachedToTarget event.
371
-
372
- Called automatically by Chrome when a new target/session is created.
373
- This is the ONLY place where sessions are added to the pool.
374
- """
375
- target_id = event['targetInfo']['targetId']
376
- session_id = event['sessionId']
377
- target_type = event['targetInfo']['type']
378
- target_info = event['targetInfo']
379
- waiting_for_debugger = event.get('waitingForDebugger', False)
380
-
381
- self.logger.debug(
382
- f'[SessionManager] Target attached: {target_id[:8]}... (session={session_id[:8]}..., '
383
- f'type={target_type}, waitingForDebugger={waiting_for_debugger})'
384
- )
385
-
386
- # Defensive check: browser may be shutting down and _cdp_client_root could be None
387
- if self.browser_session._cdp_client_root is None:
388
- self.logger.debug(
389
- f'[SessionManager] Skipping target attach for {target_id[:8]}... - browser shutting down (no CDP client)'
390
- )
391
- return
392
-
393
- # Enable auto-attach for this session's children (do this FIRST, outside lock)
394
- try:
395
- await self.browser_session._cdp_client_root.send.Target.setAutoAttach(
396
- params={'autoAttach': True, 'waitForDebuggerOnStart': False, 'flatten': True}, session_id=session_id
397
- )
398
- except Exception as e:
399
- error_str = str(e)
400
- # Expected for short-lived targets (workers, temp iframes) that detach before this executes
401
- if '-32001' not in error_str and 'Session with given id not found' not in error_str:
402
- self.logger.debug(f'[SessionManager] Auto-attach failed for {target_type}: {e}')
403
-
404
- async with self._lock:
405
- # Track this session for the target
406
- if target_id not in self._target_sessions:
407
- self._target_sessions[target_id] = set()
408
-
409
- self._target_sessions[target_id].add(session_id)
410
- self._session_to_target[session_id] = target_id
411
-
412
- # Create or update Target (source of truth for url/title)
413
- if target_id not in self._targets:
414
- from browser_use.browser.session import Target
415
-
416
- target = Target(
417
- target_id=target_id,
418
- target_type=target_type,
419
- url=target_info.get('url', 'about:blank'),
420
- title=target_info.get('title', 'Unknown title'),
421
- )
422
- self._targets[target_id] = target
423
- self.logger.debug(f'[SessionManager] Created target {target_id[:8]}... (type={target_type})')
424
- else:
425
- # Update existing target info
426
- existing_target = self._targets[target_id]
427
- existing_target.url = target_info.get('url', existing_target.url)
428
- existing_target.title = target_info.get('title', existing_target.title)
429
-
430
- # Create CDPSession (communication channel)
431
- from browser_use.browser.session import CDPSession
432
-
433
- assert self.browser_session._cdp_client_root is not None, 'Root CDP client required'
434
-
435
- cdp_session = CDPSession(
436
- cdp_client=self.browser_session._cdp_client_root,
437
- target_id=target_id,
438
- session_id=session_id,
439
- )
440
-
441
- # Add to sessions dict
442
- self._sessions[session_id] = cdp_session
443
-
444
- self.logger.debug(
445
- f'[SessionManager] Created session {session_id[:8]}... for target {target_id[:8]}... '
446
- f'(total sessions: {len(self._sessions)})'
447
- )
448
-
449
- # Enable lifecycle events and network monitoring for page targets
450
- if target_type in ('page', 'tab'):
451
- await self._enable_page_monitoring(cdp_session)
452
-
453
- # Resume execution if waiting for debugger
454
- if waiting_for_debugger:
455
- try:
456
- assert self.browser_session._cdp_client_root is not None
457
- await self.browser_session._cdp_client_root.send.Runtime.runIfWaitingForDebugger(session_id=session_id)
458
- except Exception as e:
459
- self.logger.warning(f'[SessionManager] Failed to resume execution: {e}')
460
-
461
- async def _handle_target_info_changed(self, event: dict) -> None:
462
- """Handle Target.targetInfoChanged event.
463
-
464
- Updates target title/URL without polling getTargetInfo().
465
- Chrome fires this automatically when title or URL changes.
466
- """
467
- target_info = event.get('targetInfo', {})
468
- target_id = target_info.get('targetId')
469
-
470
- if not target_id:
471
- return
472
-
473
- async with self._lock:
474
- # Update target if it exists (source of truth for url/title)
475
- if target_id in self._targets:
476
- target = self._targets[target_id]
477
-
478
- target.title = target_info.get('title', target.title)
479
- target.url = target_info.get('url', target.url)
480
-
481
- async def _handle_target_detached(self, event: DetachedFromTargetEvent) -> None:
482
- """Handle Target.detachedFromTarget event.
483
-
484
- Called automatically by Chrome when a target/session is destroyed.
485
- This is the ONLY place where sessions are removed from the pool.
486
- """
487
- session_id = event['sessionId']
488
- target_id = event.get('targetId') # May be empty
489
-
490
- # If targetId not in event, look it up via session mapping
491
- if not target_id:
492
- async with self._lock:
493
- target_id = self._session_to_target.get(session_id)
494
-
495
- if not target_id:
496
- self.logger.warning(f'[SessionManager] Session detached but target unknown (session={session_id[:8]}...)')
497
- return
498
-
499
- agent_focus_lost = False
500
- target_fully_removed = False
501
- target_type = None
502
-
503
- async with self._lock:
504
- # Remove this session from target's session set
505
- if target_id in self._target_sessions:
506
- self._target_sessions[target_id].discard(session_id)
507
-
508
- remaining_sessions = len(self._target_sessions[target_id])
509
-
510
- self.logger.debug(
511
- f'[SessionManager] Session detached: target={target_id[:8]}... '
512
- f'session={session_id[:8]}... (remaining={remaining_sessions})'
513
- )
514
-
515
- # Only remove target when NO sessions remain
516
- if remaining_sessions == 0:
517
- self.logger.debug(f'[SessionManager] No sessions remain for target {target_id[:8]}..., removing target')
518
-
519
- target_fully_removed = True
520
-
521
- # Check if agent_focus points to this target
522
- agent_focus_lost = self.browser_session.agent_focus_target_id == target_id
523
-
524
- # Immediately clear stale focus to prevent operations on detached target
525
- if agent_focus_lost:
526
- self.logger.debug(
527
- f'[SessionManager] Clearing stale agent_focus_target_id {target_id[:8]}... '
528
- f'to prevent operations on detached target'
529
- )
530
- self.browser_session.agent_focus_target_id = None
531
-
532
- # Get target type before removing (needed for TabClosedEvent dispatch)
533
- target = self._targets.get(target_id)
534
- target_type = target.target_type if target else None
535
-
536
- # Remove target (entity) from owned data
537
- if target_id in self._targets:
538
- self._targets.pop(target_id)
539
- self.logger.debug(
540
- f'[SessionManager] Removed target {target_id[:8]}... (remaining targets: {len(self._targets)})'
541
- )
542
-
543
- # Clean up tracking
544
- del self._target_sessions[target_id]
545
- else:
546
- # Target not tracked - already removed or never attached
547
- self.logger.debug(
548
- f'[SessionManager] Session detached from untracked target: target={target_id[:8]}... '
549
- f'session={session_id[:8]}... (target was already removed or attach event was missed)'
550
- )
551
-
552
- # Remove session from owned sessions dict
553
- if session_id in self._sessions:
554
- self._sessions.pop(session_id)
555
- self.logger.debug(
556
- f'[SessionManager] Removed session {session_id[:8]}... (remaining sessions: {len(self._sessions)})'
557
- )
558
-
559
- # Remove from reverse mapping
560
- if session_id in self._session_to_target:
561
- del self._session_to_target[session_id]
562
-
563
- # Dispatch TabClosedEvent only for page/tab targets that are fully removed (not iframes/workers or partial detaches)
564
- if target_fully_removed:
565
- if target_type in ('page', 'tab'):
566
- from browser_use.browser.events import TabClosedEvent
567
-
568
- self.browser_session.event_bus.dispatch(TabClosedEvent(target_id=target_id))
569
- self.logger.debug(f'[SessionManager] Dispatched TabClosedEvent for page target {target_id[:8]}...')
570
- elif target_type:
571
- self.logger.debug(
572
- f'[SessionManager] Target {target_id[:8]}... fully removed (type={target_type}) - not dispatching TabClosedEvent'
573
- )
574
-
575
- # Auto-recover agent_focus outside the lock to avoid blocking other operations
576
- if agent_focus_lost:
577
- # Create recovery task instead of awaiting directly - allows concurrent operations to wait on same recovery
578
- if not self._recovery_in_progress:
579
- self._recovery_task = create_task_with_error_handling(
580
- self._recover_agent_focus(target_id),
581
- name='recover_agent_focus',
582
- logger_instance=self.logger,
583
- suppress_exceptions=False,
584
- )
585
-
586
- async def _recover_agent_focus(self, crashed_target_id: TargetID) -> None:
587
- """Auto-recover agent_focus when the focused target crashes/detaches.
588
-
589
- Uses recovery lock to prevent concurrent recovery attempts from creating multiple emergency tabs.
590
- Coordinates with ensure_valid_focus() via events for efficient waiting.
591
-
592
- Args:
593
- crashed_target_id: The target ID that was lost
594
- """
595
- try:
596
- # Prevent concurrent recovery attempts
597
- async with self._recovery_lock:
598
- # Set recovery state INSIDE lock to prevent race conditions
599
- if self._recovery_in_progress:
600
- self.logger.debug('[SessionManager] Recovery already in progress, waiting for it to complete')
601
- # Wait for ongoing recovery instead of starting a new one
602
- if self._recovery_complete_event:
603
- try:
604
- await asyncio.wait_for(self._recovery_complete_event.wait(), timeout=5.0)
605
- except TimeoutError:
606
- self.logger.error('[SessionManager] Timed out waiting for ongoing recovery')
607
- return
608
-
609
- # Set recovery state
610
- self._recovery_in_progress = True
611
- self._recovery_complete_event = asyncio.Event()
612
-
613
- if self.browser_session._cdp_client_root is None:
614
- self.logger.debug('[SessionManager] Skipping focus recovery - browser shutting down (no CDP client)')
615
- return
616
-
617
- # Check if another recovery already fixed agent_focus
618
- if self.browser_session.agent_focus_target_id and self.browser_session.agent_focus_target_id != crashed_target_id:
619
- self.logger.debug(
620
- f'[SessionManager] Agent focus already recovered by concurrent operation '
621
- f'(now: {self.browser_session.agent_focus_target_id[:8]}...), skipping recovery'
622
- )
623
- return
624
-
625
- # Note: agent_focus_target_id may already be None (cleared in _handle_target_detached)
626
- current_focus_desc = (
627
- f'{self.browser_session.agent_focus_target_id[:8]}...'
628
- if self.browser_session.agent_focus_target_id
629
- else 'None (already cleared)'
630
- )
631
-
632
- self.logger.warning(
633
- f'[SessionManager] Agent focus target {crashed_target_id[:8]}... detached! '
634
- f'Current focus: {current_focus_desc}. Auto-recovering by switching to another target...'
635
- )
636
-
637
- # Perform recovery (outside lock to allow concurrent operations)
638
- # Try to find another valid page target
639
- page_targets = self.get_all_page_targets()
640
-
641
- new_target_id = None
642
- is_existing_tab = False
643
-
644
- if page_targets:
645
- # Switch to most recent page that's not the crashed one
646
- new_target_id = page_targets[-1].target_id
647
- is_existing_tab = True
648
- self.logger.info(f'[SessionManager] Switching agent_focus to existing tab {new_target_id[:8]}...')
649
- else:
650
- # No pages exist - create a new one
651
- self.logger.warning('[SessionManager] No tabs remain! Creating new tab for agent...')
652
- new_target_id = await self.browser_session._cdp_create_new_page('about:blank')
653
- self.logger.info(f'[SessionManager] Created new tab {new_target_id[:8]}... for agent')
654
-
655
- # Dispatch TabCreatedEvent so watchdogs can initialize
656
- from browser_use.browser.events import TabCreatedEvent
657
-
658
- self.browser_session.event_bus.dispatch(TabCreatedEvent(url='about:blank', target_id=new_target_id))
659
-
660
- # Wait for CDP attach event to create session
661
- # Note: This polling is necessary - waiting for external Chrome CDP event
662
- # _handle_target_attached will add session to pool when Chrome fires attachedToTarget
663
- new_session = None
664
- for attempt in range(20): # Wait up to 2 seconds
665
- await asyncio.sleep(0.1)
666
- new_session = self._get_session_for_target(new_target_id)
667
- if new_session:
668
- break
669
-
670
- if new_session:
671
- self.browser_session.agent_focus_target_id = new_target_id
672
- self.logger.info(f'[SessionManager] ✅ Agent focus recovered: {new_target_id[:8]}...')
673
-
674
- # Visually activate the tab in browser (only for existing tabs)
675
- if is_existing_tab:
676
- try:
677
- assert self.browser_session._cdp_client_root is not None
678
- await self.browser_session._cdp_client_root.send.Target.activateTarget(params={'targetId': new_target_id})
679
- self.logger.debug(f'[SessionManager] Activated tab {new_target_id[:8]}... in browser UI')
680
- except Exception as e:
681
- self.logger.debug(f'[SessionManager] Failed to activate tab visually: {e}')
682
-
683
- # Get target to access url (from owned data)
684
- target = self.get_target(new_target_id)
685
- target_url = target.url if target else 'about:blank'
686
-
687
- # Dispatch focus changed event
688
- from browser_use.browser.events import AgentFocusChangedEvent
689
-
690
- self.browser_session.event_bus.dispatch(AgentFocusChangedEvent(target_id=new_target_id, url=target_url))
691
- return
692
-
693
- # Recovery failed - create emergency fallback tab
694
- self.logger.error(
695
- f'[SessionManager] ❌ Failed to get session for {new_target_id[:8]}... after 2s, creating emergency fallback tab'
696
- )
697
-
698
- fallback_target_id = await self.browser_session._cdp_create_new_page('about:blank')
699
- self.logger.warning(f'[SessionManager] Created emergency fallback tab {fallback_target_id[:8]}...')
700
-
701
- # Try one more time with fallback
702
- # Note: This polling is necessary - waiting for external Chrome CDP event
703
- for _ in range(20):
704
- await asyncio.sleep(0.1)
705
- fallback_session = self._get_session_for_target(fallback_target_id)
706
- if fallback_session:
707
- self.browser_session.agent_focus_target_id = fallback_target_id
708
- self.logger.warning(f'[SessionManager] ⚠️ Agent focus set to emergency fallback: {fallback_target_id[:8]}...')
709
-
710
- from browser_use.browser.events import AgentFocusChangedEvent, TabCreatedEvent
711
-
712
- self.browser_session.event_bus.dispatch(TabCreatedEvent(url='about:blank', target_id=fallback_target_id))
713
- self.browser_session.event_bus.dispatch(
714
- AgentFocusChangedEvent(target_id=fallback_target_id, url='about:blank')
715
- )
716
- return
717
-
718
- # Complete failure - this should never happen
719
- self.logger.critical(
720
- '[SessionManager] 🚨 CRITICAL: Failed to recover agent_focus even with fallback! Agent may be in broken state.'
721
- )
722
-
723
- except Exception as e:
724
- self.logger.error(f'[SessionManager] ❌ Error during agent_focus recovery: {type(e).__name__}: {e}')
725
- finally:
726
- # Always signal completion and reset recovery state
727
- # This allows all waiting operations to proceed (success or failure)
728
- if self._recovery_complete_event:
729
- self._recovery_complete_event.set()
730
- self._recovery_in_progress = False
731
- self._recovery_task = None
732
- self.logger.debug('[SessionManager] Recovery state reset')
733
-
734
- async def _initialize_existing_targets(self) -> None:
735
- """Discover and initialize all existing targets at startup.
736
-
737
- Attaches to each target and initializes it SYNCHRONOUSLY.
738
- Chrome will also fire attachedToTarget events, but _handle_target_attached() is
739
- idempotent (checks if target already in pool), so duplicate handling is safe.
740
-
741
- This eliminates race conditions - monitoring is guaranteed ready before navigation.
742
- """
743
- cdp_client = self.browser_session._cdp_client_root
744
- assert cdp_client is not None
745
-
746
- # Get all existing targets
747
- targets_result = await cdp_client.send.Target.getTargets()
748
- existing_targets = targets_result.get('targetInfos', [])
749
-
750
- self.logger.debug(f'[SessionManager] Discovered {len(existing_targets)} existing targets')
751
-
752
- # Track target IDs for verification
753
- target_ids_to_wait_for = []
754
-
755
- # Just attach to ALL existing targets - Chrome fires attachedToTarget events
756
- # The on_attached handler (via create_task) does ALL the work
757
- for target in existing_targets:
758
- target_id = target['targetId']
759
- target_type = target.get('type', 'unknown')
760
-
761
- try:
762
- # Just attach - event handler does everything
763
- await cdp_client.send.Target.attachToTarget(params={'targetId': target_id, 'flatten': True})
764
- target_ids_to_wait_for.append(target_id)
765
- except Exception as e:
766
- self.logger.debug(
767
- f'[SessionManager] Failed to attach to existing target {target_id[:8]}... (type={target_type}): {e}'
768
- )
769
-
770
- # Wait for event handlers to complete their work (they run via create_task)
771
- # Use event-driven approach instead of polling for better performance
772
- ready_event = asyncio.Event()
773
-
774
- async def check_all_ready():
775
- """Check if all sessions are ready and signal completion."""
776
- while True:
777
- ready_count = 0
778
- for tid in target_ids_to_wait_for:
779
- session = self._get_session_for_target(tid)
780
- if session:
781
- target = self._targets.get(tid)
782
- target_type = target.target_type if target else 'unknown'
783
- # For pages, verify monitoring is enabled
784
- if target_type in ('page', 'tab'):
785
- if hasattr(session, '_lifecycle_events') and session._lifecycle_events is not None:
786
- ready_count += 1
787
- else:
788
- # Non-page targets don't need monitoring
789
- ready_count += 1
790
-
791
- if ready_count == len(target_ids_to_wait_for):
792
- ready_event.set()
793
- return
794
-
795
- await asyncio.sleep(0.05)
796
-
797
- # Start checking in background
798
- check_task = create_task_with_error_handling(
799
- check_all_ready(), name='check_all_targets_ready', logger_instance=self.logger
800
- )
801
-
802
- try:
803
- # Wait for completion with timeout
804
- await asyncio.wait_for(ready_event.wait(), timeout=2.0)
805
- except TimeoutError:
806
- # Timeout - count what's ready
807
- ready_count = 0
808
- for tid in target_ids_to_wait_for:
809
- session = self._get_session_for_target(tid)
810
- if session:
811
- target = self._targets.get(tid)
812
- target_type = target.target_type if target else 'unknown'
813
- # For pages, verify monitoring is enabled
814
- if target_type in ('page', 'tab'):
815
- if hasattr(session, '_lifecycle_events') and session._lifecycle_events is not None:
816
- ready_count += 1
817
- else:
818
- # Non-page targets don't need monitoring
819
- ready_count += 1
820
- self.logger.warning(
821
- f'[SessionManager] Initialization timeout after 2.0s: {ready_count}/{len(target_ids_to_wait_for)} sessions ready'
822
- )
823
- finally:
824
- check_task.cancel()
825
- try:
826
- await check_task
827
- except asyncio.CancelledError:
828
- pass
829
-
830
- async def _enable_page_monitoring(self, cdp_session: 'CDPSession') -> None:
831
- """Enable lifecycle events and network monitoring for a page target.
832
-
833
- This is called once per page when it's created, avoiding handler accumulation.
834
- Registers a SINGLE lifecycle handler per session that stores events for navigations to consume.
835
-
836
- Args:
837
- cdp_session: The CDP session to enable monitoring on
838
- """
839
- try:
840
- # Enable Page domain first (required for lifecycle events)
841
- await cdp_session.cdp_client.send.Page.enable(session_id=cdp_session.session_id)
842
-
843
- # Enable lifecycle events (load, DOMContentLoaded, networkIdle, etc.)
844
- await cdp_session.cdp_client.send.Page.setLifecycleEventsEnabled(
845
- params={'enabled': True}, session_id=cdp_session.session_id
846
- )
847
-
848
- # Enable network monitoring for networkIdle detection
849
- await cdp_session.cdp_client.send.Network.enable(session_id=cdp_session.session_id)
850
-
851
- # Initialize lifecycle event storage for this session (thread-safe)
852
- from collections import deque
853
-
854
- cdp_session._lifecycle_events = deque(maxlen=50) # Keep last 50 events
855
- cdp_session._lifecycle_lock = asyncio.Lock()
856
-
857
- # Register ONE handler per session that stores events
858
- def on_lifecycle_event(event, session_id=None):
859
- event_name = event.get('name', 'unknown')
860
- event_loader_id = event.get('loaderId', 'none')
861
-
862
- # Find which target this session belongs to
863
- target_id_from_event = None
864
- if session_id:
865
- target_id_from_event = self.get_target_id_from_session_id(session_id)
866
-
867
- # Check if this event is for our target
868
- if target_id_from_event == cdp_session.target_id:
869
- # Store event for navigations to consume
870
- event_data = {
871
- 'name': event_name,
872
- 'loaderId': event_loader_id,
873
- 'timestamp': asyncio.get_event_loop().time(),
874
- }
875
- # Append is atomic in CPython
876
- try:
877
- cdp_session._lifecycle_events.append(event_data)
878
- except Exception as e:
879
- # Only log errors, not every event
880
- self.logger.error(f'[SessionManager] Failed to store lifecycle event: {e}')
881
-
882
- # Register the handler ONCE (this is the only place we register)
883
- cdp_session.cdp_client.register.Page.lifecycleEvent(on_lifecycle_event)
884
-
885
- except Exception as e:
886
- # Don't fail - target might be short-lived or already detached
887
- error_str = str(e)
888
- if '-32001' in error_str or 'Session with given id not found' in error_str:
889
- self.logger.debug(
890
- f'[SessionManager] Target {cdp_session.target_id[:8]}... detached before monitoring could be enabled (normal for short-lived targets)'
891
- )
892
- else:
893
- self.logger.warning(
894
- f'[SessionManager] Failed to enable monitoring for target {cdp_session.target_id[:8]}...: {e}'
895
- )