diff --git a/studio/backend/tests/test_mlx_repair.py b/studio/backend/tests/test_mlx_repair.py index bb8dfce049..1b0cbf9df1 100644 --- a/studio/backend/tests/test_mlx_repair.py +++ b/studio/backend/tests/test_mlx_repair.py @@ -338,3 +338,22 @@ def test_attempts_only_once_per_process(monkeypatch): second = mr.start_mlx_autorepair_if_needed() assert first is True assert second is False # guard prevents a second concurrent attempt + + +def test_mlx_install_env_routes_uv_override_through_safe_path(monkeypatch): + # uv truncates UV_OVERRIDE at the first space (issue #6503). + seen = {} + + def _spy(path): + seen["path"] = path + return "/space free/marker.txt".replace(" ", "_") + + monkeypatch.setattr(mr, "uv_safe_path", _spy) + monkeypatch.delenv("UV_OVERRIDE", raising = False) + + env = mr._mlx_install_env() + + # The override file ships in the repo, so the helper must have run. + assert "path" in seen + assert str(seen["path"]).endswith("overrides-darwin-arm64.txt") + assert env["UV_OVERRIDE"] == "/space_free/marker.txt" diff --git a/studio/backend/utils/mlx_repair.py b/studio/backend/utils/mlx_repair.py index 7e980deb82..520c11c3b1 100644 --- a/studio/backend/utils/mlx_repair.py +++ b/studio/backend/utils/mlx_repair.py @@ -36,6 +36,8 @@ from pathlib import Path import structlog +from utils.uv_path_safety import uv_safe_path + logger = structlog.get_logger(__name__) DISABLE_ENV_VAR = "UNSLOTH_DISABLE_MLX_AUTOREPAIR" @@ -207,7 +209,8 @@ def _mlx_install_env() -> dict[str, str]: / "overrides-darwin-arm64.txt" ) if override.is_file(): - env.setdefault("UV_OVERRIDE", str(override)) + # uv truncates UV_OVERRIDE at the first space (issue #6503). + env.setdefault("UV_OVERRIDE", uv_safe_path(override)) return env diff --git a/studio/backend/utils/uv_path_safety.py b/studio/backend/utils/uv_path_safety.py new file mode 100644 index 0000000000..519014c71c --- /dev/null +++ b/studio/backend/utils/uv_path_safety.py @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Hand uv a space-free `-c`/`--override`/`-r` file path (issue #6503). + +uv splits `-c`/`--override` (and UV_OVERRIDE) on whitespace, so a path with a +space truncates. Windows uses the 8.3 short form; POSIX copies the file into a +space-free temp dir (removed at exit). Falls back to the original path on error. +Shared by install_python_stack and utils.mlx_repair. +""" + +from __future__ import annotations + +import atexit +import os +import platform +import shutil +import tempfile + +IS_WINDOWS = platform.system() == "Windows" + +_UV_SAFE_PATH_TMPDIRS: list[str] = [] + + +@atexit.register +def _cleanup_uv_safe_path_tmpdirs() -> None: + while _UV_SAFE_PATH_TMPDIRS: + shutil.rmtree(_UV_SAFE_PATH_TMPDIRS.pop(), ignore_errors = True) + + +def uv_safe_path(path: object) -> str: + s = str(path) + if " " not in s: + return s + if IS_WINDOWS: + try: + import ctypes + from ctypes import wintypes + + get_short = ctypes.windll.kernel32.GetShortPathNameW + get_short.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD] + get_short.restype = wintypes.DWORD + buf = ctypes.create_unicode_buffer(32768) + rc = get_short(s, buf, 32768) + if 0 < rc < 32768 and " " not in buf.value: + return buf.value + except Exception: + pass + return s + tmp_dir = None + try: + if not os.path.isfile(s): + return s + tmp_dir = tempfile.mkdtemp(prefix = "unsloth_uv_") + if " " in tmp_dir: # e.g. TMPDIR itself has a space + shutil.rmtree(tmp_dir, ignore_errors = True) + return s + dst = os.path.join(tmp_dir, (os.path.basename(s) or "uv_args.txt").replace(" ", "_")) + shutil.copyfile(s, dst) + _UV_SAFE_PATH_TMPDIRS.append(tmp_dir) + tmp_dir = None + return dst + except Exception: + if tmp_dir is not None: # don't leak the temp dir if the copy failed + shutil.rmtree(tmp_dir, ignore_errors = True) + return s diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py index e6095748fe..9060b57542 100644 --- a/studio/install_python_stack.py +++ b/studio/install_python_stack.py @@ -36,6 +36,7 @@ from backend.utils.wheel_utils import ( probe_torch_wheel_env, url_exists, ) +from backend.utils.uv_path_safety import uv_safe_path as _uv_safe_path IS_WINDOWS = sys.platform == "win32" IS_MACOS = sys.platform == "darwin" @@ -1374,25 +1375,7 @@ def _ensure_rocm_torch() -> None: ) -def _uv_safe_path(path: object) -> str: - # uv 0.11.x: `-c ` truncates at the space; use 8.3 short form. - s = str(path) - if not IS_WINDOWS or " " not in s: - return s - try: - import ctypes - from ctypes import wintypes - - get_short = ctypes.windll.kernel32.GetShortPathNameW - get_short.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD] - get_short.restype = wintypes.DWORD - buf = ctypes.create_unicode_buffer(32768) - rc = get_short(s, buf, 32768) - if 0 < rc < 32768 and " " not in buf.value: - return buf.value - except Exception: - pass - return s +# _uv_safe_path is imported from backend.utils.uv_path_safety (shared with mlx_repair). def _windows_hidden_subprocess_kwargs() -> dict[str, object]: @@ -1481,9 +1464,10 @@ LOCAL_DD_UNSTRUCTURED_PLUGIN = ( LOCAL_DD_GITHUB_PLUGIN = SCRIPT_DIR / "backend" / "plugins" / "data-designer-github-repo-seed" # Apple Silicon: override mlx-vlm/mlx-lm's transformers pin (see overrides). +# _uv_safe_path: uv truncates UV_OVERRIDE at the first space too (issue #6503). _MLX_OVERRIDES = SINGLE_ENV / "overrides-darwin-arm64.txt" -if IS_MAC_ARM and _MLX_OVERRIDES.is_file(): - os.environ.setdefault("UV_OVERRIDE", str(_MLX_OVERRIDES)) +if IS_MAC_ARM and _MLX_OVERRIDES.is_file() and "UV_OVERRIDE" not in os.environ: + os.environ["UV_OVERRIDE"] = _uv_safe_path(_MLX_OVERRIDES) # -- Unicode-safe printing --------------------------------------------- # On Windows the console encoding may be a legacy code page (e.g. CP1252) diff --git a/tests/python/test_install_python_stack.py b/tests/python/test_install_python_stack.py index f1a090eac9..9015ff8c9d 100644 --- a/tests/python/test_install_python_stack.py +++ b/tests/python/test_install_python_stack.py @@ -2,9 +2,11 @@ from __future__ import annotations +import glob import importlib import os import sys +import tempfile from pathlib import Path from unittest import mock @@ -51,3 +53,98 @@ class TestBuildUvCmdTorchBackend: assert not any( a.startswith("--torch-backend") for a in cmd ), f"Empty UV_TORCH_BACKEND should not add flag, got: {cmd}" + + +class TestUvSafePath: + """_uv_safe_path hands uv a space-free `-c`/`-r` path (issue #6503).""" + + def test_passthrough_when_no_space(self): + """A path without a space is returned unchanged on every platform.""" + p = "/tmp/plain/constraints.txt" + assert ips._uv_safe_path(p) == p + + @pytest.mark.skipif(ips.IS_WINDOWS, reason = "POSIX temp-copy fallback") + def test_posix_space_path_returns_spacefree_copy(self, tmp_path): + src = tmp_path / "Open Source" / "constraints.txt" + src.parent.mkdir(parents = True) + src.write_text("torch>=2.6\n") + + out = ips._uv_safe_path(str(src)) + + assert " " not in out, f"uv-safe path still has a space: {out!r}" + assert out != str(src) + assert Path(out).read_text() == "torch>=2.6\n" + + @pytest.mark.skipif(ips.IS_WINDOWS, reason = "POSIX temp-copy fallback") + def test_posix_missing_file_falls_back_to_original(self): + """No file to copy -> return the original path rather than raise.""" + p = "/nonexistent dir/constraints.txt" + assert ips._uv_safe_path(p) == p + + +class TestUvSafePathHardening: + """Edge cases for uv_safe_path + the UV_OVERRIDE channel (issue #6503).""" + + @pytest.mark.skipif(ips.IS_WINDOWS, reason = "POSIX temp-copy fallback") + def test_tmpdir_with_space_falls_back(self, tmp_path, monkeypatch): + """A space in the temp root itself -> fall back to the original path.""" + from backend.utils import uv_path_safety as uvps + + spaced = tmp_path / "tmp dir with space" + spaced.mkdir() + monkeypatch.setattr(uvps.tempfile, "mkdtemp", lambda *a, **k: str(spaced)) + src = tmp_path / "Open Source" / "constraints.txt" + src.parent.mkdir(parents = True) + src.write_text("idna\n") + assert uvps.uv_safe_path(str(src)) == str(src) + + @pytest.mark.skipif(ips.IS_WINDOWS, reason = "POSIX temp-copy fallback") + def test_no_temp_dir_leak_on_copy_failure(self, tmp_path, monkeypatch): + """A copyfile failure after mkdtemp must not orphan the temp dir.""" + from backend.utils import uv_path_safety as uvps + + src = tmp_path / "Open Source" / "constraints.txt" + src.parent.mkdir(parents = True) + src.write_text("idna\n") + pattern = os.path.join(tempfile.gettempdir(), "unsloth_uv_*") + before = set(glob.glob(pattern)) + + def boom(*a, **k): + raise OSError("boom") + + monkeypatch.setattr(uvps.shutil, "copyfile", boom) + out = uvps.uv_safe_path(str(src)) + + assert out == str(src) + assert set(glob.glob(pattern)) == before + + @pytest.mark.skipif(ips.IS_WINDOWS, reason = "POSIX temp-copy fallback") + def test_cleanup_removes_and_clears_registry(self, tmp_path): + """The atexit-registered cleanup removes the copies and empties the list.""" + from backend.utils import uv_path_safety as uvps + + src = tmp_path / "Open Source" / "constraints.txt" + src.parent.mkdir(parents = True) + src.write_text("idna\n") + out = uvps.uv_safe_path(str(src)) + tmp_dir = Path(out).parent + assert tmp_dir.is_dir() and str(tmp_dir) in uvps._UV_SAFE_PATH_TMPDIRS + + uvps._cleanup_uv_safe_path_tmpdirs() + + assert not tmp_dir.exists() + assert uvps._UV_SAFE_PATH_TMPDIRS == [] + + @pytest.mark.skipif(ips.IS_WINDOWS, reason = "POSIX temp-copy fallback") + def test_uv_override_value_is_space_safe(self, tmp_path): + """The value stored for UV_OVERRIDE must be space-free.""" + from backend.utils import uv_path_safety as uvps + + overrides = tmp_path / "Open Source" / "overrides-darwin-arm64.txt" + overrides.parent.mkdir(parents = True) + overrides.write_text("transformers>=4.57.6\n") + + value = uvps.uv_safe_path(overrides) + + assert " " not in value + assert Path(value).read_text() == "transformers>=4.57.6\n"