Skip to content

Commit 74a3652

Browse files
committed
Implement enhanced error handling and path resolution for Llama engine scanning
- Introduced a new function to generate human-readable error messages for subprocess failures during help command execution. - Updated the `scan_llama_engine_version` function to utilize the new path resolution logic for executable binaries, improving robustness. - Added a new module for resolving Llama server executable paths and their working directories, ensuring compatibility with different installation layouts. - Enhanced tests to cover new error handling scenarios and path resolution logic, ensuring comprehensive validation of the scanning process.
1 parent 3918ee4 commit 74a3652

5 files changed

Lines changed: 399 additions & 107 deletions

File tree

backend/engine_param_scanner.py

Lines changed: 81 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,41 @@ def _clear_llama_flags_cache() -> None:
2828
HELP_TIMEOUT = 90
2929

3030

31+
def _help_subprocess_failure_message(
32+
returncode: int,
33+
argv0: str,
34+
*,
35+
empty_stdout: bool,
36+
scan_engine: Optional[str] = None,
37+
) -> str:
38+
"""Human-readable scan failure (126/127 often mean exec/loader/shebang issues)."""
39+
exe = argv0 or "(unknown)"
40+
tail = " (no stdout from --help; output may be missing or only on stderr)" if empty_stdout else ""
41+
if returncode == 127:
42+
head = (
43+
f"process exited with code 127{tail}: the program could not be run (POSIX 127 — often "
44+
f"“not found” at exec or in a wrapper). Executable: {exe}. "
45+
)
46+
if scan_engine == "lmdeploy":
47+
return head + (
48+
"For LMDeploy: `lmdeploy` is usually a script; fix the venv shebang Python or a stale `venv_path`."
49+
)
50+
if scan_engine in ("llama_cpp", "ik_llama"):
51+
return head + (
52+
"For llama.cpp / ik_llama: wrong arch or libc (e.g. glibc binary on musl), missing shared "
53+
"libraries (CUDA/GGML — `.so` search path), bad `binary_path`, or a wrapper with a broken "
54+
"shebang. Run `file` on the binary and the same `--help` in the API container; ensure "
55+
"`LD_LIBRARY_PATH` includes the directory with ggml/llama shared libs (often `build/bin` next to the build)."
56+
)
57+
return head + "Check shebang, dynamic linker, and PATH/LD_LIBRARY_PATH."
58+
if returncode == 126:
59+
return (
60+
f"process exited with code 126{tail}: cannot execute (permission denied or not a valid executable). "
61+
f"Executable: {exe}"
62+
)
63+
return f"process exited with code {returncode}{tail}"
64+
65+
3166
def _abs_path(p: str) -> str:
3267
if not p:
3368
return p
@@ -41,6 +76,7 @@ def _run_help_argv(
4176
*,
4277
cwd: Optional[str] = None,
4378
extra_env: Optional[dict] = None,
79+
scan_engine: Optional[str] = None,
4480
) -> Tuple[str, Optional[str]]:
4581
env = os.environ.copy()
4682
if extra_env:
@@ -56,11 +92,18 @@ def _run_help_argv(
5692
env=env,
5793
)
5894
text = r.stdout or ""
95+
argv0 = argv[0] if argv else ""
5996
if not text.strip():
97+
if r.returncode != 0:
98+
return "", _help_subprocess_failure_message(
99+
r.returncode, argv0, empty_stdout=True, scan_engine=scan_engine
100+
)
60101
return "", "empty help output"
61102
if r.returncode != 0:
62103
# Caller may still parse stdout when --help printed despite non-zero exit.
63-
return text, f"process exited with code {r.returncode}"
104+
return text, _help_subprocess_failure_message(
105+
r.returncode, argv0, empty_stdout=False, scan_engine=scan_engine
106+
)
64107
return text, None
65108
except subprocess.TimeoutExpired:
66109
return "", "timeout"
@@ -72,44 +115,45 @@ def _run_help_argv(
72115

73116
def scan_llama_engine_version(engine: str, version_row: dict) -> dict:
74117
"""engine: llama_cpp | ik_llama"""
118+
from backend.llama_server_exec import (
119+
llama_help_ld_library_path,
120+
resolve_llama_server_invocation_paths,
121+
)
122+
75123
binary_path = version_row.get("binary_path")
76124
if not binary_path:
77125
return _error_entry("", "missing binary_path")
78126
path = _abs_path(binary_path)
79-
if not os.path.isfile(path):
80-
return _error_entry(path, f"binary not found: {path}")
127+
exec_path, work_cwd = resolve_llama_server_invocation_paths(path)
128+
if not os.path.isfile(exec_path):
129+
return _error_entry(exec_path, f"binary not found: {exec_path}")
81130

82-
binary_dir = os.path.dirname(path)
83-
working_dir = binary_dir
84-
if "/bin/" in binary_dir and "/build/bin/" not in binary_dir:
85-
working_dir = binary_dir.replace("/bin/", "/build/bin/")
86-
env_ld = binary_dir
87-
if "/bin/" in env_ld and "/build/bin/" not in env_ld:
88-
env_ld = env_ld.replace("/bin/", "/build/bin/")
131+
ld_path = llama_help_ld_library_path(work_cwd)
89132

90133
text, run_err = _run_help_argv(
91-
[path, "--help"],
92-
cwd=working_dir if os.path.isdir(working_dir) else None,
93-
extra_env={"LD_LIBRARY_PATH": env_ld},
134+
[exec_path, "--help"],
135+
cwd=work_cwd if os.path.isdir(work_cwd) else None,
136+
extra_env={"LD_LIBRARY_PATH": ld_path},
137+
scan_engine=engine,
94138
)
95139
if not text.strip():
96-
return _error_entry(path, run_err or "empty help output")
140+
return _error_entry(exec_path, run_err or "empty help output")
97141
try:
98142
sections = parse_llama_help_to_sections(text, engine)
99143
except Exception as e:
100144
logger.exception("llama help parse failed")
101-
return _error_entry(path, f"parse error: {e}")
145+
return _error_entry(exec_path, f"parse error: {e}")
102146

103147
n_params = sum(len(s.get("params") or []) for s in sections)
104148
if n_params == 0:
105149
msg = run_err or (
106150
"No CLI flags parsed from --help. If you only see GPU/CUDA lines, the binary may have exited "
107151
"before usage text was printed; try running it with --help in a shell."
108152
)
109-
return _error_entry(path, msg)
153+
return _error_entry(exec_path, msg)
110154

111155
return {
112-
"binary_path": path,
156+
"binary_path": exec_path,
113157
"scanned_at": iso_now(),
114158
"scan_error": None,
115159
"sections": sections,
@@ -131,6 +175,7 @@ def scan_lmdeploy_version(version_row: dict) -> dict:
131175
[lmdeploy_bin, "serve", "api_server", "--help"],
132176
cwd=vdir,
133177
extra_env={"VIRTUAL_ENV": vdir, "PATH": f"{os.path.join(vdir, 'bin')}:{os.environ.get('PATH', '')}"},
178+
scan_engine="lmdeploy",
134179
)
135180
if not text.strip():
136181
return _error_entry(lmdeploy_bin, run_err or "empty help output")
@@ -171,7 +216,25 @@ def scan_engine_version(store: Any, engine: str, version_row: dict) -> dict:
171216
return entry
172217

173218
if engine in ("llama_cpp", "ik_llama"):
174-
entry = scan_llama_engine_version(engine, version_row)
219+
row = dict(version_row)
220+
active = store.get_active_engine_version(engine)
221+
if (
222+
active
223+
and active.get("version") == ver
224+
and active.get("binary_path")
225+
):
226+
try:
227+
from backend.llama_engine_resolve import (
228+
get_active_llama_swap_binary_path,
229+
infer_llama_engine_for_binary,
230+
)
231+
232+
swap_bin = get_active_llama_swap_binary_path(store)
233+
if swap_bin and infer_llama_engine_for_binary(store, swap_bin) == engine:
234+
row["binary_path"] = swap_bin
235+
except Exception as e:
236+
logger.debug("Active llama-swap binary override skipped: %s", e)
237+
entry = scan_llama_engine_version(engine, row)
175238
elif engine == "lmdeploy":
176239
entry = scan_lmdeploy_version(version_row)
177240
else:

backend/llama_engine_resolve.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Resolve active llama.cpp / ik_llama binary paths from the engines store (lightweight; no hf/swap imports)."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
from typing import Any, Optional
7+
8+
from backend.logging_config import get_logger
9+
10+
logger = get_logger(__name__)
11+
12+
13+
def abs_llama_binary_path(p: Optional[str]) -> str:
14+
if not p:
15+
return ""
16+
if os.path.isabs(p):
17+
return p
18+
return os.path.join("/app", p.lstrip("/"))
19+
20+
21+
def get_active_llama_swap_binary_path(store: Any) -> Optional[str]:
22+
"""
23+
Same resolution as llama-swap: first existing ``binary_path`` on active ``llama_cpp``,
24+
else on active ``ik_llama``.
25+
"""
26+
try:
27+
for engine in ("llama_cpp", "ik_llama"):
28+
active_version = store.get_active_engine_version(engine)
29+
if not active_version or not active_version.get("binary_path"):
30+
continue
31+
binary_path = active_version["binary_path"]
32+
if not os.path.isabs(binary_path):
33+
binary_path = os.path.join("/app", binary_path)
34+
if os.path.exists(binary_path):
35+
return binary_path
36+
abs_path = os.path.abspath(binary_path)
37+
if os.path.exists(abs_path):
38+
return abs_path
39+
logger.warning("No active llama-cpp version found in data store")
40+
return None
41+
except Exception as e:
42+
logger.error("Error getting active llama swap binary path: %s", e)
43+
return None
44+
45+
46+
def infer_llama_engine_for_binary(store: Any, binary_path: str) -> str:
47+
"""Return ``llama_cpp`` or ``ik_llama`` depending on which active row references this path."""
48+
try:
49+
norm = os.path.abspath(abs_llama_binary_path(binary_path))
50+
for eng in ("ik_llama", "llama_cpp"):
51+
av = store.get_active_engine_version(eng)
52+
if av and av.get("binary_path"):
53+
if os.path.abspath(abs_llama_binary_path(av["binary_path"])) == norm:
54+
return eng
55+
except Exception as e:
56+
logger.debug("infer_llama_engine_for_binary: %s", e)
57+
return "llama_cpp"

backend/llama_server_exec.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Resolve llama-server executable + cwd to match llama-swap ``cd …/build/bin && ./llama-server`` layout."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
from typing import Optional, Tuple
7+
8+
9+
def sibling_build_bin_from_install_bin(install_bin_dir: str) -> Optional[str]:
10+
"""
11+
If ``install_bin_dir`` is ``PREFIX/bin`` (not already ``…/build/bin``), return ``PREFIX/build/bin``.
12+
13+
Handles paths that end with ``/bin`` but do not contain the substring ``/bin/`` (e.g. ``…/install/bin``).
14+
"""
15+
wd = os.path.abspath(install_bin_dir)
16+
norm = wd.rstrip(os.sep)
17+
if os.path.basename(norm) != "bin":
18+
return None
19+
parent = os.path.dirname(norm)
20+
if os.path.basename(parent) == "build":
21+
return None
22+
return os.path.join(parent, "build", "bin")
23+
24+
25+
def resolve_llama_server_invocation_paths(abs_binary_path: str) -> Tuple[str, str]:
26+
"""
27+
Match ``generate_llama_swap_config`` launcher: ``cd working_dir && ./<binary>`` where
28+
``working_dir`` is ``…/build/bin`` when that directory exists and the stored path was under ``…/bin``.
29+
30+
Args:
31+
abs_binary_path: Absolute path from engines.yaml (after ``/app`` join if needed).
32+
33+
Returns:
34+
``(executable_path, cwd)`` for subprocess (argv0 and working directory).
35+
"""
36+
p = os.path.abspath(abs_binary_path)
37+
working_dir = os.path.dirname(p)
38+
binary_name = os.path.basename(p)
39+
alt_dir = sibling_build_bin_from_install_bin(working_dir)
40+
if alt_dir:
41+
alt_exec = os.path.join(alt_dir, binary_name)
42+
if os.path.isfile(alt_exec):
43+
return os.path.abspath(alt_exec), os.path.abspath(alt_dir)
44+
# Legacy layout: path segment contains ``/bin/`` (e.g. ``…/something/bin/extra``)
45+
if "/bin/" in working_dir and "/build/bin/" not in working_dir:
46+
legacy_alt = working_dir.replace("/bin/", "/build/bin/")
47+
alt_exec = os.path.join(legacy_alt, binary_name)
48+
if os.path.isfile(alt_exec):
49+
return os.path.abspath(alt_exec), os.path.abspath(legacy_alt)
50+
return p, os.path.abspath(working_dir)
51+
52+
53+
def llama_help_ld_library_path(binary_dir: str) -> str:
54+
"""Dirs to search for ggml/CUDA .so when running ``llama-server --help`` (scan / flag probes)."""
55+
candidates: list[str] = []
56+
57+
def consider(path: str) -> None:
58+
if not path:
59+
return
60+
ap = os.path.abspath(path)
61+
if os.path.isdir(ap) and ap not in candidates:
62+
candidates.append(ap)
63+
64+
consider(binary_dir)
65+
sbb = sibling_build_bin_from_install_bin(binary_dir)
66+
if sbb:
67+
consider(sbb)
68+
if "/bin/" in binary_dir and "/build/bin/" not in binary_dir:
69+
consider(binary_dir.replace("/bin/", "/build/bin/"))
70+
consider(os.path.join(binary_dir, "build", "bin"))
71+
consider(os.path.join(binary_dir, "build"))
72+
consider(os.path.join(binary_dir, "..", "build", "bin"))
73+
74+
seen = set(candidates)
75+
tail = os.environ.get("LD_LIBRARY_PATH", "").strip()
76+
if tail:
77+
for part in tail.split(os.pathsep):
78+
p = part.strip()
79+
if p and p not in seen:
80+
ap = os.path.abspath(p)
81+
seen.add(ap)
82+
candidates.append(ap)
83+
return os.pathsep.join(candidates)

0 commit comments

Comments
 (0)