Browser tool timeout and concurrency race conditions
Cluster 14331 · agent. Data as of
1 open item. Fix merged in #54766 on 29 Jun 2026. Check it covers your case.
The short answer
- Known?
- Yes. 10 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #54766. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- 1 issue and 0 PRs.
Next step: read #54766, then check whether your report is the one still open.
Read #54766Pick up a Lab task
These issues and fixes center on two related problems in browser_tool.py: _get_command_timeout returning None on certain exception or cleanup paths, and TOCTOU race conditions in lazy-init singletons. Multiple fixes address guarding the timeout cache and closing concurrency windows.
Open issues1
- Issue#24669concurrency: four TOCTOU races in browser_tool.py lazy-init singletons
Open PRs0
Fixes on record
-
#54766 fix(browser_tool): prevent _get_command_timeout from returning None on torn-cache path
files:
tests/tools/test_browser_command_timeout_race.py,tools/browser_tool.pyShow the diff
-
tests/tools/test_browser_command_timeout_race.py@@ -0,0 +1,109 @@ +"""Regression tests for the _get_command_timeout cache race (#14331). + +Before the fix: + ``_command_timeout_resolved`` was set to ``True`` *before* + ``_cached_command_timeout`` was assigned. If the body raised between those + two statements (e.g. inside ``read_raw_config``), or a re-entrant/concurrent + reader hit the cache between them, the function returned ``None``. Callers + then evaluated ``max(None, 60)`` and crashed with:: + + TypeError: '>' not supported between instances of 'int' and 'NoneType' + +The fix: + 1. assign cache before flipping the resolved flag, + 2. flip the resolved flag *off* before nulling the cache in + ``cleanup_all_browsers()``, + 3. expose ``_safe_command_timeout()`` as defense in depth. +""" + +from unittest.mock import patch + + +class TestGetCommandTimeoutRace: + def setup_method(self): + from tools import browser_tool + + self.bt = browser_tool + self._orig_cache = browser_tool._cached_command_timeout + self._orig_resolved = browser_tool._command_timeout_resolved + browser_tool._cached_command_timeout = None + browser_tool._command_timeout_resolved = False + + def teardown_method(self): + self.bt._cached_command_timeout = self._orig_cache + self.bt._command_timeout_resolved = self._orig_resolved + + def test_returns_default_when_config_read_raises(self): + """If config reading blows up, we still return an int (not None).""" + with patch( + "hermes_cli.config.read_raw_config", side_effect=RuntimeError("boom") + ): + result = self.bt._get_command_timeout() + + assert isinstance(result, int) + assert result == self.bt.DEFAULT_COMMAND_TIMEOUT + # Cache must be populated (not left as None) once resolved is True. + assert self.bt._cached_command_timeout is not None + assert self.bt._command_timeout_resolved is True + + def test_cache_assigned_before_resolved_flag(self): + """Invariant: if resolved=True then cache must not be None.""" + with patch( + "hermes_cli.config.read_raw_config", side_effect=RuntimeError("boom") + ): + self.bt._get_command_timeout() + + # The bug was: resolved=True while cache=None. Assert that's impossible. + assert not ( + self.bt._command_timeout_resolved + and self.bt._cached_command_timeout is None + ) + + def test_safe_command_timeout_never_returns_none(self): + """Defense-in-depth helper survives a manually corrupted cache.""" + # Simulate the pre-fix bug state directly. + self.bt._command_timeout_resolved = True + self.bt._cached_command_timeout = None + + result = self.bt._safe_command_timeout() + assert isinstance(result, int) + assert result == self.bt.DEFAULT_COMMAND_TIMEOUT + + def test_safe_command_timeout_preserves_zero(self): + """``or DEFAULT_COMMAND_TIMEOUT`` would swallow a legit 0. + + We use ``is not None`` so a configured 0 stays 0. (In practice the + caller floor is 5s, but the helper itself must be honest.) + """ + self.bt._command_timeout_resolved = True + self.bt._cached_command_timeout = 0 + + assert self.bt._safe_command_timeout() == 0 + + def test_cleanup_resets_flag_before_nulling_cache(self): + """After cleanup, observers must never see resolved=True with cache=None.""" + # Warm the cache first. + with patch( + "hermes_cli.config.read_raw_config", side_effect=RuntimeError("boom") + ): + self.bt._get_command_timeout() + assert self.bt._command_timeout_resolved is True + + self.bt.cleanup_all_browsers() + + # Post-cleanup: both must be reset together; specifically resolved must + # not be True while cache is None (the original race window). + assert not ( + self.bt._command_timeout_resolved + and self.bt._cached_command_timeout is None + ) + + def test_max_call_site_pattern_never_raises(self): + """The exact expression from browser_navigate must not raise TypeError.""" + # Force the corrupted state the bug used to produce. + self.bt._command_timeout_resolved = True + self.bt._cached_command_timeout = None + + # This is the literal line from browser_navigate() after the fix. + timeout = max(self.bt._safe_command_timeout(), 60) + assert timeout == 60 -
tools/browser_tool.py@@ -244,10 +244,9 @@ def _get_command_timeout() -> int: cached after the first call and cleared by ``cleanup_all_browsers()``. """ global _cached_command_timeout, _command_timeout_resolved - if _command_timeout_resolved: - return _cached_command_timeout # type: ignore[return-value] + if _command_timeout_resolved and _cached_command_timeout is not None: + return _cached_command_timeout - _command_timeout_resolved = True result = DEFAULT_COMMAND_TIMEOUT try: from hermes_cli.config import read_raw_config @@ -257,13 +256,29 @@ def _get_command_timeout() -> int: result = max(int(val), 5) # Floor at 5s to avoid instant kills except Exception as e: logger.debug("Could not read command_timeout from config: %s", e) + # Assign the cached value BEFORE flipping the resolved flag so a + # concurrent reader cannot observe ``resolved=True`` while the cache + # is still ``None`` (see issue #14331). _cached_command_timeout = result + _command_timeout_resolved = True return result +def _safe_command_timeout() -> int: + """Like ``_get_command_timeout`` but guaranteed non-None. + + Defense in depth against the race fixed in ``_get_command_timeout``: + if anything ever returns ``None`` (e.g. cache reset mid-flight), fall + back to ``DEFAULT_COMMAND_TIMEOUT``. Uses ``is not None`` rather than + ``or`` so a legitimately configured ``0`` is preserved. + """ + val = _get_command_timeout() + return val if val is not None else DEFAULT_COMMAND_TIMEOUT + + def _get_open_command_timeout(*, first_open: bool = False) -> int: """Timeout for agent-browser ``open`` (navigation / daemon cold start).""" - base = _get_command_timeout() + base = _safe_command_timeout() floor = MIN_FIRST_OPEN_TIMEOUT if first_open else MIN_OPEN_TIMEOUT return max(base, floor) @@ -2180,7 +2195,7 @@ def _run_browser_command( Parsed JSON response from agent-browser """ if timeout is None: - timeout = _get_command_timeout() + timeout = _safe_command_timeout() args = args or [] # Build the command @@ -4029,8 +4044,10 @@ def cleanup_all_browsers() -> None: _cached_agent_browser = None _agent_browser_resolved = False _discover_homebrew_node_dirs.cache_clear() - _cached_command_timeout = None + # Flip the resolved flag BEFORE nulling the cache so a concurrent + # reader never sees ``resolved=True`` with ``cache=None`` (#14331). _command_timeout_resolved = False + _cached_command_timeout = None _cached_chromium_installed = None global _chromium_autoinstall_attempted _chromium_autoinstall_attempted = False
-
6 PRs closed without merging
From the close comments: done elsewhere 2 · duplicate 2 · closed without comment 1 · merged via another change 1
-
#14338 fix(browser_tool): prevent _get_command_timeout from returning None on exception path · merged via another change →
c79e6bc,#54766 -
#14346 fix(browser): guard command timeout cache reset · done elsewhere →
#14331 -
#14779 fix(tools): guard _get_command_timeout against None return after browser cleanup race · duplicate →
#14331,#54766,#14338 -
#14843 fix(browser): guard command timeout cache invalidation · duplicate →
#14331,#54766,#14338 - #24671 fix(browser_tool): eliminate four TOCTOU races in lazy-init singletons · closed without comment
Show 1 more
-
#24772 fix(core): close TOCTOU race windows in lazy module resolvers · done elsewhere →
#24671,#24669,#24692,#24687,#24694,#24679,#24673
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.22: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- agent
- Merged PRs
- #54766
- Closed issues
- 2
- Attempts
- 6 PRs closed without merging
- Fix link checked
- 29 Jun 2026