Skip to content

fix: Executing cli-anything-browser --json fs ls yields no return res…#218

Open
qingminglong wants to merge 1 commit intoHKUDS:mainfrom
qingminglong:fix/issue-217
Open

fix: Executing cli-anything-browser --json fs ls yields no return res…#218
qingminglong wants to merge 1 commit intoHKUDS:mainfrom
qingminglong:fix/issue-217

Conversation

@qingminglong
Copy link
Copy Markdown

…ults (fixes #217)

Description

Fixes #

Type of Change

  • New Software CLI (in-repo) — adds a CLI harness inside this monorepo
  • New Software CLI (standalone repo) — registry-only PR pointing to an external repo
  • New Feature — adds new functionality to an existing harness or the plugin
  • Bug Fix — fixes incorrect behavior
  • Documentation — updates docs only
  • Other — please describe:

For New Software CLIs (in-repo)

  • <SOFTWARE>.md SOP document exists at <software>/agent-harness/<SOFTWARE>.md
  • SKILL.md exists inside the Python package (cli_anything/<software>/SKILL.md)
  • Unit tests at cli_anything/<software>/tests/test_core.py are present and pass without backend
  • E2E tests at cli_anything/<software>/tests/test_full_e2e.py are present
  • README.md includes the new software (with link to harness directory)
  • registry.json includes an entry with source_url: null (see Contributing guide)
  • repl_skin.py in utils/ is an unmodified copy from the plugin

For New Software CLIs (standalone repo)

  • CLI is installable via pip install <package-name> or a pip install git+https://... URL
  • SKILL.md exists in the external repo
  • External repo has its own test suite
  • registry.json entry includes source_url pointing to the external repo
  • registry.json entry includes skill_md with full URL to the external SKILL.md
  • install_cmd in registry.json works (tested locally)

For Existing CLI Modifications

  • All unit tests pass: python3 -m pytest cli_anything/<software>/tests/test_core.py -v
  • All E2E tests pass: python3 -m pytest cli_anything/<software>/tests/test_full_e2e.py -v
  • No test regressions — no previously passing tests were removed or weakened
  • registry.json entry is updated if version, description, or requirements changed

General Checklist

  • Code follows existing patterns and conventions
  • --json flag is supported on any new commands
  • Commit messages follow the conventional format (feat:, fix:, docs:, test:)
  • I have tested my changes locally

Test Results

<paste test output here>

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes the cli-anything-browser --json fs ls hang by changing the DOMShell MCP call lifecycle to avoid blocking during shutdown and by normalizing MCP CallToolResult objects into plain dicts that the CLI can JSON-serialize reliably.

Changes:

  • Add configurable MCP call/close timeouts and a custom sync runner to reduce shutdown-related hangs/noise.
  • Normalize MCP tool results (notably domshell_ls) into structured dictionaries (entries, matches, etc.) for direct CLI consumption.
  • Add unit tests covering result normalization and safe async context close timeout behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.

File Description
browser/agent-harness/cli_anything/browser/utils/domshell_backend.py Adds timeouts, custom sync loop runner, result normalization, and altered MCP call flow to prevent --json fs ls from hanging.
browser/agent-harness/cli_anything/browser/tests/test_core.py Adds tests for ls text parsing, normalization, and timeout-protected async context exits.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

loop = asyncio.new_event_loop()
old_loop = None
try:
old_loop = asyncio.get_event_loop_policy().get_event_loop()
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_run_sync() calls get_event_loop_policy().get_event_loop() to capture the previous loop. In Python 3.11+, this can implicitly create a new event loop when none exists, and then you restore it at the end without closing it, leaking a loop for the rest of the process. Consider restoring to None unless there was an explicitly set loop, or using get_running_loop() to detect an active loop and otherwise leaving the policy’s current loop unset.

Suggested change
old_loop = asyncio.get_event_loop_policy().get_event_loop()
old_loop = asyncio.get_running_loop()

Copilot uses AI. Check for mistakes.
Comment on lines +193 to +202
client_ctx = stdio_client(server_params)
read, write = await client_ctx.__aenter__()
session_ctx = ClientSession(read, write)
session = await session_ctx.__aenter__()
await session.initialize()
result = await asyncio.wait_for(
session.call_tool(tool_name, arguments),
timeout=MCP_CALL_TIMEOUT_SECONDS
)
return _normalize_tool_result(tool_name, result, arguments)
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one-shot code path manually calls aenter on stdio_client / ClientSession but never calls aexit for either context. That can leak subprocesses/pipes (and potentially keep an orphaned npx/domshell-proxy running after the CLI prints output). Consider adding a finally block that closes both contexts using the new _safe_aexit() (with timeout) so you still avoid the observed hang while ensuring resources are released.

Copilot uses AI. Check for mistakes.
Comment on lines +51 to +62
def _should_ignore_loop_error(context: dict[str, Any]) -> bool:
"""判断是否忽略已知的 anyio 关闭阶段噪声异常。"""
exc = context.get("exception")
if not exc:
return False
text = str(exc)
return "Attempted to exit cancel scope in a different task" in text


def _run_sync(coro):
"""以可控事件循环执行协程,避免 asyncio.run 关闭阶段噪声。"""
loop = asyncio.new_event_loop()
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new docstrings/comments here are in Chinese while the rest of this module’s docstrings are in English. To keep the harness maintainable for the broader contributor base, please translate these docstrings/comments to English (or keep bilingual text consistently across the file).

Copilot uses AI. Check for mistakes.
Comment on lines 372 to 377
Example:
>>> ls("/")
{"path": "/", "entries": [{"name": "main", "role": "landmark", ...}]}
"""
result = asyncio.run(_call_tool("domshell_ls", {"options": path}, use_daemon))
result = _run_sync(_call_tool("domshell_ls", {"options": path}, use_daemon))
return result
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the sync wrappers have been switched from asyncio.run() to _run_sync(), the NOTE above about daemon mode using asyncio.run() per tool call is now outdated/misleading. Please update the NOTE to reflect the current mechanism (and any remaining limitations) so future readers aren’t debugging the wrong thing.

Copilot uses AI. Check for mistakes.
Comment on lines +394 to +399
class TestBackendResultNormalization:
"""测试后端返回结构标准化。"""

def test_parse_ls_text_to_entries(self):
"""应把 ls 文本结果解析为 entries 列表。"""
text = " windows/ (1 windows)\n tabs/ (44 tabs)"
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new test docstrings are in Chinese while the surrounding test file uses English docstrings/comments. For consistency and easier maintenance by the rest of the team, please translate these docstrings to English (or adopt a consistent bilingual style across the file).

Copilot uses AI. Check for mistakes.
Comment on lines 179 to 181
result = await _daemon_session.call_tool(tool_name, arguments)
return result
return _normalize_tool_result(tool_name, result, arguments)
except Exception as e:
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_call_tool() is now returning normalized dicts (via _normalize_tool_result) rather than the raw MCP CallToolResult, but its signature/docstring still imply it returns the MCP server result unchanged (-> Any). Please update the type hint/docstring to reflect the new return contract so callers and tests don’t have to guess.

Copilot uses AI. Check for mistakes.
Comment on lines +29 to 32
MCP_CALL_TIMEOUT_SECONDS = float(os.environ.get("DOMSHELL_MCP_CALL_TIMEOUT", "15"))
MCP_CLOSE_TIMEOUT_SECONDS = float(os.environ.get("DOMSHELL_MCP_CLOSE_TIMEOUT", "2"))


Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These timeout constants are parsed with float() at import time; an invalid env value (e.g., "15s") will raise ValueError and prevent the harness/CLI from importing. Consider parsing defensively (try/except with fallback to defaults) and/or validating that the value is > 0.

Suggested change
MCP_CALL_TIMEOUT_SECONDS = float(os.environ.get("DOMSHELL_MCP_CALL_TIMEOUT", "15"))
MCP_CLOSE_TIMEOUT_SECONDS = float(os.environ.get("DOMSHELL_MCP_CLOSE_TIMEOUT", "2"))
def _get_timeout_seconds(env_var_name: str, default: float) -> float:
"""Parse a positive timeout value from the environment, with fallback."""
raw_value = os.environ.get(env_var_name)
if raw_value is None:
return default
try:
timeout = float(raw_value)
except (TypeError, ValueError):
return default
if timeout <= 0:
return default
return timeout
MCP_CALL_TIMEOUT_SECONDS = _get_timeout_seconds("DOMSHELL_MCP_CALL_TIMEOUT", 15.0)
MCP_CLOSE_TIMEOUT_SECONDS = _get_timeout_seconds("DOMSHELL_MCP_CLOSE_TIMEOUT", 2.0)

Copilot uses AI. Check for mistakes.
@yuh-yang
Copy link
Copy Markdown
Collaborator

@qingminglong please review and resolve the issues reported above by copilot. Thx!

@yuh-yang
Copy link
Copy Markdown
Collaborator

Thanks for tackling the browser fs hang. The cleanup path still needs work: _call_tool() returns after manually entering the stdio/client contexts, so successful non-daemon calls leak the MCP process/session.

Please keep the bounded cleanup, but close the contexts in a finally or move the lifecycle into a supervised daemon. Also please parse timeout env vars with defaults instead of float() at import time so bad env values do not crash imports.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Executing "cli-anything-browser --json fs ls" yields no return results

3 participants