mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 14:06:18 +00:00
The AIO sandbox's implicit persistent shell session hangs forever when a
command containing a bare 'exit' is executed: exit kills the session's
shell process, and the server's response path for that exec_command
request never completes. Verified against a standalone all-in-one-sandbox
1.11.0 container via the raw SDK:
shell.exec_command('seq 1 1000 | head -n 5') # OK (not a SIGPIPE issue)
shell.exec_command('echo x; exit 0') # HANGS every time
shell.exec_command('echo after') # OK (server recreates shell)
shell.exec_command('( echo x; exit 0 )') # OK, exit code propagates
remote_list_dir_command and remote_search_command both end their probe
scripts with a bare 'exit' (to propagate the find/grep status code), so
every list_dir/grep/glob call deterministically wedges the session —
this is the root cause behind parallel [ls, bash] tool calls deadlocking
an entire run (same defect family as #1433 and #5128).
Fix: keep 'set +e' as the outermost prefix (pinned by existing tests) and
wrap the rest of each probe script in a subshell, so exit only terminates
the subshell. Output and exit codes propagate identically.
Tests: 116 passed (backend/tests/test_aio_sandbox.py, test_remote_list_dir.py,
test_remote_search.py); one endswith assertion updated to the wrapped form.
Co-authored-by: mad_max <mad_max@coscoshipping.local>
102 lines
4.7 KiB
Python
102 lines
4.7 KiB
Python
"""Remote ``grep`` / ``glob`` command wrapper and stdout contract.
|
|
|
|
Remote providers search with ``grep ... | head`` or ``find ... | head`` under
|
|
``sh -lc``. POSIX ``sh`` has no ``pipefail`` and the search's stderr is
|
|
discarded, so the pipeline status is ``head``'s: a missing search root, a
|
|
missing ``grep``/``find`` binary (127) or an unreadable tree all printed
|
|
nothing and exited 0, exactly like a genuine "no matches" (#5376).
|
|
|
|
:func:`remote_search_command` checks the root first and records the search
|
|
command's own status after the bounded output, the same technique as
|
|
:mod:`deerflow.sandbox.remote_list_dir`. The script always exits 0 so SDKs that
|
|
raise on a non-zero exit still return the marker; the marker alone decides.
|
|
|
|
Callers filter the bounded lines in Python (ignored directories, glob scope),
|
|
so returning fewer than ``max_results`` does not prove the search was complete.
|
|
The command lets one line past ``limit`` through, and the parser reports
|
|
whether it arrived; exactly ``limit`` lines is a complete result.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shlex
|
|
from typing import Literal, NamedTuple
|
|
|
|
SearchTool = Literal["grep", "find"]
|
|
|
|
_STATUS_PREFIX = "__DF_SEARCH_STATUS__:"
|
|
_MISSING_ROOT = "missing"
|
|
# head closing the pipe after the limit kills the search with SIGPIPE: a
|
|
# successful truncation, not an error.
|
|
_SIGPIPE = 141
|
|
# Statuses of a complete search. grep: 0 = matches, 1 = no match. Everything
|
|
# else fails, even after printed results.
|
|
_OK_STATUSES: dict[str, tuple[int, ...]] = {"grep": (0, 1, _SIGPIPE), "find": (0, _SIGPIPE)}
|
|
# grep 2 / find 1 usually mean an unreadable file or subdirectory. Printed
|
|
# results are then incomplete and callers have no partial-result channel, so
|
|
# the error tells the agent to narrow the search instead.
|
|
_READ_ERROR_STATUS: dict[str, int] = {"grep": 2, "find": 1}
|
|
|
|
|
|
class RemoteSearchOutput(NamedTuple):
|
|
"""Search output lines, and whether the search produced more than ``limit``."""
|
|
|
|
text: str
|
|
truncated: bool
|
|
|
|
|
|
def remote_search_command(search: str, root: str, *, limit: int) -> str:
|
|
"""Wrap a ``grep``/``find`` command so its outcome survives ``| head``.
|
|
|
|
``search`` must write only results to stdout; callers keep ``2>/dev/null``.
|
|
Pass the same ``limit`` to :func:`parse_remote_search_output`.
|
|
"""
|
|
quoted = shlex.quote(root)
|
|
# One extra line is the truncation signal; the parser drops it.
|
|
n = int(limit) + 1
|
|
# ``set +e`` stays outermost (pinned by existing tests); the rest runs in a
|
|
# ( ... ) subshell: a bare ``exit`` in the implicit persistent session kills
|
|
# the session's shell process and the AIO server's response path hangs
|
|
# forever; a subshell ``exit`` only kills the subshell, so the session
|
|
# survives and the exit code propagates unchanged.
|
|
return (
|
|
f"set +e; ( if [ ! -e {quoted} ]; then printf '%s\\n' {_STATUS_PREFIX}{_MISSING_ROOT}; exit 0; fi; "
|
|
f'_st=/tmp/df_search_$$; {{ {search}; echo $? > "$_st"; }} | head -n {n}; '
|
|
f'st=$(cat "$_st" 2>/dev/null); rm -f "$_st"; '
|
|
f"printf '\\n%s\\n' {_STATUS_PREFIX}\"$st\"; exit 0 )"
|
|
)
|
|
|
|
|
|
def parse_remote_search_output(stdout: str | None, root: str, *, tool: SearchTool, limit: int) -> RemoteSearchOutput:
|
|
"""Return at most ``limit`` output lines without the status marker.
|
|
|
|
``truncated`` is true when the search printed more than ``limit`` lines, so
|
|
results filtered from ``text`` may be incomplete.
|
|
|
|
Raises:
|
|
FileNotFoundError: The search root does not exist.
|
|
OSError: The search did not complete (missing binary, unreadable
|
|
root or subtree, invalid invocation) or its status was lost.
|
|
"""
|
|
# Split on "\n" only: splitlines() would also split on characters that are
|
|
# legal in Linux filenames. Callers keep their own per-line handling.
|
|
lines = (stdout or "").split("\n")
|
|
if lines and lines[-1] == "":
|
|
lines.pop()
|
|
if not lines or not lines[-1].startswith(_STATUS_PREFIX):
|
|
raise OSError(f"Failed to {tool} under {root}: search status marker missing")
|
|
raw = lines.pop()[len(_STATUS_PREFIX) :]
|
|
if raw == _MISSING_ROOT:
|
|
raise FileNotFoundError(root)
|
|
if lines and lines[-1] == "":
|
|
lines.pop()
|
|
try:
|
|
status = int(raw)
|
|
except ValueError:
|
|
raise OSError(f"Failed to {tool} under {root}: search status unavailable") from None
|
|
if status in _OK_STATUSES[tool]:
|
|
return RemoteSearchOutput("\n".join(lines[:limit]), len(lines) > limit)
|
|
if status == _READ_ERROR_STATUS[tool]:
|
|
raise OSError(f"Failed to {tool} under {root}: {tool} exited with code {status}, usually because some files or directories could not be read; results would be incomplete, so search a narrower path")
|
|
raise OSError(f"Failed to {tool} under {root}: command exited with code {status}")
|