{"repo":"NousResearch/hermes-agent","public_id":"14331","display_label":"Browser tool timeout and concurrency race conditions","summary":"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.","subsystem":"agent","counts":{"members":10,"open_issues":1,"open_issues_distinct":1,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":54766,"merged_at":"2026-06-29T09:24:57+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-29T09:24:57+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.21599999999999997},"fix_edges":[{"issue_number":14331,"pr_number":54766,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-29T09:24:57+00:00"}],"members":[{"kind":"issue","number":14331,"title":"[Bug]: browser_navigate crashes with TypeError: '>' not supported between instances of 'int' and 'NoneType' after hermes update","url":null,"state":"closed"},{"kind":"issue","number":14783,"title":"[Bug]: browser_tool _get_command_timeout crashes with TypeError after cleanup race","url":null,"state":"closed"},{"kind":"issue","number":24669,"title":"concurrency: four TOCTOU races in browser_tool.py lazy-init singletons","url":null,"state":"open"},{"kind":"pr","number":14338,"title":"fix(browser_tool): prevent _get_command_timeout from returning None on exception path","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c79e6bcea","number":0},{"kind":"pr","value":"#54766","number":54766}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":14346,"title":"fix(browser): guard command timeout cache reset","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14331","number":14331}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":14779,"title":"fix(tools): guard _get_command_timeout against None return after browser cleanup race","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14331","number":14331},{"kind":"pr","value":"#54766","number":54766},{"kind":"pr","value":"#14338","number":14338}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":14843,"title":"fix(browser): guard command timeout cache invalidation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14331","number":14331},{"kind":"pr","value":"#54766","number":54766},{"kind":"pr","value":"#14338","number":14338}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":24671,"title":"fix(browser_tool): eliminate four TOCTOU races in lazy-init singletons","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":24772,"title":"fix(core): close TOCTOU race windows in lazy module resolvers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24671","number":24671},{"kind":"pr","value":"#24669","number":24669},{"kind":"pr","value":"#24692","number":24692},{"kind":"pr","value":"#24687","number":24687},{"kind":"pr","value":"#24694","number":24694},{"kind":"pr","value":"#24679","number":24679},{"kind":"pr","value":"#24673","number":24673}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":54766,"title":"fix(browser_tool): prevent _get_command_timeout from returning None on torn-cache path","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_browser_command_timeout_race.py","tools/browser_tool.py"],"fix_hunks":[{"file":"tests/tools/test_browser_command_timeout_race.py","hunks":["@@ -0,0 +1,109 @@\n+\"\"\"Regression tests for the _get_command_timeout cache race (#14331).\n+\n+Before the fix:\n+  ``_command_timeout_resolved`` was set to ``True`` *before*\n+  ``_cached_command_timeout`` was assigned. If the body raised between those\n+  two statements (e.g. inside ``read_raw_config``), or a re-entrant/concurrent\n+  reader hit the cache between them, the function returned ``None``. Callers\n+  then evaluated ``max(None, 60)`` and crashed with::\n+\n+      TypeError: '>' not supported between instances of 'int' and 'NoneType'\n+\n+The fix:\n+  1. assign cache before flipping the resolved flag,\n+  2. flip the resolved flag *off* before nulling the cache in\n+     ``cleanup_all_browsers()``,\n+  3. expose ``_safe_command_timeout()`` as defense in depth.\n+\"\"\"\n+\n+from unittest.mock import patch\n+\n+\n+class TestGetCommandTimeoutRace:\n+    def setup_method(self):\n+        from tools import browser_tool\n+\n+        self.bt = browser_tool\n+        self._orig_cache = browser_tool._cached_command_timeout\n+        self._orig_resolved = browser_tool._command_timeout_resolved\n+        browser_tool._cached_command_timeout = None\n+        browser_tool._command_timeout_resolved = False\n+\n+    def teardown_method(self):\n+        self.bt._cached_command_timeout = self._orig_cache\n+        self.bt._command_timeout_resolved = self._orig_resolved\n+\n+    def test_returns_default_when_config_read_raises(self):\n+        \"\"\"If config reading blows up, we still return an int (not None).\"\"\"\n+        with patch(\n+            \"hermes_cli.config.read_raw_config\", side_effect=RuntimeError(\"boom\")\n+        ):\n+            result = self.bt._get_command_timeout()\n+\n+        assert isinstance(result, int)\n+        assert result == self.bt.DEFAULT_COMMAND_TIMEOUT\n+        # Cache must be populated (not left as None) once resolved is True.\n+        assert self.bt._cached_command_timeout is not None\n+        assert self.bt._command_timeout_resolved is True\n+\n+    def test_cache_assigned_before_resolved_flag(self):\n+        \"\"\"Invariant: if resolved=True then cache must not be None.\"\"\"\n+        with patch(\n+            \"hermes_cli.config.read_raw_config\", side_effect=RuntimeError(\"boom\")\n+        ):\n+            self.bt._get_command_timeout()\n+\n+        # The bug was: resolved=True while cache=None. Assert that's impossible.\n+        assert not (\n+            self.bt._command_timeout_resolved\n+            and self.bt._cached_command_timeout is None\n+        )\n+\n+    def test_safe_command_timeout_never_returns_none(self):\n+        \"\"\"Defense-in-depth helper survives a manually corrupted cache.\"\"\"\n+        # Simulate the pre-fix bug state directly.\n+        self.bt._command_timeout_resolved = True\n+        self.bt._cached_command_timeout = None\n+\n+        result = self.bt._safe_command_timeout()\n+        assert isinstance(result, int)\n+        assert result == self.bt.DEFAULT_COMMAND_TIMEOUT\n+\n+    def test_safe_command_timeout_preserves_zero(self):\n+        \"\"\"``or DEFAULT_COMMAND_TIMEOUT`` would swallow a legit 0.\n+\n+        We use ``is not None`` so a configured 0 stays 0. (In practice the\n+        caller floor is 5s, but the helper itself must be honest.)\n+        \"\"\"\n+        self.bt._command_timeout_resolved = True\n+        self.bt._cached_command_timeout = 0\n+\n+        assert self.bt._safe_command_timeout() == 0\n+\n+    def test_cleanup_resets_flag_before_nulling_cache(self):\n+        \"\"\"After cleanup, observers must never see resolved=True with cache=None.\"\"\"\n+        # Warm the cache first.\n+        with patch(\n+            \"hermes_cli.config.read_raw_config\", side_effect=RuntimeError(\"boom\")\n+        ):\n+            self.bt._get_command_timeout()\n+        assert self.bt._command_timeout_resolved is True\n+\n+        self.bt.cleanup_all_browsers()\n+\n+        # Post-cleanup: both must be reset together; specifically resolved must\n+        # not be True while cache is None (the original race window).\n+        assert not (\n+            self.bt._command_timeout_resolved\n+            and self.bt._cached_command_timeout is None\n+        )\n+\n+    def test_max_call_site_pattern_never_raises(self):\n+        \"\"\"The exact expression from browser_navigate must not raise TypeError.\"\"\"\n+        # Force the corrupted state the bug used to produce.\n+        self.bt._command_timeout_resolved = True\n+        self.bt._cached_command_timeout = None\n+\n+        # This is the literal line from browser_navigate() after the fix.\n+        timeout = max(self.bt._safe_command_timeout(), 60)\n+        assert timeout == 60"]},{"file":"tools/browser_tool.py","hunks":["@@ -244,10 +244,9 @@ def _get_command_timeout() -> int:\n     cached after the first call and cleared by ``cleanup_all_browsers()``.\n     \"\"\"\n     global _cached_command_timeout, _command_timeout_resolved\n-    if _command_timeout_resolved:\n-        return _cached_command_timeout  # type: ignore[return-value]\n+    if _command_timeout_resolved and _cached_command_timeout is not None:\n+        return _cached_command_timeout\n \n-    _command_timeout_resolved = True\n     result = DEFAULT_COMMAND_TIMEOUT\n     try:\n         from hermes_cli.config import read_raw_config","@@ -257,13 +256,29 @@ def _get_command_timeout() -> int:\n             result = max(int(val), 5)  # Floor at 5s to avoid instant kills\n     except Exception as e:\n         logger.debug(\"Could not read command_timeout from config: %s\", e)\n+    # Assign the cached value BEFORE flipping the resolved flag so a\n+    # concurrent reader cannot observe ``resolved=True`` while the cache\n+    # is still ``None`` (see issue #14331).\n     _cached_command_timeout = result\n+    _command_timeout_resolved = True\n     return result\n \n \n+def _safe_command_timeout() -> int:\n+    \"\"\"Like ``_get_command_timeout`` but guaranteed non-None.\n+\n+    Defense in depth against the race fixed in ``_get_command_timeout``:\n+    if anything ever returns ``None`` (e.g. cache reset mid-flight), fall\n+    back to ``DEFAULT_COMMAND_TIMEOUT``. Uses ``is not None`` rather than\n+    ``or`` so a legitimately configured ``0`` is preserved.\n+    \"\"\"\n+    val = _get_command_timeout()\n+    return val if val is not None else DEFAULT_COMMAND_TIMEOUT\n+\n+\n def _get_open_command_timeout(*, first_open: bool = False) -> int:\n     \"\"\"Timeout for agent-browser ``open`` (navigation / daemon cold start).\"\"\"\n-    base = _get_command_timeout()\n+    base = _safe_command_timeout()\n     floor = MIN_FIRST_OPEN_TIMEOUT if first_open else MIN_OPEN_TIMEOUT\n     return max(base, floor)\n ","@@ -2180,7 +2195,7 @@ def _run_browser_command(\n         Parsed JSON response from agent-browser\n     \"\"\"\n     if timeout is None:\n-        timeout = _get_command_timeout()\n+        timeout = _safe_command_timeout()\n     args = args or []\n \n     # Build the command","@@ -4029,8 +4044,10 @@ def cleanup_all_browsers() -> None:\n     _cached_agent_browser = None\n     _agent_browser_resolved = False\n     _discover_homebrew_node_dirs.cache_clear()\n-    _cached_command_timeout = None\n+    # Flip the resolved flag BEFORE nulling the cache so a concurrent\n+    # reader never sees ``resolved=True`` with ``cache=None`` (#14331).\n     _command_timeout_resolved = False\n+    _cached_command_timeout = None\n     _cached_chromium_installed = None\n     global _chromium_autoinstall_attempted\n     _chromium_autoinstall_attempted = False\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}