mirror of
https://github.com/unslothai/unsloth.git
synced 2026-11-01 03:30:08 +00:00
Studio: notice a quarantined payload before trusting the update fast path (#10053)
* Studio: notice a quarantined payload before trusting the update fast path An update can report success while leaving the venv unbootable. Every check the fast path makes reads metadata: installed_version_probe reads dist-info, verify_install compares the manifest and walks missing_requirements through importlib.metadata. None of them looks at whether the recorded files are still on disk. So when an antivirus quarantines site-packages/unsloth after a good install, the version still answers, verify_install still says ok, and setup takes the fast path and skips the dependency pass that would have repaired it. Measured on a real 2026.8.22 install: deleting a recorded file, truncating one, removing a nested module, or removing the whole tree all leave `studio update` exiting 0 and reporting the update successful. damaged_payload_files walks the managed distribution's own RECORD and reports rows that are gone, are not regular files, or are shorter than recorded. verify_install runs it last, once every metadata check has already passed, and fails with a new reason, studio_install_damaged. That is the whole change on the shell side: setup.sh and setup.ps1 already call verify_install and already force the dependency pass when it says no, so neither script is touched. Deliberately narrow, in three ways. It answers for the managed package alone rather than for every installed distribution, because its only job here is deciding whether to repair ours. It runs last, after manifest_ok and deps_ok are both established, so none of the six existing reason strings can be diverted into the new one. The reason is additive and nothing matches it against a closed set: the Tauri preflight carries it as an opaque Option<String>, and is_context_reason is a negated two-item allowlist, so an unknown reason keeps can_auto_repair true, which is what we want here. It is off on the boot path. `deep` defaults on so the two shell callers, which pass no arguments, get the scan; install_state passes deep=False because it feeds `desktop-capabilities`, which the preflight spawns under a 10 second timeout. Spending that budget on a stat of every recorded file risks a timeout reporting a healthy install stale and repairing it, which is worse than the quarantine it would have caught. `unsloth studio update --verify` already scans the whole tree after the pass. The kwarg is guarded by the same _verify_install_supports introspection as installed_conflicts, so a newer CLI against an older install_manifest.py still works. Two carve-outs, both for trees our own setup rewrites. package-lock.json loses only its size claim, since npm dedupes hoisted entries and shrinks it, matching _INSTALLER_REWRITTEN_NAMES. studio/frontend/dist is skipped as a subtree: setup runs `npm run build` in the installed tree, vite empties dist and rewrites every asset under a fresh content hash, and the wheel ships all 704 of those files in RECORD, so a rebuild leaves rows naming files that no longer exist. A size waiver cannot reach that, and without the carve-out one rebuild would fail every later update permanently, since a no-op pip pass restores nothing. RECORD is parsed with csv over io.StringIO, not splitlines, because a quoted field may contain a newline and splitlines also breaks on \v, \f and the Unicode separators. Unreadable is not missing: anything but FileNotFoundError is skipped, because a reinstall cannot fix EACCES. An environment the scan cannot read reports undamaged, so a check that cannot answer leaves the fast path as it found it. Verified: 294 distributions in a real environment, zero flagged, slowest scan 0.168s; unsloth 2024.10.0 through 2026.8.22 all clean; all four damage states caught; a frontend rebuilt in place is clean while real damage in the same tree is still caught. Existing suites unchanged at 2887 passed, including the 83 that pin verify_install's reasons, and the shell fast-path suites still pass with no shell changes. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Make the payload scan opt-in and bounded Three problems with the first cut, all found by auditing it rather than by using it. The scan defaulted on, which is the wrong direction under version skew. An external CLI driving a managed venv resolves install_manifest out of THAT venv: _manifest_candidates yields the CLI's own sibling copy first, then falls through to sys.prefix, so an older published CLI can load a newer copy of this file. It calls verify_install(root=root) with no `deep`, so defaulting the scan on put a stat of every recorded file inside the 10 second timeout that desktop-capabilities runs under on the Tauri boot path, with no way for the old caller to decline. interrupted-install-ci.yml builds exactly that pairing on purpose. `deep` now defaults False and the two shell callers opt in; they import the module from their own directory, so they can never be skewed against it, and they fall back to the old call on a TypeError so an older tree still works. The scan was unbounded. Neither installer wraps that call in a timeout, and setup.sh only reaches it inside the "is up to date" fast path, so the cost lands on the no-op run. 3644 of unsloth's 4358 recorded rows get stat'ed, ~65ms warm. Measured with latency injected into a real scan of a real install: at 5ms per stat a full walk is 18s, at 50ms it is 182s of wedged setup. A 5 second budget, checked every 64 rows so the clock read costs less than the stat it guards, holds those to 5.3s and 6.3s. Giving up reports undamaged, so a scan that cannot finish leaves the fast path exactly as it found it, which is what main does today. `unsloth studio verify-install` could not see the failure mode this was written to catch. It shares _install_state with desktop-capabilities, so opting the boot path out opted the diagnostic out too. install_state takes `deep` now and verify-install passes it: nothing times that command out, and a quarantined payload is precisely what a user running a verify step wants told. _fail_if_install_damaged does not cover this, because it returns silently when the CLI is outside the managed venv, which is the desktop configuration. Also: reuse the manifest already in scope instead of re-reading it from disk, which removes a second TOCTOU window. The boot path staying cheap was load-bearing and unpinned, so a refactor could have reinstated the scan there with a green suite. Two tests now hold it: one that both installers ask for the scan and keep their older-tree fallback, one that install_state and _install_state default it off while verify-install turns it on. Verified: the fast-path decision is unchanged from main on a healthy install and now forces the repair pass on a quarantined one, across [Linux, WSL, macOS, Windows] x [NVIDIA, AMD, CPU, XPU], 32 cells run twice each against both module versions, driving the real setup.sh block against a real venv. Run differentially because absolute expectations are useless here: unrelated escapes clear the flag too, and the anyio #6483 one masked the effect entirely until the venv was pinned to the anyio the requirements actually ask for. 15 compatibility checks over all four old/new pairings of the two files. 294 distributions scanned, zero flagged. unsloth 2024.10.0 through 2026.8.22 clean. unsloth_cli/tests fails the same 41 tests as origin/main, name for name. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Repair the damage the scan finds, and widen what counts as damage The scan told setup an install was damaged and setup then ran an upgrade, which for a distribution already at the wanted version installs nothing: uv audits it, pip calls it satisfied, and both are reading metadata that a quarantine of the payload leaves intact. Detection without repair only moved the failure later, so the core phase now names a damaged distribution for reinstall, with --no-deps so the pre-installed torch build is not re-resolved. --reinstall-package is uv-only; the pip fallback gets --force-reinstall, which is safe there only because that call also passes --no-deps and one name. Also five cases the first pass read as healthy: - a package directory replaced by a regular file. NotADirectoryError is an OSError but not a FileNotFoundError, so every row beneath it fell to the generic arm and reported merely unreadable. - unsloth-zoo. studio.txt does not list it, so nothing else here would notice it quarantined, and training, export and inference all import it. - a foreign managed venv. verify_install now takes scan_paths, and the CLI hands over that venv's own site-packages; RECORD rows resolve against the distribution that declared them, so without it the scan answered for the external CLI's tree and deep was a no-op on that branch. - a distribution gone entirely. Every version check compares against the installed version, so an absent one passes all of them. - the scan budget. It was consulted every 64 rows, which let a mount taking a second per stat overrun 5 seconds by a minute. A clock read is ~68ns against ~1343ns for a warm stat, so the guard is cheaper than the call it guards. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Trim the comments added by this branch Comments and docstrings only, verified with comment_tools.py check --strip-docstrings. 179 comment lines to 66. * Abort when the repair cannot restore the files, and scan the launcher A forced reinstall that cannot reach an index left the tree damaged and the pass carried on, where the ordinary upgrade audits the intact metadata as satisfied and write_manifest records a success nothing rechecks. The repair now rescans and the installer aborts. Judged on the tree rather than pip's exit code, so a reinstall that restored the files while exiting non-zero is not held against it. Console scripts and data files are recorded relative to site-packages, so `../../../bin/unsloth` was dropped by the blanket `..` rejection: an antivirus could take the launcher, leave the package tree intact, and the deep check would call the install healthy while the command was gone. Parent-relative rows are now resolved and required to land inside the venv, which is what keeps a row naming something outside it from being read as our damage. Measured across every distribution installed here: 647 such rows in 81 of 294 distributions, none flagged, and a quarantined `bin/flask` detected. A companion whose dist-info is gone left the scan nothing to walk, and no check above ever looked at its version, so unsloth-zoo could vanish whole. Only for the default install: a custom `--package` need not depend on it, and demanding it there would repair that environment on every run. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Refuse an absent core package, and leave a custom package's neighbours alone A distribution that is not installed at all has no RECORD to walk, so the scan read it as undamaged. That is only safe before the core phase, where a fresh run has nothing installed yet, so a second gate runs after it with require_present: by then the phase has run or was deliberately skipped, and the install.sh handoff that sets SKIP_STUDIO_BASE=1 never reaches it at all. Without this, write_manifest recorded a finished install for an environment with no unsloth-zoo in it. unsloth-zoo is now claimed only by the default install, in the installer and in verify_install alike. `--package X` installs X on its own, so a damaged unsloth-zoo beside it belongs to something else: force-reinstalling it would mutate an unrelated distribution and fail the update on an unreachable index. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Check presence after a repair, not only before it --force-reinstall uninstalls before it installs, so a failure in between leaves no distribution at all, and a distribution that is not there has no RECORD for the follow-up scan to call damaged. The repair reported success on the state it had just created. Presence is now rechecked after every reinstall attempt, unconditionally rather than under require_present: we only reinstall what the scan already found, so it was installed a moment earlier. The manifest written in that state records no version, and verify_install compared against it with `bool(recorded) and not current`, so the deep check called an environment with nothing installed healthy. An empty version is only ever absence here, since two records or an unreadable one set local_conflict and report studio_install_metadata_conflict before this runs. * Bound a stat that never returns, not only many that are slow The deadline inside the walk is checked between calls, so it bounds an accumulation of slow stats but not one that hangs outright, which is what a wedged mount or a filesystem filter produces. Neither installer wraps its verify_install call in a timeout, so that case blocked setup rather than reporting the scan unfinished as documented. The walk now runs on a daemon thread and is abandoned past the budget. The interpreter does not wait for one at exit: measured at 1.04s to exit with a thread parked forever in a syscall. budget_seconds = 0 keeps the old inline call for the installer, which has already committed to a full pass and needs the real answer. The new test hangs until killed without this and passes with it. * Do not read our own staged launcher as a quarantined one _move_launcher_aside renames Scripts/unsloth.exe to .update-stale before setup runs, and setup.ps1's deep check runs inside that window, so scanning parent-relative rows made every ordinary Windows update see its own launcher missing: the fast path was lost and the core package force-reinstalled over the network on a healthy no-op update. A recorded file that is absent with one of the three siblings _recover_missing_launcher reads beside it is staged, not quarantined. Absent without one is still damage. _core_package_names compared the package name exactly while verify_install canonicalizes it, so `--package Unsloth` had the deep check scan unsloth-zoo and force a pass that neither repair gate would then act on. Two unit tests called the real installed_versions through the presence check added beside the scan, so they answered for the host rather than the code and failed on a checkout with no unsloth distribution installed. * Narrow the staged-launcher excuse, and refuse a helper that will not import The staged exemption applied to any recorded file and asked only whether a sibling existed. Both were too loose: nothing but Scripts/unsloth.exe is ever moved aside, so a stray .update-stale could hide a quarantine anywhere in the tree, and the transaction recovers through _is_valid_pe, so an orphaned or truncated copy is one it would reject while this accepted it and left the launcher missing with the fast path intact. Same two-byte test now, and only for that name. An install_manifest.py that is present but will not import is damage, not an old release, and it is the one file whose damage silences every check that follows it. Both installers now force the dependency pass for that. Absence keeps the old escape: setup.sh is resolved out of the installed studio package so it should be impossible, separating it from an old release here would need a RECORD walk inside the installer, and the CLI already reports that case as studio_install_manifest_missing. Verified by extracting the probe from setup.sh and running it: absent keeps the fast path, truncated and raising both force the repair. * Trim the comments added since the opening pass Comments and docstrings only, verified with comment_tools.py check --strip-docstrings. 120 comment lines to 73. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
e5767a01e4
commit
fa57a7986f
9 changed files with 1328 additions and 17 deletions
|
|
@ -773,6 +773,177 @@ class TestBuildPipCmdUpgradeIntent:
|
|||
assert cmd == [sys.executable, "-m", "pip", "install", "--no-cache-dir", "somepackage"]
|
||||
|
||||
|
||||
class TestDamagedCorePayloadRepair:
|
||||
"""An upgrade of a distribution already at the wanted version installs
|
||||
nothing: uv audits it, pip calls it satisfied, and both read metadata a
|
||||
quarantine of the payload leaves intact."""
|
||||
|
||||
def test_the_uv_command_targets_only_the_named_package(self):
|
||||
cmd = ips._build_uv_cmd(
|
||||
("--no-cache-dir", "--no-deps", "--reinstall-package", "x", "--force-reinstall", "x")
|
||||
)
|
||||
assert "--reinstall-package" in cmd
|
||||
# Not --reinstall: that is the whole environment, torch included.
|
||||
assert "--reinstall" not in cmd
|
||||
assert "--no-deps" in cmd
|
||||
|
||||
def test_a_plain_force_reinstall_still_becomes_uv_reinstall(self):
|
||||
assert "--reinstall" in ips._build_uv_cmd(("--force-reinstall", "x"))
|
||||
|
||||
def test_the_pip_fallback_drops_the_uv_only_flag(self):
|
||||
cmd = ips._build_pip_cmd(
|
||||
("--no-cache-dir", "--no-deps", "--reinstall-package", "x", "--force-reinstall", "x")
|
||||
)
|
||||
assert "--reinstall-package" not in cmd
|
||||
assert "--force-reinstall" in cmd and "--no-deps" in cmd
|
||||
assert cmd.count("x") == 1
|
||||
# It is not an upgrade request, so it must not acquire pip's upgrade flags.
|
||||
assert "--upgrade" not in cmd
|
||||
|
||||
def test_a_damaged_package_is_reinstalled(self, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
ips.install_manifest,
|
||||
"damaged_payload_files",
|
||||
lambda name, **kwargs: ["gone"] if name == "unsloth-zoo" else [],
|
||||
)
|
||||
monkeypatch.setattr(ips.install_manifest, "installed_versions", lambda name: ["1.0"])
|
||||
monkeypatch.setattr(ips, "pip_install_try", lambda label, *args: calls.append(args))
|
||||
monkeypatch.setattr(ips, "_step", lambda *a, **k: None)
|
||||
ips._repair_damaged_core_payload(("unsloth", "unsloth-zoo"))
|
||||
assert len(calls) == 1
|
||||
assert "--reinstall-package" in calls[0] and calls[0][-1] == "unsloth-zoo"
|
||||
|
||||
def test_a_healthy_tree_runs_nothing(self, monkeypatch):
|
||||
monkeypatch.setattr(ips.install_manifest, "damaged_payload_files", lambda *a, **k: [])
|
||||
monkeypatch.setattr(
|
||||
ips, "pip_install_try", lambda *a, **k: pytest.fail("nothing to repair")
|
||||
)
|
||||
ips._repair_damaged_core_payload(("unsloth", "unsloth-zoo"))
|
||||
|
||||
def test_a_local_checkout_is_left_alone(self, monkeypatch):
|
||||
"""Its core packages are an editable overlay, not an index install."""
|
||||
monkeypatch.setattr(
|
||||
ips.install_manifest,
|
||||
"damaged_payload_files",
|
||||
lambda *a, **k: pytest.fail("a local checkout must not even be scanned"),
|
||||
)
|
||||
ips._repair_damaged_core_payload(("unsloth",), local_repo = "/src/unsloth")
|
||||
|
||||
def test_a_scan_that_raises_does_not_stop_the_install(self, monkeypatch):
|
||||
def boom(*args, **kwargs):
|
||||
raise OSError("unreadable")
|
||||
|
||||
monkeypatch.setattr(ips.install_manifest, "damaged_payload_files", boom)
|
||||
monkeypatch.setattr(
|
||||
ips, "pip_install_try", lambda *a, **k: pytest.fail("nothing was proven damaged")
|
||||
)
|
||||
ips._repair_damaged_core_payload(("unsloth",))
|
||||
|
||||
def test_a_repair_that_leaves_the_files_missing_fails(self, monkeypatch, capsys):
|
||||
"""Otherwise the pass that follows audits the intact metadata as
|
||||
satisfied and write_manifest records a success nothing rechecks."""
|
||||
monkeypatch.setattr(
|
||||
ips.install_manifest, "damaged_payload_files", lambda name, **kwargs: ["gone"]
|
||||
)
|
||||
monkeypatch.setattr(ips, "pip_install_try", lambda label, *args: False)
|
||||
monkeypatch.setattr(ips, "_step", lambda *a, **k: None)
|
||||
assert ips._repair_damaged_core_payload(("unsloth",)) is False
|
||||
|
||||
def test_a_reinstall_that_restored_the_files_is_a_success(self, monkeypatch):
|
||||
"""Judged on the tree, not pip's exit code: uv and pip both exit non-zero
|
||||
for reasons that have nothing to do with the payload."""
|
||||
seen = {"n": 0}
|
||||
|
||||
def scan(name, **kwargs):
|
||||
seen["n"] += 1
|
||||
return ["gone"] if seen["n"] == 1 else []
|
||||
|
||||
monkeypatch.setattr(ips.install_manifest, "damaged_payload_files", scan)
|
||||
# Stubbed, or the presence check added beside the scan answers for the
|
||||
# host: a source checkout with no unsloth distribution installed would
|
||||
# fail this on the environment rather than on the code.
|
||||
monkeypatch.setattr(ips.install_manifest, "installed_versions", lambda name: ["1.0"])
|
||||
monkeypatch.setattr(ips, "pip_install_try", lambda label, *args: False)
|
||||
monkeypatch.setattr(ips, "_step", lambda *a, **k: None)
|
||||
assert ips._repair_damaged_core_payload(("unsloth",)) is True
|
||||
|
||||
def test_an_absent_distribution_is_refused_after_the_core_phase(self, monkeypatch):
|
||||
"""It has no RECORD to walk, so the scan alone reads it as undamaged.
|
||||
|
||||
The install.sh handoff sets SKIP_STUDIO_BASE=1 and the core phase never
|
||||
runs, so this is the only place that would see unsloth-zoo gone before
|
||||
write_manifest records the environment as a finished install.
|
||||
"""
|
||||
monkeypatch.setattr(ips.install_manifest, "damaged_payload_files", lambda *a, **k: [])
|
||||
monkeypatch.setattr(ips.install_manifest, "installed_versions", lambda name: [])
|
||||
monkeypatch.setattr(ips, "_safe_print", lambda *a, **k: None)
|
||||
assert ips._repair_damaged_core_payload(("unsloth",), require_present = True) is False
|
||||
# Off before the core phase: a fresh run has nothing installed yet.
|
||||
assert ips._repair_damaged_core_payload(("unsloth",)) is True
|
||||
|
||||
def test_a_present_distribution_passes_the_presence_check(self, monkeypatch):
|
||||
monkeypatch.setattr(ips.install_manifest, "damaged_payload_files", lambda *a, **k: [])
|
||||
monkeypatch.setattr(ips.install_manifest, "installed_versions", lambda name: ["1.0"])
|
||||
assert ips._repair_damaged_core_payload(("unsloth",), require_present = True) is True
|
||||
|
||||
def test_the_companion_is_only_for_the_default_install(self):
|
||||
assert ips._core_package_names("unsloth") == ("unsloth", "unsloth-zoo")
|
||||
assert ips._core_package_names("unsloth-nightly") == ("unsloth-nightly",)
|
||||
|
||||
def test_the_default_is_recognised_however_it_is_spelled(self):
|
||||
"""verify_install canonicalizes, and the two disagreeing meant the deep
|
||||
check scanned zoo, forced a pass, and no repair gate would touch it."""
|
||||
for spelling in ("Unsloth", "UNSLOTH", "UnSloth"):
|
||||
assert ips._core_package_names(spelling)[1:] == ("unsloth-zoo",), spelling
|
||||
# PEP 503 folds runs of -_. to -, it does not delete them, so this is a
|
||||
# different project and keeps its neighbours to itself.
|
||||
assert ips._core_package_names("uns_loth") == ("uns_loth",)
|
||||
|
||||
def test_both_repair_sites_use_that_list(self):
|
||||
source = inspect.getsource(ips)
|
||||
assert (
|
||||
source.count("_repair_damaged_core_payload(\n _core_package_names")
|
||||
+ source.count("_repair_damaged_core_payload(_core_package_names")
|
||||
== 2
|
||||
)
|
||||
assert "_repair_damaged_core_payload((package_name" not in source
|
||||
|
||||
def test_the_second_site_runs_before_the_manifest_is_written(self):
|
||||
source = inspect.getsource(ips)
|
||||
gate = source.rindex("_repair_damaged_core_payload(")
|
||||
assert gate < source.index("install_manifest.write_manifest(")
|
||||
|
||||
def test_a_reinstall_that_removed_the_distribution_fails(self, monkeypatch):
|
||||
"""--force-reinstall uninstalls before it installs, and a failure in
|
||||
between leaves nothing: an absent distribution has no RECORD, so the
|
||||
payload scan alone would call the repair a success."""
|
||||
scans = {"n": 0}
|
||||
|
||||
def scan(name, **kwargs):
|
||||
scans["n"] += 1
|
||||
# Damaged on the way in, and afterwards nothing left to call damaged.
|
||||
return ["gone"] if scans["n"] == 1 else []
|
||||
|
||||
monkeypatch.setattr(ips.install_manifest, "damaged_payload_files", scan)
|
||||
monkeypatch.setattr(ips.install_manifest, "installed_versions", lambda name: [])
|
||||
monkeypatch.setattr(ips, "pip_install_try", lambda label, *args: True)
|
||||
monkeypatch.setattr(ips, "_step", lambda *a, **k: None)
|
||||
monkeypatch.setattr(ips, "_safe_print", lambda *a, **k: None)
|
||||
assert ips._repair_damaged_core_payload(("unsloth",)) is False
|
||||
|
||||
def test_the_caller_aborts_the_install(self):
|
||||
source = inspect.getsource(ips)
|
||||
assert "if not _repair_damaged_core_payload(" in source
|
||||
|
||||
def test_the_repair_runs_before_the_core_phase(self):
|
||||
"""After it, the upgrade would already have audited the damage as fine."""
|
||||
source = inspect.getsource(ips)
|
||||
repair = source.index("_repair_damaged_core_payload(_core_package_names")
|
||||
core = source.index("# 3. Core packages")
|
||||
assert repair < core
|
||||
|
||||
|
||||
class TestDuplicateCoreMetadataRepair:
|
||||
def test_an_unrewritable_record_stops_the_repair_before_pip_runs(
|
||||
self, tmp_path, monkeypatch, capsys
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue