mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-20 14:23:55 +00:00
* Studio: give llama-server a dyld search path on macOS, and stop losing macOS startup diagnostics _llama_server_env_for_binary branched win32 vs "everything else", and that else branch is Linux: WSL/ROCm probing, pip nvidia wheel globs, CUDA toolkit paths, and LD_LIBRARY_PATH. dyld ignores LD_LIBRARY_PATH, so llama-server was launched on macOS with no library search path at all, while the installer's own staged-binary validation does set DYLD_LIBRARY_PATH and therefore passed on a path the real launch never took. sd_cpp_engine and stt_ggml_sidecar already map the variable per platform; llama_cpp.py was the outlier. Add the darwin branch plus a shared _loader_path_var(), and use it at the two other sites that equated "not Windows" with LD_LIBRARY_PATH (the Vulkan probe and the CPU fallback replay). Classification was Linux-only too. dyld shares no wording with glibc, so "Library not loaded", "Reason: tried: ... (no such file)", an invalid code signature, an incompatible architecture and "built for macOS X which is newer than running OS" all fell through to "check that the GGUF file is valid and you have enough memory" - advice about a file and a resource that were never the problem. Classify them, reusing the existing provenance-aware missing/unloadable library messages, and keep them ahead of the returncode heuristics: a dyld diagnostic is a fact, signal 9 is a guess. macOS also SIGKILLs a binary whose code signature is invalid before it can print anything, so signal 9 there now offers both readings instead of memory alone. Finally, make the unknown-failure fallback self-diagnosing. Studio already writes every byte of llama-server's stdout and stderr to a per-attempt log, and already keeps the last 50 lines in memory, but the 400 response threw both away precisely when nothing recognised the output. It now carries a bounded, control-character-stripped output tail and the log path. With neither, the old message is returned byte for byte. Reported in #8566, where a Mac could load no GGUF at all and the error named the two things that were fine. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Pin the platform in the SIGKILL test macOS SIGKILLs a binary with an invalid code signature the same way the OOM killer does, so the -9 message names both readings there. The existing test asserted the .wslconfig wording against whatever host it ran on, which made it fail on the macOS runners. Pin it to the non-Darwin platform and assert the two messages stay distinct. * Update the capability-probe env test for the macOS loader path The test simulated darwin and asserted the binary's dir landed on LD_LIBRARY_PATH, which is the bug: probe_server_capabilities goes through _llama_server_env_for_binary, so on macOS it ran llama-server --help with a variable dyld ignores. Assert DYLD_LIBRARY_PATH instead, and that an inherited LD_LIBRARY_PATH is left as the user set it. * Address review: bound and scrub the diagnostics, judge the right dyld candidate Six fixes from the review of the first two commits. A stray "Reason:" anywhere in the output used to be read as dyld's, because the capture was DOTALL and matched the first occurrence anywhere, running to the end of the output. llama.cpp prints its own "Reason:" lines, and Studio appends its own health-timeout marker, so the classifier could quote either back as dyld's diagnosis, or pick a verdict from a line belonging to a different failure. The reason is now bounded to its own line plus the indented continuations and read only after the "Library not loaded:" it explains. dyld4 lists every path it searched with a per-path verdict, so a mixed list is normal: our own dylib missing, and a leftover Intel Homebrew copy under /usr/local reporting an incompatible architecture or a policy-blocked signature. Scanning the flattened list let the scariest entry decide, which told users their install had been tampered with when a file was merely absent. Parse the "tried:" list and judge the candidate in the runtime's own lib dir. A stray "Reason:" also disabled the symbol-mismatch branch entirely, since the lib/reason block returned instead of falling through. "@rpath/libfoo.dylib" was passed to the shared message helpers as if it were a path, producing "missing from that exact location" for a search directive that is not a location. Strip the directive down to the soname. The macOS SIGKILL message hardcoded `unsloth studio update`, which cannot touch a pinned LLAMA_SERVER_PATH, and promised the log would say which cause it was when a signature kill leaves the log empty. Route it through _runtime_remedy and let it carry whatever diagnostics exist. The output tail is llama-server's own stdout, and llama-server inherits nearly all of Studio's environment, so a wrapper or diagnostic build echoing its env would have put an API key in the load error. Redact credential-shaped env values, plus bare hf_ and Bearer tokens, and slice the tail before filtering it character by character so a runaway unterminated line is not walked in full. Also fixes the same missing dyld search path in the RAG embedding server, which Apple Silicon reaches through its use_gpu branch. It is the same bug as the chat server's, in a second launcher. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Assert Studio's llama-server launch environment in the macOS CI The Mac job already installs a real llama.cpp and launches llama-server to prove it loads on that host, but it launches it from the shell's own environment, which says nothing about the one Studio builds for its child. That environment was Linux-shaped on macOS (LD_LIBRARY_PATH, which dyld ignores), and the installer's own staged-binary validation does set DYLD_LIBRARY_PATH, so nothing at install time could see the defect. Assert it here, where a real runtime is installed. A unit test with a monkeypatched sys.platform can only prove the branch is taken; this proves the launch environment points dyld at the dylibs that are actually on disk. Runs under the interpreter the `unsloth` shim points at, so the backend imports resolve without installing anything extra, and skips cleanly if no shim is on PATH. Verified on macos-14: fails on the pre-fix revision of llama_cpp.py, passes on the current one. * Cover the CPU, wrapper and probe paths the first macOS fix missed Five follow-ups from the second review round, all consequences of the first fix not reaching far enough. The RAG embedding server only got the dyld search path on its GPU branch, because it was bolted onto the CUDA helper. A CPU start (EMBED_DEVICE=cpu, or the retry after a failed GPU start) loads the same sibling dylibs, so the path now applies whichever device it lands on. That server also passed Path(binary).parent as the library directory, while the chat backend resolves the entrypoint with _llama_lib_dir. The managed install puts an entrypoint in front of the real server, so on a normal install the wrapper's own directory was named instead of the one holding the dylibs. Its capability probe ran `llama-server --help` with no loader environment at all, ahead of any launch. A bundle that needs the search path dies in the loader there, and the error text reads as help output with no --embedding in it, so the user was told their build lacks embedding support instead of that it failed to load. The probe now runs under the same environment as the launch. On macOS the launch resolves a shell entrypoint before spawning it. SIP purges DYLD_* while starting the protected /bin/sh, so the loader path did not survive a wrapper's exec of the real binary. The wrapper does nothing but exec the target, so this is the same launch without the shell hop. Secret redaction in the diagnostics tail now uses the same predicate that decides what to strip from a managed server's own environment, plus URL userinfo and common token shapes. The previous name-marker list missed DATABASE_URL, REDIS_URL and GITHUB_PAT, which is exactly the class of variable whose name says nothing about its contents. * Read the symbol's provenance, and stop the CI check from skipping itself Two follow-ups from the third review round. A "Symbol not found" with no missing file was always reported as llama.cpp libraries from different builds. That is right when dyld expected the symbol in one of our dylibs, and wrong when it expected it in a system framework: there the build wants a newer macOS than this one, and reinstalling the same build cannot make the OS export the symbol. Read the "Expected in:" path and pick the remedy from it. The MTLResidency special case is the same failure, now covered by the general rule as well. The macOS launch-environment assertion looked the interpreter up with `command -v unsloth`. The clean-machine lane scrubs PATH to system directories and puts the shim under its own UNSLOTH_STUDIO_HOME, so the lookup came back empty exactly there and the check skipped, while the job still reported success. It resolves from STUDIO_HOME first now, and a missing interpreter fails rather than skips: an install that produced a llama-server but no reachable interpreter is a broken install, and a skip is indistinguishable from a pass. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Cover the tauri venv, the embedding entrypoint, and the launch api-key Three follow-ups from the fourth review round. The interpreter lookup added last round misses the tauri delivery, which nests its venv one level deeper at $STUDIO_HOME/studio/unsloth_studio. Since the same commit made a missing interpreter fail rather than skip, that turned a good tauri install into a red job. Both layouts are candidates now. The embedding server built its loader environment from the resolved directory but still probed and launched the entrypoint itself, so on macOS SIP would take DYLD_* away again on the way through the wrapper. It resolves the executable once, before the capability probe, so both the probe and the spawn run the real binary. The startup diagnostics could echo our own --api-key. It is minted per launch with secrets.token_urlsafe(32), so it is in no environment variable to look up and matches no token shape; the log path already treated it as sensitive but the API error did not. It is now redacted by its flag, and the live value is handed to the scrubber so it goes wherever it appears. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Scope the macOS resolution and the redaction more tightly Five follow-ups from the fifth review round, all narrowing something the previous rounds made too broad. Resolving a shell entrypoint past its exec line is right for the one the installer generates and wrong for a user's own: a custom LLAMA_SERVER_PATH wrapper may export backend variables before it execs, and jumping to the target would drop that silently. Resolution is now gated on the binary being ours, through a single _exec_path_for_launch used by the chat launch, the capability probe and the embedding backend. That probe needed it too. Only load_model resolved, so the startup, status and preflight routes still probed the wrapper, lost DYLD_* to SIP, and read back inconclusive capabilities, which clamps parallel slots and disables DSpark and DFlash sizing on a server that supports both. Stripping only the leading directive left "@loader_path/../Frameworks/libomp.dylib" as "../Frameworks/libomp.dylib", still path-like enough to earn the same "missing from that exact location" advice one level in. Placeholder install names reduce to the library name now. The classifier is pure text and runs on every platform, so keying a macOS diagnosis on the words "Symbol not found" claimed a Linux wrapper's plugin error as a mixed macOS runtime, and suppressed the output tail that would have shown what really happened. It now requires dyld's own framing. Redaction skipped any value under eight characters, which is the right rule for a port number and the wrong one for DATABASE_PASSWORD=hunter2. Short values from secret-named variables are redacted where they appear beside their name, rather than globally. * Tighten the comments this change adds Comment-only pass over the new code: same intent, 27 fewer lines. The explanations of why macOS needed each of these were written while working the problem out and read like it. * Compare the binary revision in the path space the launch recorded _binary_revision keys on the path string, and the macOS launch now resolves a managed entrypoint to its target, so the changed-since-launch check compared a resolved path against an unresolved discovery path. On a managed install, where the entrypoint is a symlink or wrapper, those never match, so an unchanged llama.cpp read as a fresh update and every Apply reloaded the model. Resolve on the comparison side too. The regression test fails without the fix, and its sibling pins that a genuine update is still detected. * Treat a --with-llama-cpp-dir tree as the user's own _is_unsloth_managed_binary asked only whether the binary sits under the managed root, which a --with-llama-cpp-dir install does: setup.sh makes the canonical llama.cpp directory a symlink to the user's checkout. The update flow already detects that case and refuses to write through the link, so the same install was being called managed here and unmanaged there. Two consequences, both now fixed by consulting the same predicate the update flow uses: the macOS launch resolved past the user's own entrypoint, dropping whatever setup it does, and a failure told them to run an updater that declines to touch their tree. * Pin the inherited value instead of asserting the key is absent The macOS embedding-env test asserted LD_LIBRARY_PATH was not in the child env at all. That env is a copy of os.environ, so the key is there whenever the ambient environment has it, whatever this branch does: the test passed alone and failed in a full run. Set a sentinel and assert it survives untouched, matching how the chat-backend test states the same invariant. * Scope the non-macOS behaviour changes out, and bound the dyld parsing Five independent audits of this branch, all asked the same question: is anything outside macOS different afterwards. Everything they found that was, is fixed here, each one reproduced against origin/main first. Cross-platform behaviour restored: - The macOS loader classifier ran on any output that merely contained 'Library not loaded:' or 'Symbol not found'. llama.cpp echoes GGUF metadata to stderr while loading, so a model whose general.name holds either string got library advice, and on two paths that outranked a correct answer: status 127 and signal 15. Every branch now requires dyld's own line-anchored framing. - _is_unsloth_managed_binary learning about --with-llama-cpp-dir also moved the gate on the Vulkan CPU fallback, which reads and copies the tree and needs no updater. Split into a second predicate so those installs keep the fallback on every platform. - The embedding capability probe was given a rebuilt environment, and its GPU library dir was resolved through the entrypoint, on all three platforms. Both are macOS-only now; Linux and Windows are byte for byte as they were. Correctness: - An exact LLAMA_SERVER_PATH pin now outranks inferred ownership. A wrapper pinned inside a managed tree read as ours and was resolved past, dropping whatever it exported before its exec line. - A quoted 'Expected in:' path failed the /System/ test, so a too-new build was blamed on a mismatched install. - Older dyld says 'mach-o, but wrong architecture'; 'Bad CPU type in executable' never reaches dyld at all. Both were falling through. - The signal 9 wording no longer reads as an exhaustive pair of causes. Bounds and idempotency: - The tried-candidate scan went quadratic on adversarial output: 100KB of "'a' (" took 6.3s against 0.0s on main. The reason is windowed before matching, capped after it, and candidates are counted. - _with_startup_diagnostics recognises its own output, so a second call cannot double the tail. One call site exists, so this cannot fire yet. - One shared loader-path prepend, deduplicating on the normalised spelling, and the embedding one returns a new dict instead of editing the caller's. - Bearer matching covers the full token68 alphabet and Basic. Verified by differential probes against origin/main: 1080 child-env scenarios over platform x GPU vendor x install layout x inherited environment differ on darwin only, and 1392 classifier scenarios differ only where dyld is genuinely the cause or the generic fallback gains its new output tail. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Make three loader tests independent of the host they run on They simulate a POSIX platform by monkeypatching sys.platform, but os.path and pathlib stay the host's, so on the Windows runner Path('/opt/llama/bin/llama-server').resolve() came back drive-anchored and splitting an LD_LIBRARY_PATH on ':' cut 'C:\...' after the drive letter. Product behaviour is not involved: the darwin branch never runs on Windows, and the Linux branch's own separator is correct there. Paths now come from tmp_path, and the ordering assertion uses startswith rather than splitting on a character that appears inside Windows paths. * Stub the install-tree gate where the CPU replay tests stub provenance _cpu_isolated_binary now asks _is_llama_install_tree rather than _is_unsloth_managed_binary, and three fixtures stub only the latter, so the gate fell through to the real _llama_install_root on a tmp_path with no install marker and every staged-runtime test got None. Product behaviour is not involved. Checked directly against origin/main on four trees: a marked install, a --with-llama-cpp-dir link, an unmarked directory and a binary pinned outside any install. The new gate returns what main's gate returned in all four, including restoring True for the link, which is the case this PR was fixing. * Use the Sequence import instead of hiding it in a string annotation verify_import_hoist flagged Sequence as added-but-unused: the three annotations that need it are quoted, so nothing references the name at module scope. Both Sequence and Optional are unconditional imports here, so the quotes bought nothing. Unquoting them makes the import real. scripts/verify_import_hoist.py --before origin/main --after now reports PASS rather than OVERALL: FAIL. * Follow a wrapper chain, and make re-classification a fixed point Two findings from a second round of ten independent audits, both reproduced against origin/main first. _resolve_llama_binary followed one hop. A wrapper whose target is another wrapper resolved to the intermediate script, which on macOS is the very defect this fix exists to avoid (SIP drops DYLD_* through the shell) and also handed _llama_lib_dir the wrapper's directory instead of the one holding the dylibs. It now follows the chain, bounded, and stops on a repeat so a pair pointing at each other cannot spin. _with_startup_diagnostics guarded its "message" argument, but the composition that actually grows is feeding a classified result back in as the child "output": 222 -> 413 -> 604 characters over three passes, and a specific dyld diagnosis downgraded to the generic fallback. The detector is now shared and applied to both sides. A classified message carries no marker of its own, so one wrap still happens before it settles; tagging the return type to close that too is a bigger change to a widely used error path than an unreachable property justifies, and the test says so. No caller feeds either composition today. These keep stated properties true rather than fixing live bugs. * Skip only the entrypoint the installer wrote, and redact quoted secrets Two P2 items from this round, both reproduced against head first. _llama_install_root treats the directory named by UNSLOTH_LLAMA_CPP_PATH as the active install with no marker file needed, so provenance alone answered "ours" for a wrapper at the root of a user's own checkout, and launching its target dropped whatever it exported. The LLAMA_SERVER_PATH pin added earlier covers only the case where they name the file. install_llama_prebuilt writes a fixed three-line wrapper, so that exact shape, a symlink, or a real executable is now the condition for resolving past an entrypoint; anything else is theirs and is launched as written. _resolve_llama_binary keeps resolving any wrapper, because _llama_lib_dir needs the directory holding the dylibs and that is the target's directory whoever wrote the script. The name-adjacent rule for environment values under eight characters matched only a bare NAME=value, so DB_PASSWORD='hunter2' and the JSON form went into the startup-output tail intact. It now allows quotes around the name and the value, requiring the same quote on both sides so an unrelated neighbouring quote cannot be swallowed. Verified before and after on a markerless custom directory and on five env-dump spellings; 680 tests pass. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Widen the tail window by the longest secret before redacting _scrub_secret_values matches a whole value, so a credential longer than the 8000-character prefilter lost its head to the slice that runs before it, and the surviving suffix matched neither the literal nor any token shape. A PEM key or a service-account blob dumped by a wrapper therefore reached the API error. Reproduced with a 12010-character MY_PRIVATE_KEY: the marker at its end came through before, and does not now. The window is widened by the longest value the scrubber could be asked to match, capped at 256KB so a pathological environment value cannot turn a bounded slice into a scan of everything the child printed. The final 2000 character bound on the emitted tail is unchanged. Worth recording: a secret SHORTER than the window cannot straddle its boundary, since a value whose end is inside a window wider than itself has its start inside too. Only values longer than the window were ever at risk, and a test pins that reasoning. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Decide on the wrapper's shape alone, not on how it was reached The exact LLAMA_SERVER_PATH pin added two rounds ago short-circuited resolution outright, to protect a custom wrapper's setup. That was too broad. Pointing LLAMA_SERVER_PATH at the installer's own entrypoint is a supported configuration, and refusing to resolve it just because it was named put the launch back through /bin/sh, where SIP drops DYLD_* before the real binary runs, reproducing #8566 for that setup and for the capability and embedding probes with it. _is_installer_entrypoint already answers the real question, so the pin check is gone. An installer-template wrapper does nothing but exec its target, so resolving past one is never a loss however it was reached; anything else is somebody's own script and is launched as written however it was reached. Reproduced inside a marked install tree before and after: a pinned template wrapper now resolves to build/bin/llama-server, a pinned custom wrapper is still launched as written. 684 tests pass, and the 1080-scenario child environment differential still differs from main on darwin only. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Never trust the child's output for being our own framing _classify_llama_start_failure returned the child's stdout unchanged when that stdout carried the framing _with_startup_diagnostics writes. I added that early return two rounds ago to keep re-classification a fixed point. It was a bad trade: the guard reads untrusted text, so a wrapper or a diagnostic build whose first line happens to be "llama-server output:" or "Full log: " had its whole stdout returned as the API error, past the redaction and past the 2000-character cap. Measured on the branch before this commit, with an environment dump behind the heading: 50067 characters returned verbatim with the credential intact and no log path, against 2136 characters, scrubbed, for the same bytes without the heading. Removed the early return. The only guard on our own framing is the one in _with_startup_diagnostics, which reads the message this module built itself, and that one is safe. The property this drops was for a caller that does not exist. What replaces it is the property that actually matters and is now pinned by a test: repeated passes stay bounded. Each pass wraps the previous message in a fresh tail, roughly 136 characters, and the tail cap stops it at 2136 no matter how many times it runs. Tests: the fixed-point test is replaced by a bounded-growth test plus a parametrised regression test covering all four ways the heading can appear. 141 tests in the classification suite, 688 across the changed areas. * Do not let the child's output rewrite the diagnosis Attaching a tail of llama-server's own stdout to the failure message had a consequence I missed. The inference route scans the whole error string for unsupported-model phrases: _NOT_SUPPORTED_HINTS = ("No config file found", "not yet supported", "is not supported", "does not support") Those now match inside the quoted child output. So a llama-server that prints a line like "ggml_vulkan: device Intel(R) UHD does not support 16-bit storage" gets its failure rewritten to "This model is not supported yet. Try a different model." The model was fine; the message sends the user to replace it. llama.cpp prints lines of that shape for ordinary reasons, so this was reachable on Linux and Windows as much as on macOS. Reproduced on the branch before this commit with three different phrasings. _diagnosis_text() cuts the message at the diagnostics block, and both the unsupported-model matcher and the NVFP4 matcher now decide on the part this backend wrote. The evidence still reaches the user, and is still quoted in full when a rewrite does happen; it just no longer votes on the diagnosis. A message with no diagnostics block is returned unchanged, so every other error source is untouched. Tests: 11 new, covering four real llama.cpp phrasings, the same phrase in our own text (still rewritten), our text carrying a tail (still rewritten, evidence preserved), the NVFP4 matcher, and four shapes that must pass through unchanged. 710 tests pass across the changed areas. Whole backend suite against origin/main: 244 failures on each side, the same 244, no regressions. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Redact by name as well as by value, and bound the library name Two findings from a fresh adversarial pass over the diagnostics tail, both reproduced on the branch before this commit. Redaction missed an encoded secret. _scrub_secret_values matched the credential's literal text, so it only worked when the child printed the value verbatim. A wrapper dumping its environment as JSON prints DB_PASSWORD = pa"ss\word-12345 as {"DB_PASSWORD": "pa\"ss\\word-12345"} which is a different string. The escaped form went into the API error fully reconstructible. URL-encoding it did the same. _redact_secret_assignments replaces whatever sits beside a secret-looking NAME, in the three shapes an environment dump takes: shell-ish, JSON and bare. Positional, so the encoding does not matter, and it also covers a value we never set and therefore could not have matched on. The old value pass stays, because it catches a credential under a name that does not look like one. The remaining gap is an unrecognised name AND an escaped value. Checked against over-redaction, which would defeat the point of the tail: PATH, port, n_ctx, model_path, timings and llama_model_loader lines are all untouched. Both regex arms are unambiguous single-character alternations, so the pass is linear; 200000-character pathological inputs run in 7 to 52 ms. The library name was unbounded. A dyld install name is a path and macOS PATH_MAX is 1024, but nothing enforced that on untrusted output: a 200093-character input produced a 100444-character HTTP error. Capped at 2048 with an ellipsis, which cannot truncate anything the loader could have produced. A real name is unchanged. Tests: 22 new across encoded secrets, the never-set value, six over-redaction controls, four linearity checks and the name cap. 722 pass across the changed areas. The 1392-scenario classifier differential against origin/main is unchanged: 540 non-dyld differences, all still strictly additive, 0 violations. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Redact every classified branch, fix the quoted arm, stop at custom wrappers Three findings, all reproduced on the branch before this commit. Two of the three are defects in code I added earlier in this PR. Redaction only covered the tail. _scrub_secret_values ran inside _with_startup_diagnostics, so it protected the fallback and nothing else. Every other branch also quotes untrusted text: a dyld message names the library it could not load, and that name comes from the child. A credential appearing there went out in the API error while the same credential in the tail was starred out. Verified with a token as the library name: leaked before, redacted now. The fix is one boundary rather than one guard per interpolation, which is the arrangement that let this through in the first place. The classifier body moves to _classify_start_failure_text and the public function scrubs whatever comes back. Scrubbing twice is a no-op, since a redacted value no longer matches. The quoted arm ended on either quote character. So a JSON value holding an apostrophe, or a shell value holding a quote, failed to match, fell through to the bare arm, and that arm stops at whitespace: {"DB_PASSWORD": "prefix' supersecret"} left ` supersecret"` standing in the API error. The arm now closes on the delimiter it opened with. Still linear, still an unambiguous alternation. Launch resolution stepped over somebody else's wrapper. Approving the outer entrypoint says nothing about what it points at, so an installer-shaped symlink whose target was a hand-written wrapper had that wrapper skipped on macOS, along with any exports it set, even though running the symlink directly would have executed it. _resolve_llama_binary grows a template_only flag: launching stops at the first link that is not the installer's own template, while the library-directory lookup keeps following the whole chain, because that is where the dylibs sit whoever wrote the links. Pinned by a test on each of the three shapes. Tests: 9 new. 731 pass across the changed areas, 1482 across every llama.cpp and secret-handling suite. Both differentials against origin/main are unchanged: 1392 classifier scenarios with 540 non-dyld differences, all still strictly additive and 0 violations, and 1080 env scenarios differing on darwin only. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Close three more holes in the name-anchored redaction All three reproduced on the previous head. Each is reachable by a wrapper or crash handler that prints its own configuration rather than the environment we can match values against, which is the case the name pass exists to cover and where it was weakest. An unterminated quoted value leaked its tail. A truncated line ends the output mid-value, so DB_PASSWORD="prefix supersecret has no closing delimiter, cannot match the terminated arm, and fell to the bare arm, which stops at whitespace and left ` supersecret` behind. A third arm now runs an opened-but-unclosed quote to the end of the line, ordered after the terminated arm so a properly closed value still prefers that one. The opening delimiter is kept and no closing one is invented, since that would misreport what the child printed. Only the value's line is consumed; a following line survives. A config-style key was never tested. The name accepted shell identifiers only, so `db-password` and `api-key` never reached is_secret_env_name at all. The name now accepts hyphens and dots, and the separator is normalised to an underscore before the predicate is asked, because the predicate's markers are underscored (API_KEY, PRIVATE_KEY). The predicate stays the one place that decides what counts as a secret. Checked hard against over-redaction, since a wider name is the obvious way to make this worse: model-path, ggml.backend, n-gpu-layers, cache.type-k, llama_model_loader and timing lines are all untouched. Overlapping literals masked each other. With TOKEN_A=abcdefgh and TOKEN_B="abcdefgh VERYSECRET", replacing the short value first rewrote the long one's only occurrence to "*** VERYSECRET", after which the long value no longer matched itself and its tail went out. The minted secrets and the environment's own values are now collected into one set and replaced longest first, so a credential cannot partially mask another before it is scrubbed. Order-independent, verified both ways round. Tests: 20 new. 185 in the classification suite, 1715 across every llama.cpp and secret-handling suite. Both differentials against origin/main unchanged: 540 non-dyld classifier differences, all strictly additive, 0 violations; 1080 env scenarios differing on darwin only. Linearity re-checked on the widened pattern: 200000-character inputs in 15 to 61 ms. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
109 lines
5.1 KiB
Bash
Executable file
109 lines
5.1 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
|
#
|
|
# Assert Unsloth installed a llama.cpp that loads and runs on THIS macOS. Tests
|
|
# the contract that matters (binaries load and their minimum-OS is <= this host)
|
|
# instead of the old "did install.sh fall back to a source build?" grep, since a
|
|
# source build with a correct deployment target is a valid outcome.
|
|
set -uo pipefail
|
|
|
|
UNSLOTH_HOME="${STUDIO_HOME:-$HOME/.unsloth}"
|
|
LLAMA_DIR="${LLAMA_CPP_DIR:-$UNSLOTH_HOME/llama.cpp}"
|
|
BIN_DIR="$LLAMA_DIR/build/bin"
|
|
|
|
fail() {
|
|
echo "::error::$*"
|
|
if [ -f logs/install.log ]; then
|
|
echo "---- install.log (llama.cpp lines) ----"
|
|
grep -E "llama-prebuilt|llama\.cpp|macos prebuilt|falling back" logs/install.log | tail -80 || true
|
|
fi
|
|
exit 1
|
|
}
|
|
|
|
SERVER="$(find "$LLAMA_DIR" -type f -name 'llama-server' 2>/dev/null | head -1)"
|
|
QUANT="$(find "$LLAMA_DIR" -type f -name 'llama-quantize' 2>/dev/null | head -1)"
|
|
[ -n "$SERVER" ] || fail "llama-server not found under $LLAMA_DIR after install"
|
|
[ -n "$QUANT" ] || fail "llama-quantize not found under $LLAMA_DIR after install"
|
|
|
|
HOST_VER="$(sw_vers -productVersion 2>/dev/null || echo '0')"
|
|
HOST_MAJOR="${HOST_VER%%.*}"
|
|
|
|
# Static minimum-OS check on every Mach-O we ship. vtool ships with the Xcode
|
|
# command line tools, which GitHub macOS runners always have; if it is somehow
|
|
# missing we skip the static check and rely on the runtime launch below.
|
|
if command -v vtool >/dev/null 2>&1; then
|
|
while IFS= read -r macho; do
|
|
[ -n "$macho" ] || continue
|
|
minos="$(vtool -show-build "$macho" 2>/dev/null | awk '/minos/{print $2; exit}')"
|
|
[ -n "$minos" ] || continue
|
|
min_major="${minos%%.*}"
|
|
if [ "$min_major" -gt "$HOST_MAJOR" ] 2>/dev/null; then
|
|
fail "$(basename "$macho") is built for macOS $minos but this runner is macOS $HOST_VER (prebuilt is newer than the host)"
|
|
fi
|
|
done < <(find "$BIN_DIR" -type f \( -name '*.dylib' -o -name 'llama-server' -o -name 'llama-quantize' \) 2>/dev/null)
|
|
fi
|
|
|
|
# Runtime launch: --version forces dyld to load every linked dylib (including
|
|
# libggml-metal.dylib). A missing Metal symbol or too-new binary fails here.
|
|
if ! "$SERVER" --version >/tmp/llama-server-version.txt 2>&1; then
|
|
echo "---- llama-server --version output ----"
|
|
cat /tmp/llama-server-version.txt || true
|
|
fail "llama-server failed to launch on macOS $HOST_VER (dyld load / symbol error)"
|
|
fi
|
|
|
|
# The launch above uses this shell's environment, not the one Studio builds for
|
|
# its child. That one was Linux-shaped on macOS (LD_LIBRARY_PATH, which dyld
|
|
# ignores) while the installer's own validation set DYLD_LIBRARY_PATH, so the
|
|
# defect could not show up at install time (#8566). A unit test with a
|
|
# monkeypatched sys.platform cannot prove the real thing; this can.
|
|
# Resolve the interpreter from STUDIO_HOME first, not from PATH. The
|
|
# clean-machine lane scrubs PATH down to system directories and puts the shim
|
|
# under its own UNSLOTH_STUDIO_HOME, so `command -v unsloth` is empty there and
|
|
# a PATH-only lookup would skip this assertion in the one lane whose whole
|
|
# point is a clean install, while CI still reported success. The tauri delivery
|
|
# nests its venv one level deeper (clean-machine-install-ci.yml checks
|
|
# $HOME_DIR/studio/unsloth_studio), so both layouts are candidates.
|
|
STUDIO_PY=""
|
|
for candidate in \
|
|
"$UNSLOTH_HOME/unsloth_studio/bin/python" \
|
|
"$UNSLOTH_HOME/studio/unsloth_studio/bin/python" \
|
|
"$UNSLOTH_HOME/.venv/bin/python" \
|
|
"$HOME/.unsloth/unsloth_studio/bin/python"; do
|
|
[ -x "$candidate" ] && { STUDIO_PY="$candidate"; break; }
|
|
done
|
|
if [ -z "$STUDIO_PY" ]; then
|
|
for shim in "$UNSLOTH_HOME/bin/unsloth" "$(command -v unsloth || true)"; do
|
|
[ -n "$shim" ] && [ -x "$shim" ] || continue
|
|
candidate="$(head -1 "$shim" | sed 's/^#!//' | awk '{print $1}')"
|
|
[ -n "$candidate" ] && [ -x "$candidate" ] && { STUDIO_PY="$candidate"; break; }
|
|
done
|
|
fi
|
|
# Fail rather than skip: an install that produced a llama-server but no
|
|
# reachable interpreter is itself a broken install, and a skip here is
|
|
# indistinguishable from a pass.
|
|
[ -n "$STUDIO_PY" ] || fail "no Unsloth interpreter found under $UNSLOTH_HOME or on PATH; cannot check the launch environment"
|
|
if [ -n "$STUDIO_PY" ]; then
|
|
if ! PYTHONPATH=studio/backend "$STUDIO_PY" - "$SERVER" <<'PY'
|
|
import os, sys
|
|
from core.inference.llama_cpp import LlamaCppBackend, _llama_lib_dir
|
|
|
|
binary = sys.argv[1]
|
|
lib_dir = str(_llama_lib_dir(binary))
|
|
env = LlamaCppBackend._llama_server_env_for_binary(binary)
|
|
got = env.get("DYLD_LIBRARY_PATH", "")
|
|
print(f"DYLD_LIBRARY_PATH: {got or '<unset>'}")
|
|
if not got:
|
|
sys.exit("Studio would launch llama-server with no DYLD_LIBRARY_PATH; dyld ignores LD_LIBRARY_PATH")
|
|
if got.split(os.pathsep)[0] != lib_dir:
|
|
sys.exit(f"expected {lib_dir} first on DYLD_LIBRARY_PATH, got {got}")
|
|
print("child launch environment is correct for dyld")
|
|
PY
|
|
then
|
|
fail "Studio's llama-server launch environment is wrong for macOS (see above)"
|
|
fi
|
|
fi
|
|
|
|
echo "llama.cpp load validation passed on macOS $HOST_VER"
|
|
echo " server: $SERVER"
|
|
sed -n '1,4p' /tmp/llama-server-version.txt 2>/dev/null || true
|