Merge remote-tracking branch 'origin/main' into r7188

This commit is contained in:
Daniel Han 2026-07-29 09:05:07 +00:00
commit 559d17dac1
141 changed files with 13472 additions and 669 deletions

View file

@ -17,7 +17,8 @@ if [ -n "${STUDIO_PERMISSION_FRONTEND:-}" ]; then
fi
mkdir -p "$artifact_dir"
unsloth studio reset-password
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf "$studio_home/auth"
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$port" "$@" \
>"$server_log" 2>&1 &
studio_pid=$!

View file

@ -167,7 +167,9 @@ jobs:
# ── boot the server under test (factored helper) ──────────────────
- name: Serve unsloth run --disable-tools (gemma-4-E4B)
run: |
unsloth studio reset-password
# Wipe, not reset-password: since #7573 the reset rotates in place and
# prints the new passphrase, which would land unmasked in the job log.
rm -rf ~/.unsloth/studio/auth
bash .github/scripts/serve-unsloth-run.sh \
--gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
--port "$STUDIO_PORT" --log-dir logs \
@ -371,7 +373,7 @@ jobs:
- name: Serve unsloth run --disable-tools (gemma-4-E4B)
run: |
unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
bash .github/scripts/serve-unsloth-run.sh \
--gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
--port "$STUDIO_PORT" --log-dir logs \
@ -554,7 +556,7 @@ jobs:
- name: Serve unsloth run --disable-tools (gemma-4-E4B)
run: |
unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
bash .github/scripts/serve-unsloth-run.sh \
--gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
--port "$STUDIO_PORT" --log-dir logs \
@ -718,7 +720,7 @@ jobs:
- name: Serve unsloth run --disable-tools (gemma-3-270m)
run: |
unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
bash .github/scripts/serve-unsloth-run.sh \
--model "$GGUF_REPO" --gguf-variant "$GGUF_VARIANT" \
--port "$STUDIO_PORT" --log-dir logs \

View file

@ -766,6 +766,7 @@ jobs:
env:
GH_REPO: ${{ github.repository }}
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
PYPI_VERSION: ${{ needs.prepare-version.outputs.pypi_version }}
STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }}
DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }}
DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}
@ -911,6 +912,8 @@ jobs:
notes = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-notes.md').read_text()
metadata = {
'version': os.environ['APP_VERSION'],
# App version is SemVer; CHANGELOG.md is keyed by the backend release.
'pypi_version': os.environ['PYPI_VERSION'],
'notes': notes,
'pub_date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
'platforms': {

156
.github/workflows/startup-profile-ci.yml vendored Normal file
View file

@ -0,0 +1,156 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Measures where Studio's startup time goes, on each platform.
#
# Nothing recorded a number before: main.py logs "lifespan startup completed in X ms"
# and studio_test_kit polls /healthz, but both throw the elapsed time away. A first
# local run (Linux, warm cache, 18-core server) put `import main` at 5.7-6.6s BEFORE
# the server can bind, dominated by eager module-level imports pulled in by routes:
# torch ~1.9s self, unsloth_zoo ~0.8s, routes ~0.6s, transformers ~0.5s.
#
# Not a gate yet: --max-healthz-seconds exists, but a budget should come from
# observed numbers rather than a guess.
name: Startup profile
on:
pull_request:
paths:
# The measured import graph is the whole backend tree: main.py imports auth,
# core, hub, loggers, models, picker, routes and utils at module scope.
- 'studio/backend/**'
- '!studio/backend/tests/**'
# The launch phase spawns `unsloth studio --api-only`, so the CLI counts too.
- 'unsloth_cli/**'
- 'studio/src-tauri/src/preflight**'
# The profiler hardcodes the desktop argv that process.rs::backend_args builds,
# so a change there must schedule a run or the two silently diverge.
- 'studio/src-tauri/src/process.rs'
- 'scripts/profile_startup.py'
- '.github/workflows/startup-profile-ci.yml'
# The job profiles whatever `install.sh --local` built: the installers pick the
# venv's Python and the dependency specs, and pyproject's include list is what
# makes --local overlay studio.backend*.
- 'install.sh'
- 'install.ps1'
- 'pyproject.toml'
# --local also runs the checkout's setup scripts (install.sh picks
# $_REPO_ROOT/studio/setup.sh, the editable install resolves setup.ps1 to the
# repo), and both call install_python_stack.py, which picks the dependencies.
- 'studio/setup.sh'
- 'studio/setup.ps1'
- 'studio/install_python_stack.py'
workflow_dispatch:
inputs:
repeats:
description: 'launch repeats per OS (median reported)'
type: string
default: '3'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
profile:
name: startup ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 60
continue-on-error: true
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-14, windows-latest]
env:
UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home
# A wildcard bind calls ifconfig.me on the startup path; loopback times our code.
UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install Studio
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -o pipefail
mkdir -p logs
# --local is load-bearing: it overlays the checkout, so the profiled server
# is this diff. Without it install.sh resolves unsloth from PyPI.
if [ "${{ runner.os }}" = "Windows" ]; then
pwsh -NoProfile -File ./install.ps1 --local 2>&1 | tee logs/install.log
else
bash install.sh --local 2>&1 | tee logs/install.log
fi
- name: Profile startup
shell: bash
run: |
BIN="$UNSLOTH_STUDIO_HOME/unsloth_studio/bin/unsloth"
[ -x "$BIN" ] || BIN="$UNSLOTH_STUDIO_HOME/unsloth_studio/Scripts/unsloth.exe"
[ -x "$BIN" ] || BIN=""
# Profile imports with the INSTALLED interpreter: that venv is what launches.
PY="$UNSLOTH_STUDIO_HOME/unsloth_studio/bin/python"
[ -x "$PY" ] || PY="$UNSLOTH_STUDIO_HOME/unsloth_studio/Scripts/python.exe"
[ -x "$PY" ] || PY="$(command -v python3 || command -v python)"
python3 scripts/profile_startup.py \
--python "$PY" \
${BIN:+--bin "$BIN"} \
--repeats "${{ inputs.repeats || '3' }}" \
--json "startup-${{ matrix.os }}.json" 2>&1 | tee logs/profile.log
- name: Summary
if: always()
shell: bash
run: |
f="startup-${{ matrix.os }}.json"
[ -f "$f" ] || { echo "no profile produced"; exit 0; }
python3 - "$f" >> "$GITHUB_STEP_SUMMARY" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
print(f"### {d['platform']} / {d['machine']} (py {d['python']}, {d['cpu_count']} cpu)\n")
imp = d.get("imports", {})
# Gate on ok: a failed `import main` still leaves rows, so a total can lie.
if imp.get("ok"):
print(f"**`import main`: {imp['total_seconds']}s**\n")
print("| package | self ms |")
print("|---|---:|")
for k, v in list(imp.get("self_by_package_ms", {}).items())[:8]:
print(f"| {k} | {v} |")
print()
else:
print("**`import main` failed - no valid import profile**\n")
print("```\n" + (imp.get("error") or "")[-1500:] + "\n```\n")
lau = d.get("launch") or {}
runs = len(lau.get("runs") or [])
failed = lau.get("failed_runs") or 0
if lau.get("healthz_median_seconds") is not None:
# The aggregates cover only the runs that reached healthz, so flag the
# failures: bare numbers would read as a normal fast startup.
note = f" _({runs - failed} of {runs} launches; {failed} never became healthy)_" if failed else ""
print(f"**time to a healthy port: {lau['healthz_median_seconds']}s median, "
f"{lau['healthz_max_seconds']}s max**{note}\n")
elif lau.get("skipped"):
print(f"_launch phase skipped: {lau['skipped']}_\n")
elif runs:
print(f"**no launch measurement: all {runs} launches failed to become healthy**\n")
PY
- name: Upload profile
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: startup-profile-${{ matrix.os }}
path: |
startup-*.json
logs/
retention-days: 14
if-no-files-found: warn

View file

@ -113,7 +113,8 @@ jobs:
- name: Reset auth + boot Unsloth (API-only)
run: |
unsloth studio reset-password
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &

View file

@ -223,6 +223,16 @@ jobs:
tests/studio/test_is_mlx_dispatch_gate.py \
tests/studio/test_xpu_spoof_pipeline.py
- name: CLI tests (unsloth_cli)
# unsloth_cli/tests had no CI at all: `unsloth_cli/**` was only a paths
# trigger and a ruff target, so 673 tests covering the studio launcher,
# the pre-exposure gate and the auth secret writers ran nowhere, and
# four of them had been failing on main unnoticed.
# Own step, not folded into the tests/ discovery above: pyproject's
# testpaths is tests/, and this suite needs no PYTHONPATH or CUDA spoof
# (it self-bootstraps sys.path and imports neither unsloth nor torch).
run: python -m pytest unsloth_cli/tests -q --tb=short
- name: Shell installer tests
# Auto-discovered rather than allowlisted. The old hardcoded list had
# silently fallen seven files behind tests/run_all.sh, including

View file

@ -127,7 +127,8 @@ jobs:
- name: Reset auth + boot Unsloth (API-only)
run: |
unsloth studio reset-password
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -400,7 +401,7 @@ jobs:
# tool_policy=None so each request's `enable_tools` field is
# honoured.
run: |
unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -978,7 +979,7 @@ jobs:
# response_format requests aren't routed through the agentic
# tool loop.
run: |
unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &

View file

@ -101,7 +101,8 @@ jobs:
- name: Reset auth + boot Unsloth (API-only)
run: |
unsloth studio reset-password
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &

View file

@ -126,7 +126,8 @@ jobs:
- name: Reset auth + boot Unsloth (API-only)
run: |
unsloth studio reset-password
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -386,7 +387,7 @@ jobs:
# tool_policy=None so each request's `enable_tools` field is
# honoured.
run: |
unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -831,7 +832,7 @@ jobs:
# response_format requests aren't routed through the agentic
# tool loop.
run: |
unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &

View file

@ -146,7 +146,8 @@ jobs:
- name: Reset auth + boot Unsloth
run: |
unsloth studio reset-password
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -190,7 +191,7 @@ jobs:
# runner's kernel briefly runs out of socket buffers, and (3) a
# goto 'interrupted by another navigation' when the SPA auth
# guard redirects mid-navigation. The retry FULLY resets Unsloth
# (kill, reset-password, reboot, wait /api/health, re-export
# (kill, wipe auth, reboot, wait /api/health, re-export
# bootstrap pw) before re-running the script. A real test failure
# (assertion / timeout) does NOT match any pattern so it bypasses
# retry and surfaces immediately.
@ -213,7 +214,7 @@ jobs:
echo "::warning::Playwright flake on attempt ${attempt}; resetting Unsloth and retrying..."
kill "${STUDIO_PID}" 2>/dev/null || true
sleep 2
unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> "logs/studio_retry_${attempt}.log" 2>&1 &
STUDIO_PID=$!
@ -251,7 +252,7 @@ jobs:
- name: Reset auth + boot Unsloth for extra UI tests (port 18897)
run: |
unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
> logs/studio_extra.log 2>&1 &
@ -308,7 +309,7 @@ jobs:
echo "::warning::Playwright flake on attempt ${attempt}; resetting Unsloth and retrying..."
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
sleep 2
unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
> "logs/studio_extra_retry_${attempt}.log" 2>&1 &
STUDIO_EXTRA_PID=$!

View file

@ -115,7 +115,8 @@ jobs:
- name: Reset auth + boot Unsloth
run: |
unsloth studio reset-password
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -193,7 +194,7 @@ jobs:
# warm install we already did) so this adds little wall time.
- name: Reset auth + boot Unsloth for extra UI tests (port 18894)
run: |
unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18894 \
> logs/studio_extra.log 2>&1 &
@ -253,7 +254,7 @@ jobs:
# (RAG embedder + llama.cpp probe) stay hidden from the picker.
- name: Reset auth + boot Unsloth for model-config tests (port 18898)
run: |
unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18898 \
> logs/studio_modelcfg.log 2>&1 &
@ -299,7 +300,7 @@ jobs:
# earlier UI tests. No GGUF -- the bug surface is the composer.
- name: Reset auth + boot Unsloth for IME / i18n tests (port 18896)
run: |
unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18896 \
> logs/studio_ime.log 2>&1 &

View file

@ -179,7 +179,8 @@ jobs:
- name: Reset auth + boot Unsloth (API-only)
run: |
unsloth studio reset-password
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &

View file

@ -229,7 +229,8 @@ jobs:
- name: Reset auth + boot Unsloth (API-only)
run: |
unsloth studio reset-password
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -573,7 +574,7 @@ jobs:
- name: Reset auth + boot Unsloth (API-only, default tool policy)
run: |
unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -1074,7 +1075,7 @@ jobs:
- name: Reset auth + boot Unsloth (API-only)
run: |
unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -1546,7 +1547,7 @@ jobs:
- name: Reset auth + boot Unsloth (API-only)
run: |
unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -1888,8 +1889,11 @@ jobs:
# (step/substep -> Write-StudioStdoutMirror / Get-StudioAnsi).
$script:StudioVtOk = $false
$script:UnslothVerbose = $false
# Get-HostMachineArch is reached only on the absent path, where
# Test-VCRedistInstalled consults it before trusting the System32 DLL, so
# part A passes without it and only the clean-box part fails.
foreach ($fn in @('Get-StudioAnsi', 'Write-StudioStdoutMirror', 'step', 'substep',
'Invoke-SetupCommand', 'Refresh-Environment',
'Invoke-SetupCommand', 'Refresh-Environment', 'Get-HostMachineArch',
'Test-VCRedistInstalled', 'Ensure-VCRedist')) {
$src = Get-FunctionSource -Path $setup -Name $fn
if (-not $src) { throw "Function '$fn' not found in setup.ps1" }

View file

@ -297,7 +297,8 @@ jobs:
- name: Reset auth + boot Unsloth
run: |
unsloth studio reset-password
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -352,7 +353,7 @@ jobs:
- name: Reset auth + boot Unsloth for extra UI tests (port 18897)
run: |
unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
> logs/studio_extra.log 2>&1 &

3
.gitignore vendored
View file

@ -208,6 +208,9 @@ tmp/
**/node_modules/
auth.db
# Packaging snapshot of the root CHANGELOG.md (written by build.sh)
studio/CHANGELOG.md
# Tauri local build/generated output
studio/src-tauri/target/
studio/src-tauri/gen/

88
CHANGELOG.md Normal file
View file

@ -0,0 +1,88 @@
# Changelog
Release notes for Unsloth and Unsloth Studio.
Unsloth Studio reads this file to show release notes inside the "New Unsloth
version" update popup. Edit it here and the popup picks the change up on the
next update check, with no release or rebuild required.
## Format
Every release is a level-2 heading whose first token is the version, optionally
followed by a date:
```md
## 2026.7.6 - 2026-07-22
```
`## [2026.7.6] - 2026-07-22` and `## v2026.7.6` also work. Everything under a
heading, up to the next level-2 heading, is that release's notes and renders as
Markdown in the popup.
Notes are matched to one exact version. When Studio offers an update to
`2026.7.6` it renders the `2026.7.6` section and nothing else. If that section
is missing, the popup links out to the online changelog rather than showing
notes from an unrelated release, so a new version needs its own section here
before its notes can appear.
Keep the newest release at the top. Lead each bullet with the change itself:
the collapsed popup highlights the first sentence and dims the rest.
`## Unreleased` is ignored by the popup, so it is safe to stage notes there and
rename the heading at release time.
<!-- Add new releases directly below this line. -->
## Unreleased
## 2026.7.5
### What's Changed
- AMD support is here. Train, run RL, chat with and deploy 500+ models on
Radeon, Instinct, Ryzen and data center GPUs across Windows, WSL and Linux,
up to 2x faster with 70% less VRAM and no accuracy loss.
- Intel XPU support lands in Studio, so Arc and Data Center GPUs run chat and
training alongside the NVIDIA, AMD and Apple paths.
- Local speech to text dictation runs fully offline, with slim Whisper bundles
and a picker for custom models.
- DoRA training is available in Studio, selectable next to LoRA and full
fine-tuning in the training tab.
- The update popup previews release notes inline, pulled from this file and
matched to the exact version being offered.
### AMD, 23 July update
Our AMD collaboration, custom Triton kernels and math algorithms bring local
training and inference to AMD hardware. The 23 July update builds on the
[AMD release](https://github.com/unslothai/unsloth/releases/tag/v0.1.501-beta):
- RDNA2 and Gorgon Halo are supported, and the installer no longer fails to
detect GPUs on Strix Halo and other AMD cards.
- RDNA4 handling is better, and HIP and ROCm failures are caught and fixed
automatically instead of stopping the install.
- Unified memory safetensors loading is 2x faster, with much faster gradient
checkpointing on unified memory devices.
- Voice dictation through whisper.cpp has preliminary support.
- Rollback environments left by installs no longer eat 5GB of disk. They are
cleaned up automatically.
Optimized ROCm builds cover GGUF and safetensors inference, and ROCm
compatibility is improved for MI300X and MI325X. Full guide:
[unsloth.ai/docs/basics/amd](https://unsloth.ai/docs/basics/amd).
### Running larger models
- Automatic GPU placement, or pick exactly which GPUs and layers to use.
- Move MoE expert layers into system memory so larger models fit.
- Split a model across several GPUs, or use tensor parallelism.
- Hardware settings are saved per model and quant.
### Also in this release
- Remote access with `unsloth studio --secure` over free HTTPS via Cloudflare.
- Web search reads PDF papers and manuals, and parallel tool calls, reasoning
output and tool retries are more reliable.
- The model download location is configurable, so weights can live on a second
drive instead of the default cache.
- Stalled Hugging Face XET downloads retry over standard HTTP, and existing
GGUF files are reused instead of downloaded again.

2
MANIFEST.in Normal file
View file

@ -0,0 +1,2 @@
include _changelog_build.py
include CHANGELOG.md

36
_changelog_build.py Normal file
View file

@ -0,0 +1,36 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Snapshot CHANGELOG.md into the studio package at build time.
CHANGELOG.md at the repo root stays the one file to edit. Copying it here,
rather than in build.sh, means every packaging path ships it, so release notes
still render when the popup cannot reach GitHub."""
from __future__ import annotations
import shutil
from pathlib import Path
from setuptools.command.build_py import build_py as _build_py
ROOT = Path(__file__).resolve().parent
SOURCE = ROOT / "CHANGELOG.md"
SNAPSHOT = ROOT / "studio" / "CHANGELOG.md"
class build_py(_build_py):
def run(self) -> None:
# Beside the sources only if writable (PEP 517 may build an immutable
# checkout); into the staging directory always.
if SOURCE.is_file():
try:
shutil.copyfile(SOURCE, SNAPSHOT)
except OSError:
pass
super().run()
if not SOURCE.is_file():
return
staged = Path(self.build_lib) / "studio" / "CHANGELOG.md"
staged.parent.mkdir(parents = True, exist_ok = True)
shutil.copyfile(SOURCE, staged)

View file

@ -103,9 +103,13 @@ else
STUDIO_STAMPED_VERSION="$(python scripts/stamp_studio_release.py)"
fi
# 4. Build wheel/sdist
# 4. Build wheel/sdist. _changelog_build.py snapshots CHANGELOG.md into the studio
# package so release notes render offline.
python -m build
# Drop the snapshot so a source checkout never serves a stale copy.
rm -f studio/CHANGELOG.md
if [ "${1:-}" = "publish" ]; then
python scripts/stamp_studio_release.py --verify-dist dist --expected "$STUDIO_STAMPED_VERSION"
fi

View file

@ -57,6 +57,26 @@ function Install-UnslothStudio {
}
}
# Machine arch; Get-TauriDiagArch above reports the process. An emulated x64 shell on
# ARM64 reports AMD64, but PROCESSOR_ARCHITEW6432 is ARM64 in exactly that case.
function Get-HostMachineArch {
$osArch = ""
try { $osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() } catch { $osArch = "" }
$signals = @([string]$env:PROCESSOR_ARCHITEW6432, [string]$env:PROCESSOR_ARCHITECTURE, $osArch)
foreach ($s in $signals) {
if ($s.ToLowerInvariant() -eq "arm64") { return "arm64" }
}
foreach ($s in $signals) {
if ([string]::IsNullOrWhiteSpace($s)) { continue }
switch ($s.ToLowerInvariant()) {
"amd64" { return "x86_64" }
"x64" { return "x86_64" }
"x86" { return "x86" }
}
}
return "unknown"
}
function Get-TauriTorchIndexFamily {
param([string]$TorchIndexUrl)
if ($SkipTorch) { return "none" }
@ -1124,10 +1144,27 @@ exit 0
return $false
}
# The interpreter's own arch, asked of it: win-amd64|win-arm64|win32|"".
function Get-PythonPlatformTag {
param([string]$Exe)
try {
return (& $Exe -c "import sysconfig; print(sysconfig.get_platform())" 2>$null | Out-String).Trim().ToLowerInvariant()
} catch { return "" }
}
# Returns @{ Version = "3.13"; Path = "C:\...\python.exe" } or $null.
# The resolved Path is passed to `uv venv --python` to prevent uv from
# re-resolving the version string back to a conda interpreter.
function Find-CompatiblePython {
# -X64Only: best installed x64 interpreter or $null, never ARM64. Last resort for
# Install-X64Python, where x64 of a lower-priority minor beats ARM64.
param([switch]$X64Only)
# Windows on ARM: prefer x64. pyarrow (via datasets) and hf-transfer ship no
# win_arm64 wheel, so a native ARM64 Python source-builds both and dies on CMake /
# Rust minutes in; x64 runs fine emulated. ARM64 is still returned when it is all
# there is, and the caller then bootstraps x64 or warns.
$preferX64 = $X64Only -or ((Get-HostMachineArch) -eq "arm64")
$candidates = @()
# Try the Python Launcher first (most reliable on Windows)
# py.exe resolves to the standard CPython install, not conda.
# Prefer the requested $PythonVersion, then newest-first fallback.
@ -1145,7 +1182,8 @@ exit 0
# Resolve the actual executable path and verify it is not conda-based
$resolvedExe = (& $pyLauncher.Source "-$minor" -c "import sys; print(sys.executable)" 2>$null | Out-String).Trim()
if ($resolvedExe -and (Test-Path $resolvedExe) -and -not (Test-IsCondaPython $resolvedExe)) {
return @{ Version = $ver; Path = $resolvedExe }
if (-not $preferX64) { return @{ Version = $ver; Path = $resolvedExe; Arch = "" } }
$candidates += @{ Version = $ver; Path = $resolvedExe }
}
}
} catch {}
@ -1166,11 +1204,53 @@ exit 0
try {
$out = & $cmd.Source --version 2>&1 | Out-String
if ($out -match "Python (3\.1[1-3])\.\d+") {
return @{ Version = $Matches[1]; Path = $cmd.Source }
if (-not $preferX64) { return @{ Version = $Matches[1]; Path = $cmd.Source; Arch = "" } }
$candidates += @{ Version = $Matches[1]; Path = $cmd.Source }
}
} catch {}
}
}
# `py -3.12` runs the launcher's preferred build, normally the native ARM64 one, so
# a same-minor x64 install that is neither preferred nor on PATH never becomes a
# candidate. `-3.12-64` cannot disambiguate (deprecated, it only means "not
# 32-bit"), so enumerate every registration with -0p and probe each path.
if ($preferX64) {
foreach ($pyLauncher in @(Get-Command py -All -CommandType Application -ErrorAction SilentlyContinue)) {
if ($pyLauncher.Source -match $script:CondaSkipPattern) { continue }
$listed = @()
try { $listed = @(& $pyLauncher.Source "-0p" 2>$null) } catch {}
foreach ($line in $listed) {
# " -V:3.12 * C:\...\python.exe": tag, optional default marker, path.
$m = [regex]::Match([string]$line, '(?i)^\s*-\S+\s+\*?\s*"?(?<p>\S.*?\.exe)"?\s*$')
if (-not $m.Success) { continue }
$exe = $m.Groups['p'].Value.Trim()
if ($candidates | Where-Object { $_.Path -eq $exe }) { continue }
if (-not (Test-Path -LiteralPath $exe)) { continue }
if (Test-IsCondaPython $exe) { continue }
try {
$out = & $exe --version 2>&1 | Out-String
if ($out -match "Python (3\.1[1-3])\.\d+") {
$candidates += @{ Version = $Matches[1]; Path = $exe }
}
} catch {}
}
}
}
# Prefer x64, but only within one minor: $minors is the caller's version preference,
# so ranking on arch alone would answer UNSLOTH_PYTHON=3.12 with an x64 3.13 and
# never bootstrap x64 3.12. Probing costs a subprocess, so non-ARM returned above.
foreach ($c in $candidates) {
$tag = Get-PythonPlatformTag $c.Path
$c.Arch = if ($tag -eq "win-amd64") { "x86_64" } elseif ($tag -eq "win-arm64") { "arm64" } else { "unknown" }
}
foreach ($minor in $minors) {
$sameMinor = @($candidates | Where-Object { $_.Version -eq $minor })
if ($sameMinor.Count -eq 0) { continue }
$x64 = $sameMinor | Where-Object { $_.Arch -eq "x86_64" } | Select-Object -First 1
if ($x64) { return $x64 }
if (-not $X64Only) { return $sameMinor[0] }
}
if (-not $X64Only -and $candidates.Count -gt 0) { return $candidates[0] }
return $null
}
@ -1181,8 +1261,11 @@ exit 0
# (no UAC), putting python.exe + the py launcher on PATH. Mirrors the uv ->
# astral.sh fallback below. Returns @{ Version; Path } or $null.
function Install-PythonFromPythonOrg {
# $Arch overrides the host arch, to pull x64 onto an ARM64 box.
param([string]$Arch = "")
# python.org ships one installer per architecture.
$archSuffix = switch (Get-TauriDiagArch) {
$targetArch = if ($Arch) { $Arch } else { Get-TauriDiagArch }
$archSuffix = switch ($targetArch) {
"x86_64" { "-amd64" }
"arm64" { "-arm64" }
"x86" { "" }
@ -1247,6 +1330,28 @@ exit 0
return (Find-CompatiblePython)
}
# ── Windows on ARM: get an x64 CPython ──
# --architecture x64 forces winget off the ARM64 build; python.org takes the same override.
function Install-X64Python {
if ($script:WingetAvailable) {
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
winget install -e --id "Python.Python.$PythonVersion" --source winget --architecture x64 --accept-package-agreements --accept-source-agreements
} catch { }
$ErrorActionPreference = $prevEAP
Refresh-SessionPath
$found = Find-CompatiblePython
if ($found -and $found.Arch -eq "x86_64") { return $found }
substep "winget could not provide an x64 Python -- trying python.org..." "Yellow"
}
$found = Install-PythonFromPythonOrg -Arch "x86_64"
if ($found -and $found.Arch -eq "x86_64") { return $found }
# Nothing installable (offline / no winget): an x64 build of another supported minor
# still runs the wheels ARM64 cannot, so take it over the native interpreter.
return (Find-CompatiblePython -X64Only)
}
# ── Install Python if no compatible version (3.11-3.13) found ──
# Find-CompatiblePython returns @{ Version = "3.13"; Path = "C:\...\python.exe" } or $null.
Write-TauriLog "STEP" "Installing Python"
@ -1318,6 +1423,26 @@ exit 0
return (Exit-InstallFailure "Python installation failed")
}
}
# ── Windows on ARM: swap a native ARM64 interpreter for x64 ──
# pyarrow and hf-transfer publish no win_arm64 wheel, so an ARM64 Python source-builds
# both and fails deep into the run. Warn up front if x64 is unobtainable.
if ($DetectedPython -and (Get-HostMachineArch) -eq "arm64" -and $DetectedPython.Arch -ne "x86_64") {
substep "windows on arm: only a native ARM64 Python $($DetectedPython.Version) was found." "Yellow"
substep "pyarrow and hf-transfer publish no win_arm64 wheels, so installing x64 Python..." "Yellow"
$X64Python = Install-X64Python
if ($X64Python) {
$DetectedPython = $X64Python
step "python" "using x64 Python $($DetectedPython.Version) under emulation"
} else {
Write-Host "[WARN] Could not install an x64 Python on this ARM64 machine." -ForegroundColor Yellow
Write-Host " Continuing with ARM64 Python $($DetectedPython.Version), but the install is likely to fail:" -ForegroundColor Yellow
Write-Host " pyarrow (via datasets) and hf-transfer ship no win_arm64 wheels and will be" -ForegroundColor Yellow
Write-Host " built from source, which needs CMake plus the MSVC and Rust toolchains." -ForegroundColor Yellow
Write-Host " Fix: install x64 Python from https://www.python.org/downloads/windows/" -ForegroundColor Yellow
Write-Host " (choose 'Windows installer (64-bit)', not ARM64), then re-run this installer." -ForegroundColor Yellow
}
}
$DiagPythonVersion = $PythonVersion
if ($DetectedPython) { $DiagPythonVersion = $DetectedPython.Version }
$InitialGpuBranch = "unknown"
@ -2438,6 +2563,13 @@ exit 0
}
} else {
Write-TauriLog "STEP" "Installing PyTorch"
# Windows on ARM lacks only torchaudio (whl/cpu win_arm64: torch 42,
# torchvision 60, torchaudio 0), so drop that pin instead of aborting. Ask the
# interpreter, not PROCESSOR_ARCHITECTURE; reached when no x64 Python exists.
$VenvPlatform = ""
try {
$VenvPlatform = (& $VenvPython -c "import sysconfig; print(sysconfig.get_platform())" 2>$null | Out-String).Trim().ToLowerInvariant()
} catch { $VenvPlatform = "" }
substep "installing PyTorch ($(Remove-IndexUrlCredentials $TorchIndexUrl))..."
# Bound the companions to the capped torch on EVERY index, cu<digits>
# families included: torchaudio 2.11 dropped its exact torch pin from
@ -2445,7 +2577,13 @@ exit 0
# resolve a mismatched 2.11.0 build. Mirrors install.sh.
$_pinVisionSpec = "torchvision>=0.19,<0.26.0"
$_pinAudioSpec = "torchaudio>=2.4,<2.11.0"
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch" { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" $_pinVisionSpec $_pinAudioSpec --default-index $TorchIndexUrl }
$_torchSpecs = @("torch>=2.4,<2.11.0", $_pinVisionSpec, $_pinAudioSpec)
if ($VenvPlatform -eq "win-arm64") {
substep "windows on arm: skipping torchaudio (upstream publishes no"
substep "win_arm64 wheel); torch and torchvision install normally."
$_torchSpecs = @("torch>=2.4,<2.11.0", $_pinVisionSpec)
}
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch" { uv pip install --python $VenvPython @_torchSpecs --default-index $TorchIndexUrl }
if ($torchInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install PyTorch (exit code $torchInstallExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit)

View file

@ -19,6 +19,17 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
set -e
# ── Why the installer lives in a function ──
# Under `curl ... | sh`, sh is the pipe READER. This file is ~150KB, so a top-level
# `exit` left most of it unread, the write end failed, and curl tacked
# "(56) Failure writing output to destination" onto our own error message. Wrapping
# the body forces sh to parse to the closing brace first, so the pipe always drains
# (install.ps1 has always had this shape).
#
# Body is deliberately NOT reindented: reflowing 4000+ lines would bury the change,
# and `exit` still exits the shell from inside a function. Do not add
# `exec < /dev/null`: for a piped shell that closes the script's own source.
_unsloth_main() {
# ── Output style (aligned with studio/setup.sh) ──
RULE=""
@ -4447,3 +4458,8 @@ else
substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)"
echo ""
fi
}
# Every byte above is parsed before this line runs, which is the point.
_unsloth_main "$@"

View file

@ -47,9 +47,14 @@ version = {attr = "unsloth.models._utils.__version__"}
[tool.setuptools]
include-package-data = true
[tool.setuptools.cmdclass]
# Snapshots CHANGELOG.md into studio/ so every build path ships it.
build_py = "_changelog_build.build_py"
[tool.setuptools.package-data]
unsloth_cli = ["codex_fallback_prompt.md", "pi_subagent.ts"]
studio = [
"CHANGELOG.md",
"*.sh",
"*.ps1",
"*.bat",
@ -128,14 +133,19 @@ huggingfacenotorch = [
]
# torchcodec backend for Gemma audio / datasets>=4 (#7225).
# Pick the audio-torch* pin matching your torch minor (see TORCH_TORCHCODEC).
# torchcodec publishes no sdist and only manylinux_2_28_x86_64, macosx_*_arm64
# and win_amd64 wheels, so Linux aarch64, Windows ARM64 and Intel Mac have
# nothing to resolve and pip fails the whole install rather than skipping audio.
# Gate on the platforms that have a wheel, matching
# PLATFORM_LACKS_TORCHCODEC_WHEEL in studio/install_python_stack.py.
audio-torch210 = [
"torchcodec>=0.10.0,<0.11.0 ; python_version >= '3.10'",
"torchcodec>=0.10.0,<0.11.0 ; python_version >= '3.10' and (((sys_platform == 'linux' or sys_platform == 'win32') and (platform_machine == 'x86_64' or platform_machine == 'AMD64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))",
]
audio-torch290 = [
"torchcodec>=0.8.0,<0.10.0 ; python_version >= '3.10'",
"torchcodec>=0.8.0,<0.10.0 ; python_version >= '3.10' and (((sys_platform == 'linux' or sys_platform == 'win32') and (platform_machine == 'x86_64' or platform_machine == 'AMD64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))",
]
audio-torch280 = [
"torchcodec>=0.6.0,<0.8.0 ; python_version >= '3.9'",
"torchcodec>=0.6.0,<0.8.0 ; python_version >= '3.9' and (((sys_platform == 'linux' or sys_platform == 'win32') and (platform_machine == 'x86_64' or platform_machine == 'AMD64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))",
]
huggingface = [
"unsloth[huggingfacenotorch]",

377
scripts/profile_startup.py Normal file
View file

@ -0,0 +1,377 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Measure where Unsloth Studio's startup time goes, per platform.
Nothing measured this before: the backend logs "lifespan startup completed in X ms"
but no test or CI job asserted a budget, and studio_test_kit discards the elapsed
time of its /healthz poll. A first local run (Linux, warm cache, fast server CPU)
found `import main` alone costs 6.6s before the server can bind, dominated by eager
module-level imports pulled in by the `routes` package:
torch 1930 ms self
unsloth_zoo 914 ms self
routes 779 ms self
transformers 524 ms self
Phases measured:
import `python -X importtime -c "import main"`, top cumulative + per-package self
spawn process start -> first byte on stdout
healthz process start -> /api/health (or /healthz) answers 200
lifespan the backend's own "lifespan startup completed in X ms" log line
Usage:
python scripts/profile_startup.py --repeats 3 --json out.json
python scripts/profile_startup.py --import-only # no server, no port needed
Exit code is 0 unless --max-healthz-seconds is given and exceeded.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import platform
import re
import shutil
import socket
import statistics
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
BACKEND = REPO_ROOT / "studio" / "backend"
_IMPORTTIME_RE = re.compile(r"import time:\s+(\d+)\s+\|\s+(\d+)\s+\|(\s*)(\S.*)")
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return int(s.getsockname()[1])
def profile_imports(python: str, top: int = 15) -> dict:
"""Cumulative and self import cost for the backend's module graph.
Run in a subprocess with -X importtime: the numbers are only meaningful for a
cold interpreter, and importing in-process would measure a warm sys.modules.
"""
proc = subprocess.run(
[python, "-X", "importtime", "-c", "import sys; sys.path.insert(0, '.'); import main"],
cwd = BACKEND,
capture_output = True,
text = True,
timeout = 900,
)
rows = []
for line in proc.stderr.splitlines():
m = _IMPORTTIME_RE.match(line)
if m:
rows.append((int(m.group(1)), int(m.group(2)), m.group(4).strip()))
if not rows:
return {"ok": False, "error": (proc.stderr or proc.stdout)[-2000:]}
if proc.returncode != 0:
# Rows survive up to the failure, so any total from a partial graph is wrong.
return {
"ok": False,
"error": (proc.stderr or proc.stdout)[-2000:],
"partial_rows": len(rows),
}
by_cum = sorted(rows, key = lambda r: -r[1])
# Total comes from the `main` row, not by_cum[0]: -X importtime also prints the
# interpreter's own startup graph (`site`), which can outrank a trivial main.
main_row = next((r for r in reversed(rows) if r[2] == "main"), None)
if main_row is None:
return {
"ok": False,
"error": "no `import main` row in -X importtime output\n"
+ (proc.stderr or proc.stdout)[-2000:],
}
self_by_pkg: dict[str, int] = {}
for self_us, _cum, name in rows:
pkg = name.split(".")[0]
self_by_pkg[pkg] = self_by_pkg.get(pkg, 0) + self_us
return {
"ok": True,
"total_seconds": round(main_row[1] / 1e6, 3),
"top_cumulative": [
{"module": n, "seconds": round(c / 1e6, 3)} for _s, c, n in by_cum[:top]
],
"self_by_package_ms": {
k: round(v / 1000) for k, v in sorted(self_by_pkg.items(), key = lambda x: -x[1])[:top]
},
}
def _terminate_tree(proc: subprocess.Popen) -> None:
"""Stop the server AND its children, which on Windows are a separate process.
CI profiles `Scripts/unsloth.exe`, a distlib launcher stub that CreateProcess's
the venv python and waits, so terminate() reaps the stub only: the real backend
keeps the inherited stdout handle, the reader thread never sees EOF, and
--repeats strands one server per iteration on the shared UNSLOTH_STUDIO_HOME.
taskkill /T walks the tree, as unsloth_cli/commands/start.py already does.
"""
if proc.poll() is not None:
return
if os.name == "nt":
try:
killed = subprocess.run(
["taskkill", "/PID", str(proc.pid), "/T", "/F"],
capture_output = True,
timeout = 30,
check = False,
)
if killed.returncode == 0:
return
except Exception:
# taskkill missing or timed out; fall through so the stub still dies.
pass
# check=False: a nonzero taskkill does not raise, so fall through as well.
proc.terminate()
def profile_launch(
bin_path: str,
port: int,
timeout_s: int = 300,
) -> dict:
"""Spawn the backend the way the desktop app does and time it to first 200."""
log_lines: list[str] = []
first_byte: list[float] = []
t0 = time.perf_counter()
proc = subprocess.Popen(
[bin_path, "studio", "--api-only", "-H", "127.0.0.1", "-p", str(port)],
cwd = REPO_ROOT,
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
bufsize = 1,
)
def _drain() -> None:
# Runs alongside the health polling: the first read timestamps the spawn
# phase, and an undrained pipe blocks the backend before it binds.
for line in proc.stdout:
if not first_byte:
first_byte.append(time.perf_counter() - t0)
log_lines.append(line.rstrip("\n"))
reader = threading.Thread(target = _drain, daemon = True)
reader.start()
t_healthz = None
deadline = t0 + timeout_s
try:
while time.perf_counter() < deadline:
if proc.poll() is not None:
break
if t_healthz is None:
for url in (
f"http://127.0.0.1:{port}/api/health",
f"http://127.0.0.1:{port}/healthz",
):
try:
with urllib.request.urlopen(url, timeout = 2) as r:
if r.status == 200:
t_healthz = time.perf_counter() - t0
break
except (urllib.error.URLError, OSError, TimeoutError):
pass
if t_healthz is not None:
break
time.sleep(0.25)
finally:
_terminate_tree(proc)
try:
# Safe: the reader drains the pipe, so the child cannot block on write().
proc.wait(timeout = 30)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
reader.join(timeout = 10)
t_first_byte = first_byte[0] if first_byte else None
lifespan_ms = None
for line in log_lines:
m = re.search(r"lifespan startup completed in ([\d.]+)ms", line)
if m:
lifespan_ms = float(m.group(1))
return {
"spawn_seconds": round(t_first_byte, 3) if t_first_byte is not None else None,
"healthz_seconds": round(t_healthz, 3) if t_healthz is not None else None,
"lifespan_ms": lifespan_ms,
"reached_healthz": t_healthz is not None,
"log_tail": log_lines[-25:],
}
def python_version_of(python: str) -> str:
"""Version of the interpreter that runs the imports, not the one running us.
--python points at the installed Studio venv while this script runs under the
runner's system python, so platform.python_version() would label it wrong.
"""
if python == sys.executable:
return platform.python_version()
try:
proc = subprocess.run(
[python, "-c", "import platform; print(platform.python_version())"],
capture_output = True,
text = True,
timeout = 60,
)
if proc.returncode == 0 and proc.stdout.strip():
return proc.stdout.strip()
except (OSError, subprocess.SubprocessError):
pass
return "unknown"
def find_bin() -> str | None:
home = os.environ.get("UNSLOTH_STUDIO_HOME") or str(Path.home() / ".unsloth" / "studio")
names = ["unsloth.exe", "unsloth"] if platform.system() == "Windows" else ["unsloth"]
subdirs = ["unsloth_studio/Scripts", "unsloth_studio/bin", "bin", "Scripts"]
for sd in subdirs:
for n in names:
p = Path(home) / sd / n
if p.exists():
return str(p)
return shutil.which("unsloth")
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(
description = __doc__, formatter_class = argparse.RawDescriptionHelpFormatter
)
ap.add_argument(
"--repeats",
type = int,
default = 1,
help = "launch repeats; the median is reported (imports are measured once)",
)
ap.add_argument(
"--python",
default = sys.executable,
help = "interpreter used for the import profile (default: this one)",
)
ap.add_argument("--bin", help = "path to the unsloth CLI (default: autodetect)")
ap.add_argument(
"--import-only",
action = "store_true",
help = "skip the server phases (no install needed beyond the deps)",
)
ap.add_argument(
"--max-healthz-seconds",
type = float,
help = "fail if the median time to a healthy port exceeds this",
)
ap.add_argument("--json", help = "write the full report here")
a = ap.parse_args(argv)
# range(0) launches nothing, leaving the budget check with nothing to fail on.
if a.repeats < 1:
ap.error("--repeats must be at least 1")
# Same reason: --import-only never launches anything.
if a.import_only and a.max_healthz_seconds is not None:
ap.error("--max-healthz-seconds cannot be combined with --import-only")
# nan and inf parse fine as floats but `med > budget` is then always False,
# so the gate would report success without ever bounding anything.
if a.max_healthz_seconds is not None and not math.isfinite(a.max_healthz_seconds):
ap.error("--max-healthz-seconds must be a finite number")
report: dict = {
"platform": platform.system().lower(),
"machine": platform.machine(),
"python": python_version_of(a.python),
"cpu_count": os.cpu_count(),
}
print("== import graph ==")
report["imports"] = profile_imports(a.python)
imp = report["imports"]
if imp.get("ok"):
print(f" import main: {imp['total_seconds']}s")
for row in imp["top_cumulative"][:8]:
print(f" {row['seconds']:7.3f}s {row['module']}")
print(" self time by package (ms):")
for k, v in list(imp["self_by_package_ms"].items())[:8]:
print(f" {v:8} ms {k}")
else:
print(f" FAILED: {imp.get('error', '')[:400]}")
if not a.import_only:
bin_path = a.bin or find_bin()
if not bin_path:
print(
"== launch == skipped: no unsloth CLI found "
"(set UNSLOTH_STUDIO_HOME or pass --bin)"
)
report["launch"] = {"skipped": "no unsloth CLI found"}
else:
print(f"== launch == {bin_path}")
runs = []
for i in range(a.repeats):
r = profile_launch(bin_path, _free_port())
runs.append(r)
print(
f" run {i + 1}: healthz={r['healthz_seconds']}s "
f"lifespan={r['lifespan_ms']}ms reached={r['reached_healthz']}"
)
got = [r["healthz_seconds"] for r in runs if r["healthz_seconds"] is not None]
report["launch"] = {
"runs": runs,
"failed_runs": sum(1 for r in runs if not r["reached_healthz"]),
"healthz_median_seconds": round(statistics.median(got), 3) if got else None,
"healthz_max_seconds": round(max(got), 3) if got else None,
}
if got:
print(
f" median time to healthy port: {report['launch']['healthz_median_seconds']}s"
)
if a.json:
Path(a.json).write_text(json.dumps(report, indent = 2), encoding = "utf-8")
print(f"\nwrote {a.json}")
if a.max_healthz_seconds is not None:
launch = report.get("launch") or {}
med = launch.get("healthz_median_seconds")
failed = launch.get("failed_runs") or 0
if failed:
# Failed launches fail the budget; dropping them would keep only the fast ones.
print(
f"::error::startup regression: {failed} of {len(launch.get('runs') or [])} "
f"launches never became healthy within the timeout"
)
return 1
if med is None:
# Nothing measured: exiting 0 would pass a requested budget without a
# single health request, so fail closed.
print(
"::error::startup regression: no healthz measurement, so the "
f"{a.max_healthz_seconds}s budget was never checked "
f"({launch.get('skipped') or 'launch phase produced no runs'})"
)
return 1
elif med > a.max_healthz_seconds:
print(
f"::error::startup regression: {med}s median to a healthy port "
f"exceeds the {a.max_healthz_seconds}s budget"
)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View file

@ -11,11 +11,12 @@ import jwt
from .storage import (
API_KEY_PREFIX,
credential_generation,
get_jwt_secret,
get_user_and_secret,
load_jwt_secret,
save_refresh_token,
validate_api_key,
validate_api_key_with_credential,
verify_refresh_token,
)
@ -54,11 +55,14 @@ def create_access_token(
expires_delta: Optional[timedelta] = None,
*,
desktop: bool = False,
secret: Optional[str] = None,
) -> str:
"""
Create a signed JWT for the given subject (e.g. username).
Valid across restarts: the signing secret is stored in SQLite.
Valid across restarts: the signing secret is stored in SQLite. Callers that
already verified a credential pass ``secret`` so a rotation landing mid-request
cannot sign the token with the credential that just replaced it.
"""
to_encode = {"sub": subject}
if desktop:
@ -69,7 +73,7 @@ def create_access_token(
to_encode.update({"exp": expire})
return jwt.encode(
to_encode,
_get_secret_for_subject(subject),
secret if secret is not None else _get_secret_for_subject(subject),
algorithm = ALGORITHM,
)
@ -96,15 +100,28 @@ def is_desktop_access_token(token: str) -> bool:
return payload.get("sub") == subject and payload.get("desktop") is True
def create_refresh_token(subject: str, *, desktop: bool = False) -> str:
def create_refresh_token(
subject: str,
*,
desktop: bool = False,
secret: Optional[str] = None,
) -> str:
"""
Create a random refresh token, store its hash in SQLite, and return it.
Refresh tokens are opaque (not JWTs); expire after REFRESH_TOKEN_EXPIRE_DAYS.
``secret`` stamps the token with the credential version the caller verified,
so a rotation cannot leave a token minted from the replaced credential valid.
"""
token = secrets.token_urlsafe(48)
expires_at = datetime.now(timezone.utc) + timedelta(days = REFRESH_TOKEN_EXPIRE_DAYS)
save_refresh_token(token, subject, expires_at.isoformat(), is_desktop = desktop)
save_refresh_token(
token,
subject,
expires_at.isoformat(),
is_desktop = desktop,
secret_gen = credential_generation(secret) if secret is not None else None,
)
return token
@ -137,7 +154,22 @@ def reload_secret() -> None:
async def get_current_subject(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
"""Validate JWT and require the password-change flow to be completed."""
return await _get_current_subject(
subject, _generation = await _get_current_credential(
credentials,
allow_password_change = False,
)
return subject
async def get_current_credential(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> Tuple[str, Optional[str]]:
"""As get_current_subject, but also returns the credential generation.
For routes that persist a new credential and must not do so on behalf of one
a concurrent reset has revoked.
"""
return await _get_current_credential(
credentials,
allow_password_change = False,
)
@ -158,10 +190,11 @@ async def get_current_subject_allow_password_change(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> str:
"""Validate JWT but allow access to the password-change endpoint."""
return await _get_current_subject(
subject, _generation = await _get_current_credential(
credentials,
allow_password_change = True,
)
return subject
# The literal the examples ship with; pasted unedited more often than a revoked key.
@ -179,21 +212,27 @@ def _invalid_api_key_detail(token: str) -> str:
return "Invalid or expired API key"
async def _get_current_subject(
async def _get_current_credential(
credentials: HTTPAuthorizationCredentials, *, allow_password_change: bool
) -> str:
"""FastAPI dependency: validate the JWT and return the subject. Use on protected routes."""
) -> Tuple[str, Optional[str]]:
"""Validate the bearer and return ``(subject, credential generation)``.
The generation is the credential version this request actually authenticated
against. Routes that persist new credentials must bind their write to it, or
a reset landing mid-request would bless what it just revoked.
"""
token = credentials.credentials
# --- API key path (sk-unsloth-...) ---
if token.startswith(API_KEY_PREFIX):
username = validate_api_key(token)
if username is None:
verified = validate_api_key_with_credential(token)
if verified is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = _invalid_api_key_detail(token),
)
return username
username, secret = verified
return username, credential_generation(secret)
# --- JWT path ---
subject = _decode_subject_without_verification(token)
@ -224,7 +263,7 @@ async def _get_current_subject(
status_code = status.HTTP_403_FORBIDDEN,
detail = "Password change required",
)
return subject
return subject, credential_generation(jwt_secret)
except jwt.InvalidTokenError:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,

View file

@ -9,6 +9,7 @@ import ipaddress
import os
import secrets
import sqlite3
import tempfile
import threading
from datetime import datetime, timezone
from typing import Optional, Tuple
@ -30,6 +31,97 @@ _BOOTSTRAP_PW_PATH = DB_PATH.parent / ".bootstrap_password"
_bootstrap_password: Optional[str] = None
def _bootstrap_file_bytes(password: str) -> bytes:
"""Exact on-disk form: the secret plus one LF.
Bytes, not text: text mode writes CRLF on Windows, and `$(cat ...)` strips
the LF but leaves the CR attached to the credential.
"""
return (password + "\n").encode("utf-8")
def _persist_bootstrap_password(password: str) -> None:
"""Atomically write the bootstrap password 0600, LF terminated on every OS.
A partial write would destroy the only plaintext recovery credential.
"""
fd, tmp_name = tempfile.mkstemp(
prefix = f".{_BOOTSTRAP_PW_PATH.name}.", dir = _BOOTSTRAP_PW_PATH.parent
)
try:
with os.fdopen(fd, "wb") as f:
f.write(_bootstrap_file_bytes(password))
try:
os.chmod(tmp_name, 0o600)
except OSError:
pass
os.replace(tmp_name, _BOOTSTRAP_PW_PATH)
except BaseException:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
def _normalise_bootstrap_file(raw: bytes, password: str) -> None:
"""Append the LF a pre-newline release left off.
Append-only, and only when the file is exactly the credential:
clear_bootstrap_password() may unlink or (when unlink fails, notably on
Windows while this descriptor is open) truncate through another descriptor
after we read, so a rewrite could restore revoked plaintext. An append
cannot: worst case is a lone "\\n" over a cleared file, which strips back to
no bootstrap password. Pre-newline releases wrote no terminator at all, so
that is the only shape in the wild; anything else reads fine, since every
reader strips, and is left alone.
"""
if raw != password.encode("utf-8"):
return
# O_BINARY: without it Windows opens in text mode and turns the LF straight
# back into CRLF, the bug being fixed.
fd = os.open(
_BOOTSTRAP_PW_PATH,
os.O_WRONLY | os.O_APPEND | getattr(os, "O_BINARY", 0),
)
try:
os.write(fd, b"\n")
try:
os.fchmod(fd, 0o600)
except (AttributeError, OSError):
# fchmod only reached Windows in 3.13.
pass
finally:
os.close(fd)
def _read_persisted_bootstrap_password() -> Optional[str]:
"""Read the persisted password, normalising the file if it is malformed."""
if not _BOOTSTRAP_PW_PATH.is_file():
return None
# No caller handles a raise, so an unreadable file has to mean "no bootstrap
# password", not a dead backend. We write UTF-8, so undecodable bytes are
# damage whose plaintext is worthless anyway.
try:
raw = _BOOTSTRAP_PW_PATH.read_bytes()
password = raw.decode("utf-8").strip()
except (OSError, UnicodeDecodeError):
return None
if not password:
return None
# Older releases wrote no terminator; best-effort, a read-only auth dir must
# not fail startup.
if raw != _bootstrap_file_bytes(password):
try:
_normalise_bootstrap_file(raw, password)
except OSError:
pass
return password
def generate_bootstrap_password() -> str:
"""Generate a 4-word diceware passphrase and persist it to disk.
@ -43,10 +135,10 @@ def generate_bootstrap_password() -> str:
return _bootstrap_password
# Persisted from a previous run?
if _BOOTSTRAP_PW_PATH.is_file():
_bootstrap_password = _BOOTSTRAP_PW_PATH.read_text(encoding = "utf-8").strip()
if _bootstrap_password:
return _bootstrap_password
persisted = _read_persisted_bootstrap_password()
if persisted:
_bootstrap_password = persisted
return _bootstrap_password
# First startup: generate a fresh passphrase.
import diceware
@ -57,11 +149,7 @@ def generate_bootstrap_password() -> str:
# Persist so the same passphrase survives restarts until password change.
ensure_dir(_BOOTSTRAP_PW_PATH.parent)
_BOOTSTRAP_PW_PATH.write_text(_bootstrap_password, encoding = "utf-8")
try:
os.chmod(_BOOTSTRAP_PW_PATH, 0o600)
except OSError:
pass
_persist_bootstrap_password(_bootstrap_password)
return _bootstrap_password
@ -72,13 +160,14 @@ def get_bootstrap_password() -> Optional[str]:
def _load_bootstrap_password() -> Optional[str]:
"""Load an existing bootstrap password without creating one."""
"""Load an existing bootstrap password without creating one.
Upgrades take this path, not generate_bootstrap_password()
(ensure_default_admin short-circuits once the admin row exists), so it has
to normalise too.
"""
global _bootstrap_password
_bootstrap_password = None
if _BOOTSTRAP_PW_PATH.is_file():
bootstrap_password = _BOOTSTRAP_PW_PATH.read_text(encoding = "utf-8").strip()
if bootstrap_password:
_bootstrap_password = bootstrap_password
_bootstrap_password = _read_persisted_bootstrap_password()
return _bootstrap_password
@ -97,7 +186,7 @@ def clear_bootstrap_password() -> None:
# Removal failed (Windows AV, read-only auth dir). The hash is already
# committed, so don't fail the change -- but truncate the file so its
# stale plaintext can't be re-seeded by generate_bootstrap_password()
# if a later reset-password deletes auth.db and re-validates it.
# if auth.db is ever recreated.
try:
_BOOTSTRAP_PW_PATH.write_text("", encoding = "utf-8")
cleared = True
@ -132,6 +221,31 @@ def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
class CredentialRotated(Exception):
"""A password reset revoked the credential this request authenticated with."""
def credential_generation(jwt_secret: str) -> str:
"""Marker for the credential version a refresh token was issued under.
Every password change rotates ``jwt_secret``, so a token stamped with the
previous one is rejected even if it was inserted after the revoking DELETE.
"""
return hashlib.sha256(jwt_secret.encode("utf-8")).hexdigest()
def _current_secret(conn: sqlite3.Connection, username: str) -> Optional[str]:
row = conn.execute(
"SELECT jwt_secret FROM auth_user WHERE username = ?", (username,)
).fetchone()
return row["jwt_secret"] if row else None
def _current_generation(conn: sqlite3.Connection, username: str) -> Optional[str]:
secret = _current_secret(conn, username)
return credential_generation(secret) if secret is not None else None
def get_connection() -> sqlite3.Connection:
"""Get a connection to the auth database, creating tables if needed."""
ensure_dir(DB_PATH.parent)
@ -175,7 +289,8 @@ def get_connection() -> sqlite3.Connection:
token_hash TEXT NOT NULL,
username TEXT NOT NULL,
expires_at TEXT NOT NULL,
is_desktop INTEGER NOT NULL DEFAULT 0
is_desktop INTEGER NOT NULL DEFAULT 0,
secret_gen TEXT
);
"""
)
@ -214,6 +329,8 @@ def get_connection() -> sqlite3.Connection:
refresh_columns = {row["name"] for row in conn.execute("PRAGMA table_info(refresh_tokens)")}
if "is_desktop" not in refresh_columns:
conn.execute("ALTER TABLE refresh_tokens ADD COLUMN is_desktop INTEGER NOT NULL DEFAULT 0")
if "secret_gen" not in refresh_columns:
conn.execute("ALTER TABLE refresh_tokens ADD COLUMN secret_gen TEXT")
conn.commit()
return conn
@ -587,12 +704,22 @@ def update_password(
new_password: str,
*,
revoke_refresh_tokens: bool = False,
) -> bool:
expect_password_hash: Optional[str] = None,
) -> Optional[str]:
"""Update password, clear first-login requirement, rotate JWT secret.
Returns the new JWT secret, or None when nothing was updated. Callers that
mint tokens for the caller must sign with the returned secret: re-reading it
would pick up a reset that landed between this commit and the mint.
``revoke_refresh_tokens`` deletes the user's refresh tokens in the SAME
transaction: a separate delete could fail after the password commit and
leave a pre-change token still able to mint access tokens.
``expect_password_hash`` makes the write conditional on the credential the
caller verified still being current, so a request that checked the old
password cannot overwrite a reset that landed while it was in flight.
Returns False when the credential moved underneath it.
"""
from .hashing import hash_password
@ -600,21 +727,32 @@ def update_password(
jwt_secret = secrets.token_urlsafe(64)
conn = get_connection()
try:
cursor = conn.execute(
"""
UPDATE auth_user
SET password_salt = ?, password_hash = ?, jwt_secret = ?, must_change_password = 0
WHERE username = ?
""",
(salt, pwd_hash, jwt_secret, username),
)
if expect_password_hash is None:
cursor = conn.execute(
"""
UPDATE auth_user
SET password_salt = ?, password_hash = ?, jwt_secret = ?, must_change_password = 0
WHERE username = ?
""",
(salt, pwd_hash, jwt_secret, username),
)
else:
cursor = conn.execute(
"""
UPDATE auth_user
SET password_salt = ?, password_hash = ?, jwt_secret = ?, must_change_password = 0
WHERE username = ? AND password_hash = ?
""",
(salt, pwd_hash, jwt_secret, username, expect_password_hash),
)
if revoke_refresh_tokens and cursor.rowcount > 0:
conn.execute("DELETE FROM refresh_tokens WHERE username = ?", (username,))
conn.commit()
if cursor.rowcount > 0:
clear_bootstrap_password()
clear_desktop_secret()
return cursor.rowcount > 0
return jwt_secret
return None
finally:
conn.close()
@ -625,35 +763,49 @@ def save_refresh_token(
expires_at: str,
*,
is_desktop: bool = False,
secret_gen: Optional[str] = None,
) -> None:
"""
Store a hashed refresh token with its associated username and expiry.
``secret_gen`` binds the token to a credential version; it defaults to the
current one, and callers that already verified a credential must pass the
version they verified rather than let this re-read a rotated one.
"""
token_hash = _hash_token(token)
conn = get_connection()
try:
if secret_gen is None:
secret_gen = _current_generation(conn, username)
conn.execute(
"""
INSERT INTO refresh_tokens (token_hash, username, expires_at, is_desktop)
VALUES (?, ?, ?, ?)
INSERT INTO refresh_tokens (token_hash, username, expires_at, is_desktop, secret_gen)
VALUES (?, ?, ?, ?, ?)
""",
(token_hash, username, expires_at, int(is_desktop)),
(token_hash, username, expires_at, int(is_desktop), secret_gen),
)
conn.commit()
finally:
conn.close()
def consume_refresh_token(token: str) -> Optional[Tuple[str, bool]]:
def consume_refresh_token(token: str) -> Optional[Tuple[str, bool, str]]:
"""Atomically validate-and-delete a refresh token for single-use rotation.
DELETE RETURNING fuses validate and delete into one statement so two
concurrent refresh requests cannot both consume the same token.
concurrent refresh requests cannot both consume the same token. Returns
``(username, is_desktop, jwt_secret)``; the caller must mint the replacement
tokens against that secret so a rotation landing mid-refresh cannot issue a
post-rotation session from a pre-rotation token.
"""
token_hash = _hash_token(token)
now = datetime.now(timezone.utc).isoformat()
conn = get_connection()
try:
# One transaction with the delete: an unstamped legacy row has no
# generation to compare, so reading the credential after committing would
# hand a reset's new secret to a token issued before it.
conn.execute("BEGIN IMMEDIATE")
conn.execute(
"DELETE FROM refresh_tokens WHERE expires_at < ?",
(now,),
@ -662,15 +814,21 @@ def consume_refresh_token(token: str) -> Optional[Tuple[str, bool]]:
"""
DELETE FROM refresh_tokens
WHERE token_hash = ? AND expires_at >= ?
RETURNING username, is_desktop
RETURNING username, is_desktop, secret_gen
""",
(token_hash, now),
)
row = cur.fetchone()
conn.commit()
if row is None:
conn.commit()
return None
return row["username"], bool(row["is_desktop"])
secret = _current_secret(conn, row["username"])
conn.commit()
if secret is None:
return None
if row["secret_gen"] is not None and row["secret_gen"] != credential_generation(secret):
return None
return row["username"], bool(row["is_desktop"]), secret
finally:
conn.close()
@ -694,7 +852,7 @@ def verify_refresh_token(token: str) -> Optional[Tuple[str, bool]]:
cur = conn.execute(
"""
SELECT id, username, expires_at, is_desktop FROM refresh_tokens
SELECT id, username, expires_at, is_desktop, secret_gen FROM refresh_tokens
WHERE token_hash = ?
""",
(token_hash,),
@ -703,6 +861,13 @@ def verify_refresh_token(token: str) -> Optional[Tuple[str, bool]]:
if row is None:
return None
if row["secret_gen"] is not None and row["secret_gen"] != _current_generation(
conn, row["username"]
):
conn.execute("DELETE FROM refresh_tokens WHERE id = ?", (row["id"],))
conn.commit()
return None
# Check expiry
expires_at = datetime.fromisoformat(row["expires_at"])
if datetime.now(timezone.utc) > expires_at:
@ -747,30 +912,41 @@ def create_desktop_secret() -> str:
conn.close()
def validate_desktop_secret(raw_secret: str) -> Optional[str]:
"""Return the real admin username when the desktop secret matches."""
def validate_desktop_secret_with_credential(raw_secret: str) -> Optional[Tuple[str, str]]:
"""Validate the desktop secret and return ``(username, jwt_secret)``.
Both reads share one transaction so the returned secret is the credential
version the desktop secret was checked against; a reset landing mid-request
then invalidates the tokens minted from it rather than blessing them.
"""
if not raw_secret.startswith(DESKTOP_SECRET_PREFIX):
return None
if get_user_and_secret(DEFAULT_ADMIN_USERNAME) is None:
return None
secret_hash = _pbkdf2_desktop_secret(raw_secret)
conn = get_connection()
try:
cur = conn.execute(
conn.execute("BEGIN")
row = conn.execute(
"SELECT value FROM app_secrets WHERE key = ?",
(_DESKTOP_SECRET_HASH_KEY,),
)
row = cur.fetchone()
if row is None:
).fetchone()
if row is None or not secrets.compare_digest(row["value"], secret_hash):
return None
if not secrets.compare_digest(row["value"], secret_hash):
jwt_secret = _current_secret(conn, DEFAULT_ADMIN_USERNAME)
if jwt_secret is None:
return None
return DEFAULT_ADMIN_USERNAME
return DEFAULT_ADMIN_USERNAME, jwt_secret
finally:
conn.rollback()
conn.close()
def validate_desktop_secret(raw_secret: str) -> Optional[str]:
"""Return the real admin username when the desktop secret matches."""
verified = validate_desktop_secret_with_credential(raw_secret)
return verified[0] if verified else None
def clear_desktop_secret() -> None:
"""Remove backend-side desktop auth state."""
conn = get_connection()
@ -796,6 +972,7 @@ def create_api_key(
name: str,
expires_at: Optional[str] = None,
internal: bool = False,
expect_gen: Optional[str] = None,
) -> Tuple[str, dict]:
"""Create a new API key for *username*.
@ -804,6 +981,10 @@ def create_api_key(
Pass ``internal=True`` for keys minted by workflows (e.g. data-recipe
runs) that should not appear in user-facing key listings.
``expect_gen`` ties the insert to the credential generation the request
authenticated under, so a session revoked by a concurrent password reset
cannot mint a key that outlives it. Raises ``CredentialRotated`` if it moved.
"""
raw_key = API_KEY_PREFIX + secrets.token_hex(16)
key_hash = _pbkdf2_api_key(raw_key)
@ -812,6 +993,12 @@ def create_api_key(
conn = get_connection()
try:
if expect_gen is not None:
conn.execute("BEGIN IMMEDIATE")
if _current_generation(conn, username) != expect_gen:
raise CredentialRotated(
"The credential this request authenticated with was revoked."
)
conn.execute(
"""
INSERT INTO api_keys (username, key_prefix, key_hash, name, created_at, expires_at, is_internal)
@ -900,15 +1087,25 @@ def revoke_internal_api_key(key_id: int) -> bool:
def validate_api_key(raw_key: str) -> Optional[str]:
"""Validate *raw_key* and return the owning username, or ``None``.
"""Validate *raw_key* and return the owning username, or ``None``."""
verified = validate_api_key_with_credential(raw_key)
return verified[0] if verified else None
Also updates ``last_used_at`` on success.
def validate_api_key_with_credential(raw_key: str) -> Optional[Tuple[str, str]]:
"""Validate *raw_key* and return ``(username, jwt_secret)``, or ``None``.
Also updates ``last_used_at`` on success. The key check and the credential
read share one write transaction, so the returned version is the one the key
was actually valid under: a reset committing right after cannot have its new
generation handed to a request the key it revoked authenticated.
"""
cache_id = _api_key_cache_id(raw_key)
cached_hash = _api_key_hash_cache.get(cache_id)
key_hash = cached_hash if cached_hash is not None else _pbkdf2_api_key(raw_key)
conn = get_connection()
try:
conn.execute("BEGIN IMMEDIATE")
cur = conn.execute(
"SELECT id, username, is_active, expires_at FROM api_keys WHERE key_hash = ?",
(key_hash,),
@ -928,11 +1125,15 @@ def validate_api_key(raw_key: str) -> Optional[str]:
expires = datetime.fromisoformat(row["expires_at"])
if datetime.now(timezone.utc) > expires:
return None
secret = _current_secret(conn, row["username"])
if secret is None:
return None
conn.execute(
"UPDATE api_keys SET last_used_at = ? WHERE id = ?",
(datetime.now(timezone.utc).isoformat(), row["id"]),
)
conn.commit()
return row["username"]
return row["username"], secret
finally:
conn.rollback()
conn.close()

View file

@ -310,6 +310,7 @@ class CloudflareTunnel:
stderr = subprocess.STDOUT,
stdin = subprocess.DEVNULL,
text = True,
encoding = "utf-8",
errors = "replace",
bufsize = 1,
**_windows_hidden_kwargs(),

View file

@ -257,6 +257,8 @@ def _run_oxc_batch(
cwd = str(_OXC_TOOL_DIR),
input = json.dumps(payload),
text = True,
encoding = "utf-8",
errors = "replace",
capture_output = True,
check = False,
env = env,

View file

@ -567,7 +567,7 @@ class InferenceBackend:
_meta_path = Path(config.path) / "export_metadata.json"
try:
if _meta_path.exists():
_meta = json.loads(_meta_path.read_text(encoding = "utf-8"))
_meta = json.loads(_meta_path.read_text(encoding = "utf-8-sig"))
if _meta.get("base_model"):
processor_source = _meta["base_model"]
except Exception:

View file

@ -85,6 +85,7 @@ from core.tool_healing import (
strip_outside_think,
)
from utils.native_path_leases import child_env_without_native_path_secret
from utils.child_stdio import utf8_child_env
from utils.hf_xet_fallback import hf_hub_download_with_xet_fallback
from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
@ -581,7 +582,7 @@ def _load_swa_cache() -> dict:
if _SWA_CACHE is not None:
return _SWA_CACHE
try:
with open(_swa_cache_path(), encoding = "utf-8") as f:
with open(_swa_cache_path(), encoding = "utf-8-sig") as f:
_SWA_CACHE = json.load(f)
if not isinstance(_SWA_CACHE, dict):
_SWA_CACHE = {}
@ -632,7 +633,7 @@ def _fetch_swa_entry_from_hf(repo_id: str) -> Optional[object]:
repo_type = "model",
cache_dir = active_hf_hub_cache(),
)
with open(cfg_path, encoding = "utf-8") as f:
with open(cfg_path, encoding = "utf-8-sig") as f:
cfg = json.load(f)
except Exception:
return None
@ -3087,6 +3088,7 @@ class LlamaCppBackend:
[bin_path, "--help"],
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = 10,
check = False,
@ -3692,6 +3694,8 @@ class LlamaCppBackend:
],
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = 10,
env = child_env_without_native_path_secret(),
**_windows_hidden_subprocess_kwargs(),
@ -3806,7 +3810,7 @@ class LlamaCppBackend:
encoding = "utf-8",
errors = "replace",
timeout = 15,
env = env,
env = utf8_child_env(env),
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode != 0:
@ -5556,7 +5560,9 @@ class LlamaCppBackend:
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
env = env,
encoding = "utf-8",
errors = "replace",
env = utf8_child_env(env),
**_windows_hidden_subprocess_kwargs(),
**_child_popen_kwargs(),
)
@ -6770,6 +6776,8 @@ class LlamaCppBackend:
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
encoding = "utf-8",
errors = "replace",
env = env,
**_windows_hidden_subprocess_kwargs(),
**_child_popen_kwargs(),
@ -8841,6 +8849,8 @@ class LlamaCppBackend:
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
encoding = "utf-8",
errors = "replace",
env = env,
**_windows_hidden_subprocess_kwargs(),
**_child_popen_kwargs(),
@ -10357,6 +10367,8 @@ class LlamaCppBackend:
["pgrep", "-a", "-f", "llama-server"],
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = 5,
env = child_env_without_native_path_secret(),
)

View file

@ -151,7 +151,7 @@ def _resolve_lora_4bit(mc, load_in_4bit: bool) -> bool:
import json
try:
with open(adapter_cfg_path, encoding = "utf-8") as f:
with open(adapter_cfg_path, encoding = "utf-8-sig") as f:
adapter_cfg = json.load(f)
training_method = adapter_cfg.get("unsloth_training_method")
if training_method == "lora" and load_in_4bit:
@ -963,7 +963,7 @@ def run_inference_process(
if _local_adapter_cfg.is_file():
try:
_lora_base = (
_json.loads(_local_adapter_cfg.read_text(encoding = "utf-8")).get(
_json.loads(_local_adapter_cfg.read_text(encoding = "utf-8-sig")).get(
"base_model_name_or_path"
)
or None

View file

@ -103,6 +103,8 @@ class LlamaServerBackend:
[binary, "--help"],
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = 30,
**windows_hidden_subprocess_kwargs(),
)
@ -331,6 +333,8 @@ class LlamaServerBackend:
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
encoding = "utf-8",
errors = "replace",
env = env,
**windows_hidden_subprocess_kwargs(),
**child_popen_kwargs(),

View file

@ -100,7 +100,7 @@ def _st_module_subdirs(name: str, token: str | None) -> tuple[str, ...]:
path = Path(normalize_path(name)).expanduser() / "modules.json"
if not path.is_file():
return ()
data = json.loads(path.read_text(encoding = "utf-8"))
data = json.loads(path.read_text(encoding = "utf-8-sig"))
else:
from huggingface_hub import hf_hub_download
from huggingface_hub.utils import EntryNotFoundError
@ -115,7 +115,7 @@ def _st_module_subdirs(name: str, token: str | None) -> tuple[str, ...]:
)
except EntryNotFoundError:
return ()
data = json.loads(open(local, encoding = "utf-8").read())
data = json.loads(open(local, encoding = "utf-8-sig").read())
subdirs = []
for module in data or ():
sub = str((module or {}).get("path", "")).strip().strip("/")

View file

@ -43,6 +43,7 @@ if sys.platform.startswith("linux") and "HSA_ENABLE_DXG_DETECTION" not in os.env
pass
logger = get_logger(__name__)
from utils.child_stdio import utf8_child_env
from utils.hardware import apply_gpu_ids
from utils.training_runs import build_default_output_dir_name
from utils.wheel_utils import (
@ -385,6 +386,10 @@ def _install_package_wheel_first(
"stdout": _sp.PIPE,
"stderr": _sp.STDOUT,
"text": True,
"encoding": "utf-8",
"errors": "replace",
# Make the Python child emit the UTF-8 we decode above.
"env": utf8_child_env(),
}
if is_hip:
_run_kwargs["timeout"] = 1800
@ -606,6 +611,9 @@ def _ensure_flash_linear_attention_unconditional(event_queue: Any) -> bool:
stdout = _sp.PIPE,
stderr = _sp.STDOUT,
text = True,
encoding = "utf-8",
errors = "replace",
env = utf8_child_env(),
timeout = _TILELANG_INSTALL_TIMEOUT_S,
)
except _sp.TimeoutExpired:
@ -849,6 +857,9 @@ def _run_pip(cmd: list[str], event_queue: Any, label: str) -> bool:
stdout = _sp.PIPE,
stderr = _sp.STDOUT,
text = True,
encoding = "utf-8",
errors = "replace",
env = utf8_child_env(),
timeout = _TILELANG_INSTALL_TIMEOUT_S,
)
except _sp.TimeoutExpired:

View file

@ -215,7 +215,7 @@ def _ollama_model_info_from_manifest(
return None
try:
manifest = json.loads(tag_file.read_text(encoding = "utf-8"))
manifest = json.loads(tag_file.read_text(encoding = "utf-8-sig"))
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
logger.debug("Skipping unreadable/invalid Ollama manifest %s: %s", tag_file, e)
return None
@ -228,7 +228,7 @@ def _ollama_model_info_from_manifest(
config_blob = _ollama_blob_path(blobs_dir, config_digest)
if config_blob is not None and _safe_is_file(config_blob):
try:
cfg = json.loads(config_blob.read_text(encoding = "utf-8"))
cfg = json.loads(config_blob.read_text(encoding = "utf-8-sig"))
model_type = cfg.get("model_type", "")
file_type = cfg.get("file_type", "")
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:

View file

@ -464,6 +464,8 @@ def _read_marker_value(marker: Path) -> Optional[str]:
return None
value = marker.read_text(encoding = "utf-8").strip()
except (OSError, UnicodeDecodeError):
# UnicodeDecodeError is a ValueError, so it would escape and abort
# prepare_cache_for_transport. An unknown value just purges and restarts.
return None
return value if value in VALID_TRANSPORTS else None

View file

@ -42,8 +42,12 @@ class LogConfig:
log_level_name = os.getenv("LOG_LEVEL", "INFO").upper()
log_level = getattr(logging, log_level_name, logging.INFO)
if sys.platform == "win32":
for stream in (sys.stdout, sys.stderr):
# Non-ASCII on a non-UTF-8 stream raises UnicodeEncodeError (Windows,
# LANG=C), so key off the stream, not the platform.
for stream in (sys.stdout, sys.stderr):
if getattr(stream, "encoding", "") and not str(stream.encoding).lower().replace(
"-", ""
).startswith("utf8"):
if hasattr(stream, "reconfigure"):
try:
stream.reconfigure(encoding = "utf-8", errors = "replace")

View file

@ -347,6 +347,7 @@ from utils.update_status import (
get_studio_install_source_status,
get_studio_update_status,
)
from utils.changelog import get_release_notes, is_supported_version_query
from utils.studio_version import get_studio_version
from utils.api_errors import install_api_error_handlers
@ -1154,6 +1155,18 @@ def studio_update_status(_current_subject: str = Depends(get_current_subject)):
return get_studio_update_status(UNSLOTH_VERSION)
@app.get("/api/studio/release-notes")
def studio_release_notes(
version: str = Query(..., max_length = 64),
refresh: bool = Query(False),
_current_subject: str = Depends(get_current_subject),
):
"""Return CHANGELOG.md notes for exactly `version` (never a nearby one)."""
if not is_supported_version_query(version):
raise HTTPException(status_code = 422, detail = "Invalid version.")
return get_release_notes(version, refresh = refresh)
@app.get(
"/api/studio/download-transport-capabilities",
response_model = TransportCapabilities,

View file

@ -6,10 +6,93 @@
from __future__ import annotations
import json
import locale
import os
import threading
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, NamedTuple
def _locale_encoding() -> str:
"""The codepage a pre-UTF-8 release here would have written, or "".
Empty on a UTF-8 host, where there is no codepage to attribute the file to.
"""
try:
preferred = locale.getencoding()
except AttributeError: # Python < 3.11
preferred = locale.getpreferredencoding(False)
if preferred.lower().replace("-", "").replace("_", "") == "utf8":
return ""
return preferred
# Trail bytes can land on JSON punctuation, so a single-byte fallback misreads these.
_DOUBLE_BYTE_ENCODINGS = ("cp932", "cp936", "cp949", "cp950")
def _parse(raw: bytes, encoding: str) -> Any:
"""Parse one JSON document under *encoding*, or None if it does not.
RecursionError is a RuntimeError, so nesting json.loads will not descend is
the one parse failure the other three miss. Both callers run this outside
any further handler, so it has to answer None here or a single damaged
record aborts the scraper at startup instead of being skipped.
"""
try:
return json.loads(raw.decode(encoding))
except (UnicodeDecodeError, LookupError, ValueError, RecursionError):
return None
class _Reading(NamedTuple):
as_utf8: Any
as_legacy: Any
def _read_line(raw: bytes, codepage: str) -> _Reading:
"""Read one line as UTF-8 and as a codepage, for dedup keys only.
Requiring valid JSON, not merely a successful decode, is what separates a
genuine legacy record from a half-written UTF-8 one: a torn multibyte
character decodes under cp1252 but leaves the JSON unterminated. Some byte
strings parse both ways, e.g. cp1251 ``Р°`` is ``D0 B0``, which is also
UTF-8 ``а``.
The codepage reading is never authoritative, because the file's own encoding
cannot be recovered from its bytes. Reading a cp1251 shard on a cp1252
machine turns ``Привет`` into ``Ïðèâåò`` and every byte of it decodes
cleanly, so a successful decode proves nothing about who wrote it. It is
used only to recover the dedup keys, which are ASCII ids and come back the
same under any of these, so the first reading that parses will do.
That is also why several are tried. latin-1 alone mangles the double-byte
codepages: cp932 ```` is ``95 5C``, and latin-1 turns the trail byte into
a JSON backslash, so the record fails to parse and its id is forgotten.
"""
as_utf8 = _parse(raw, "utf-8")
# A record that reads as UTF-8 needs no second reading: re-parsing cost 2.8x on a
# 76 MB shard, and these reach gigabytes. Only a dict, since key lookup falls
# through to the codepage when UTF-8 yields none.
if isinstance(as_utf8, dict):
return _Reading(as_utf8, None)
for encoding in (codepage, "latin-1", *_DOUBLE_BYTE_ENCODINGS):
if not encoding:
continue
as_legacy = _parse(raw, encoding)
if as_legacy is not None:
return _Reading(as_utf8, as_legacy)
return _Reading(as_utf8, None)
class _Scan(NamedTuple):
"""What a pass over an existing shard established about it."""
legacy: bool # enough evidence to trust the codepage reading's keys
readable: bool
saw_non_ascii: bool # some line's meaning depends on the encoding
utf8_keys: set # keys from lines UTF-8 could read
legacy_keys: set # keys only the codepage reading yields
class StateStore:
@ -18,12 +101,19 @@ class StateStore:
self.path.parent.mkdir(parents = True, exist_ok = True)
self._lock = threading.Lock()
self._data: Dict[str, Any] = {}
# Read whole, and UTF-8 only unlike the shards below: a checkpoint holds
# nothing but base64 cursors and booleans, so a codepage retry could only ever
# add non-ASCII. That would resume on a mojibaked cursor, which GitHub rejects
# with INVALID_CURSOR_ARGUMENTS, and the empty page it returns marks the stream
# done and skips the rest for good. Dropping a damaged checkpoint re-scrapes
# from the first page, which the writers dedup.
if self.path.exists():
try:
with self.path.open(encoding = "utf-8") as f:
self._data = json.load(f)
except Exception:
self._data = {}
raw = self.path.read_bytes()
except OSError:
raw = b""
data = _parse(raw, "utf-8")
self._data = data if isinstance(data, dict) else {}
def get(
self,
@ -63,24 +153,83 @@ class JsonlWriter:
self.path = Path(path)
self.path.parent.mkdir(parents = True, exist_ok = True)
self._lock = threading.Lock()
self._fh = self.path.open("a", buffering = 1, encoding = "utf-8")
self._count_seen_keys: set[str] = set()
# Preload seen keys for dedup across resumes
self._codepage = _locale_encoding()
self._ensure_ascii = False
encoding = "utf-8"
if self.path.exists() and self.path.stat().st_size > 0:
try:
# No guess is safe for a file an older build wrote in the
# operator's locale, so read past whatever will not decode.
with self.path.open(encoding = "utf-8", errors = "replace") as f:
for line in f:
try:
obj = json.loads(line)
k = self._key(obj)
if k is not None:
self._count_seen_keys.add(k)
except Exception:
pass
except Exception:
pass
scan = self._scan_existing()
self._count_seen_keys = scan.utf8_keys
if scan.legacy:
self._count_seen_keys |= scan.legacy_keys
if scan.saw_non_ascii or not scan.readable:
# Never convert: the writing encoding is unrecoverable and guessing
# mojibakes the records. Pure ASCII appends store identically under
# every codepage, and json.loads turns the \uXXXX escapes back.
encoding = "ascii"
self._ensure_ascii = True
self._fh = self.path.open("a", buffering = 1, encoding = encoding, errors = "strict")
def _scan_existing(self) -> _Scan:
"""Read the shard once to recover dedup keys and judge its encoding.
Line by line: these shards reach gigabytes on a large scrape, so neither
the bytes nor the decoded text are held whole.
The verdict weighs the whole file. Each line with non-ASCII bytes votes:
one that parses only under the codepage is evidence of a legacy shard,
one that parses as UTF-8 is evidence against, since arbitrary codepage
text almost never forms valid multibyte UTF-8. A single corrupt byte in
a healthy shard therefore cannot outvote the records around it, and a
genuinely legacy shard has a legacy vote on every line that carries an
umlaut.
More than one such line is required, because a single one is genuinely
undecidable: a legacy record holding one accented character and an ASCII
record holding one stray byte are the same shape. Reading it as damage
risks a duplicate; reading it as legacy marks an unreadable record seen
and blocks the retry that would replace it, losing it for good. Only one
of those is recoverable.
The verdict only picks which reading supplies the dedup keys. The file
itself is never rewritten either way, so a wrong answer costs at most a
duplicate, never a corrupted record.
"""
legacy_votes = 0
utf8_votes = 0
saw_non_ascii = False
utf8_keys: set[str] = set()
legacy_keys: set[str] = set()
try:
with self.path.open("rb") as handle:
for raw in handle:
line = raw.strip()
reading = _read_line(line, self._codepage)
# ASCII reads the same everywhere: no vote, no constraint.
if not line.isascii():
saw_non_ascii = True
if reading.as_utf8 is None and reading.as_legacy is not None:
legacy_votes += 1
elif reading.as_utf8 is not None:
utf8_votes += 1
# Kept apart so a damaged line does not block its own retry.
if isinstance(reading.as_utf8, dict):
key = self._key(reading.as_utf8)
if key is not None:
utf8_keys.add(key)
elif isinstance(reading.as_legacy, dict):
key = self._key(reading.as_legacy)
if key is not None:
legacy_keys.add(key)
except OSError:
return _Scan(False, False, False, utf8_keys, legacy_keys)
return _Scan(
legacy_votes > 1 and legacy_votes > utf8_votes,
True,
saw_non_ascii,
utf8_keys,
legacy_keys,
)
def _key(self, obj: dict) -> str | None:
for k in ("id", "node_id", "number", "sha", "url"):
@ -99,7 +248,7 @@ class JsonlWriter:
return False
if k is not None:
self._count_seen_keys.add(k)
self._fh.write(json.dumps(obj, default = str, ensure_ascii = False))
self._fh.write(json.dumps(obj, default = str, ensure_ascii = self._ensure_ascii))
self._fh.write("\n")
self._fh.flush()
return True

View file

@ -30,6 +30,8 @@ class UnstructuredSeedReader(SeedReader[UnstructuredSeedSource]):
meta = json_mod.loads(meta_path.read_text(encoding = "utf-8"))
orig_name = meta.get("original_filename", path_obj.name)
except (json_mod.JSONDecodeError, OSError, UnicodeDecodeError):
# Undecodable metadata is as malformed as invalid JSON, so
# fall back to the file's own name rather than abort the seed.
pass
file_entries.append((path_obj, orig_name))

View file

@ -31,6 +31,7 @@ from auth import storage, hashing
from auth.authentication import (
create_access_token,
create_refresh_token,
get_current_credential,
get_current_subject,
get_current_subject_allow_password_change,
refresh_access_token,
@ -399,7 +400,7 @@ async def login(payload: AuthLoginRequest, request: Request) -> Token:
detail = f"Incorrect password. To reset it, run this in your terminal: {_reset_password_command()}",
)
salt, pwd_hash, _jwt_secret, must_change_password = record
salt, pwd_hash, jwt_secret, must_change_password = record
if not hashing.verify_password(payload.password, salt, pwd_hash):
_record_login_failure(key)
raise HTTPException(
@ -409,8 +410,10 @@ async def login(payload: AuthLoginRequest, request: Request) -> Token:
_clear_login_bucket(key)
_clear_login_bucket(unknown_key)
access_token = create_access_token(subject = payload.username)
refresh_token = create_refresh_token(subject = payload.username)
# Issue against the credential version just verified, not whatever is in the DB
# now: a concurrent reset-password must not hand this login a post-reset session.
access_token = create_access_token(subject = payload.username, secret = jwt_secret)
refresh_token = create_refresh_token(subject = payload.username, secret = jwt_secret)
return Token(
access_token = access_token,
refresh_token = refresh_token,
@ -438,16 +441,17 @@ async def logout(
@router.post("/desktop-login", response_model = Token)
async def desktop_login(payload: DesktopLoginRequest) -> Token:
"""Exchange a local desktop secret for normal admin-subject tokens."""
username = storage.validate_desktop_secret(payload.secret)
if username is None:
verified = storage.validate_desktop_secret_with_credential(payload.secret)
if verified is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Desktop authentication failed",
)
username, jwt_secret = verified
return Token(
access_token = create_access_token(subject = username, desktop = True),
refresh_token = create_refresh_token(subject = username, desktop = True),
access_token = create_access_token(subject = username, desktop = True, secret = jwt_secret),
refresh_token = create_refresh_token(subject = username, desktop = True, secret = jwt_secret),
token_type = "bearer",
must_change_password = False,
)
@ -462,9 +466,11 @@ async def refresh(payload: RefreshTokenRequest) -> Token:
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invalid or expired refresh token",
)
username, is_desktop = consumed
new_access_token = create_access_token(subject = username, desktop = is_desktop)
new_refresh_token = create_refresh_token(subject = username, desktop = is_desktop)
username, is_desktop, jwt_secret = consumed
new_access_token = create_access_token(subject = username, desktop = is_desktop, secret = jwt_secret)
new_refresh_token = create_refresh_token(
subject = username, desktop = is_desktop, secret = jwt_secret
)
return Token(
access_token = new_access_token,
@ -507,13 +513,25 @@ async def change_password(
# Single transaction: a separate refresh-token purge could fail after the
# password commit, leaving pre-change tokens able to mint access tokens.
storage.update_password(current_subject, payload.new_password, revoke_refresh_tokens = True)
# Conditional on the hash just verified: a reset-password that landed while
# this request was in flight must not be overwritten by it.
new_secret = storage.update_password(
current_subject,
payload.new_password,
revoke_refresh_tokens = True,
expect_password_hash = pwd_hash,
)
if new_secret is None:
raise HTTPException(
status_code = status.HTTP_409_CONFLICT,
detail = "The password changed while this request was in flight. Sign in again.",
)
try:
request.app.state.bootstrap_password = None
except AttributeError:
pass
access_token = create_access_token(subject = current_subject)
refresh_token = create_refresh_token(subject = current_subject)
access_token = create_access_token(subject = current_subject, secret = new_secret)
refresh_token = create_refresh_token(subject = current_subject, secret = new_secret)
return Token(
access_token = access_token,
refresh_token = refresh_token,
@ -541,20 +559,28 @@ def _row_to_api_key_response(row: dict) -> ApiKeyResponse:
@router.post("/api-keys", response_model = CreateApiKeyResponse)
async def create_api_key(
payload: CreateApiKeyRequest, current_subject: str = Depends(get_current_subject)
payload: CreateApiKeyRequest, credential: tuple = Depends(get_current_credential)
) -> CreateApiKeyResponse:
"""Create a new API key. The raw key is returned once and cannot be retrieved later."""
current_subject, generation = credential
expires_at = None
if payload.expires_in_days is not None:
expires_at = (
datetime.now(timezone.utc) + timedelta(days = payload.expires_in_days)
).isoformat()
raw_key, row = storage.create_api_key(
username = current_subject,
name = payload.name,
expires_at = expires_at,
)
try:
raw_key, row = storage.create_api_key(
username = current_subject,
name = payload.name,
expires_at = expires_at,
expect_gen = generation,
)
except storage.CredentialRotated:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invalid or expired token",
)
return CreateApiKeyResponse(
key = raw_key,
api_key = _row_to_api_key_response(row),

View file

@ -10,7 +10,10 @@ from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from auth.authentication import get_current_credential
from auth.storage import CredentialRotated
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import ValidationError
@ -257,7 +260,11 @@ def _inject_local_structured_response_format(
model_configs.extend(new_configs)
def _inject_local_providers(recipe: dict[str, Any], request: Request) -> Optional[int]:
def _inject_local_providers(
recipe: dict[str, Any],
request: Request,
expect_gen: Optional[str] = None,
) -> Optional[int]:
"""Mutate recipe in-place: point is_local providers at this server and mint
a short-lived internal sk-unsloth-* key for workflow auth.
@ -313,6 +320,7 @@ def _inject_local_providers(recipe: dict[str, Any], request: Request) -> Optiona
name = "data-recipe workflow",
expires_at = expires_at,
internal = True,
expect_gen = expect_gen,
)
internal_key_id = int(row["id"])
@ -375,7 +383,11 @@ def _normalize_run_name(value: Any) -> str | None:
@router.post("/jobs", response_class = JSONResponse, response_model = JobCreateResponse)
def create_job(payload: RecipePayload, request: Request):
def create_job(
payload: RecipePayload,
request: Request,
credential: tuple = Depends(get_current_credential),
):
recipe = payload.recipe
if not recipe.get("columns"):
raise HTTPException(status_code = 400, detail = "Recipe must include columns.")
@ -406,7 +418,11 @@ def create_job(payload: RecipePayload, request: Request):
) from exc
try:
internal_api_key_id = _inject_local_providers(recipe, request)
internal_api_key_id = _inject_local_providers(recipe, request, credential[1])
except CredentialRotated as exc:
# A reset-password landed after this request authenticated; the workflow key
# is refused, so answer like any other revoked credential rather than 500.
raise HTTPException(status_code = 401, detail = "Invalid or expired token") from exc
except ValueError as exc:
raise log_and_http_error(
exc,

View file

@ -4451,7 +4451,7 @@ def _effective_load_in_4bit(config: ModelConfig, requested: bool) -> bool:
if not adapter_cfg_path.exists():
return load_in_4bit
try:
with open(adapter_cfg_path, encoding = "utf-8") as f:
with open(adapter_cfg_path, encoding = "utf-8-sig") as f:
adapter_cfg = json.load(f)
if not isinstance(adapter_cfg, dict): # malformed -> keep requested
return load_in_4bit

View file

@ -722,7 +722,7 @@ def _scan_ollama_dir(ollama_dir: Path, limit: Optional[int] = None) -> List[Loca
stem_hash = hashlib.sha256(manifest_key.encode()).hexdigest()[:10]
try:
manifest = json.loads(tag_file.read_text(encoding = "utf-8"))
manifest = json.loads(tag_file.read_text(encoding = "utf-8-sig"))
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
logger.debug(
"Skipping unreadable/invalid Ollama manifest %s: %s",
@ -738,7 +738,7 @@ def _scan_ollama_dir(ollama_dir: Path, limit: Optional[int] = None) -> List[Loca
config_blob = blobs_dir / config_digest.replace(":", "-")
if config_blob.is_file():
try:
cfg = json.loads(config_blob.read_text(encoding = "utf-8"))
cfg = json.loads(config_blob.read_text(encoding = "utf-8-sig"))
model_type = cfg.get("model_type", "")
file_type = cfg.get("file_type", "")
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
@ -1042,7 +1042,7 @@ def _dir_has_downloaded_model(directory: Path, max_entries: int = 4000) -> bool:
if not m.is_file():
continue
try:
manifest = json.loads(m.read_text(encoding = "utf-8"))
manifest = json.loads(m.read_text(encoding = "utf-8-sig"))
except (json.JSONDecodeError, OSError, ValueError):
continue
for layer in manifest.get("layers") or []:
@ -3360,6 +3360,8 @@ def _wsl_reveal_in_explorer(path: Path) -> bool:
["wslpath", "-w", str(path)],
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
check = True,
timeout = 10,
).stdout.strip()

View file

@ -10,7 +10,7 @@ import os
import sys
import time
from pathlib import Path
from typing import Optional, Tuple
from typing import NoReturn, Optional, Sequence, Tuple
def _fix_torch_cuda_ld_path():
@ -689,6 +689,33 @@ def _get_pid_on_port(port: int) -> "tuple[int, str] | None":
return None
def _bind_addresses(host: str, port: int) -> "set[str]":
"""Every address *host* resolves to. `localhost` is both 127.0.0.1 and ::1, and
recording only the first lets a later launch on the other one miss us."""
import socket
try:
infos = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)
except OSError:
return {host}
return {info[4][0] for info in infos} or {host}
def _addresses_collide(recorded: "str | None", host: str, port: int) -> bool:
"""Would a server bound to *recorded* block a bind to *host*?
*recorded* may list several addresses. Unknown or wildcard on either side
collides: refusing with a clear message beats silently starting a duplicate.
"""
wildcards = ("0.0.0.0", "::", "")
if not recorded or host in wildcards:
return True
listed = {a.strip() for a in recorded.split(",") if a.strip()}
if not listed or listed & set(wildcards):
return True
return bool(listed & _bind_addresses(host, port))
def _is_port_free(host: str, port: int) -> bool:
"""Check if a port is available for binding.
@ -733,18 +760,213 @@ def _find_free_port(
host: str,
start: int,
max_attempts: int = 20,
avoid_own_studio: bool = False,
) -> int:
"""Find a free port from `start`, trying up to max_attempts ports."""
"""Find a free port from `start`, trying up to max_attempts ports.
``avoid_own_studio`` aborts rather than skipping past one of our own servers
in the fallback range, which would start a duplicate on a later port.
"""
for offset in range(max_attempts):
candidate = start + offset
if _is_port_free(host, candidate):
return candidate
if avoid_own_studio:
own = _own_studio_on_port(candidate, host)
if own is not None:
_abort_already_running(own, candidate)
raise RuntimeError(f"Could not find a free port in range {start}-{start + max_attempts - 1}")
from utils.paths.storage_roots import studio_root as _studio_root
# Legacy single-instance file; still read so `stop` finds an older build's server.
_PID_FILE = _studio_root() / "studio.pid"
PID_FILE_GLOB = "studio-*.pid"
def _pid_file_for_port(port: int) -> Path:
# PID in the name: 127.0.0.1 and ::1 can share a port, and one file per port
# would let the second bind overwrite the first.
return _studio_root() / f"studio-{port}-{os.getpid()}.pid"
def _pid_alive(pid: int) -> bool:
try:
import psutil
return psutil.pid_exists(pid)
except ImportError:
pass
if sys.platform == "win32":
# os.kill(pid, 0) raises OSError for every pid on Windows, so tasklist is
# the only usable probe here.
import subprocess
try:
out = subprocess.run(
["tasklist", "/FI", f"PID eq {int(pid)}", "/NH", "/FO", "CSV"],
capture_output = True,
text = True,
timeout = 10,
).stdout
except Exception:
# Unconfirmed means keep, matching the CLI's _pid_alive. Pruning a
# live server's record is what lets the next launch fall back past it
# and strand it, which is the bug this file exists to fix. A stale
# record instead costs one clear "already running" message.
return True
return f'"{int(pid)}"' in out
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except OSError:
return True
return True
def _process_create_time(pid: int) -> "float | None":
try:
import psutil
return psutil.Process(pid).create_time()
except Exception:
return None
def _read_pid_record(path: Path) -> "tuple[int, float | None, str | None] | None":
"""Parse ``pid`` / optional ``create_time`` / optional bind address."""
try:
lines = path.read_text(encoding = "utf-8").splitlines()
except (OSError, UnicodeDecodeError):
return None
if not lines or not lines[0].strip().isdigit():
return None
try:
# isdigit() is not enough: a superscript two passes it but int() rejects it.
pid = int(lines[0].strip())
except ValueError:
return None
# kill(0) signals our whole process group; kill(1) is init. Never either.
if pid < 2:
return None
created = None
if len(lines) > 1:
try:
created = float(lines[1].strip())
except ValueError:
created = None
address = lines[2].strip() if len(lines) > 2 and lines[2].strip() else None
return pid, created, address
def _pid_is_studio_backend(pid: int, created_times: "Sequence[float | None]" = ()) -> bool:
"""False only when a recorded start time proves this PID is a different process.
Any recorded time matching is enough -- a stale record must not veto a live
server that reused the PID. Untimed records cannot be checked at all, so they
are trusted: a legacy `python run.py` has no telltale argv, and guessing from
the command line rejected real servers.
"""
known = [c for c in created_times if c is not None]
if not known:
return True
actual = _process_create_time(pid)
if actual is None:
return True
return any(abs(actual - c) < 1.0 for c in known)
def _own_studio_on_port(port: int, host: str) -> "int | None":
"""PID of one of our own servers already bound to *port* for *host*.
Reads our own records rather than enumerating listeners: psutil is optional,
and without it a listener scan finds nothing and we silently start a duplicate.
"""
try:
paths = list(_studio_root().glob(f"studio-{port}-*.pid"))
except OSError:
return None
for path in paths:
record = _read_pid_record(path)
if record is None:
continue
pid, created, address = record
if not _pid_alive(pid):
# Pruning is a courtesy; an undeletable record must not abort startup.
try:
path.unlink(missing_ok = True)
except OSError:
pass
continue
if not _addresses_collide(address, host, port):
continue
if _pid_is_studio_backend(pid, [created]):
return pid
return _legacy_studio_on_port(port)
def _legacy_studio_on_port(port: int) -> "int | None":
"""A pre-upgrade server recorded only its PID, so match it to the listener.
Falling back past one leaves it running while `_write_pid_file` overwrites the
only record of it. When the listener is unknowable, assume it is ours.
"""
record = _read_pid_record(_PID_FILE)
if record is None:
return None
pid, created, _address = record
if not _pid_alive(pid):
return None
# A current build writes a per-port file too, so its port is already known --
# and this port's records were just checked. Only count a record that still
# matches the live process: a stale one may just share a reused PID.
for other in _per_port_records():
if other and other[0] == pid and _pid_is_studio_backend(pid, [other[1]]):
return None
blocker = _get_pid_on_port(port)
if blocker is not None and blocker[0] != pid:
return None
if not _pid_is_studio_backend(pid, [created]):
return None
return pid
def _per_port_records() -> "list[tuple[int, float | None, str | None] | None]":
try:
return [_read_pid_record(p) for p in _studio_root().glob(PID_FILE_GLOB)]
except OSError:
return []
def _resolve_port(
host: str,
port: int,
avoid_own_studio: bool = True,
) -> int:
"""The requested port, or the next free one.
With ``avoid_own_studio`` this aborts rather than falling back past one of our
own servers, on *port* itself or anywhere in the fallback range: skipping one
is what strands it. Callers that read the bound port back pass False and keep
the plain fallback.
"""
if _is_port_free(host, port):
return port
if avoid_own_studio:
own = _own_studio_on_port(port, host)
if own is not None:
_abort_already_running(own, port)
return _find_free_port(host, port + 1, avoid_own_studio = avoid_own_studio)
def _abort_already_running(pid: int, port: int) -> "NoReturn":
print(
f"Error: Unsloth Studio is already running on port {port} (PID {pid}). Run "
"`unsloth studio stop` first, or start this one on a different --port.",
file = sys.stderr,
flush = True,
)
sys.exit(1)
# Direct backend launches bypass the CLI's env re-export; do it here for
# real custom roots so unsloth-zoo's import-time LLAMA_CPP_DEFAULT_DIR
@ -770,23 +992,101 @@ if _STUDIO_ROOT_RESOLVED != _LEGACY_STUDIO_ROOT:
os.environ.setdefault("UNSLOTH_IS_PRESENT", "1")
def _write_pid_file():
"""Write the current process PID to the studio PID file."""
_OWN_PID_FILE: "Path | None" = None
def _write_pid_file(port: int, host: str = ""):
"""Record this PID under its own port so `stop` can find every server."""
global _OWN_PID_FILE
path = _pid_file_for_port(port)
try:
_PID_FILE.parent.mkdir(parents = True, exist_ok = True)
_PID_FILE.write_text(str(os.getpid()), encoding = "utf-8")
path.parent.mkdir(parents = True, exist_ok = True)
except OSError:
pass
try:
# Start time pins the record to this process; the bind address tells a
# later launch whether this server would actually block it.
created = _process_create_time(os.getpid())
address = ",".join(sorted(_bind_addresses(host, port))) if host else ""
body = f"{os.getpid()}\n{'' if created is None else repr(created)}\n{address}"
# Write-then-rename: `stop` reads these concurrently, and a reader that
# catches the truncate window sees a corrupt record and deletes it.
tmp = path.with_name(path.name + ".tmp")
try:
tmp.write_text(body, encoding = "utf-8")
os.replace(tmp, path)
finally:
# A failed replace would otherwise leave the scratch file behind. It
# does not end in .pid, so no glob picks it up either way.
tmp.unlink(missing_ok = True)
except OSError:
pass
else:
_OWN_PID_FILE = path
# An older CLI's `stop` only reads this one, and expects a bare PID. Written
# independently of the per-port record: if that one failed, this is the only
# thing keeping the server stoppable at all.
try:
# Never take it from a server that is still running. A pre-upgrade server
# is recorded here and nowhere else, so overwriting its entry is exactly
# what strands it -- the orphan this file exists to prevent.
prior = _read_pid_record(_PID_FILE) if _PID_FILE.is_file() else None
if prior is None or prior[0] == os.getpid() or not _pid_alive(prior[0]):
_PID_FILE.write_text(str(os.getpid()), encoding = "utf-8")
except OSError:
pass
def _remove_pid_file():
"""Remove the PID file if it belongs to this process."""
def _legacy_heir() -> "int | None":
"""Another live server's PID, to hand the legacy studio.pid over to.
Only one server owns studio.pid at a time, so its exit would otherwise drop
the single record an older CLI can read, stranding any sibling that is still
serving.
"""
try:
if _PID_FILE.is_file():
stored = _PID_FILE.read_text(encoding = "utf-8").strip()
if stored == str(os.getpid()):
paths = sorted(_studio_root().glob(PID_FILE_GLOB))
except OSError:
return None
for path in paths:
if _OWN_PID_FILE is not None and path == _OWN_PID_FILE:
continue
record = _read_pid_record(path)
if record is None or record[0] == os.getpid():
continue
if _pid_alive(record[0]) and _pid_is_studio_backend(record[0], [record[1]]):
return record[0]
return None
def _remove_pid_file():
"""Remove the PID files that belong to this process.
_PID_FILE is checked even when the per-port record was never written, since
_write_pid_file writes the two independently.
"""
# Nothing here may raise: _graceful_shutdown calls this at the end, and an
# unreadable or undeletable record must not abandon the rest of the exit
# path. _read_pid_record already swallows OSError/UnicodeDecodeError.
if _OWN_PID_FILE is not None:
try:
record = _read_pid_record(_OWN_PID_FILE) if _OWN_PID_FILE.is_file() else None
if record is not None and record[0] == os.getpid():
_OWN_PID_FILE.unlink(missing_ok = True)
except OSError:
pass
try:
record = _read_pid_record(_PID_FILE) if _PID_FILE.is_file() else None
if record is not None and record[0] == os.getpid():
# Hand the pointer to a live sibling rather than deleting it. An
# older CLI reads only this file, so dropping it while another
# server is still up leaves that server unstoppable.
heir = _legacy_heir()
if heir is None:
_PID_FILE.unlink(missing_ok = True)
except (OSError, UnicodeDecodeError):
else:
_PID_FILE.write_text(str(heir), encoding = "utf-8")
except OSError:
pass
@ -796,7 +1096,6 @@ def _graceful_shutdown(server = None):
Called from signal handlers to clean up children before exit. Critical on
Windows where atexit handlers are unreliable after Ctrl+C.
"""
_remove_pid_file()
logger.info("Graceful shutdown initiated -- cleaning up subprocesses...")
# 1. Shut down uvicorn (releases the listening socket).
@ -849,6 +1148,9 @@ def _graceful_shutdown(server = None):
except Exception as e:
logger.warning("Error in process-lifetime sweep: %s", e)
# Last: while cleanup runs the server is still alive, and dropping the record
# early leaves a retried `stop` or a new launch unable to find it.
_remove_pid_file()
logger.info("All subprocesses cleaned up")
@ -1326,7 +1628,8 @@ def _apply_supplied_password(password_value: "Optional[str]") -> None:
if not _auth_storage.requires_password_change(_admin):
print(
"Error: an Unsloth admin password is already set; --password only sets "
"the initial password. Run `unsloth studio reset-password` first.",
"the initial password. Change it in the UI, or run `unsloth studio "
"reset-password` for a new one.",
file = sys.stderr,
flush = True,
)
@ -1397,6 +1700,7 @@ def run_server(
enable_tools: "Optional[bool]" = None,
password: "Optional[str]" = None,
emit_tauri_port: bool = True,
abort_if_own_studio: "Optional[bool]" = None,
):
"""
Start the FastAPI server.
@ -1530,10 +1834,16 @@ def run_server(
)
# Auto-find a free port if the requested one is in use.
if not _is_port_free(host, port):
original_port = port
blocker = _get_pid_on_port(port)
port = _find_free_port(host, port + 1)
original_port = port
# Refusing rather than falling back is for callers that cannot follow us to
# the new port. `studio run` reads app.state.server_port back and the desktop
# app reads TAURI_PORT, so both should keep the plain fallback; only the
# bare launch, which has nothing but the banner, benefits from the refusal.
if abort_if_own_studio is None:
abort_if_own_studio = not api_only
port = _resolve_port(host, port, avoid_own_studio = abort_if_own_studio)
if port != original_port:
blocker = _get_pid_on_port(original_port)
if not silent:
print("")
print("=" * 50)
@ -1731,7 +2041,7 @@ def run_server(
(time.perf_counter() - boot_started) * 1000,
)
_write_pid_file()
_write_pid_file(port, host)
import atexit
atexit.register(_remove_pid_file)

View file

@ -67,9 +67,11 @@ def test_rejects_password_containing_spaces(_user):
def test_allows_password_without_spaces(_user, monkeypatch):
monkeypatch.setattr(auth_routes.storage, "update_password", lambda *args, **kwargs: True)
monkeypatch.setattr(auth_routes, "create_access_token", lambda subject: "at")
monkeypatch.setattr(auth_routes, "create_refresh_token", lambda subject: "rt")
monkeypatch.setattr(
auth_routes.storage, "update_password", lambda *args, **kwargs: "rotated-secret"
)
monkeypatch.setattr(auth_routes, "create_access_token", lambda subject, **kwargs: "at")
monkeypatch.setattr(auth_routes, "create_refresh_token", lambda subject, **kwargs: "rt")
token = _change("correct-horse-battery")
assert token.access_token == "at"
assert token.must_change_password is False

View file

@ -0,0 +1,195 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Model text stays intact when it carries non-ASCII.
``open()`` and ``Path.read_text()`` fall back to ``locale.getencoding()`` when
no ``encoding`` is passed. On Windows that is the ANSI codepage, not UTF-8, so
a chat template or model config holding ``ä ö ü `` mojibakes or raises
``UnicodeDecodeError``. These files are UTF-8, so the reads must say so.
Each fixture writes raw UTF-8 (``ensure_ascii = False``), matching what
Hugging Face actually ships, rather than ASCII ``\\uXXXX`` escapes.
"""
from __future__ import annotations
import json
import subprocess
import sys
import textwrap
from pathlib import Path
BACKEND_ROOT = Path(__file__).resolve().parent.parent
def test_config_json_round_trips_non_ascii(tmp_path: Path) -> None:
from utils import transformers_version
name = "Modell für Grüße 世界"
(tmp_path / "config.json").write_text(
json.dumps({"model_type": "llama", "_name_or_path": name}, ensure_ascii = False),
encoding = "utf-8",
)
transformers_version._config_json_cache.clear()
cfg = transformers_version._load_config_json(str(tmp_path))
assert cfg is not None
assert cfg["_name_or_path"] == name
def test_tokenizer_config_round_trips_non_ascii_chat_template(tmp_path: Path) -> None:
"""Chat templates commonly hold ``→`` and smart quotes, which cp1252 mangles."""
from utils import transformers_version
template = "{{ '→ Grüße 世界' }}"
(tmp_path / "tokenizer_config.json").write_text(
json.dumps(
{"tokenizer_class": "TokenizersBackend", "chat_template": template},
ensure_ascii = False,
),
encoding = "utf-8",
)
transformers_version._tokenizer_class_cache.clear()
assert transformers_version._check_tokenizer_config_needs_v5(str(tmp_path)) is True
def test_config_json_survives_a_utf8_bom(tmp_path: Path) -> None:
"""Notepad wrote "UTF-8 with BOM" by default for years, so hand-edited
configs on Windows carry one. Plain utf-8 keeps the BOM and json.load then
fails on it; utf-8-sig strips it and is identical otherwise."""
from utils import transformers_version
name = "Grüße 世界"
(tmp_path / "config.json").write_text(
json.dumps({"model_type": "llama", "_name_or_path": name}, ensure_ascii = False),
encoding = "utf-8-sig",
)
transformers_version._config_json_cache.clear()
cfg = transformers_version._load_config_json(str(tmp_path))
assert cfg is not None
assert cfg["_name_or_path"] == name
def test_remote_code_scan_reads_non_ascii_sources(tmp_path: Path) -> None:
"""A German Windows profile also puts umlauts in the model sources scanned."""
from utils.security import remote_code_scan
source = "# Grüße über Öl\nVALUE = '世界'\n"
# newline = "" pins the bytes on disk, so Windows line end translation cannot make the
# read back differ by \r. open() because Path.write_text() only grew newline in 3.10.
with open(
tmp_path / "modeling_custom.py",
"w",
encoding = "utf-8",
newline = "",
) as handle:
handle.write(source)
files = remote_code_scan.repo_remote_code_files(str(tmp_path))
assert files["modeling_custom.py"] == source
def test_model_config_reads_do_not_rely_on_the_locale_encoding(tmp_path: Path) -> None:
"""The reads above pass anywhere the locale is already UTF-8, which hides
the Windows bug on Linux and macOS. ``-X warn_default_encoding`` makes
CPython flag any text I/O that falls back to the locale, so this fails on
every platform if an ``encoding`` argument goes missing again."""
# The readers swallow exceptions, so record the warnings instead of raising.
script = textwrap.dedent(
f"""
import sys, warnings
sys.path.insert(0, {str(BACKEND_ROOT)!r})
from utils import transformers_version
target = {str(tmp_path)!r}
with warnings.catch_warnings(record = True) as caught:
warnings.simplefilter("always")
transformers_version._config_json_cache.clear()
transformers_version._tokenizer_class_cache.clear()
assert transformers_version._load_config_json(target) is not None
assert transformers_version._check_tokenizer_config_needs_v5(target) is True
missing = [str(w.message) for w in caught if w.category is EncodingWarning]
if missing:
sys.exit("text I/O fell back to the locale encoding: " + "; ".join(missing))
"""
)
for name, payload in (
("config.json", {"model_type": "llama", "_name_or_path": "Grüße"}),
("tokenizer_config.json", {"tokenizer_class": "TokenizersBackend"}),
):
(tmp_path / name).write_text(json.dumps(payload, ensure_ascii = False), encoding = "utf-8")
result = subprocess.run(
[sys.executable, "-X", "warn_default_encoding", "-c", script],
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = 120,
)
assert result.returncode == 0, result.stderr
def test_utf8_child_env_round_trips_non_ascii(tmp_path: Path) -> None:
"""A Python child encodes stdout with its locale unless told otherwise, so
reading its pipe as utf-8 needs the child told to emit utf-8."""
from utils.child_stdio import utf8_child_env
payload = "Grüße über Öl → 世界"
child = tmp_path / "child.py"
child.write_text("import sys\nsys.stdout.write(" + repr(payload) + ")\n", encoding = "utf-8")
env = utf8_child_env()
assert env["PYTHONIOENCODING"] == "utf-8"
proc = subprocess.run(
[sys.executable, str(child)],
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
env = env,
timeout = 120,
)
assert proc.returncode == 0, proc.stderr
assert proc.stdout == payload
def test_python_children_are_told_to_emit_utf8() -> None:
"""Any child we decode as utf-8 must also be told to write utf-8, or a
cp1252 console silently mangles what it prints."""
import ast
offenders: list[str] = []
for path in sorted(BACKEND_ROOT.rglob("*.py")):
parts = path.relative_to(BACKEND_ROOT).parts
if any(p in ("tests", "node_modules", "plugins", "__pycache__") for p in parts):
continue
source = path.read_text(encoding = "utf-8")
for node in ast.walk(ast.parse(source, filename = str(path))):
if not isinstance(node, ast.Call):
continue
func = node.func
if not (isinstance(func, ast.Attribute) and func.attr in ("run", "Popen")):
continue
segment = ast.get_source_segment(source, node) or ""
if "sys.executable" not in segment or 'encoding = "utf-8"' not in segment:
continue
if "utf8_child_env" in segment or "PYTHONIOENCODING" in segment:
continue
offenders.append(f"{path.name}:{node.lineno}")
assert not offenders, (
"these spawn a Python child and decode it as utf-8 without setting the "
"child's own stdio encoding; wrap env in utf8_child_env():\n " + "\n ".join(offenders)
)

View file

@ -0,0 +1,255 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""A password rotation must not leave a session minted from the replaced credential.
`unsloth studio reset-password` rotates in place against a live server, so a login
can verify the old password, have the rotation land, and only then mint its tokens.
Issuance is bound to the credential version that was verified, so such a login gets
tokens that are already dead rather than a session that outlives the reset.
"""
import secrets
from datetime import datetime, timedelta, timezone
import jwt
import pytest
from auth import hashing, storage
from auth.authentication import ALGORITHM, create_access_token, create_refresh_token
@pytest.fixture(autouse = True)
def isolated_auth_db(tmp_path, monkeypatch):
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
monkeypatch.setattr(storage, "_BOOTSTRAP_PW_PATH", tmp_path / ".bootstrap_password")
monkeypatch.setattr(storage, "_bootstrap_password", None)
monkeypatch.setattr(storage, "_api_key_pbkdf2_salt_cache", None)
yield
@pytest.fixture
def admin():
storage.create_initial_user(
username = storage.DEFAULT_ADMIN_USERNAME,
password = "old-password-123",
jwt_secret = secrets.token_urlsafe(64),
)
return storage.DEFAULT_ADMIN_USERNAME
def _verified_secret(username):
return storage.get_user_and_secret(username)[2]
def test_access_token_from_the_replaced_credential_is_rejected(admin):
secret = _verified_secret(admin)
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
token = create_access_token(subject = admin, secret = secret)
with pytest.raises(jwt.InvalidTokenError):
jwt.decode(token, storage.get_jwt_secret(admin), algorithms = [ALGORITHM])
def test_refresh_token_from_the_replaced_credential_is_rejected(admin):
secret = _verified_secret(admin)
# Inserted AFTER the rotation's DELETE, so revocation alone cannot catch it.
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
token = create_refresh_token(subject = admin, secret = secret)
assert storage.verify_refresh_token(token) is None
assert storage.consume_refresh_token(token) is None
def test_a_rejected_refresh_token_is_dropped(admin):
secret = _verified_secret(admin)
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
token = create_refresh_token(subject = admin, secret = secret)
storage.verify_refresh_token(token)
conn = storage.get_connection()
try:
assert conn.execute("SELECT COUNT(*) AS c FROM refresh_tokens").fetchone()["c"] == 0
finally:
conn.close()
def test_tokens_from_the_current_credential_still_work(admin):
secret = _verified_secret(admin)
access = create_access_token(subject = admin, secret = secret)
refresh = create_refresh_token(subject = admin, secret = secret)
jwt.decode(access, storage.get_jwt_secret(admin), algorithms = [ALGORITHM])
assert storage.verify_refresh_token(refresh) == (admin, False)
def test_refresh_cannot_outlive_a_rotation_it_raced(admin):
# /refresh consumes, then mints. A rotation landing in between must not let
# the replacement pair be signed with the credential that just replaced it.
secret = _verified_secret(admin)
token = create_refresh_token(subject = admin, secret = secret)
consumed = storage.consume_refresh_token(token)
assert consumed is not None
_username, _is_desktop, consumed_secret = consumed
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
access = create_access_token(subject = admin, secret = consumed_secret)
refresh = create_refresh_token(subject = admin, secret = consumed_secret)
with pytest.raises(jwt.InvalidTokenError):
jwt.decode(access, storage.get_jwt_secret(admin), algorithms = [ALGORITHM])
assert storage.verify_refresh_token(refresh) is None
def test_desktop_login_cannot_outlive_a_rotation_it_raced(admin):
# The reset deletes the desktop secret, so a desktop-login that validated it
# just beforehand must not mint a session that survives.
raw = storage.create_desktop_secret()
verified = storage.validate_desktop_secret_with_credential(raw)
assert verified is not None
_username, verified_secret = verified
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
access = create_access_token(subject = admin, desktop = True, secret = verified_secret)
refresh = create_refresh_token(subject = admin, desktop = True, secret = verified_secret)
with pytest.raises(jwt.InvalidTokenError):
jwt.decode(access, storage.get_jwt_secret(admin), algorithms = [ALGORITHM])
assert storage.verify_refresh_token(refresh) is None
def test_change_password_cannot_overwrite_a_rotation_it_raced(admin):
# A change-password that verified the old hash must not clobber a reset that
# committed while it was in flight.
_salt, verified_hash, _secret, _must_change = storage.get_user_and_secret(admin)
storage.update_password(admin, "reset-by-the-cli-789", revoke_refresh_tokens = True)
assert not storage.update_password(
admin,
"attacker-chosen-000",
revoke_refresh_tokens = True,
expect_password_hash = verified_hash,
)
salt, pwd_hash, _s, _m = storage.get_user_and_secret(admin)
assert hashing.verify_password("reset-by-the-cli-789", salt, pwd_hash)
def test_api_key_creation_from_a_revoked_credential_is_refused(admin):
generation = storage.credential_generation(_verified_secret(admin))
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
with pytest.raises(storage.CredentialRotated):
storage.create_api_key(username = admin, name = "k", expect_gen = generation)
conn = storage.get_connection()
try:
assert conn.execute("SELECT COUNT(*) AS c FROM api_keys").fetchone()["c"] == 0
finally:
conn.close()
def test_api_key_creation_under_the_current_credential_still_works(admin):
generation = storage.credential_generation(_verified_secret(admin))
raw_key, _row = storage.create_api_key(username = admin, name = "k", expect_gen = generation)
assert storage.validate_api_key(raw_key) == admin
def test_change_password_tokens_are_bound_to_its_own_write(admin):
# The tokens returned to a successful change-password must be signed with the
# secret that write produced, not whatever a later reset put in the DB.
_salt, verified_hash, _secret, _must = storage.get_user_and_secret(admin)
new_secret = storage.update_password(
admin,
"chosen-by-the-user",
revoke_refresh_tokens = True,
expect_password_hash = verified_hash,
)
assert new_secret is not None
storage.update_password(admin, "reset-by-the-cli-789", revoke_refresh_tokens = True)
access = create_access_token(subject = admin, secret = new_secret)
refresh = create_refresh_token(subject = admin, secret = new_secret)
with pytest.raises(jwt.InvalidTokenError):
jwt.decode(access, storage.get_jwt_secret(admin), algorithms = [ALGORITHM])
assert storage.verify_refresh_token(refresh) is None
def test_internal_api_key_minting_honours_the_request_generation(admin):
generation = storage.credential_generation(_verified_secret(admin))
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
with pytest.raises(storage.CredentialRotated):
storage.create_api_key(
username = admin,
name = "data-recipe workflow",
internal = True,
expect_gen = generation,
)
def test_api_key_auth_reports_the_version_the_key_was_valid_under(admin):
# The generation must come from the same transaction as the key check, or a
# revoked key could hand a route the post-reset generation and mint again.
raw, _row = storage.create_api_key(username = admin, name = "agent")
verified = storage.validate_api_key_with_credential(raw)
assert verified is not None
_user, secret = verified
generation = storage.credential_generation(secret)
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
conn = storage.get_connection()
try:
conn.execute("DELETE FROM api_keys")
conn.commit()
finally:
conn.close()
assert storage.validate_api_key(raw) is None
with pytest.raises(storage.CredentialRotated):
storage.create_api_key(username = admin, name = "after", expect_gen = generation)
def test_consuming_a_legacy_token_reports_the_pre_reset_credential(admin):
# An unstamped row has no generation to compare, so consume must read the
# credential inside the delete transaction rather than after committing it.
token = secrets.token_urlsafe(48)
expires_at = (datetime.now(timezone.utc) + timedelta(days = 7)).isoformat()
storage.save_refresh_token(token, admin, expires_at, secret_gen = None)
conn = storage.get_connection()
try:
conn.execute("UPDATE refresh_tokens SET secret_gen = NULL")
conn.commit()
finally:
conn.close()
consumed = storage.consume_refresh_token(token)
assert consumed is not None
_username, _is_desktop, consumed_secret = consumed
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
access = create_access_token(subject = admin, secret = consumed_secret)
with pytest.raises(jwt.InvalidTokenError):
jwt.decode(access, storage.get_jwt_secret(admin), algorithms = [ALGORITHM])
def test_unstamped_legacy_tokens_still_verify(admin):
# Rows written before the secret_gen column existed must not log users out.
token = secrets.token_urlsafe(48)
expires_at = (datetime.now(timezone.utc) + timedelta(days = 7)).isoformat()
storage.save_refresh_token(token, admin, expires_at, secret_gen = None)
conn = storage.get_connection()
try:
conn.execute("UPDATE refresh_tokens SET secret_gen = NULL")
conn.commit()
finally:
conn.close()
assert storage.verify_refresh_token(token) == (admin, False)

View file

@ -134,6 +134,218 @@ def test_ensure_default_admin_loads_existing_bootstrap_after_restart(monkeypatch
assert storage.get_bootstrap_password() == bootstrap_pw
def test_bootstrap_password_file_ends_with_a_newline():
# Otherwise `cat` welds the passphrase onto the shell prompt.
storage.ensure_default_admin()
# Bytes: read_text would decode CRLF back to "\n" and hide a CR.
raw = storage._BOOTSTRAP_PW_PATH.read_bytes()
assert raw == storage.get_bootstrap_password().encode("utf-8") + b"\n"
def test_bootstrap_password_round_trips_across_a_restart_with_the_newline():
storage.ensure_default_admin()
original = storage.get_bootstrap_password()
storage._bootstrap_password = None
assert storage.generate_bootstrap_password() == original
def test_upgrade_normalises_the_bootstrap_file():
# Upgrade path: the admin row exists, so generate_bootstrap_password() never runs.
seed_user()
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
storage.ensure_default_admin()
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == b"legacy-bootstrap-secret\n"
assert storage.get_bootstrap_password() == "legacy-bootstrap-secret"
@pytest.mark.parametrize(
"other",
[
b"legacy-bootstrap-secret\r\n", # only an unreleased build wrote this
b"legacy-bootstrap-secret\r",
b"legacy-bootstrap-secret ",
],
)
def test_only_an_exactly_unterminated_bootstrap_file_is_touched(other):
# Appending is safe only because it is restricted to the one released shape.
seed_user()
storage._BOOTSTRAP_PW_PATH.write_bytes(other)
storage.ensure_default_admin()
assert storage.get_bootstrap_password() == "legacy-bootstrap-secret"
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == other
def test_upgrade_normalises_when_the_admin_row_is_missing():
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
assert storage.generate_bootstrap_password() == "legacy-bootstrap-secret"
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == b"legacy-bootstrap-secret\n"
def test_a_well_formed_bootstrap_file_is_not_rewritten():
seed_user()
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret\n")
mtime = storage._BOOTSTRAP_PW_PATH.stat().st_mtime_ns
storage.ensure_default_admin()
assert storage._BOOTSTRAP_PW_PATH.stat().st_mtime_ns == mtime
def test_migration_failure_does_not_break_startup(monkeypatch):
seed_user()
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
real_open = storage.os.open
def refuse(path, flags, *args, **kwargs):
if str(path) == str(storage._BOOTSTRAP_PW_PATH):
raise PermissionError("read-only auth dir")
return real_open(path, flags, *args, **kwargs)
monkeypatch.setattr(storage.os, "open", refuse)
storage.ensure_default_admin()
assert storage.get_bootstrap_password() == "legacy-bootstrap-secret"
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == b"legacy-bootstrap-secret"
def test_normalising_never_recreates_a_cleared_bootstrap_file(monkeypatch):
# A rename would resurrect revoked plaintext if the password changed after the read.
seed_user()
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
real_open = storage.os.open
def clear_then_open(path, flags, *args, **kwargs):
if str(path) == str(storage._BOOTSTRAP_PW_PATH):
storage._BOOTSTRAP_PW_PATH.unlink(missing_ok = True)
return real_open(path, flags, *args, **kwargs)
monkeypatch.setattr(storage.os, "open", clear_then_open)
assert storage._read_persisted_bootstrap_password() == "legacy-bootstrap-secret"
assert not storage._BOOTSTRAP_PW_PATH.exists()
def test_normalising_does_not_overwrite_a_rotated_bootstrap_file(monkeypatch):
seed_user()
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
real_open = storage.os.open
def rotate_then_open(path, flags, *args, **kwargs):
if str(path) == str(storage._BOOTSTRAP_PW_PATH):
storage._BOOTSTRAP_PW_PATH.write_bytes(b"brand-new-secret\n")
return real_open(path, flags, *args, **kwargs)
monkeypatch.setattr(storage.os, "open", rotate_then_open)
storage._read_persisted_bootstrap_password()
# The append may add a second newline; the rotated credential must survive.
raw = storage._BOOTSTRAP_PW_PATH.read_bytes()
assert raw.strip() == b"brand-new-secret"
storage._bootstrap_password = None
assert storage._load_bootstrap_password() == "brand-new-secret"
def test_leading_whitespace_bootstrap_file_is_left_alone(monkeypatch):
# An in-place rewrite is not atomic, so only the exact unterminated shape is touched.
seed_user()
storage._BOOTSTRAP_PW_PATH.write_bytes(b" legacy-bootstrap-secret ")
storage.ensure_default_admin()
assert storage.get_bootstrap_password() == "legacy-bootstrap-secret"
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == b" legacy-bootstrap-secret "
def test_normalising_opens_the_file_in_binary_mode(monkeypatch):
# Without O_BINARY, Windows text mode turns the written LF back into CRLF.
seed_user()
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
monkeypatch.setattr(storage.os, "O_BINARY", 0x8000, raising = False)
seen = []
real_open = storage.os.open
def spy(path, flags, *args, **kwargs):
if str(path) == str(storage._BOOTSTRAP_PW_PATH):
seen.append(flags)
return real_open(path, flags & ~0x8000, *args, **kwargs)
monkeypatch.setattr(storage.os, "open", spy)
storage.ensure_default_admin()
assert seen and all(f & 0x8000 for f in seen), seen
def test_clearing_by_truncation_mid_normalisation_is_not_undone(monkeypatch):
# clear_bootstrap_password() truncates through its own descriptor when the unlink
# fails (Windows, while ours is open); the append must not restore the plaintext.
seed_user()
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
real_open = storage.os.open
def truncate_then_open(path, flags, *args, **kwargs):
fd = real_open(path, flags, *args, **kwargs)
if str(path) == str(storage._BOOTSTRAP_PW_PATH):
storage._BOOTSTRAP_PW_PATH.write_text("", encoding = "utf-8")
return fd
monkeypatch.setattr(storage.os, "open", truncate_then_open)
storage._read_persisted_bootstrap_password()
# A lone newline over a cleared file still reads back as no password.
assert storage._BOOTSTRAP_PW_PATH.read_bytes().strip() == b""
storage._bootstrap_password = None
assert storage._load_bootstrap_password() is None
def test_normalising_works_without_fchmod(monkeypatch):
# os.fchmod only reached Windows in 3.13; its absence must not raise.
seed_user()
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
monkeypatch.delattr(storage.os, "fchmod", raising = False)
storage.ensure_default_admin()
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == b"legacy-bootstrap-secret\n"
assert storage.get_bootstrap_password() == "legacy-bootstrap-secret"
def test_persisting_the_bootstrap_password_is_atomic(monkeypatch, tmp_path):
# A partial write would destroy the only plaintext recovery credential.
storage._persist_bootstrap_password("original-secret")
def boom(src, dst):
raise OSError("crash before replace")
monkeypatch.setattr(storage.os, "replace", boom)
with pytest.raises(OSError):
storage._persist_bootstrap_password("new-secret")
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == b"original-secret\n"
leftovers = [
p.name
for p in storage._BOOTSTRAP_PW_PATH.parent.iterdir()
if "bootstrap_password." in p.name
]
assert leftovers == []
def test_ensure_default_admin_does_not_generate_for_empty_existing_bootstrap():
seed_user()
storage._BOOTSTRAP_PW_PATH.write_text(" \n", encoding = "utf-8")
@ -233,7 +445,7 @@ def test_consume_refresh_token_second_call_returns_none():
storage.save_refresh_token(raw, storage.DEFAULT_ADMIN_USERNAME, expires)
first = storage.consume_refresh_token(raw)
assert first == (storage.DEFAULT_ADMIN_USERNAME, False)
assert first[:2] == (storage.DEFAULT_ADMIN_USERNAME, False)
second = storage.consume_refresh_token(raw)
assert second is None
@ -262,7 +474,7 @@ def test_consume_refresh_token_concurrent_only_one_succeeds(tmp_path, monkeypatc
successes = [r for r in results if r is not None]
assert len(successes) == 1, f"expected exactly one consumer to win, got {len(successes)}"
assert successes[0] == (storage.DEFAULT_ADMIN_USERNAME, False)
assert successes[0][:2] == (storage.DEFAULT_ADMIN_USERNAME, False)
def test_consume_refresh_token_expired_returns_none():
@ -336,6 +548,28 @@ def test_local_recipe_token_authenticates_as_admin_for_web_user(loaded_local_mod
assert asyncio.run(get_current_subject(credentials)) == storage.DEFAULT_ADMIN_USERNAME
def test_rotated_credential_job_start_is_401_not_500(loaded_local_model):
# A reset-password landing mid-request makes the workflow-key mint refuse.
# That must reach the client as a revoked credential, not an unhandled error.
from fastapi import HTTPException
seed_user()
jobs_route = data_recipe_jobs_module()
stale_gen = storage.credential_generation(secrets.token_urlsafe(64))
with pytest.raises(storage.CredentialRotated):
jobs_route._inject_local_providers(local_recipe(), local_recipe_request("t"), stale_gen)
def _boom(*_a, **_k):
raise storage.CredentialRotated("revoked")
jobs_route._inject_local_providers = _boom
payload = SimpleNamespace(recipe = local_recipe(), run = {})
with pytest.raises(HTTPException) as excinfo:
jobs_route.create_job(payload, local_recipe_request("t"), ("unsloth", stale_gen))
assert excinfo.value.status_code == 401
def test_desktop_login_rejects_invalid_secret():
seed_user(must_change_password = False)
client = auth_client()
@ -358,7 +592,7 @@ def test_write_desktop_secret_file_is_0600_on_unix(tmp_path):
studio_cli._write_auth_secret(path, "desktop-secret")
assert path.read_text() == "desktop-secret"
assert path.read_bytes() == b"desktop-secret\n"
if platform.system() != "Windows":
assert oct(path.stat().st_mode & 0o777) == "0o600"
@ -368,18 +602,31 @@ def test_reset_password_removes_desktop_secret_files(tmp_path, monkeypatch):
from unsloth_cli.commands import studio as studio_cli
auth_dir = tmp_path / "auth"
auth_dir.mkdir()
(auth_dir / "auth.db").write_text("db")
(auth_dir / ".bootstrap_password").write_text("boot")
(auth_dir / ".desktop_secret").write_text("new")
monkeypatch.setattr(studio_cli, "STUDIO_HOME", tmp_path)
secret = studio_cli._create_desktop_secret_in_cli()
studio_cli._write_auth_secret(auth_dir / studio_cli.DESKTOP_SECRET_FILE, secret)
(auth_dir / studio_cli.BOOTSTRAP_PASSWORD_FILE).write_text("boot")
result = CliRunner().invoke(studio_cli.studio_app, ["reset-password"])
assert result.exit_code == 0
assert not (auth_dir / "auth.db").exists()
assert not (auth_dir / ".bootstrap_password").exists()
assert not (auth_dir / ".desktop_secret").exists()
assert result.exit_code == 0, result.output
# The DB survives on purpose: a running server keeps serving from its admin row.
assert (auth_dir / "auth.db").exists()
assert not (auth_dir / studio_cli.BOOTSTRAP_PASSWORD_FILE).exists()
assert not (auth_dir / studio_cli.DESKTOP_SECRET_FILE).exists()
conn = studio_cli._connect_auth_db()
try:
surviving = conn.execute(
"SELECT COUNT(*) FROM app_secrets WHERE key IN (?, ?)",
(
studio_cli.DESKTOP_SECRET_HASH_KEY,
studio_cli.DESKTOP_SECRET_CREATED_AT_KEY,
),
).fetchone()[0]
finally:
conn.close()
assert surviving == 0
def test_reset_password_removes_desktop_secret_files_without_db(tmp_path, monkeypatch):
@ -525,7 +772,8 @@ if result.exit_code != 0:
capture_output = True,
)
assert result.returncode == 0, result.stderr + result.stdout
secret = (auth_dir / ".desktop_secret").read_text()
# Strip like the src-tauri readers do.
secret = (auth_dir / ".desktop_secret").read_text().strip()
assert secret.startswith("desktop-")
conn = sqlite3.connect(auth_dir / "auth.db")
@ -633,7 +881,7 @@ def test_update_password_clears_desktop_secret():
assert storage.validate_desktop_secret(raw) == storage.DEFAULT_ADMIN_USERNAME
changed = storage.update_password(storage.DEFAULT_ADMIN_USERNAME, "new-admin-password")
assert changed is True
assert changed
assert storage.validate_desktop_secret(raw) is None
@ -642,7 +890,7 @@ def test_update_password_on_unknown_user_leaves_desktop_secret_intact():
raw = storage.create_desktop_secret()
changed = storage.update_password("not-a-user", "irrelevant")
assert changed is False
assert not changed
assert storage.validate_desktop_secret(raw) == storage.DEFAULT_ADMIN_USERNAME

View file

@ -247,8 +247,8 @@ def test_lifespan_honors_bootstrap_suppression_in_source():
def test_clear_bootstrap_password_truncates_when_unlink_fails(monkeypatch, tmp_path):
# If the file cannot be unlinked (Windows AV / read-only auth dir), clear must
# truncate it so its stale plaintext cannot be re-seeded by
# generate_bootstrap_password() after a later reset-password deletes auth.db,
# which would re-validate the revoked bootstrap password.
# generate_bootstrap_password() if auth.db is ever recreated, which would
# re-validate the revoked bootstrap password.
import pathlib
pw_path = tmp_path / ".bootstrap_password"

View file

@ -45,8 +45,20 @@ def _build_structlog_stub():
_maybe_stub("loggers", _build_loggers_stub)
_maybe_stub("structlog", _build_structlog_stub)
import pytest
import utils.hardware.hardware as hw # noqa: E402
# The DRM/KFD readers below are Linux-only in production: _rocm_linux_amdgpu_cards and
# _rocm_linux_sysfs_vram_by_pci_gb return early unless platform.system() is "Linux", and
# _rocm_kfd_gpu_pci_ids only ever globs /sys/class/kfd. Their fake sysfs tree needs PCI
# addresses like "0000:00:02.0" as directory names and POSIX separators in the paths the
# readers match; Windows permits neither, so the tree cannot be represented there.
linux_only = pytest.mark.skipif(
not sys.platform.startswith("linux"),
reason = "covers Linux-only DRM/KFD sysfs parsing driven by a fake /sys tree",
)
def _device(
index,
@ -99,6 +111,7 @@ def _fake_drm(tmp_path, monkeypatch, cards):
return card_paths
@linux_only
def test_linux_vram_keyed_by_pci_excludes_foreign_adapters(monkeypatch, tmp_path):
# Foreign (non-amdgpu) adapters contribute no entry, so they cannot shift ordinals.
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
@ -117,6 +130,7 @@ def test_linux_vram_keyed_by_pci_excludes_foreign_adapters(monkeypatch, tmp_path
}
@linux_only
def test_linux_vram_omits_bad_cards_without_shifting(monkeypatch, tmp_path):
# A zero-total card has no entry; identity keying means its absence renumbers nothing.
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
@ -131,6 +145,7 @@ def test_linux_vram_omits_bad_cards_without_shifting(monkeypatch, tmp_path):
assert hw._rocm_linux_sysfs_vram_by_pci_gb() == {"0000:41:00.0": (2.0, 16.0)}
@linux_only
def test_linux_vram_omits_amd_card_without_vram_files(monkeypatch, tmp_path):
# An APU with no mem_info_vram_* files has no entry; the discrete card keeps its address.
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
@ -174,6 +189,7 @@ def _fake_kfd(tmp_path, monkeypatch, nodes):
return node_paths
@linux_only
def test_kfd_lists_gpu_nodes_in_device_order(monkeypatch, tmp_path):
# The CPU node (simd_count 0) takes no ordinal; GPU nodes in node-id order are HIP's order.
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
@ -189,12 +205,14 @@ def test_kfd_lists_gpu_nodes_in_device_order(monkeypatch, tmp_path):
assert hw._rocm_kfd_gpu_pci_ids() == ["0000:03:00.0", "0000:41:00.0"]
@linux_only
def test_kfd_decodes_domain_device_and_function(monkeypatch, tmp_path):
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
_fake_kfd(tmp_path, monkeypatch, [(1, 64, (0xC1 << 8) | (0x1F << 3) | 5, 0x1234, _AMD)])
assert hw._rocm_kfd_gpu_pci_ids() == ["1234:c1:1f.5"]
@linux_only
def test_kfd_skips_non_amd_gpu_nodes(monkeypatch, tmp_path):
# An NVIDIA KFD node is not a HIP device: it must take no ordinal, else it
# shifts every AMD GPU and ROCm device 1 resolves to AMD GPU 0.
@ -212,6 +230,7 @@ def test_kfd_skips_non_amd_gpu_nodes(monkeypatch, tmp_path):
assert hw._rocm_kfd_gpu_pci_ids() == ["0000:03:00.0", "0000:41:00.0"]
@linux_only
def test_kfd_fails_closed_when_a_gpu_has_no_location(monkeypatch, tmp_path):
# Dropping an unplaceable AMD GPU shifts later ordinals; fail closed for the whole map.
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
@ -226,6 +245,7 @@ def test_kfd_fails_closed_when_a_gpu_has_no_location(monkeypatch, tmp_path):
assert hw._rocm_kfd_gpu_pci_ids() == []
@linux_only
def test_kfd_fails_closed_when_a_node_is_unreadable(monkeypatch, tmp_path):
# An unreadable node could be a GPU; assuming otherwise would shift ordinals.
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
@ -241,6 +261,23 @@ def test_kfd_fails_closed_when_a_node_is_unreadable(monkeypatch, tmp_path):
assert hw._rocm_kfd_gpu_pci_ids() == []
@linux_only
def test_kfd_fails_closed_when_a_node_does_not_decode(monkeypatch, tmp_path):
# UnicodeDecodeError is a ValueError, so it slips past `except OSError` and
# would shift every later HIP ordinal.
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
paths = _fake_kfd(
tmp_path,
monkeypatch,
[
(1, 304, (0x03 << 8) | 0, 0, _AMD),
(2, 304, (0x41 << 8) | 0, 0, _AMD),
],
)
(Path(paths[0]) / "properties").write_bytes(b"simd_count 304\nvendor_id \x80\xff\n")
assert hw._rocm_kfd_gpu_pci_ids() == []
def test_kfd_absent_yields_no_device_order(monkeypatch):
monkeypatch.setattr(hw.glob, "glob", lambda pattern: [])
assert hw._rocm_kfd_gpu_pci_ids() == []
@ -422,6 +459,10 @@ def test_visible_utilization_rocm_fallback_overlays(monkeypatch):
):
monkeypatch.delenv(_var, raising = False)
monkeypatch.setattr(hw, "IS_ROCM", True)
# No AMD adapter data on this host. On Windows this branch runs ahead of the torch
# fallback under test, and probing it imports torch, which the CI runner does not
# install. Off Windows the real function is never reached, so this changes nothing.
monkeypatch.setattr(hw, "_rocm_windows_per_device_vram", lambda ids: [])
monkeypatch.setattr(hw, "get_device", lambda: hw.DeviceType.CUDA)
monkeypatch.setattr(hw, "_smi_query", lambda *a, **k: None) # amd-smi unavailable
monkeypatch.setattr(
@ -450,6 +491,10 @@ def test_visible_utilization_rocm_fallback_overlays(monkeypatch):
def test_visible_utilization_relative_index_skips_overlay(monkeypatch):
# UUID/MIG mask gives relative indices; the overlay matches physical index, so it must not run.
monkeypatch.setattr(hw, "IS_ROCM", True)
# No AMD adapter data on this host. On Windows this branch runs ahead of the torch
# fallback under test, and probing it imports torch, which the CI runner does not
# install. Off Windows the real function is never reached, so this changes nothing.
monkeypatch.setattr(hw, "_rocm_windows_per_device_vram", lambda ids: [])
monkeypatch.setattr(hw, "get_device", lambda: hw.DeviceType.CUDA)
monkeypatch.setattr(hw, "_smi_query", lambda *a, **k: None)
monkeypatch.setattr(

View file

@ -0,0 +1,568 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Per-port PID files, so `unsloth studio stop` can find every server.
Imports run.py directly, so run under the Unsloth venv.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
_BACKEND = Path(__file__).resolve().parents[1]
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))
import run # noqa: E402
# Captured before the autouse fixture stubs them, for the tests that exercise them.
_REAL_IS_STUDIO_BACKEND = run._pid_is_studio_backend
_REAL_PID_ALIVE = run._pid_alive
@pytest.fixture(autouse = True)
def isolated_root(tmp_path, monkeypatch):
monkeypatch.setattr(run, "_studio_root", lambda: tmp_path)
monkeypatch.setattr(run, "_PID_FILE", tmp_path / "studio.pid")
monkeypatch.setattr(run, "_OWN_PID_FILE", None)
monkeypatch.setattr(run, "_pid_alive", lambda pid: True)
monkeypatch.setattr(run, "_pid_is_studio_backend", lambda pid, created_times = (): True)
yield
def _files(tmp_path):
return sorted(p.name for p in tmp_path.glob("studio-*.pid"))
def _pid_of(path):
return path.read_text(encoding = "utf-8").splitlines()[0]
def test_write_pid_file_records_port_and_pid(tmp_path):
run._write_pid_file(8901)
assert _files(tmp_path) == [f"studio-8901-{os.getpid()}.pid"]
assert _pid_of(tmp_path / f"studio-8901-{os.getpid()}.pid") == str(os.getpid())
def test_write_pid_file_records_the_start_time(tmp_path):
# Pins the record to this process, so a reused PID isn't mistaken for it.
run._write_pid_file(8901)
record = run._read_pid_record(tmp_path / f"studio-8901-{os.getpid()}.pid")
assert record[0] == os.getpid()
assert record[1] == pytest.approx(run._process_create_time(os.getpid()))
def test_write_pid_file_keeps_the_legacy_file_a_bare_pid(tmp_path):
# An older CLI's `stop` reads studio.pid and expects only digits.
run._write_pid_file(8901)
assert (tmp_path / "studio.pid").read_text(encoding = "utf-8") == str(os.getpid())
def test_second_port_does_not_clobber_the_first(tmp_path):
(tmp_path / "studio-8901-8550.pid").write_text("8550", encoding = "utf-8")
run._write_pid_file(8902)
assert _pid_of(tmp_path / "studio-8901-8550.pid") == "8550"
assert (tmp_path / f"studio-8902-{os.getpid()}.pid").exists()
def test_same_port_on_two_binds_does_not_clobber(tmp_path):
# 127.0.0.1:8888 and ::1:8888 can both listen; one file per port would lose one.
(tmp_path / "studio-8888-8550.pid").write_text("8550", encoding = "utf-8")
run._write_pid_file(8888)
assert len(_files(tmp_path)) == 2
def test_remove_pid_file_only_removes_our_own(tmp_path, monkeypatch):
run._write_pid_file(8901)
(tmp_path / "studio-8902-8600.pid").write_text("8600", encoding = "utf-8")
# Nothing to hand the legacy pointer to, so it goes away with us.
monkeypatch.setattr(run, "_pid_alive", lambda pid: pid == os.getpid())
run._remove_pid_file()
assert _files(tmp_path) == ["studio-8902-8600.pid"]
assert not (tmp_path / "studio.pid").exists()
def test_the_legacy_pointer_moves_to_a_live_sibling(tmp_path):
# Only one server owns studio.pid. Deleting it on our way out would leave an
# older CLI, which reads nothing else, unable to stop the sibling still up.
run._write_pid_file(8901)
(tmp_path / "studio-8902-8600.pid").write_text("8600", encoding = "utf-8")
run._remove_pid_file()
assert (tmp_path / "studio.pid").read_text(encoding = "utf-8").strip() == "8600"
def test_the_legacy_pointer_is_not_handed_to_a_dead_sibling(tmp_path, monkeypatch):
run._write_pid_file(8901)
(tmp_path / "studio-8902-8600.pid").write_text("8600", encoding = "utf-8")
monkeypatch.setattr(run, "_pid_is_studio_backend", lambda pid, created_times = (): False)
run._remove_pid_file()
assert not (tmp_path / "studio.pid").exists()
def test_remove_pid_file_leaves_a_reused_entry_alone(tmp_path):
run._write_pid_file(8901)
own = tmp_path / f"studio-8901-{os.getpid()}.pid"
own.write_text("999999", encoding = "utf-8")
run._remove_pid_file()
assert own.read_text(encoding = "utf-8") == "999999"
def test_windows_liveness_does_not_call_every_pid_alive(monkeypatch):
# os.kill(pid, 0) raises OSError for every pid on Windows, so without the
# tasklist fallback a stale record would block its port forever.
import subprocess
monkeypatch.setattr(run, "_pid_alive", _REAL_PID_ALIVE)
monkeypatch.setitem(sys.modules, "psutil", None)
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setattr(
subprocess, "run", lambda *a, **k: SimpleNamespace(stdout = '"python.exe","8550",...')
)
assert run._pid_alive(8550) is True
assert run._pid_alive(9999) is False
def test_windows_liveness_keeps_the_record_when_tasklist_fails(monkeypatch):
# Unconfirmed must mean keep, matching the CLI's _pid_alive. Pruning a live
# server's record lets the next launch fall back past it and strand it, which
# is the bug this file exists to fix; a stale record costs one clear abort.
import subprocess
def _boom(*a, **k):
raise OSError("tasklist missing")
monkeypatch.setattr(run, "_pid_alive", _REAL_PID_ALIVE)
monkeypatch.setitem(sys.modules, "psutil", None)
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setattr(subprocess, "run", _boom)
assert run._pid_alive(8550) is True
def test_read_pid_record_parses_pid_time_and_address(tmp_path):
(tmp_path / "r.pid").write_text("8550\n111.5\n127.0.0.1", encoding = "utf-8")
assert run._read_pid_record(tmp_path / "r.pid") == (8550, 111.5, "127.0.0.1")
def test_read_pid_record_tolerates_a_bare_pid(tmp_path):
(tmp_path / "r.pid").write_text("8550", encoding = "utf-8")
assert run._read_pid_record(tmp_path / "r.pid") == (8550, None, None)
def test_read_pid_record_rejects_pid_zero_and_init(tmp_path):
# kill(0) signals our whole process group.
(tmp_path / "zero.pid").write_text("0", encoding = "utf-8")
(tmp_path / "init.pid").write_text("1", encoding = "utf-8")
assert run._read_pid_record(tmp_path / "zero.pid") is None
assert run._read_pid_record(tmp_path / "init.pid") is None
def test_read_pid_record_rejects_a_corrupt_file(tmp_path):
(tmp_path / "r.pid").write_text("not-a-pid", encoding = "utf-8")
assert run._read_pid_record(tmp_path / "r.pid") is None
def test_graceful_shutdown_drops_the_record_last(monkeypatch):
# Cleanup can take seconds while the server is still alive. Dropping the record
# first leaves a retried `stop` or a new launch unable to find it.
order = []
monkeypatch.setattr(run, "_remove_pid_file", lambda: order.append("remove_record"))
class _Server:
def __setattr__(self, name, value):
order.append("release_socket")
run._graceful_shutdown(_Server())
assert order == ["release_socket", "remove_record"]
def test_own_studio_on_port_is_found_without_psutil(tmp_path, monkeypatch):
# psutil is optional; a listener scan finds nothing without it, so detection
# must come from our own records or we silently start a duplicate.
monkeypatch.setitem(sys.modules, "psutil", None)
(tmp_path / "studio-8901-8550.pid").write_text("8550\n\n127.0.0.1", encoding = "utf-8")
assert run._own_studio_on_port(8901, "127.0.0.1") == 8550
def test_no_record_for_the_port_means_no_own_studio(tmp_path):
# jupyter-lab on 8888 must keep the fallback, not abort the launch.
(tmp_path / "studio-8901-8550.pid").write_text("8550", encoding = "utf-8")
assert run._own_studio_on_port(8888, "127.0.0.1") is None
def test_own_studio_on_port_prunes_a_dead_record(tmp_path, monkeypatch):
monkeypatch.setattr(run, "_pid_alive", lambda pid: False)
(tmp_path / "studio-8901-8550.pid").write_text("8550", encoding = "utf-8")
assert run._own_studio_on_port(8901, "127.0.0.1") is None
assert not (tmp_path / "studio-8901-8550.pid").exists()
def test_a_reused_pid_is_not_treated_as_our_studio(tmp_path, monkeypatch):
# Stale record + the OS handing that PID to something else must not abort.
monkeypatch.setattr(run, "_pid_is_studio_backend", lambda pid, created_times = (): False)
(tmp_path / "studio-8901-8550.pid").write_text("8550", encoding = "utf-8")
assert run._own_studio_on_port(8901, "127.0.0.1") is None
def test_an_unverifiable_record_still_blocks_a_duplicate(tmp_path, monkeypatch):
# Can't tell: refusing with a clear message beats a silent second instance.
monkeypatch.setattr(run, "_pid_is_studio_backend", lambda pid, created_times = (): True)
(tmp_path / "studio-8901-8550.pid").write_text("8550", encoding = "utf-8")
assert run._own_studio_on_port(8901, "127.0.0.1") == 8550
def test_start_time_mismatch_rejects_a_reused_pid(monkeypatch):
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
assert run._pid_is_studio_backend(8550, [111.5]) is False
assert run._pid_is_studio_backend(8550, [999.0]) is True
def test_a_stale_record_does_not_veto_a_live_server_sharing_the_pid(monkeypatch):
# Crash leaves studio-8888-1234.pid, the OS reuses 1234 for a new server on
# another port. Keeping only the first timestamp would reject the live one.
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
assert run._pid_is_studio_backend(1234, [111.5, 999.0]) is True
assert run._pid_is_studio_backend(1234, [111.5, 222.5]) is False
def test_a_stale_record_on_another_port_does_not_hide_a_live_server(tmp_path, monkeypatch):
# 1234 was reused: the stale 8888 record must not stop us seeing 9000.
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
(tmp_path / "studio-8888-1234.pid").write_text("1234\n111.5\n", encoding = "utf-8")
(tmp_path / "studio-9000-1234.pid").write_text("1234\n999.0\n", encoding = "utf-8")
assert run._own_studio_on_port(8888, "127.0.0.1") is None
assert run._own_studio_on_port(9000, "127.0.0.1") == 1234
def test_a_start_time_is_the_only_thing_that_disproves_a_record(monkeypatch):
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
assert run._pid_is_studio_backend(8550, [999.0]) is True
assert run._pid_is_studio_backend(8550, [111.5]) is False
def test_a_bare_run_py_command_line_is_not_rejected(monkeypatch):
# `cd studio/backend && python run.py --port 8901` has no "studio" or "unsloth"
# in argv. Guessing from the command line called that "not ours".
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
class _FakeProcess:
def __init__(self, pid):
self.pid = pid
def cmdline(self):
return ["python", "run.py", "--port", "8901"]
def create_time(self):
return 111.5
monkeypatch.setitem(sys.modules, "psutil", SimpleNamespace(Process = _FakeProcess))
assert run._pid_is_studio_backend(8550) is True
def test_an_untimed_legacy_record_is_trusted(monkeypatch):
# `python run.py --port 8901` has no telltale argv, so guessing from the
# command line rejected real servers. Only a start time can disprove one.
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
assert run._pid_is_studio_backend(8550) is True
assert run._pid_is_studio_backend(8550, [None]) is True
def test_the_untimed_legacy_record_does_not_cancel_a_timed_one(monkeypatch):
# Mirrors _pid_is_studio_server in the CLI. An untimed record carries no
# information, so it must not overrule a start time that says "not ours" --
# every current server writes one of each, which made the check inert.
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
assert run._pid_is_studio_backend(8550, [111.5, None]) is False
assert run._pid_is_studio_backend(8550, [111.5, 999.0]) is True
def test_a_legacy_server_on_the_port_is_recognised(tmp_path, monkeypatch):
# Pre-upgrade servers wrote only studio.pid. Falling back past one strands it
# and then overwrites its record.
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: (8550, "python"))
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
assert run._own_studio_on_port(8901, "127.0.0.1") == 8550
def test_a_legacy_record_for_a_different_listener_falls_back(tmp_path, monkeypatch):
# jupyter holds the port; the legacy server is elsewhere. Keep falling back.
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: (117, "jupyter-lab"))
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
assert run._own_studio_on_port(8901, "127.0.0.1") is None
def test_an_unknowable_listener_treats_the_legacy_record_as_ours(tmp_path, monkeypatch):
# No psutil: _get_pid_on_port can't say. Refusing beats a silent duplicate.
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: None)
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
assert run._own_studio_on_port(8901, "127.0.0.1") == 8550
def test_a_dead_legacy_record_falls_back(tmp_path, monkeypatch):
monkeypatch.setattr(run, "_pid_alive", lambda pid: False)
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: None)
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
assert run._own_studio_on_port(8901, "127.0.0.1") is None
def test_a_stale_per_port_record_does_not_mask_a_legacy_server(tmp_path, monkeypatch):
# Crashed current build left studio-8901-8550.pid; 8550 was then reused by a
# pre-upgrade server recorded only in studio.pid. The stale record must not
# count as "port already known" and send us falling back past the live one.
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: (8550, "python"))
(tmp_path / "studio-8901-8550.pid").write_text("8550\n111.5\n127.0.0.1", encoding = "utf-8")
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
assert run._own_studio_on_port(8901, "127.0.0.1") == 8550
def test_a_current_server_elsewhere_does_not_block_a_foreign_port(tmp_path, monkeypatch):
# Current builds write studio.pid too. Without psutil the legacy check can't
# see the listener, so it must not claim our 8901 server holds jupyter's 8888.
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: None)
(tmp_path / "studio-8901-5000.pid").write_text("5000\n\n127.0.0.1", encoding = "utf-8")
(tmp_path / "studio.pid").write_text("5000", encoding = "utf-8")
assert run._own_studio_on_port(8888, "127.0.0.1") is None
def test_a_per_port_record_is_preferred_over_the_legacy_one(tmp_path, monkeypatch):
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: (8550, "python"))
(tmp_path / "studio-8901-8600.pid").write_text("8600\n\n127.0.0.1", encoding = "utf-8")
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
assert run._own_studio_on_port(8901, "127.0.0.1") == 8600
def test_our_studio_on_another_bind_address_does_not_abort(tmp_path):
# Our server holds ::1:8889; binding 127.0.0.1:8889 is not a conflict with us,
# so fall through to the next port instead of refusing.
(tmp_path / "studio-8889-8550.pid").write_text("8550\n\n::1", encoding = "utf-8")
assert run._own_studio_on_port(8889, "127.0.0.1") is None
assert run._own_studio_on_port(8889, "::1") == 8550
def test_address_matching(tmp_path):
assert run._addresses_collide("0.0.0.0", "127.0.0.1", 8889) is True
assert run._addresses_collide("127.0.0.1", "0.0.0.0", 8889) is True
assert run._addresses_collide("127.0.0.1", "127.0.0.1", 8889) is True
assert run._addresses_collide("::1", "127.0.0.1", 8889) is False
# An unrecorded address is unknown, so assume a conflict.
assert run._addresses_collide(None, "127.0.0.1", 8889) is True
def test_a_hostname_resolves_the_same_way_the_bind_does(tmp_path):
# `localhost` and the address _is_port_free actually binds must agree, or a
# recorded server is missed and a duplicate starts.
recorded = ",".join(sorted(run._bind_addresses("localhost", 8889)))
assert run._addresses_collide(recorded, "localhost", 8889) is True
def test_a_hostname_records_every_address_it_resolves_to(tmp_path):
# `localhost` binds 127.0.0.1 AND ::1. Recording only the first lets a later
# launch on the other literal miss us and start a duplicate.
addrs = run._bind_addresses("localhost", 8889)
recorded = ",".join(sorted(addrs))
for literal in addrs:
assert run._addresses_collide(recorded, literal, 8889) is True
def test_a_multi_address_record_matches_either_literal(tmp_path):
recorded = "127.0.0.1,::1"
assert run._addresses_collide(recorded, "127.0.0.1", 8889) is True
assert run._addresses_collide(recorded, "::1", 8889) is True
assert run._addresses_collide("127.0.0.1", "::1", 8889) is False
def test_fallback_aborts_on_our_own_server_further_up_the_range(tmp_path, monkeypatch):
# jupyter holds 8888, our server holds 8889: skipping to 8890 is the duplicate.
(tmp_path / "studio-8889-8550.pid").write_text("8550\n\n127.0.0.1", encoding = "utf-8")
monkeypatch.setattr(run, "_is_port_free", lambda host, p: p >= 8890)
with pytest.raises(SystemExit) as excinfo:
run._find_free_port("127.0.0.1", 8889, avoid_own_studio = True)
assert excinfo.value.code == 1
def test_fallback_still_skips_foreign_processes(tmp_path, monkeypatch):
# No record for 8889, so the blocker is not ours: keep falling back.
monkeypatch.setattr(run, "_is_port_free", lambda host, p: p >= 8890)
assert run._find_free_port("127.0.0.1", 8889, avoid_own_studio = True) == 8890
def test_the_requested_port_is_kept_when_it_is_free(monkeypatch):
monkeypatch.setattr(run, "_is_port_free", lambda host, p: True)
assert run._resolve_port("127.0.0.1", 8888) == 8888
def test_our_own_server_on_the_requested_port_aborts_rather_than_falling_back(
tmp_path, monkeypatch
):
# The reported bug: 8888 is ours, so falling back to 8889 is the duplicate
# that leaves 8888 serving with nothing recording it.
monkeypatch.setattr(run, "_is_port_free", lambda host, p: p != 8888)
(tmp_path / "studio-8888-8550.pid").write_text("8550\n\n127.0.0.1", encoding = "utf-8")
with pytest.raises(SystemExit) as excinfo:
run._resolve_port("127.0.0.1", 8888)
assert excinfo.value.code == 1
def test_a_foreign_process_on_the_requested_port_still_falls_back(monkeypatch):
# jupyter-lab on 8888 must not stop Unsloth starting on 8889.
monkeypatch.setattr(run, "_is_port_free", lambda host, p: p != 8888)
assert run._resolve_port("127.0.0.1", 8888) == 8889
def test_a_caller_that_reads_the_port_back_keeps_the_plain_fallback(tmp_path, monkeypatch):
# api-only callers (the desktop app via TAURI_PORT, `studio run` via
# app.state.server_port) follow us to the new port, so aborting there only
# turns a working launch into a crash the desktop app reports as "stopped
# unexpectedly". Both servers are still recorded, so `stop` finds them.
monkeypatch.setattr(run, "_is_port_free", lambda host, p: p != 8888)
(tmp_path / "studio-8888-8550.pid").write_text("8550\n\n127.0.0.1", encoding = "utf-8")
assert run._resolve_port("127.0.0.1", 8888, avoid_own_studio = False) == 8889
def test_the_recorded_address_is_every_address_the_bind_resolves_to(tmp_path):
# The only test that runs the writer with a real host. Recording `host`
# verbatim, or dropping the line, passes every other test here and silently
# stops matching a launch that spells the same interface differently.
run._write_pid_file(8901, "localhost")
record = run._read_pid_record(tmp_path / f"studio-8901-{os.getpid()}.pid")
assert record[2] is not None, "no bind address recorded"
assert set(record[2].split(",")) == run._bind_addresses("localhost", 8901)
def test_a_server_started_on_a_hostname_is_found_again_by_ip(tmp_path):
run._write_pid_file(8901, "localhost")
for literal in run._bind_addresses("localhost", 8901):
assert run._own_studio_on_port(8901, literal) == os.getpid()
def test_bind_addresses_keeps_every_family_a_hostname_resolves_to(monkeypatch):
# Independent oracle: the sibling test derives its expectation from this
# function's own output, so dropping a family would pass it.
import socket
monkeypatch.setattr(
socket,
"getaddrinfo",
lambda *a, **k: [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 8889)),
(socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("::1", 8889, 0, 0)),
],
)
assert run._bind_addresses("localhost", 8889) == {"127.0.0.1", "::1"}
def test_the_legacy_file_is_written_even_when_the_per_port_record_fails(tmp_path, monkeypatch):
# A studio root that cannot take a new entry used to leave the server
# recorded nowhere at all, so the CLI could not stop it. studio.pid is an
# overwrite of an existing path, so it can still succeed and must be tried.
blocked = tmp_path / "not-a-directory"
blocked.write_text("", encoding = "utf-8")
monkeypatch.setattr(
run, "_pid_file_for_port", lambda port: blocked / f"studio-{port}-{os.getpid()}.pid"
)
run._write_pid_file(8901, "127.0.0.1")
assert (tmp_path / "studio.pid").read_text(encoding = "utf-8") == str(os.getpid())
assert run._OWN_PID_FILE is None
def test_a_record_whose_pid_is_not_ascii_digits_is_discarded(tmp_path):
# A superscript two passes isdigit() but int() rejects it, so that gate alone
# let a ValueError escape into every caller of _read_pid_record.
(tmp_path / "r.pid").write_text("²", encoding = "utf-8")
assert run._read_pid_record(tmp_path / "r.pid") is None
def test_the_legacy_file_is_not_taken_from_a_live_server(tmp_path):
# A pre-upgrade server is recorded in studio.pid and nowhere else, so a
# second launch overwriting it is exactly what strands it. That is the
# orphan this file exists to prevent, reached from the other direction.
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
run._write_pid_file(8902, "127.0.0.1")
assert (tmp_path / "studio.pid").read_text(encoding = "utf-8") == "8550"
assert (tmp_path / f"studio-8902-{os.getpid()}.pid").exists()
def test_the_legacy_file_is_taken_over_from_a_dead_server(tmp_path, monkeypatch):
# A stale record must not keep the pointer forever, or an older CLI could
# never stop anything again.
monkeypatch.setattr(run, "_pid_alive", lambda pid: False)
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
run._write_pid_file(8902, "127.0.0.1")
assert (tmp_path / "studio.pid").read_text(encoding = "utf-8") == str(os.getpid())

View file

@ -0,0 +1,809 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Text I/O must name its encoding, or Windows silently uses the ANSI codepage.
``open()``, ``Path.read_text()`` and ``subprocess(text = True)`` fall back to
``locale.getencoding()`` when no ``encoding`` is passed. On Windows that is
cp1252 (or cp932, cp1251, ... by system locale), not UTF-8, so a chat template,
model config or path containing ``ä ö ü `` mojibakes or raises
``UnicodeDecodeError`` mid-load. Studio's files are UTF-8, so say so.
"""
from __future__ import annotations
import ast
import importlib.util
import json
import os
from pathlib import Path
from types import SimpleNamespace
import pytest
BACKEND_ROOT = Path(__file__).resolve().parent.parent
# Not runtime source. Shipped plugins under plugins/*/src are, so only builds are skipped.
_SKIPPED_DIRS = ("node_modules", "build", "tests", "__pycache__")
# Path.open()'s signature is what tells it apart from other libraries' open(),
# e.g. fitz.open(stream=...) and av.open(..., metadata_errors=...).
_FILE_MODE_CHARS = set("rwxabt+")
_PATH_OPEN_ARGS = ("mode", "buffering", "encoding", "errors", "newline")
_PATH_OPEN_KWARGS = set(_PATH_OPEN_ARGS)
_PATH_OPEN_ENCODING_ARG = _PATH_OPEN_ARGS.index("encoding")
_SUBPROCESS_CALLS = {"run", "Popen", "check_output", "check_call", "call"}
# open(file, mode, buffering, encoding, ...), and os.fdopen forwards the same
# signature with a descriptor in place of the path.
_OPEN_ENCODING_ARG = 3
def _studio_sources() -> list[Path]:
return [
path
for path in sorted(BACKEND_ROOT.rglob("*.py"))
if not any(part in _SKIPPED_DIRS for part in path.relative_to(BACKEND_ROOT).parts)
]
def _has_keyword(node: ast.Call, name: str) -> bool:
return any(keyword.arg == name for keyword in node.keywords)
def _mode_is_binary(node: ast.Call) -> bool:
mode: str | None = None
if len(node.args) >= 2 and isinstance(node.args[1], ast.Constant):
value = node.args[1].value
mode = value if isinstance(value, str) else None
for keyword in node.keywords:
if keyword.arg == "mode" and isinstance(keyword.value, ast.Constant):
value = keyword.value.value
if isinstance(value, str):
mode = value
return bool(mode and "b" in mode)
def _open_has_encoding(node: ast.Call) -> bool:
"""open()/os.fdopen() also take encoding positionally: open(p, "w", 1, "utf-8")."""
return _has_keyword(node, "encoding") or len(node.args) > _OPEN_ENCODING_ARG
def _path_open_mode(node: ast.Call) -> str | None:
if node.args and isinstance(node.args[0], ast.Constant):
value = node.args[0].value
if isinstance(value, str):
return value
for keyword in node.keywords:
if keyword.arg == "mode" and isinstance(keyword.value, ast.Constant):
value = keyword.value.value
if isinstance(value, str):
return value
return None
def _is_path_open(node: ast.Call) -> bool:
"""True only for calls matching ``Path.open``'s signature."""
if len(node.args) > len(_PATH_OPEN_ARGS):
return False
if any(k.arg not in _PATH_OPEN_KWARGS for k in node.keywords):
return False
mode = _path_open_mode(node)
if mode is not None:
return bool(mode) and set(mode) <= _FILE_MODE_CHARS
return not node.args
def _path_open_has_encoding(node: ast.Call) -> bool:
"""Path.open() also takes encoding positionally: open("w", 1, "utf-8")."""
return _has_keyword(node, "encoding") or len(node.args) > _PATH_OPEN_ENCODING_ARG
def _call_name(node: ast.Call) -> str | None:
func = node.func
if isinstance(func, ast.Name):
return func.id
if isinstance(func, ast.Attribute):
return func.attr
return None
def _subprocess_names(tree: ast.AST) -> set[str]:
"""Names subprocess is reachable under here, e.g. `import subprocess as _sp`."""
names = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
if alias.name == "subprocess":
names.add(alias.asname or alias.name)
return names
def _subprocess_aliases(tree: ast.AST, names: set[str]) -> set[str]:
"""Plain names bound to a subprocess callable, called without the module.
``install_wheel(run = subprocess.run)`` calls its injected ``run`` as a bare
name, so matching only the attribute form leaves those installer calls
unguarded. Imports, assignments and parameter defaults all bind one.
"""
def _is_bound(value: ast.expr | None) -> bool:
return (
isinstance(value, ast.Attribute)
and value.attr in _SUBPROCESS_CALLS
and isinstance(value.value, ast.Name)
and value.value.id in names
)
aliases: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module == "subprocess":
aliases.update(a.asname or a.name for a in node.names if a.name in _SUBPROCESS_CALLS)
elif isinstance(node, ast.Assign) and _is_bound(node.value):
aliases.update(t.id for t in node.targets if isinstance(t, ast.Name))
elif isinstance(node, ast.AnnAssign) and _is_bound(node.value):
if isinstance(node.target, ast.Name):
aliases.add(node.target.id)
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
args = node.args
positional = args.posonlyargs + args.args
# Defaults cover the tail of the positional parameters; kw_defaults
# is aligned with kwonlyargs already, holding None where absent.
padded = [None] * (len(positional) - len(args.defaults)) + list(args.defaults)
pairs = list(zip(positional, padded)) + list(zip(args.kwonlyargs, args.kw_defaults))
aliases.update(arg.arg for arg, default in pairs if _is_bound(default))
return aliases
def _is_subprocess_call(node: ast.Call, names: set[str], aliases: set[str]) -> bool:
func = node.func
if isinstance(func, ast.Name):
return func.id in aliases
if not isinstance(func, ast.Attribute) or func.attr not in _SUBPROCESS_CALLS:
return False
value = func.value
return isinstance(value, ast.Name) and value.id in names
def _text_mode_subprocess(node: ast.Call) -> bool:
for keyword in node.keywords:
if keyword.arg not in ("text", "universal_newlines"):
continue
if isinstance(keyword.value, ast.Constant) and keyword.value.value is True:
return True
return False
def _text_mode_dict(node: ast.Dict) -> bool:
"""A ``{"text": True, ...}`` literal with no "encoding" key."""
keys = [k.value for k in node.keys if isinstance(k, ast.Constant)]
if "encoding" in keys:
return False
for key, value in zip(node.keys, node.values):
if not isinstance(key, ast.Constant) or key.value not in (
"text",
"universal_newlines",
):
continue
if isinstance(value, ast.Constant) and value.value is True:
return True
return False
def _splatted_names(tree: ast.AST) -> set[str]:
"""Names handed to a call as ``**name``."""
names = set()
for node in ast.walk(tree):
if isinstance(node, ast.Call):
for keyword in node.keywords:
if keyword.arg is None and isinstance(keyword.value, ast.Name):
names.add(keyword.value.id)
return names
def _encoding_assigned_later(tree: ast.AST, name: str) -> bool:
"""``name["encoding"] = ...`` somewhere, so the literal need not carry it."""
for node in ast.walk(tree):
if not isinstance(node, ast.Subscript) or not isinstance(node.ctx, ast.Store):
continue
target, key = node.value, node.slice
if isinstance(target, ast.Name) and target.id == name:
if isinstance(key, ast.Constant) and key.value == "encoding":
return True
return False
def _splatted_kwargs_offenders(tree: ast.AST) -> list[ast.Dict]:
"""Text-mode kwargs built in a dict and splatted into a call.
Kwargs are collected in a dict and splatted (``run(cmd, **run_kwargs)``)
where a branch has to add a timeout or an env, and the call is often through
a helper, so neither the callee nor the keywords are visible at the call
site. Only dicts that reach a call this way are judged: an unrelated payload
that happens to carry ``"text": True`` is not subprocess configuration.
"""
found = []
# ``run(cmd, **{...})``: the literal is at the call already.
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
for keyword in node.keywords:
if keyword.arg is None and isinstance(keyword.value, ast.Dict):
if _text_mode_dict(keyword.value):
found.append(keyword.value)
splatted = _splatted_names(tree)
if not splatted:
return found
for node in ast.walk(tree):
targets = []
if isinstance(node, ast.Assign):
targets = [t for t in node.targets if isinstance(t, ast.Name)]
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
targets = [node.target]
if not targets or not isinstance(node.value, ast.Dict):
continue
if not _text_mode_dict(node.value):
continue
for target in targets:
if target.id in splatted and not _encoding_assigned_later(tree, target.id):
found.append(node.value)
break
return found
def _offenders(path: Path) -> list[str]:
source = path.read_text(encoding = "utf-8")
tree = ast.parse(source, filename = str(path))
subprocess_names = _subprocess_names(tree)
subprocess_aliases = _subprocess_aliases(tree, subprocess_names)
found: list[str] = []
for node in _splatted_kwargs_offenders(tree):
found.append(
f"{path.name}:{node.lineno}: subprocess kwargs with text = True and no encoding"
)
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
name = _call_name(node)
if _is_subprocess_call(node, subprocess_names, subprocess_aliases):
if _text_mode_subprocess(node) and not _has_keyword(node, "encoding"):
found.append(f"{path.name}:{node.lineno}: subprocess(text = True) without encoding")
continue
if name == "open" and isinstance(node.func, ast.Name):
if _mode_is_binary(node) or _open_has_encoding(node):
continue
found.append(f"{path.name}:{node.lineno}: open() without encoding")
continue
# os.fdopen(fd, "w") is open() on a descriptor, so text mode takes the
# same locale default. Its mode defaults to "r", i.e. text, like open's.
if name == "fdopen":
if _mode_is_binary(node) or _open_has_encoding(node):
continue
found.append(f"{path.name}:{node.lineno}: os.fdopen() without encoding")
continue
if name == "open" and isinstance(node.func, ast.Attribute):
if not _is_path_open(node) or _path_open_has_encoding(node):
continue
if _path_open_mode(node) and "b" in _path_open_mode(node):
continue
found.append(f"{path.name}:{node.lineno}: Path.open() without encoding")
continue
if name in ("read_text", "write_text") and isinstance(node.func, ast.Attribute):
if _has_keyword(node, "encoding"):
continue
# importlib.metadata Distribution.read_text() takes no encoding kwarg.
if isinstance(node.func.value, ast.Name) and node.func.value.id == "dist":
continue
found.append(f"{path.name}:{node.lineno}: {name}() without encoding")
return found
@pytest.mark.parametrize("path", _studio_sources(), ids = lambda p: str(p.name))
def test_text_io_names_its_encoding(path: Path) -> None:
offenders = _offenders(path)
assert not offenders, (
"Text I/O without an explicit encoding falls back to the Windows ANSI "
'codepage and corrupts non-ASCII (ä ö ü → 世). Pass encoding = "utf-8":\n '
+ "\n ".join(offenders)
)
_STATE_STORE = (
BACKEND_ROOT
/ "plugins/data-designer-github-repo-seed/src"
/ "data_designer_github_repo_seed/scraper_impl/state_store.py"
)
def _load_state_store(codepage: str):
"""Load state_store with the writing machine's codepage pinned."""
spec = importlib.util.spec_from_file_location(f"state_store_{codepage}", _STATE_STORE)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
module.locale = SimpleNamespace(
getencoding = lambda: codepage,
getpreferredencoding = lambda _ = True: codepage,
)
return module
@pytest.mark.parametrize(
("codepage", "name"), [("cp1252", "Jürgen"), ("cp1251", "Юрий"), ("cp932", "田中")]
)
def test_resuming_a_legacy_jsonl_keeps_one_encoding(
tmp_path: Path, codepage: str, name: str
) -> None:
"""A scrape written before UTF-8 was explicit must resume, not duplicate."""
path = tmp_path / "out.jsonl"
records = [{"id": 1, "author": name}, {"id": 2, "author": name}]
body = "".join(json.dumps(r, ensure_ascii = False) + "\n" for r in records)
path.write_bytes(body.encode(codepage))
before = path.read_bytes()
writer = _load_state_store(codepage).JsonlWriter(path)
try:
# Seen keys survive the resume, so a repeat is refused, not appended.
assert writer.has("id:1") and writer.has("id:2")
assert writer.write(records[0]) is False
assert writer.write({"id": 3, "author": name}) is True
finally:
writer.close()
# Never converted, so it still reads in its own codepage; the append is ASCII.
blob = path.read_bytes()
assert blob.startswith(before)
assert blob[len(before) :].isascii()
lines = [json.loads(x) for x in blob.decode(codepage).splitlines() if x.strip()]
assert len(lines) == 3
assert [line["author"] for line in lines] == [name] * 3
def test_a_coincidentally_utf8_legacy_line_is_left_alone(tmp_path: Path) -> None:
"""cp1251 `Р°` is D0 B0, which is also UTF-8 `а`, and nothing can tell them apart."""
path = tmp_path / "out.jsonl"
ambiguous = "Р°"
assert ambiguous.encode("cp1251").decode("utf-8") == "а" # the trap
authors = ["Привет", "Здравствуйте", "Москва", ambiguous]
path.write_bytes(
b"".join(
json.dumps({"id": i, "author": a}, ensure_ascii = False).encode("cp1251") + b"\n"
for i, a in enumerate(authors)
)
)
before = path.read_bytes()
_load_state_store("cp1251").JsonlWriter(path).close()
# Untouched, so the ambiguity never had to be resolved.
assert path.read_bytes() == before
rows = [json.loads(x) for x in path.read_text(encoding = "cp1251").splitlines() if x.strip()]
assert [row["author"] for row in rows] == authors
@pytest.mark.parametrize(
("codepage", "word"), [("cp1251", "Привет"), ("cp932", "こんにちは"), ("cp1252", "Jürgen")]
)
def test_a_moved_shard_is_not_rewritten_by_guesswork(
tmp_path: Path, codepage: str, word: str
) -> None:
"""Off the writing machine there is no codepage to attribute the file to."""
path = tmp_path / "out.jsonl"
# Two records: a lone non-UTF-8 line would count as damage, not legacy.
path.write_bytes(
b"".join(
json.dumps({"id": i, "author": word}, ensure_ascii = False).encode(codepage) + b"\n"
for i in (1, 4)
)
)
before = path.read_bytes()
# A UTF-8 host: latin-1 would read cp1251 `Привет` back as `Ïðèâåò`.
writer = _load_state_store("utf-8").JsonlWriter(path)
try:
assert writer.has("id:1") # ASCII keys still recover
assert writer.write({"id": 2, "author": "Grüße"}) is True
finally:
writer.close()
blob = path.read_bytes()
assert blob.startswith(before) # never rewritten
assert blob[len(before) :].isascii() # appended as \uXXXX, so no second encoding
rows = [json.loads(x) for x in blob.decode(codepage).splitlines() if x.strip()]
assert [row["author"] for row in rows] == [word, word, "Grüße"]
def test_an_all_ambiguous_shard_still_gets_ascii_appends(tmp_path: Path) -> None:
"""Every line valid under both readings still means the append must not pick one."""
path = tmp_path / "out.jsonl"
ambiguous = "Р°" # cp1251 D0 B0, also valid UTF-8 for "а"
path.write_bytes(
b"".join(
json.dumps({"id": i, "a": ambiguous}, ensure_ascii = False).encode("cp1251") + b"\n"
for i in range(3)
)
)
before = path.read_bytes()
writer = _load_state_store("cp1251").JsonlWriter(path)
try:
assert writer.write({"id": 9, "a": "世界"}) is True
finally:
writer.close()
blob = path.read_bytes()
assert blob.startswith(before)
# ASCII, so the appended record survives whichever reading is chosen.
assert blob[len(before) :].isascii()
for codec in ("cp1251", "utf-8"):
rows = [json.loads(x) for x in blob.decode(codec).splitlines() if x.strip()]
assert rows[-1]["a"] == "世界"
def test_a_damaged_line_in_an_ascii_shard_does_not_block_its_retry(tmp_path: Path) -> None:
"""With no non-ASCII records to outvote it, one damaged line is still damage."""
path = tmp_path / "out.jsonl"
path.write_bytes(
b'{"id": 1, "author": "alice"}\n'
+ b'{"id": 99, "author": "bad \x96 byte"}\n'
+ b'{"id": 2, "author": "bob"}\n'
)
writer = _load_state_store("cp1252").JsonlWriter(path)
try:
assert writer.has("id:1") and writer.has("id:2")
assert not writer.has("id:99")
assert writer.write({"id": 99, "author": "good byte"}) is True
finally:
writer.close()
def test_a_damaged_line_does_not_block_its_own_retry(tmp_path: Path) -> None:
"""Its key comes from the codepage reading, which a UTF-8 shard did not pick."""
path = tmp_path / "out.jsonl"
path.write_bytes(
json.dumps({"id": 1, "author": "Jürgen"}, ensure_ascii = False).encode()
+ b"\n"
+ b'{"id": 99, "author": "bad \x96 byte"}\n'
)
writer = _load_state_store("cp1252").JsonlWriter(path)
try:
assert writer.has("id:1")
assert not writer.has("id:99")
assert writer.write({"id": 99, "author": "good byte"}) is True
finally:
writer.close()
def test_one_damaged_byte_does_not_relabel_a_utf8_shard(tmp_path: Path) -> None:
"""A complete JSON line with a stray 0x96 parses as cp1252, but is only one vote."""
path = tmp_path / "out.jsonl"
healthy = ["Jürgen", "Grüße", "Björn"]
path.write_bytes(
json.dumps({"id": 0, "author": healthy[0]}, ensure_ascii = False).encode()
+ b"\n"
+ b'{"id": 99, "author": "bad \x96 byte"}\n'
+ b"".join(
json.dumps({"id": i, "author": a}, ensure_ascii = False).encode() + b"\n"
for i, a in enumerate(healthy[1:], start = 1)
)
)
before = path.read_bytes()
_load_state_store("cp1252").JsonlWriter(path).close()
# Untouched, so the healthy records were never re-read as cp1252.
assert path.read_bytes() == before
rows = []
for line in path.read_bytes().splitlines():
try:
rows.append(json.loads(line.decode()))
except (UnicodeDecodeError, ValueError):
continue
assert [row["author"] for row in rows] == healthy
def test_a_torn_line_does_not_relabel_a_utf8_shard(tmp_path: Path) -> None:
"""One interrupted append must not get the whole shard read as cp1252."""
path = tmp_path / "out.jsonl"
good = [{"id": 1, "author": "Jürgen"}, {"id": 3, "author": "Grüße"}]
torn = '{"id": 2, "author": "Jürgen"}'.encode()[:-6] # cut mid-character
path.write_bytes(
json.dumps(good[0], ensure_ascii = False).encode()
+ b"\n"
+ torn
+ b"\n"
+ json.dumps(good[1], ensure_ascii = False).encode()
+ b"\n"
)
before = path.read_bytes()
writer = _load_state_store("cp1252").JsonlWriter(path)
try:
assert writer.has("id:1") and writer.has("id:3")
assert not writer.has("id:2") # torn line yields no key
finally:
writer.close()
# Untouched: no rewrite, so no record was re-encoded into mojibake.
after = path.read_bytes()
assert after.startswith(before)
assert "Jürgen".encode() in after
assert "Jürgen".encode("utf-8").decode("cp1252").encode() not in after
def test_an_undecodable_transport_marker_reads_as_unknown(tmp_path: Path) -> None:
"""Pinning the decode turns an undecodable marker into UnicodeDecodeError,
which is a ValueError and so is not an OSError. Before the pin those bytes
simply read as an unknown value and the caller safely purged and restarted
the partial download; letting the error escape aborts the transfer instead.
"""
import sys
backend = str(Path(__file__).resolve().parent.parent)
if backend not in sys.path:
sys.path.insert(0, backend)
from hub.utils import download_registry as registry
marker = tmp_path / ".transport"
marker.write_bytes(b"\x80\xffnative\n")
assert registry._read_marker_value(marker) is None
# A readable but unknown value takes the same path (the behaviour restored).
marker.write_text("something-else\n", encoding = "utf-8")
assert registry._read_marker_value(marker) is None
def test_a_torn_cache_ref_reads_as_not_cached(tmp_path: Path, monkeypatch) -> None:
"""hf_cache_snapshot_dir answers "is this model already on disk", and the
offline embedding checks turn a raise into a 500. A refs/main holding a byte
the codepage used to decode into a nonsense commit simply missed the snapshot
dir before the pin; it has to keep missing it."""
import sys
backend = str(Path(__file__).resolve().parent.parent)
if backend not in sys.path:
sys.path.insert(0, backend)
from utils import utils as backend_utils
good_root = tmp_path / "good"
torn_root = tmp_path / "torn"
for root, ref_bytes in ((torn_root, b"\x80\xff\n"), (good_root, b"abc123\n")):
repo = root / "models--Org--Model"
(repo / "refs").mkdir(parents = True)
(repo / "refs" / "main").write_bytes(ref_bytes)
(good_root / "models--Org--Model" / "snapshots" / "abc123").mkdir(parents = True)
monkeypatch.setattr(backend_utils, "_hf_cache_roots", lambda: [torn_root])
assert backend_utils.hf_cache_snapshot_dir("Org/Model") is None
# The torn root is skipped, not fatal: a healthy second root still answers.
monkeypatch.setattr(backend_utils, "_hf_cache_roots", lambda: [torn_root, good_root])
found = backend_utils.hf_cache_snapshot_dir("Org/Model")
assert found is not None and found.name == "abc123"
def test_a_corrupt_pid_file_does_not_abort_shutdown(tmp_path: Path, monkeypatch) -> None:
"""_remove_pid_file runs first in _graceful_shutdown, so a raise there leaves
the inference, export, training and tunnel children alive."""
import sys
backend = str(Path(__file__).resolve().parent.parent)
if backend not in sys.path:
sys.path.insert(0, backend)
import run as studio_run
pid_file = tmp_path / "studio.pid"
pid_file.write_bytes(b"\x80\xff")
monkeypatch.setattr(studio_run, "_PID_FILE", pid_file)
studio_run._remove_pid_file()
# Not this process's PID, so the file stays; the point is that it returned.
assert pid_file.exists()
pid_file.write_text(str(os.getpid()), encoding = "utf-8")
studio_run._remove_pid_file()
assert not pid_file.exists()
def test_the_kwargs_guard_only_judges_dicts_that_reach_a_call(tmp_path: Path) -> None:
"""Only a dict splatted into a call is subprocess configuration. An unrelated
payload that happens to carry "text": True is not, and neither is one whose
encoding is filled in on a later line."""
cases = {
"offender.py": 'kw = {"text": True}\nrun(cmd, **kw)\n',
"annotated.py": 'kw: dict = {"universal_newlines": True}\nrun(cmd, **kw)\n',
"payload.py": 'payload = {"text": True}\nrequests.post(url, json = payload)\n',
"inline.py": 'run(cmd, **{"text": True})\n',
"later.py": 'kw = {"text": True}\nkw["encoding"] = "utf-8"\nrun(cmd, **kw)\n',
"carried.py": 'kw = {"text": True, "encoding": "utf-8"}\nrun(cmd, **kw)\n',
}
flagged = set()
for name, source in cases.items():
path = tmp_path / name
path.write_text(source, encoding = "utf-8")
if any("subprocess kwargs" in line for line in _offenders(path)):
flagged.add(name)
assert flagged == {"offender.py", "annotated.py", "inline.py"}, flagged
def test_the_guard_follows_subprocess_through_an_alias(tmp_path: Path) -> None:
"""install_wheel() takes ``run = subprocess.run`` and calls it as a bare
name, so an attribute-only match let both of its installer calls drop their
encoding unnoticed. A name bound to something else is still not subprocess."""
cases = {
"param_default.py": (
"import subprocess\n"
"def install(*, run = subprocess.run):\n"
" run(cmd, text = True)\n"
),
"assigned.py": "import subprocess\n_run = subprocess.run\n_run(cmd, text = True)\n",
"imported.py": "from subprocess import check_output\ncheck_output(cmd, text = True)\n",
"renamed.py": "from subprocess import run as _r\n_r(cmd, universal_newlines = True)\n",
"encoded.py": (
"import subprocess\n"
"def install(*, run = subprocess.run):\n"
' run(cmd, text = True, encoding = "utf-8")\n'
),
"unrelated.py": "def run(cmd, text = False):\n pass\nrun(cmd, text = True)\n",
}
flagged = set()
for name, source in cases.items():
path = tmp_path / name
path.write_text(source, encoding = "utf-8")
if any("subprocess(text = True)" in line for line in _offenders(path)):
flagged.add(name)
assert flagged == {"param_default.py", "assigned.py", "imported.py", "renamed.py"}, flagged
def test_the_guard_sees_os_fdopen(tmp_path: Path) -> None:
"""os.fdopen(fd, mode) is open() on a descriptor and takes the same locale
default in text mode, so leaving it out let the swap lock file keep the
codepage on the write side while its reader was pinned to UTF-8."""
cases = {
"text.py": 'import os\nos.fdopen(fd, "w")\n',
"default_mode.py": "import os\nos.fdopen(fd)\n", # defaults to "r", still text
"binary.py": 'import os\nos.fdopen(fd, "wb")\n',
"keyword.py": 'import os\nos.fdopen(fd, "w", encoding = "utf-8")\n',
"positional.py": 'import os\nos.fdopen(fd, "w", 1, "utf-8")\n',
}
flagged = set()
for name, source in cases.items():
path = tmp_path / name
path.write_text(source, encoding = "utf-8")
if any("fdopen" in line for line in _offenders(path)):
flagged.add(name)
assert flagged == {"text.py", "default_mode.py"}, flagged
def test_an_undecodable_bootstrap_password_does_not_stop_startup(
tmp_path: Path, monkeypatch
) -> None:
"""ensure_default_admin calls _load_bootstrap_password for every existing
admin and the lifespan calls that with no handler, so a raise here takes the
whole backend down instead of ignoring an unusable file."""
import sys
backend = str(Path(__file__).resolve().parent.parent)
if backend not in sys.path:
sys.path.insert(0, backend)
from auth import storage
pw_file = tmp_path / ".bootstrap_password"
pw_file.write_bytes(b"\x80\xffnot-utf8\n")
monkeypatch.setattr(storage, "_BOOTSTRAP_PW_PATH", pw_file)
assert storage._load_bootstrap_password() is None
# A readable one still loads, so this is a narrowing of failure, not of function.
pw_file.write_text("correct horse battery staple\n", encoding = "utf-8")
assert storage._load_bootstrap_password() == "correct horse battery staple"
def test_a_damaged_checkpoint_resets_instead_of_resuming_on_a_broken_cursor(tmp_path: Path) -> None:
"""A checkpoint holds only base64 cursors and booleans, so a codepage reading
can only ever add non-ASCII, never recover any. Resuming on a mojibaked cursor
sends GitHub one it answers with INVALID_CURSOR_ARGUMENTS, and the empty page
that comes back marks the stream done and skips the rest of it for good.
Dropping the checkpoint only replays pages the writers already dedup."""
module = _load_state_store("cp1252")
cursor = "Y3Vyc29yOnYyOpK0MjAxMi0wMi0xNlQwNjo1Mzo0MVrOADGL_A=="
healthy = json.dumps({"issues_cursor": cursor, "issues_done": False}, indent = 2)
path = tmp_path / "octocat__Hello-World.json"
path.write_text(healthy, encoding = "utf-8")
assert module.StateStore(path).get("issues_cursor") == cursor
# Written by a pre-UTF-8 release in the operator's codepage. Nothing is lost
# by reading UTF-8 only, because an all-ASCII document is the same bytes.
path.write_bytes(healthy.encode("cp1252"))
assert module.StateStore(path).get("issues_cursor") == cursor
# One damaged byte inside the cursor: still a whole JSON document under a
# single-byte codepage, so only refusing that reading resets the checkpoint.
raw = healthy.encode()
at = raw.index(b"MjAxMi0wMi0xNlQ") + 3
path.write_bytes(raw[:at] + b"\x96" + raw[at + 1 :])
assert json.loads(path.read_bytes().decode("latin-1"))["issues_cursor"] != cursor
store = module.StateStore(path)
assert store.all() == {}
assert store.get("issues_cursor") is None
def test_a_utf8_record_is_not_parsed_a_second_time(tmp_path: Path) -> None:
"""These shards reach gigabytes and every resume reads all of one, so a
record that already read as UTF-8 must not be decoded and parsed again under
the codepage. The legacy reading exists only to recover keys UTF-8 could not."""
module = _load_state_store("cp1252")
calls: list[str] = []
real_parse = module._parse
def counting_parse(raw, encoding):
calls.append(encoding)
return real_parse(raw, encoding)
module._parse = counting_parse
try:
healthy = json.dumps({"id": 1, "author": "Jürgen"}).encode("utf-8")
reading = module._read_line(healthy, "cp1252")
assert reading.as_utf8 == {"id": 1, "author": "Jürgen"}
assert calls == ["utf-8"], calls
# A line UTF-8 cannot read still falls through to the codepage, the whole point.
calls.clear()
legacy = json.dumps({"id": 2, "author": "Jürgen"}, ensure_ascii = False).encode("cp1252")
reading = module._read_line(legacy, "cp1252")
assert reading.as_utf8 is None
assert reading.as_legacy == {"id": 2, "author": "Jürgen"}
assert calls == ["utf-8", "cp1252"], calls
finally:
module._parse = real_parse
def _too_deeply_nested_json() -> str:
"""A JSON document nested past what this interpreter will descend into.
Probed rather than hardcoded: the depth json.loads gives up at is bounded by
sys.getrecursionlimit() up to 3.11 and by the C recursion limit from 3.12,
which sys.setrecursionlimit no longer moves and which varies by micro
version. That is ~995 on 3.9 and ~9999 on 3.13.
"""
depth = 1
while depth <= 1 << 17:
document = "[" * depth + "]" * depth
try:
json.loads(document)
except RecursionError:
return document
depth *= 2
pytest.skip("this interpreter parses arbitrarily nested JSON")
def test_an_unparseably_nested_document_is_discarded_not_raised(tmp_path: Path) -> None:
"""json.loads answers nesting it cannot descend with RecursionError, which is
a RuntimeError and so is neither a ValueError nor a UnicodeDecodeError.
_parse is called outside any other handler in both StateStore.__init__ and
JsonlWriter._scan_existing, so letting it escape aborts the scraper at
startup on a file the catch-all it replaced simply discarded."""
module = _load_state_store("cp1252")
nested = _too_deeply_nested_json()
checkpoint = tmp_path / "octocat__Hello-World.json"
checkpoint.write_text(nested, encoding = "utf-8")
assert module.StateStore(checkpoint).all() == {} # reset, not raised
shard = tmp_path / "out.jsonl"
shard.write_text(
nested + "\n" + json.dumps({"id": 1}) + "\n" + json.dumps({"id": 2}) + "\n",
encoding = "utf-8",
)
writer = module.JsonlWriter(shard)
try:
# Skipped like any other unreadable line, so its neighbours still yield the dedup
# keys that keep the resume from re-fetching them.
assert writer.has("id:1") and writer.has("id:2")
finally:
writer.close()

View file

@ -9,8 +9,28 @@ import sys
from typing import Any
from unittest import mock
import pytest
from core.training import worker
# The runtime install is Linux-only, so elsewhere these return before any status.
linux_only = pytest.mark.skipif(
not sys.platform.startswith("linux"),
reason = "the runtime flash-attn install is gated to Linux",
)
# causal-conv1d and flash-linear-attention are NOT Linux-gated: both installers bail out
# on `sys.platform == "win32"` alone (no prebuilt wheel for Windows) and run everywhere
# else, macOS included. linux_only here would skip cases that legitimately pass off Linux.
not_on_windows = pytest.mark.skipif(
sys.platform == "win32",
reason = (
"mirrors the sys.platform == 'win32' bail-out in "
"_ensure_flash_linear_attention_unconditional and "
"_ensure_causal_conv1d_fast_path"
),
)
def _missing_flash_attn_import():
real_import = builtins.__import__
@ -55,6 +75,7 @@ def test_should_try_runtime_flash_attn_install_threshold_and_skip(monkeypatch):
assert worker._should_try_runtime_flash_attn_install(32768) is False
@linux_only
def test_runtime_flash_attn_prefers_prebuilt_wheel(monkeypatch):
statuses: list[str] = []
@ -82,6 +103,7 @@ def test_runtime_flash_attn_prefers_prebuilt_wheel(monkeypatch):
assert statuses == ["Installing flash-attn for faster training..."]
@linux_only
def test_runtime_flash_attn_falls_back_to_pypi(monkeypatch):
calls: list[list[str]] = []
statuses: list[str] = []
@ -113,12 +135,7 @@ def test_runtime_flash_attn_falls_back_to_pypi(monkeypatch):
)
monkeypatch.setattr(worker, "install_wheel", mock.Mock())
def fake_run(
cmd,
stdout = None,
stderr = None,
text = None,
):
def fake_run(cmd, **kwargs):
calls.append(list(cmd))
return subprocess.CompletedProcess(cmd, 0, "")
@ -139,6 +156,7 @@ def test_runtime_flash_attn_skip_env_avoids_all_install_work(monkeypatch):
worker._sp.run.assert_not_called()
@not_on_windows
def test_causal_conv1d_fast_path_preserves_wheel_first_install_args(monkeypatch):
install_mock = mock.Mock(return_value = True)
monkeypatch.setattr(worker, "_install_package_wheel_first", install_mock)
@ -160,6 +178,7 @@ def test_causal_conv1d_fast_path_preserves_wheel_first_install_args(monkeypatch)
)
@not_on_windows
def test_causal_conv1d_fast_path_includes_qwen3_6_variants(monkeypatch):
install_mock = mock.Mock(return_value = True)
monkeypatch.setattr(worker, "_install_package_wheel_first", install_mock)
@ -225,6 +244,7 @@ def _pin_fla_model_types(monkeypatch):
)
@not_on_windows
def test_flash_linear_attention_installs_pinned_pair_for_qwen3_5(monkeypatch):
_pin_fla_model_types(monkeypatch)
monkeypatch.setattr(worker.shutil, "which", lambda name: "/usr/bin/uv")
@ -277,6 +297,7 @@ def test_flash_linear_attention_skips_for_ssm_only_models(monkeypatch):
run_mock.assert_not_called()
@not_on_windows
def test_flash_linear_attention_matches_full_qwen3_family(monkeypatch):
monkeypatch.setattr(worker.shutil, "which", lambda name: "/usr/bin/uv")
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
@ -331,6 +352,7 @@ def test_flash_linear_attention_skipped_via_env(monkeypatch):
run_mock.assert_not_called()
@not_on_windows
def test_flash_linear_attention_skipped_below_torch_2_7(monkeypatch):
_pin_fla_model_types(monkeypatch)
monkeypatch.delenv(worker._FLA_SKIP_ENV, raising = False)
@ -349,6 +371,7 @@ def test_flash_linear_attention_skipped_below_torch_2_7(monkeypatch):
assert any("torch>=" in s for s in statuses)
@not_on_windows
def test_flash_linear_attention_install_includes_einops(monkeypatch):
_pin_fla_model_types(monkeypatch)
monkeypatch.delenv(worker._FLA_SKIP_ENV, raising = False)
@ -375,6 +398,7 @@ def test_flash_linear_attention_install_includes_einops(monkeypatch):
assert f"fla-core=={worker._FLA_CORE_PACKAGE_VERSION}" in args
@not_on_windows
def test_flash_linear_attention_logs_post_install_import_failure(monkeypatch):
"""pip exits 0 but `import fla.modules` still fails (missing transitive)."""
_pin_fla_model_types(monkeypatch)
@ -421,6 +445,7 @@ def test_tilelang_backend_skipped_on_unsupported_linux_arch(monkeypatch):
run_mock.assert_not_called()
@linux_only
def test_tilelang_backend_pins_only_binary(monkeypatch):
_pin_fla_model_types(monkeypatch)
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
@ -462,6 +487,7 @@ def _force_missing_tilelang_imports(monkeypatch):
monkeypatch.setattr(builtins, "__import__", fake_import)
@linux_only
def test_tilelang_backend_installs_pinned_pair_for_qwen3_5(monkeypatch):
_pin_fla_model_types(monkeypatch)
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
@ -486,6 +512,7 @@ def test_tilelang_backend_installs_pinned_pair_for_qwen3_5(monkeypatch):
assert any("Installing TileLang" in s for s in statuses)
@linux_only
def test_tilelang_backend_reinstalls_when_tvm_ffi_is_broken(monkeypatch):
"""Repair path issues TWO pip calls:
@ -555,6 +582,7 @@ def test_tilelang_backend_skipped_on_windows(monkeypatch):
run_mock.assert_not_called()
@linux_only
def test_tilelang_backend_swallows_install_timeout(monkeypatch):
_pin_fla_model_types(monkeypatch)
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
@ -609,6 +637,7 @@ def test_tilelang_backend_skipped_via_env(monkeypatch):
run_mock.assert_not_called()
@linux_only
def test_tilelang_backend_swallows_install_failure(monkeypatch):
_pin_fla_model_types(monkeypatch)
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
@ -673,6 +702,7 @@ def _patch_iu_gates(monkeypatch, fla_gate, conv_gate):
monkeypatch.setattr(_iu, "is_causal_conv1d_available", conv_gate)
@not_on_windows
def test_hook_installs_when_gate_returns_false(monkeypatch):
_pin_fla_model_types(monkeypatch)
fla_gate = _make_fake_gate(initial_return = False)
@ -976,6 +1006,7 @@ def test_hook_does_install_tilelang_for_qwen35(monkeypatch):
tile_install.assert_called_once()
@linux_only
def test_tilelang_repair_does_not_touch_torch_cuda_stack(monkeypatch):
"""Finding #2: the broken-tvm-ffi repair must use --no-deps on the
forced step so --force-reinstall doesn't cascade through
@ -1119,6 +1150,7 @@ def test_hook_runs_tilelang_repair_when_fla_already_true(monkeypatch):
tile_install.assert_called_once()
@not_on_windows
def test_fla_installer_force_reinstalls_when_older_version_present(monkeypatch):
"""Finding #8: an older `flash-linear-attention` that is importable
but below the pin must force a reinstall (not no-op).
@ -1583,15 +1615,10 @@ def test_install_respects_user_gcc_install_dir(monkeypatch):
)
_make_hip_install_env(monkeypatch, gcc_dir = "/usr/lib/gcc/x86_64-linux-gnu/13")
captured: dict[str, str] | None = {"_called": "no"}
captured: dict[str, str] = {}
def fake_run(cmd, **kwargs):
env = kwargs.get("env")
if env is not None:
captured.clear()
captured.update(env)
else:
captured["_called"] = "yes_no_env"
captured.update(kwargs.get("env") or {})
return subprocess.CompletedProcess(cmd, 0, "")
monkeypatch.setattr(worker._sp, "run", fake_run)
@ -1607,14 +1634,11 @@ def test_install_respects_user_gcc_install_dir(monkeypatch):
release_base_url = "https://example.com",
)
# subprocess.run invoked without env override (user already set
# HIPCC_COMPILE_FLAGS_APPEND with --gcc-install-dir, so we left the
# env alone — the existing value is inherited).
assert captured == {"_called": "yes_no_env"}
assert captured["HIPCC_COMPILE_FLAGS_APPEND"] == "--gcc-install-dir=/opt/custom/gcc-13"
def test_install_does_not_inject_env_on_cuda(monkeypatch):
"""CUDA path (no hip_version in env) → no env override at all."""
"""CUDA path (no hip_version in env) → no HIP flag injected."""
monkeypatch.delenv("HIPCC_COMPILE_FLAGS_APPEND", raising = False)
monkeypatch.setattr(builtins, "__import__", _missing_module_import("causal_conv1d"))
monkeypatch.setattr(
@ -1641,7 +1665,7 @@ def test_install_does_not_inject_env_on_cuda(monkeypatch):
captured: dict[str, Any] = {}
def fake_run(cmd, **kwargs):
captured["env_in_kwargs"] = "env" in kwargs
captured.update(kwargs.get("env") or {})
return subprocess.CompletedProcess(cmd, 0, "")
monkeypatch.setattr(worker._sp, "run", fake_run)
@ -1657,5 +1681,5 @@ def test_install_does_not_inject_env_on_cuda(monkeypatch):
release_base_url = "https://example.com",
)
# CUDA branch never sets the env, never invokes the gcc helper.
assert captured.get("env_in_kwargs") is False
# env is always passed (to force UTF-8), but never the HIP flag.
assert "HIPCC_COMPILE_FLAGS_APPEND" not in captured

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Make a Python child agree with the parent that its pipes are UTF-8.
A child's ``sys.stdout`` uses ``locale.getpreferredencoding()``, which on
Windows is the ANSI code page. Reading that pipe as UTF-8 would then mangle any
non-ASCII the child prints, so the child has to be told which encoding to emit.
Only needed for Python children; llama.cpp and node already emit UTF-8.
"""
from __future__ import annotations
import os
from typing import Mapping, Optional
def utf8_child_env(env: Optional[Mapping[str, str]] = None) -> dict[str, str]:
"""Copy *env* (or the current environment) with UTF-8 stdio forced."""
child = dict(os.environ if env is None else env)
child["PYTHONIOENCODING"] = "utf-8"
return child

View file

@ -144,6 +144,8 @@ def _run_amd_smi(*args: str, timeout: int = _AMD_SMI_DEFAULT_TIMEOUT) -> Optiona
["amd-smi", *args, "--json"],
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = timeout,
env = _amd_env,
**windows_hidden_subprocess_kwargs(),

View file

@ -830,6 +830,8 @@ def _rocm_windows_perf_counter_gpu_util_pct() -> Optional[float]:
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps],
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = 5,
)
if r.returncode != 0 or not r.stdout.strip():
@ -1027,6 +1029,8 @@ def _rocm_windows_perf_counter_vram_by_adapter() -> Optional[list[tuple[str, flo
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps],
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = 5,
)
if r.returncode != 0 or not r.stdout.strip():

View file

@ -55,6 +55,8 @@ def get_physical_gpu_count() -> Optional[int]:
["nvidia-smi", "-L"],
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = 5,
env = child_env_without_native_path_secret(),
**_windows_hidden_subprocess_kwargs(),
@ -81,6 +83,8 @@ def get_primary_gpu_utilization() -> dict[str, Any]:
],
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = 5,
env = child_env_without_native_path_secret(),
**_windows_hidden_subprocess_kwargs(),
@ -131,6 +135,8 @@ def get_visible_gpu_utilization(
],
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = 5,
env = child_env_without_native_path_secret(),
**_windows_hidden_subprocess_kwargs(),
@ -215,6 +221,8 @@ def get_backend_visible_gpu_info(
],
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = 10,
env = child_env_without_native_path_secret(),
**_windows_hidden_subprocess_kwargs(),

View file

@ -121,7 +121,14 @@ def _installed_build_number(binary: Optional[str]) -> Optional[int]:
if not binary:
return None
try:
proc = subprocess.run([binary, "--version"], capture_output = True, text = True, timeout = 20)
proc = subprocess.run(
[binary, "--version"],
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = 20,
)
except Exception: # pragma: no cover - defensive
return None
m = re.search(r"version:\s*(\d+)", (proc.stderr or "") + (proc.stdout or ""))

View file

@ -254,7 +254,7 @@ def _transformers_constraint_args() -> tuple[list[str], str | None]:
except Exception:
return [], None
fd, path = tempfile.mkstemp(prefix = "mlx_repair_", suffix = ".txt")
with os.fdopen(fd, "w") as fh:
with os.fdopen(fd, "w", encoding = "utf-8") as fh:
fh.write(f"transformers=={transformers_version}\n")
return ["--constraint", path], path
@ -290,6 +290,8 @@ def attempt_mlx_repair(*, timeout: int = _REPAIR_TIMEOUT_S) -> bool:
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = timeout,
)
except subprocess.TimeoutExpired:

View file

@ -129,7 +129,7 @@ def _read_checkpoint_loss(checkpoint_path: Path) -> Optional[float]:
if not trainer_state.exists():
return None
try:
with open(trainer_state, encoding = "utf-8") as f:
with open(trainer_state, encoding = "utf-8-sig") as f:
state = json.load(f)
log_history = state.get("log_history", [])
if log_history:
@ -174,18 +174,18 @@ def scan_checkpoints(
metadata: dict = {}
try:
if adapter_config.exists():
cfg = json.loads(adapter_config.read_text(encoding = "utf-8"))
cfg = json.loads(adapter_config.read_text(encoding = "utf-8-sig"))
metadata["base_model"] = cfg.get("base_model_name_or_path")
metadata["peft_type"] = cfg.get("peft_type")
metadata["lora_rank"] = cfg.get("r")
elif config_file.exists():
cfg = json.loads(config_file.read_text(encoding = "utf-8"))
cfg = json.loads(config_file.read_text(encoding = "utf-8-sig"))
metadata["base_model"] = cfg.get("_name_or_path")
# Detect BNB quantization from config.json
if config_file.exists():
if "cfg" not in dir():
cfg = json.loads(config_file.read_text(encoding = "utf-8"))
cfg = json.loads(config_file.read_text(encoding = "utf-8-sig"))
quant_cfg = cfg.get("quantization_config")
if (
isinstance(quant_cfg, dict)

View file

@ -37,6 +37,7 @@ import yaml
from utils.native_path_leases import child_env_without_native_path_secret
from utils.child_stdio import utf8_child_env
from utils.hf_cache_settings import active_hf_hub_cache, get_hf_cache_paths
from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
@ -631,7 +632,7 @@ def _raw_config_has_vision_config(
cache_dir = active_hf_hub_cache(),
)
)
config = json.loads(config_path.read_text(encoding = "utf-8"))
config = json.loads(config_path.read_text(encoding = "utf-8-sig"))
architectures = config.get("architectures") or []
model_type = config.get("model_type")
explicit_vision = (
@ -774,8 +775,12 @@ def _is_vision_model_subprocess(model_name: str, hf_token: Optional[str] = None)
],
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = 60,
env = get_hf_cache_paths().child_env(child_env_without_native_path_secret()),
env = utf8_child_env(
get_hf_cache_paths().child_env(child_env_without_native_path_secret())
),
**_windows_hidden_subprocess_kwargs(),
)
@ -1083,7 +1088,7 @@ def _detect_audio_from_tokenizer(
]:
tok_file = snapshot / tok_path
if tok_file.exists():
tok_config = json.loads(tok_file.read_text(encoding = "utf-8"))
tok_config = json.loads(tok_file.read_text(encoding = "utf-8-sig"))
read_any = True
result = _check_token_patterns(tok_config)
if result:
@ -2283,7 +2288,7 @@ def scan_exported_models(
export_meta = run_dir / "export_metadata.json"
try:
if export_meta.exists():
meta = json.loads(export_meta.read_text(encoding = "utf-8"))
meta = json.loads(export_meta.read_text(encoding = "utf-8-sig"))
base_model = meta.get("base_model")
except Exception:
pass
@ -2312,7 +2317,7 @@ def scan_exported_models(
if adapter_config.exists():
export_type = "lora"
try:
cfg = json.loads(adapter_config.read_text(encoding = "utf-8"))
cfg = json.loads(adapter_config.read_text(encoding = "utf-8-sig"))
base_model = cfg.get("base_model_name_or_path")
except Exception:
pass
@ -2321,7 +2326,7 @@ def scan_exported_models(
export_meta = checkpoint_dir / "export_metadata.json"
try:
if export_meta.exists():
meta = json.loads(export_meta.read_text(encoding = "utf-8"))
meta = json.loads(export_meta.read_text(encoding = "utf-8-sig"))
base_model = meta.get("base_model")
except Exception:
pass
@ -2334,7 +2339,7 @@ def scan_exported_models(
export_meta = meta_dir / "export_metadata.json"
try:
if export_meta.exists():
meta = json.loads(export_meta.read_text(encoding = "utf-8"))
meta = json.loads(export_meta.read_text(encoding = "utf-8-sig"))
base_model = meta.get("base_model")
if base_model:
break
@ -2354,7 +2359,7 @@ def scan_exported_models(
outputs_adapter_cfg = resolve_output_dir(run_dir.name) / "adapter_config.json"
try:
if outputs_adapter_cfg.exists():
cfg = json.loads(outputs_adapter_cfg.read_text(encoding = "utf-8"))
cfg = json.loads(outputs_adapter_cfg.read_text(encoding = "utf-8-sig"))
base_model = cfg.get("base_model_name_or_path")
except Exception:
pass
@ -2380,7 +2385,7 @@ def get_base_model_from_checkpoint(checkpoint_path: str) -> Optional[str]:
adapter_config_path = checkpoint_path_obj / "adapter_config.json"
if adapter_config_path.exists():
with open(adapter_config_path, "r", encoding = "utf-8") as f:
with open(adapter_config_path, "r", encoding = "utf-8-sig") as f:
config = json.load(f)
base_model = config.get("base_model_name_or_path")
if base_model:
@ -2389,7 +2394,7 @@ def get_base_model_from_checkpoint(checkpoint_path: str) -> Optional[str]:
config_path = checkpoint_path_obj / "config.json"
if config_path.exists():
with open(config_path, "r", encoding = "utf-8") as f:
with open(config_path, "r", encoding = "utf-8-sig") as f:
config = json.load(f)
for key in ("model_name", "_name_or_path"):
base_model = config.get(key)
@ -2445,7 +2450,7 @@ def get_base_model_from_lora(lora_path: str) -> Optional[str]:
# adapter_config.json first
adapter_config_path = lora_path_obj / "adapter_config.json"
if adapter_config_path.exists():
with open(adapter_config_path, "r", encoding = "utf-8") as f:
with open(adapter_config_path, "r", encoding = "utf-8-sig") as f:
config = json.load(f)
base_model = config.get("base_model_name_or_path")
if base_model:
@ -2535,7 +2540,7 @@ def get_base_model_from_lora_identifier(
last_exc = exc
continue
try:
with open(cfg_path, "r", encoding = "utf-8") as f:
with open(cfg_path, "r", encoding = "utf-8-sig") as f:
base_model = json.load(f).get("base_model_name_or_path")
except Exception as exc:
logger.warning("Could not parse adapter_config.json for '%s': %s", identifier, exc)
@ -2781,7 +2786,7 @@ class ModelConfig:
meta_path = gguf_dir / "export_metadata.json"
if meta_path.exists():
try:
meta = json.loads(meta_path.read_text(encoding = "utf-8"))
meta = json.loads(meta_path.read_text(encoding = "utf-8-sig"))
base = meta.get("base_model")
if base and is_vision_model(base, hf_token = hf_token):
base_is_vision = True
@ -2912,7 +2917,7 @@ class ModelConfig:
token = hf_token,
cache_dir = active_hf_hub_cache(),
)
with open(config_path, "r", encoding = "utf-8") as f:
with open(config_path, "r", encoding = "utf-8-sig") as f:
adapter_config = json.load(f)
base_model = adapter_config.get("base_model_name_or_path")
if base_model:

View file

@ -79,6 +79,8 @@ def _node_version_ok(executable: str) -> bool:
[executable, "-v"],
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = _NODE_VERSION_PROBE_TIMEOUT_SECONDS,
**windows_hidden_subprocess_kwargs(),
)

View file

@ -212,7 +212,7 @@ def lmstudio_model_dirs() -> list[Path]:
settings_path = Path.home() / ".lmstudio" / "settings.json"
if settings_path.is_file():
try:
with open(settings_path, encoding = "utf-8") as f:
with open(settings_path, encoding = "utf-8-sig") as f:
settings = json.load(f)
downloads = settings.get("downloadsFolder", "")
if downloads:

View file

@ -24,6 +24,7 @@ from typing import Callable, Optional
import structlog
from utils.child_stdio import utf8_child_env
from utils.process_lifetime import child_popen_kwargs
logger = structlog.get_logger(__name__)
@ -159,6 +160,8 @@ def resolve_prebuilt_for_host(
cmd,
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = 60,
)
out = (proc.stdout or "").strip()
@ -303,7 +306,10 @@ def stream_installer(
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
env = env,
encoding = "utf-8",
errors = "replace",
# Make the Python child emit the UTF-8 we decode above.
env = utf8_child_env(env),
**child_popen_kwargs(),
)
timed_out = threading.Event()

View file

@ -142,7 +142,7 @@ def _load_remote_code_configs(model_name: str, hf_token: Optional[str] = None) -
for name in _REMOTE_CODE_CONFIG_FILES:
p = root / name
if p.is_file():
configs.append(json.loads(p.read_text(encoding = "utf-8")))
configs.append(json.loads(p.read_text(encoding = "utf-8-sig")))
return configs
from huggingface_hub import hf_hub_download
@ -164,7 +164,7 @@ def _load_remote_code_configs(model_name: str, hf_token: Optional[str] = None) -
# Transient/auth failure is not "absent" -> fail closed to "unknown" so
# the caller scans (a tokenizer/processor-only auto_map must not slip by).
return None
configs.append(json.loads(Path(p).read_text(encoding = "utf-8")))
configs.append(json.loads(Path(p).read_text(encoding = "utf-8-sig")))
# Every config was read or a genuine 404 -> an empty list is a definitive
# "no auto_map", not "unknown".
return configs

View file

@ -199,7 +199,7 @@ def _indexed_shard_paths(
inconclusive = True # transient: an index that might exist could not be read
continue
try:
weight_map = (json.loads(open(index_path, encoding = "utf-8").read()) or {}).get(
weight_map = (json.loads(open(index_path, encoding = "utf-8-sig").read()) or {}).get(
"weight_map"
) or {}
for shard in weight_map.values():
@ -328,7 +328,7 @@ def _st_load_roots(snapshot: Path) -> list:
roots = [snapshot]
try:
import json
modules = json.loads((snapshot / "modules.json").read_text(encoding = "utf-8"))
modules = json.loads((snapshot / "modules.json").read_text(encoding = "utf-8-sig"))
except (OSError, ValueError):
return roots # no / invalid modules.json -> snapshot root is the only load root
for module in modules or ():
@ -355,7 +355,7 @@ def _indexed_pickle_shards(index_path: Path, root: Path, snapshot: Path) -> list
try:
# JSON is UTF-8 by spec; pin it so a non-ASCII index is not misdecoded (and needlessly
# blocked) under Windows' cp1252 default.
parsed = json.loads(index_path.read_text(encoding = "utf-8"))
parsed = json.loads(index_path.read_text(encoding = "utf-8-sig"))
except (OSError, ValueError) as exc:
raise OSError(f"unreadable weight index: {index_path}") from exc
weight_map = parsed.get("weight_map") if isinstance(parsed, dict) else None

View file

@ -69,7 +69,7 @@ def approval_target_key(targets) -> str:
def _load() -> dict:
"""Parsed store, or an empty skeleton on any error (fail-safe = re-prompt)."""
try:
with open(_store_path(), encoding = "utf-8") as f:
with open(_store_path(), encoding = "utf-8-sig") as f:
data = json.load(f)
# Validate the shape, not just the version: a hand-edited ``subjects`` that is not a
# dict (e.g. ``[]``) would otherwise crash lookup/record instead of failing safe.

View file

@ -454,7 +454,7 @@ def repo_remote_code_files(model_name: str, hf_token: Optional[str] = None) -> d
p = root / name
if p.is_file():
try:
ext_refs |= _auto_map_refs(json.loads(p.read_text(encoding = "utf-8")))
ext_refs |= _auto_map_refs(json.loads(p.read_text(encoding = "utf-8-sig")))
except Exception:
pass
if not _add_external_refs(files, ext_refs, hf_token, model_name):
@ -483,7 +483,7 @@ def repo_remote_code_files(model_name: str, hf_token: Optional[str] = None) -> d
f"{model_name}: config {cfg_name} could not be fetched ({exc})"
) from exc
try:
refs |= _auto_map_refs(json.loads(Path(cfg_path).read_text(encoding = "utf-8")))
refs |= _auto_map_refs(json.loads(Path(cfg_path).read_text(encoding = "utf-8-sig")))
except Exception:
pass
own_refs = {fn for repo, fn in refs if repo is None}
@ -616,7 +616,7 @@ def external_auto_map_repos(model_name: str, hf_token: Optional[str] = None) ->
if not p.is_file():
continue
try:
refs = _auto_map_refs(json.loads(p.read_text(encoding = "utf-8")))
refs = _auto_map_refs(json.loads(p.read_text(encoding = "utf-8-sig")))
except Exception:
continue
repos.update(repo for repo, _fn in refs if repo)
@ -638,7 +638,7 @@ def external_auto_map_repos(model_name: str, hf_token: Optional[str] = None) ->
except Exception:
continue
try:
refs = _auto_map_refs(json.loads(Path(cfg_path).read_text(encoding = "utf-8")))
refs = _auto_map_refs(json.loads(Path(cfg_path).read_text(encoding = "utf-8-sig")))
except Exception:
continue
repos.update(repo for repo, _fn in refs if repo)

View file

@ -23,6 +23,7 @@ import threading
from typing import Any, Callable, Optional
from loggers import get_logger
from utils.child_stdio import utf8_child_env
from utils.wheel_utils import (
direct_wheel_url,
install_wheel,
@ -254,6 +255,12 @@ def _install_kernel(
"stdout": subprocess.PIPE,
"stderr": subprocess.STDOUT,
"text": True,
# pip and the compilers it drives write UTF-8 down this pipe; the Windows
# ANSI codepage would mojibake or raise over a fine install.
"encoding": "utf-8",
"errors": "replace",
# Make the Python child emit the UTF-8 we decode above.
"env": utf8_child_env(),
}
if is_hip:
run_kwargs["timeout"] = 1800 # ROCm builds can take 10-30 min
@ -261,7 +268,8 @@ def _install_kernel(
if "--gcc-install-dir" not in existing:
gcc_dir = _hipcc_gcc_install_dir()
if gcc_dir:
_env = os.environ.copy()
# Extends the UTF-8 env above rather than replacing it.
_env = dict(run_kwargs["env"])
_env["HIPCC_COMPILE_FLAGS_APPEND"] = (
f"{existing} --gcc-install-dir={gcc_dir}".strip()
)

View file

@ -60,6 +60,8 @@ def _exact_git_studio_tag(repo_root: Path) -> str | None:
stdout = subprocess.PIPE,
stderr = subprocess.DEVNULL,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = _GIT_TIMEOUT_SECONDS,
)
except (OSError, subprocess.TimeoutExpired):
@ -81,6 +83,8 @@ def _git_branch(repo_root: Path) -> str | None:
stdout = subprocess.PIPE,
stderr = subprocess.DEVNULL,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = _GIT_TIMEOUT_SECONDS,
)
except (OSError, subprocess.TimeoutExpired):

View file

@ -44,6 +44,7 @@ import time
from pathlib import Path
from utils.native_path_leases import child_env_without_native_path_secret
from utils.child_stdio import utf8_child_env
from utils.hf_cache_settings import get_hf_cache_paths
from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
@ -420,7 +421,7 @@ def _resolve_base_model(model_name: str) -> str:
adapter_cfg_path = local_path / "adapter_config.json"
if _safe_is_file(adapter_cfg_path):
try:
with open(adapter_cfg_path, encoding = "utf-8") as f:
with open(adapter_cfg_path, encoding = "utf-8-sig") as f:
cfg = json.load(f)
base = cfg.get("base_model_name_or_path")
if base:
@ -437,7 +438,7 @@ def _resolve_base_model(model_name: str) -> str:
config_json_path = local_path / "config.json"
if _safe_is_file(config_json_path):
try:
with open(config_json_path, encoding = "utf-8") as f:
with open(config_json_path, encoding = "utf-8-sig") as f:
cfg = json.load(f)
# Unsloth writes model_name, HF writes _name_or_path; skip a self-reference.
for _key in ("model_name", "_name_or_path"):
@ -544,7 +545,7 @@ def _adapter_base_from_hf_cache(model_name: str) -> str | None:
)
for cfg_path in candidates:
if cfg_path.is_file():
base = json.loads(cfg_path.read_text(encoding = "utf-8")).get(
base = json.loads(cfg_path.read_text(encoding = "utf-8-sig")).get(
"base_model_name_or_path"
)
return base or None
@ -616,7 +617,7 @@ def _check_tokenizer_config_needs_v5(model_name: str, hf_token: str | None = Non
local_tc = local_path / "tokenizer_config.json"
if _safe_is_file(local_tc):
try:
with open(local_tc, encoding = "utf-8") as f:
with open(local_tc, encoding = "utf-8-sig") as f:
data = json.load(f)
tokenizer_class = data.get("tokenizer_class", "")
result = tokenizer_class in _TRANSFORMERS_5_TOKENIZER_CLASSES
@ -706,7 +707,7 @@ def _config_json_from_hf_cache(model_name: str) -> dict | None:
)
for cfg_path in candidates:
if cfg_path.is_file():
with open(cfg_path, encoding = "utf-8") as f:
with open(cfg_path, encoding = "utf-8-sig") as f:
return json.load(f)
except Exception as exc:
logger.debug("HF cache config.json lookup failed for '%s': %s", model_name, exc)
@ -731,7 +732,7 @@ def _load_config_json(model_name: str, hf_token: str | None = None) -> dict | No
local_cfg = Path(model_name) / "config.json"
if _safe_is_file(local_cfg):
try:
with open(local_cfg, encoding = "utf-8") as f:
with open(local_cfg, encoding = "utf-8-sig") as f:
cfg = json.load(f)
_config_json_cache[cache_key] = cfg
return cfg
@ -1271,9 +1272,10 @@ def _probe_autoconfig(target_dir: str, model_name: str, hf_token: str | None) ->
[sys.executable, "-c", _PROBE_CONFIG_SCRIPT, target_dir, model_name],
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = _PROBE_TIMEOUT_SECS,
env = env,
env = utf8_child_env(env),
**_windows_hidden_subprocess_kwargs(),
)
except subprocess.TimeoutExpired:
@ -1811,7 +1813,11 @@ def _install_to_dir(pkg: str, target_dir: str) -> bool:
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
env = get_hf_cache_paths().child_env(child_env_without_native_path_secret()),
encoding = "utf-8",
errors = "replace",
env = utf8_child_env(
get_hf_cache_paths().child_env(child_env_without_native_path_secret())
),
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode == 0:
@ -1834,7 +1840,9 @@ def _install_to_dir(pkg: str, target_dir: str) -> bool:
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
env = get_hf_cache_paths().child_env(child_env_without_native_path_secret()),
encoding = "utf-8",
errors = "replace",
env = utf8_child_env(get_hf_cache_paths().child_env(child_env_without_native_path_secret())),
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode != 0:
@ -2079,7 +2087,7 @@ class SidecarSwapInProgress(RuntimeError):
def _read_swap_lock(path: Path) -> dict | None:
try:
data = json.loads(path.read_text(encoding = "utf-8"))
data = json.loads(path.read_text(encoding = "utf-8-sig"))
return data if isinstance(data, dict) else {}
except FileNotFoundError:
return None
@ -2120,7 +2128,7 @@ def try_begin_sidecar_swap(kind: str = "install") -> bool:
break
if fd is not None:
try:
with os.fdopen(fd, "w") as f:
with os.fdopen(fd, "w", encoding = "utf-8") as f:
f.write(
json.dumps(
{"pid": os.getpid(), "at": time.time(), "token": token, "kind": kind}
@ -2466,7 +2474,11 @@ def _ensure_venv_llmcompressor_exists() -> bool:
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
env = get_hf_cache_paths().child_env(child_env_without_native_path_secret()),
encoding = "utf-8",
errors = "replace",
env = utf8_child_env(
get_hf_cache_paths().child_env(child_env_without_native_path_secret())
),
**_windows_hidden_subprocess_kwargs(),
)
last_out = result.stdout or ""

View file

@ -30,6 +30,7 @@ PYPI_SUCCESS_TTL_SECONDS = 12 * 60 * 60
PYPI_FAILURE_TTL_SECONDS = 60 * 60
RELEASE_NOTES_URL = "https://unsloth.ai/docs/new/changelog"
DISABLE_ENV_VAR = "UNSLOTH_DISABLE_UPDATE_CHECK"
FAKE_UPDATE_ENV_VAR = "UNSLOTH_STUDIO_FAKE_UPDATE"
LOCAL_INSTALL_SOURCES = {"editable", "local_path", "vcs", "local_repo"}
@ -107,11 +108,32 @@ def get_studio_install_source_status(current_version: str) -> dict[str, Any]:
)
def _is_version(value: str) -> bool:
try:
Version(value)
except InvalidVersion:
return False
return True
def get_studio_update_status(current_version: str) -> dict[str, Any]:
"""Return public, read-only update status for the web UI."""
install_source = detect_install_source()
disabled = os.environ.get(DISABLE_ENV_VAR) == "1"
if os.environ.get(DISABLE_ENV_VAR) == "1":
# Dev-only: the popup is PyPI-install-only, so fake a version to review it
# from a checkout. The documented opt-out still wins.
forced_version = os.environ.get(FAKE_UPDATE_ENV_VAR, "").strip()
if forced_version and not disabled and _is_version(forced_version):
return _status_response(
current_version = current_version,
latest_version = forced_version,
install_source = "pypi",
update_available = True,
can_show_web_notification = True,
)
if disabled:
return _status_response(
current_version = current_version,
latest_version = None,

View file

@ -114,6 +114,8 @@ def hf_cache_snapshot_dir(model_name: str) -> Optional[Path]:
snapshot = repo_dir / "snapshots" / commit
if snapshot.is_dir():
return snapshot
# UnicodeDecodeError is a ValueError, not an OSError: a torn refs
# file must keep meaning "not cached here", not fail the offline check.
except (OSError, UnicodeDecodeError):
continue
return None

View file

@ -15,6 +15,7 @@ import urllib.request
from typing import Callable
from utils.native_path_leases import child_env_without_native_path_secret
from utils.child_stdio import utf8_child_env
from utils.subprocess_compat import windows_hidden_subprocess_kwargs
_logger = logging.getLogger(__name__)
@ -43,6 +44,8 @@ def has_blackwell_gpu() -> bool:
stdout = subprocess.PIPE,
stderr = subprocess.DEVNULL,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = 10,
env = child_env_without_native_path_secret(),
)
@ -102,8 +105,10 @@ def probe_torch_wheel_env(*, timeout: int | None = None) -> dict[str, str] | Non
stdout = subprocess.PIPE,
stderr = subprocess.PIPE,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = timeout,
env = child_env_without_native_path_secret(),
env = utf8_child_env(child_env_without_native_path_secret()),
**windows_hidden_subprocess_kwargs(),
)
except subprocess.TimeoutExpired:
@ -201,6 +206,8 @@ def install_wheel(
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
encoding = "utf-8",
errors = "replace",
env = child_env_without_native_path_secret(),
)
attempts.append(("uv", result))
@ -213,7 +220,10 @@ def install_wheel(
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
env = child_env_without_native_path_secret(),
encoding = "utf-8",
errors = "replace",
# Make the Python child emit the UTF-8 we decode above.
env = utf8_child_env(child_env_without_native_path_secret()),
)
attempts.append(("pip", result))
return attempts

View file

@ -121,7 +121,14 @@ def _installed_whisper_version(binary: Optional[str]) -> Optional[str]:
if not binary:
return None
try:
proc = subprocess.run([binary, "--version"], capture_output = True, text = True, timeout = 20)
proc = subprocess.run(
[binary, "--version"],
capture_output = True,
text = True,
encoding = "utf-8",
errors = "replace",
timeout = 20,
)
except Exception: # pragma: no cover - defensive
return None
m = re.search(r"v?(\d+\.\d+\.\d+)", (proc.stderr or "") + (proc.stdout or ""))

View file

@ -214,7 +214,8 @@ function TauriUpdateLayer({
}
return (
<div className="pointer-events-none fixed bottom-4 right-4 z-[9998] flex w-[calc(100vw-2rem)] max-w-[400px] flex-col items-stretch gap-2">
// Capped like the browser stack: the download panel shares it, so both must fit.
<div className="pointer-events-none fixed bottom-4 right-4 z-[9998] flex max-h-[calc(100dvh_-_2rem)] flex-col items-end gap-2">
<UpdateBanner
status={update.status}
info={update.info}
@ -223,6 +224,7 @@ function TauriUpdateLayer({
isExternalServer={isExternalServer}
updatePolicyMode={update.updatePolicyMode}
manualReleaseUrl={update.manualReleaseUrl}
releasePageUrl={update.releasePageUrl}
positioned={false}
onInstall={update.installUpdate}
onDismiss={update.dismiss}
@ -379,9 +381,11 @@ function TauriWrapper({ children }: { children: ReactNode }) {
return (
<>
{children}
{/* One bottom-right stack so overlays never overlap; they stack with a
gap, download panel anchored at the corner with banners above. */}
<div className="pointer-events-none fixed bottom-4 right-4 z-[9998] flex w-[calc(100vw-2rem)] max-w-[400px] flex-col items-stretch gap-2">
{/* One bottom-right stack so overlays never overlap: download panel at the
corner, banners above, each owning its width. */}
{/* Capped to the viewport, or a long download list plus expanded notes
pushes the top of the stack off screen. */}
<div className="pointer-events-none fixed bottom-4 right-4 z-[9998] flex max-h-[calc(100dvh_-_2rem)] flex-col items-end gap-2">
<WebUpdateBanner
positioned={false}
enabled={!WEB_UPDATE_HIDDEN_ROUTES.has(pathname)}

View file

@ -1226,7 +1226,9 @@ export function AppSidebar() {
openNewChat(null);
}}
className={cn(
"flex items-center gap-[6px] select-none transition-opacity",
// min-w-0 so a narrow sidebar truncates the wordmark
// instead of pushing the search icon over the logo.
"flex min-w-0 items-center gap-[6px] select-none transition-opacity",
chatDisabled && "pointer-events-none opacity-50",
)}
aria-label={t("shell.aria.home")}
@ -1238,17 +1240,17 @@ export function AppSidebar() {
<img
src="/circle-logo-small.png"
alt="Unsloth"
className="h-[calc(26px+0.5rem*var(--ui-font-scale,1))] w-[calc(26px+0.5rem*var(--ui-font-scale,1))] rounded-full object-cover"
className="h-[calc(26px+0.5rem*var(--ui-font-scale,1))] w-[calc(26px+0.5rem*var(--ui-font-scale,1))] shrink-0 rounded-full object-cover"
/>
<span className="font-heading text-[calc(13px+0.5rem*var(--ui-font-scale,1))] font-semibold tracking-[0em] leading-none text-black dark:text-white dark:tracking-[0.02em]">
<span className="truncate font-heading text-[calc(13px+0.5rem*var(--ui-font-scale,1))] font-semibold tracking-[0em] leading-none text-black dark:text-white dark:tracking-[0.02em]">
unsloth
</span>
<span className="nav-badge ml-0.5 inline-flex items-center justify-center rounded-full border border-nav-beta-border px-[5px] pt-[3px] pb-[2px] text-[calc(0.5rem*var(--ui-font-scale,1))] font-medium leading-none tracking-[0.04em] text-nav-fg-muted antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]">
<span className="nav-badge ml-0.5 inline-flex shrink-0 items-center justify-center rounded-full border border-nav-beta-border px-[5px] pt-[3px] pb-[2px] text-[calc(0.5rem*var(--ui-font-scale,1))] font-medium leading-none tracking-[0.04em] text-nav-fg-muted antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]">
{t("shell.beta")}
</span>
</Link>
)}
<div className="flex items-center gap-0.5">
<div className="flex shrink-0 items-center gap-0.25">
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
@ -1257,7 +1259,7 @@ export function AppSidebar() {
useChatSearchStore.getState().open();
closeMobileIfOpen();
}}
className="inline-flex h-[33px] w-[32px] cursor-pointer items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="inline-flex h-[33px] w-[28px] cursor-pointer items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={t("shell.navigation.search")}
>
<HugeiconsIcon icon={Search01Icon} strokeWidth={1.75} className="size-icon" />
@ -1281,7 +1283,7 @@ export function AppSidebar() {
<button
type="button"
onClick={togglePinned}
className="inline-flex h-[33px] w-[32px] cursor-pointer items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="inline-flex h-[33px] w-[28px] cursor-pointer items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={t("shell.aria.closeSidebar")}
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
@ -1325,10 +1327,10 @@ export function AppSidebar() {
)}
</SidebarHeader>
{/* Uniform pl-1.5 pr-2 keeps every hover pill the same width, inset from the edge. */}
{/* Uniform pl-1.5 pr-1.75 keeps every hover pill the same width, inset from the edge. */}
<SidebarGroup
className={cn(
"group-data-[collapsible=icon]:px-0 pl-1.5 pr-2 shrink-0 transition-[padding]",
"group-data-[collapsible=icon]:px-0 pl-1.5 pr-1.75 shrink-0 transition-[padding]",
showCompactMacBrand ? "pt-0" : "pt-[9px]",
// Scrolled: New Chat is pinned, give a little gap below it.
scrolled ? "pb-[5px]" : "pb-px",
@ -1417,7 +1419,7 @@ export function AppSidebar() {
scrolled && "is-scrolled",
)}
>
<SidebarGroup className="group-data-[collapsible=icon]:px-0 pl-1.5 pr-2 py-0 shrink-0">
<SidebarGroup className="group-data-[collapsible=icon]:px-0 pl-1.5 pr-1.75 py-0 shrink-0">
<SidebarGroupContent>
<SidebarMenu>
<NavItem
@ -1499,7 +1501,7 @@ export function AppSidebar() {
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="pl-1.5 pr-2">
<SidebarGroupContent className="pl-1.5 pr-1.75">
<SidebarMenu>
<NavItem
icon={TestTubeOutlineIcon}
@ -1574,7 +1576,7 @@ export function AppSidebar() {
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="pl-1.5 pr-2">
<SidebarGroupContent className="pl-1.5 pr-1.75">
<SidebarMenu>
{pinnedProjectRecords.map((project) => {
const projectChats =
@ -1721,7 +1723,7 @@ export function AppSidebar() {
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="pl-1.5 pr-2">
<SidebarGroupContent className="pl-1.5 pr-1.75">
<SidebarMenu>
{recentChatItems.map((item) =>
renderChatSidebarItem(item, "recent"),
@ -1753,7 +1755,7 @@ export function AppSidebar() {
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="pl-1.5 pr-2">
<SidebarGroupContent className="pl-1.5 pr-1.75">
<SidebarMenu>
{runItems.map((run) => {
// Explicit selection wins. Otherwise highlight the active

View file

@ -134,7 +134,7 @@ export function LlamaUpdateBanner({
className={cn(
positioned
? "fixed bottom-4 right-4 z-[9998] w-[calc(100vw-2rem)] max-w-[400px]"
: "pointer-events-auto w-full",
: "pointer-events-auto w-[calc(100vw-2rem)] max-w-[400px]",
)}
data-testid="llama-update-banner"
>

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { ReleaseNotesPanel } from "@/components/update/release-notes-panel";
import type {
DesktopUpdatePolicyMode,
RetainedUpdateFailure,
@ -22,6 +23,8 @@ interface UpdateBannerProps {
isExternalServer?: boolean;
updatePolicyMode: DesktopUpdatePolicyMode;
manualReleaseUrl: string | null;
// Release page for this version, preferred over the generic changelog.
releasePageUrl?: string | null;
// false fills a shared overlay stack; true self-anchors.
positioned?: boolean;
onInstall: () => void;
@ -30,6 +33,7 @@ interface UpdateBannerProps {
}
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
const LEADING_V = /^v/;
function formatVersion(version: string | null | undefined): string {
if (!version) return "";
@ -44,6 +48,7 @@ export function UpdateBanner({
isExternalServer = false,
updatePolicyMode,
manualReleaseUrl,
releasePageUrl = null,
positioned = true,
onInstall,
onDismiss,
@ -52,6 +57,8 @@ export function UpdateBanner({
const [copying, setCopying] = useState(false);
const [manualReport, setManualReport] = useState<string | null>(null);
const [manualMessage, setManualMessage] = useState<string | null>(null);
// Version whose notes are expanded; a new offer collapses the panel.
const [notesVersion, setNotesVersion] = useState<string | null>(null);
const showFailure = Boolean(lastFailure) && !dismissed;
const showAvailable = status === "available" && !dismissed && !showFailure;
const show = showFailure || (showAvailable && Boolean(info));
@ -62,6 +69,11 @@ export function UpdateBanner({
const currentVersion = formatVersion(info?.currentVersion);
const latestVersion = formatVersion(info?.version);
const Icon = showFailure ? CircleAlert : Download;
// Keyed by the backend release, not the app's SemVer; headings drop the v.
const notesTargetVersion =
(info?.pypiVersion ?? info?.version)?.replace(LEADING_V, "") ?? null;
const notesOpen =
notesTargetVersion !== null && notesVersion === notesTargetVersion;
async function handleCopyDiagnostics() {
setCopying(true);
@ -94,13 +106,14 @@ export function UpdateBanner({
exit={{ opacity: 0, y: 8, scale: 0.97 }}
transition={{ duration: 0.35, ease: EASE_OUT_QUART }}
className={cn(
// Wider than the other overlays: notes preview plus three buttons.
positioned
? "fixed bottom-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[400px]"
: "pointer-events-auto w-full",
? "fixed bottom-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[448px]"
: "pointer-events-auto flex min-h-0 w-[calc(100vw-2rem)] max-w-[448px] flex-col",
)}
data-testid="tauri-update-banner"
>
<div className="relative overflow-hidden rounded-[24px] bg-white px-5 pb-4 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]">
<div className="relative flex max-h-[calc(100dvh_-_2rem)] flex-col overflow-hidden rounded-[24px] bg-white px-5 pb-4 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]">
<button
type="button"
onClick={onDismiss}
@ -160,7 +173,40 @@ export function UpdateBanner({
</p>
)}
<div className="mt-4 flex flex-wrap items-center justify-end gap-x-1 gap-y-2">
{!showFailure && notesTargetVersion ? (
<ReleaseNotesPanel
version={notesTargetVersion}
open={notesOpen}
// Used only if CHANGELOG.md has no section for this version.
fallbackMarkdown={info?.body ?? null}
className="min-h-0 flex-1"
releaseNotesUrl={releasePageUrl ?? manualReleaseUrl}
/>
) : null}
<div
className={cn(
"mt-4 flex flex-wrap items-center gap-x-1 gap-y-2",
!showFailure && notesTargetVersion
? "justify-between"
: "justify-end",
)}
>
{!showFailure && notesTargetVersion ? (
<Button
size="sm"
variant="ghost"
// same type size as the action buttons
className="-ml-2 h-auto whitespace-nowrap rounded-full px-2.5 py-2 text-ui-13 font-medium text-foreground"
onClick={() =>
setNotesVersion(notesOpen ? null : notesTargetVersion)
}
aria-expanded={notesOpen}
data-testid="tauri-update-release-notes-toggle"
>
{notesOpen ? "Hide release notes" : "Show release notes"}
</Button>
) : null}
{showFailure ? (
<>
<Button
@ -187,28 +233,31 @@ export function UpdateBanner({
onClick={onInstall}
disabled={installDisabled}
>
{isManualLinuxPackage ? "Open release page" : "Retry update"}
{isManualLinuxPackage
? "Open release page"
: "Retry update"}
</Button>
</>
) : (
<>
// wrap + right-align so the action pair stays together
<div className="flex flex-wrap items-center justify-end gap-x-1 gap-y-2">
<Button
size="sm"
variant="ghost"
className="h-auto rounded-full px-3 py-2 text-ui-13 font-medium text-foreground"
className="h-auto whitespace-nowrap rounded-full px-2.5 py-2 text-ui-13 font-medium text-foreground"
onClick={onDismiss}
>
Remind me later
</Button>
<Button
size="sm"
className="-mr-1 h-auto rounded-full px-3.5 py-2 text-ui-13"
className="-mr-1 h-auto whitespace-nowrap rounded-full px-3 py-2 text-ui-13"
onClick={onInstall}
disabled={installDisabled}
>
{isManualLinuxPackage ? "Open release page" : "Update"}
</Button>
</>
</div>
)}
</div>
{manualMessage && (

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useSidebarPin } from "@/hooks/use-sidebar-pin";
import { useSidebarWidth } from "@/hooks/use-sidebar-width";
import { isTauri } from "@/lib/api-base";
import { cn } from "@/lib/utils";
import {
@ -110,9 +111,13 @@ export function WindowTitlebar({
const [enabled] = useState(shouldUseCustomWindowTitlebar);
const [maximized, setMaximized] = useState(false);
const { pinned, togglePinned } = useSidebarPin();
// The titlebar sits outside the sidebar wrapper, so it cannot inherit
// --sidebar-width. Read the resized width from the same store instead.
const { width } = useSidebarWidth();
const sidebarWidth = showSidebarSurface
? pinned
? "var(--studio-sidebar-expanded-width,17.5rem)"
? // The live value only exists mid-drag; otherwise the committed width.
`var(--studio-sidebar-live-width, ${width}px)`
: "var(--studio-sidebar-collapsed-width,3rem)"
: "0px";
const contentBorderLeft = pinned ? `calc(${sidebarWidth} + 12px)` : "0px";

View file

@ -0,0 +1,317 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { getClientPlatform } from "@/components/tauri/window-titlebar"
/** Pointer travel (px) below which a drag counts as a plain click. */
const DRAG_SLOP = 4
/** A compatibility click lands immediately after pointer-up. */
const CLICK_COMPAT_WINDOW_MS = 300
/** Arrow-key resize step for keyboard users. */
const RESIZE_STEP = 16
type DragState = {
startX: number
startWidth: number
moved: boolean
}
export type PanelResizeHandleProps = {
/** Which edge of the panel the handle sits on. */
edge: "left" | "right"
open: boolean
width: number
/** Uncapped stored preference, so a capped drag does not lower it. */
stored: number
min: number
max: number
clamp: (px: number) => number
setWidth: (px: number) => void
resetWidth: () => void
onToggle: () => void
/** Element to paint the live width onto, and the property to paint. */
target: () => HTMLElement | null
cssVar: string
/** Measured to start a drag from the rendered size when collapsed. */
measure: () => number
label: string
toggleLabel: string
/** Translated tooltip copy; the caller owns the translation layer. */
collapseHint: string
expandHint: string
dragHint: string
/** Shown in the tooltip when the panel has a toggle shortcut. */
shortcut?: string
dataSlot?: string
className?: string
/** Mirrors the live width onto :root for chrome outside the panel. */
rootVar?: string
}
/**
* A draggable panel edge: drag to resize, click to collapse or expand. Arrow
* keys resize, Home restores the default. The width is painted straight to the
* target while dragging and only persisted on release.
*/
export function PanelResizeHandle({
edge,
open,
width,
stored,
min,
max,
clamp,
setWidth,
resetWidth,
onToggle,
target,
cssVar,
measure,
label,
toggleLabel,
collapseHint,
expandHint,
dragHint,
shortcut,
dataSlot = "panel-resize-handle",
className,
rootVar,
}: PanelResizeHandleProps) {
const ref = React.useRef<HTMLButtonElement>(null)
const dragRef = React.useRef<DragState | null>(null)
const [dragging, setDragging] = React.useState(false)
const [hovered, setHovered] = React.useState(false)
const [focused, setFocused] = React.useState(false)
const [isMacPlatform] = React.useState(() => getClientPlatform().includes("mac"))
const hint = shortcut ? shortcut.replace("Mod", isMacPlatform ? "⌘" : "Ctrl+") : null
// Cached on pointer down so no DOM walk per move.
const targetRef = React.useRef<HTMLElement | null>(null)
const frameRef = React.useRef(0)
const pendingRef = React.useRef(0)
// What the pointer asked for, before the viewport cap. Committing the capped
// value instead would quietly downgrade a stored preference on a narrow window.
const rawRef = React.useRef(0)
// When a pointer sequence last ended. The browser's compatibility click
// lands in the same tick, so only a click that close behind is a duplicate.
// A timestamp cannot go stale the way an armed flag does: a genuine cancel
// emits no click, and a later assistive-tech click still gets through.
const handledAtRef = React.useRef(0)
const committedRef = React.useRef(width)
React.useEffect(() => {
committedRef.current = width
}, [width])
const paint = React.useCallback(
(value: string) => {
targetRef.current?.style.setProperty(cssVar, value)
if (rootVar) {
document.documentElement.style.setProperty(rootVar, value)
}
},
[cssVar, rootVar],
)
// Resizing relayouts the whole shell, and pointermove fires faster than the
// display refreshes, so coalesce to one paint per frame.
const paintWidth = React.useCallback(
(px: number) => {
pendingRef.current = px
if (frameRef.current) return
frameRef.current = requestAnimationFrame(() => {
frameRef.current = 0
paint(`${pendingRef.current}px`)
})
},
[paint],
)
const endDrag = React.useCallback(() => {
// Only a sequence that actually started can produce a compatibility click.
// This also runs as the effect cleanup, where no drag happened.
if (dragRef.current) handledAtRef.current = Date.now()
dragRef.current = null
if (frameRef.current) {
cancelAnimationFrame(frameRef.current)
frameRef.current = 0
}
// Hand the property back to the committed value. A commit re-renders with
// the new width; a cancel or a no-commit drag keeps DOM and store in step.
paint(`${committedRef.current}px`)
if (rootVar) document.documentElement.style.removeProperty(rootVar)
targetRef.current?.removeAttribute("data-resizing")
document.documentElement.removeAttribute("data-panel-resizing")
targetRef.current = null
setDragging(false)
document.body.style.removeProperty("cursor")
document.body.style.removeProperty("user-select")
}, [paint, rootVar])
const handlePointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
if (event.button !== 0) return
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
targetRef.current = target()
// Collapsed: grow from the rendered size so the edge tracks the pointer.
const start = open ? width : measure()
dragRef.current = { startX: event.clientX, startWidth: start, moved: false }
pendingRef.current = start
rawRef.current = start
targetRef.current?.setAttribute("data-resizing", "true")
document.documentElement.setAttribute("data-panel-resizing", "true")
setDragging(true)
document.body.style.setProperty("cursor", "col-resize")
document.body.style.setProperty("user-select", "none")
}
const handlePointerMove = (event: React.PointerEvent<HTMLButtonElement>) => {
const drag = dragRef.current
if (!drag) return
// A panel whose handle is on its left edge grows as the pointer moves left.
const delta = (edge === "left" ? -1 : 1) * (event.clientX - drag.startX)
if (!drag.moved && Math.abs(delta) < DRAG_SLOP) return
drag.moved = true
const next = drag.startWidth + delta
rawRef.current = next
if (!open) {
// Past the minimum, dragging the collapsed edge reopens it.
if (next >= min) {
paintWidth(clamp(next))
onToggle()
}
return
}
// Dragging inward stops at the minimum. Collapsing is click or the shortcut.
paintWidth(clamp(next))
}
const handlePointerUp = (event: React.PointerEvent<HTMLButtonElement>) => {
const drag = dragRef.current
if (!drag) return
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
endDrag()
if (!drag.moved) {
onToggle()
return
}
// A drag below the minimum leaves the stored width alone.
if (!open) return
// Capped: the visible edge is already at the cap, so an outward pull cannot
// express intent beyond it. Committing would silently lower the larger
// hidden preference. A deliberate inward drag still commits.
if (stored > max && rawRef.current >= max) return
// Commit what was asked for, not the capped paint, so a drag on a narrow
// window cannot shrink a larger stored preference. setWidth clamps.
setWidth(rawRef.current)
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
// The collapse/expand the label advertises, for keyboard users. Pointer-up
// handles it for the mouse; a synthesized click never reaches it.
if (event.key === "Enter" || event.key === " ") {
// preventDefault cancels the native click, so nothing follows to guard
// against; arming here would swallow the next assistive-tech click.
event.preventDefault()
onToggle()
return
}
const outward = edge === "left" ? "ArrowLeft" : "ArrowRight"
const inward = edge === "left" ? "ArrowRight" : "ArrowLeft"
if (event.key === outward || event.key === inward) {
event.preventDefault()
if (!open) {
// Collapsed there is nothing to resize, so the outward arrow reopens.
if (event.key === outward) onToggle()
return
}
if (event.key === outward && stored > max && width >= max) return
setWidth(width + (event.key === outward ? RESIZE_STEP : -RESIZE_STEP))
return
}
if (event.key === "Home") {
event.preventDefault()
resetWidth()
}
}
// Clear a stuck cursor override if we unmount mid-drag.
React.useEffect(() => endDrag, [endDrag])
return (
<Tooltip open={(hovered || focused) && !dragging}>
<TooltipTrigger asChild>
<button
ref={ref}
type="button"
data-slot={dataSlot}
data-dragging={dragging || undefined}
aria-label={open ? label : toggleLabel}
{...(open ? { "aria-orientation": "vertical" as const } : {})}
{...(open
? { "aria-valuenow": width, "aria-valuemin": min, "aria-valuemax": max }
: {})}
role={open ? "separator" : "button"}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={endDrag}
onKeyDown={handleKeyDown}
onClick={() => {
// Switch and voice control activate by dispatching a bare click
// with no pointer or key events, which nothing else here catches.
if (Date.now() - handledAtRef.current < CLICK_COMPAT_WINDOW_MS) return
onToggle()
}}
onPointerEnter={() => setHovered(true)}
onPointerLeave={() => setHovered(false)}
onFocus={(event) => setFocused(event.target.matches(":focus-visible"))}
onBlur={() => setFocused(false)}
className={cn(
"absolute inset-y-0 z-30 hidden w-2 touch-none select-none sm:block",
edge === "left" ? "-left-1" : "-right-1",
// `!` overrides the app-wide hand cursor on buttons.
open
? "cursor-col-resize!"
: edge === "left"
? "cursor-w-resize!"
: "cursor-e-resize!",
// Sits exactly on the panel border so hover recolours one line.
"after:absolute after:inset-y-0 after:w-px after:bg-transparent after:transition-colors after:duration-150",
edge === "left" ? "after:left-1" : "after:right-1",
"hover:after:bg-sidebar-ring/25 data-dragging:after:bg-sidebar-ring/25",
// The app zeroes the native outline on buttons, so mark focus here.
"focus-visible:outline-none focus-visible:after:bg-sidebar-ring/60",
className,
)}
/>
</TooltipTrigger>
<TooltipContent
side={edge === "left" ? "left" : "right"}
align="center"
className="tooltip-compact"
>
<span className="flex flex-col gap-px">
<span>
{open ? collapseHint : expandHint}
{hint ? ` ${hint}` : ""}
</span>
<span className="opacity-70">{dragHint}</span>
</span>
</TooltipContent>
</Tooltip>
)
}

View file

@ -24,13 +24,21 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { PanelResizeHandle } from "@/components/ui/panel-resize-handle"
import { useT } from "@/i18n"
import { useIsMobile } from "@/hooks/use-mobile"
import {
SIDEBAR_WIDTH_DEFAULT,
SIDEBAR_WIDTH_MIN,
clampSidebarWidth,
useSidebarWidth,
} from "@/hooks/use-sidebar-width"
import { HugeiconsIcon } from "@hugeicons/react"
import { LayoutAlignLeftIcon } from "@hugeicons/core-free-icons"
const noop = () => {}
const SIDEBAR_WIDTH = "17.5rem"
const SIDEBAR_WIDTH = `${SIDEBAR_WIDTH_DEFAULT}px`
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
@ -46,6 +54,11 @@ type SidebarContextProps = {
pinned: boolean
setPinned: (value: boolean) => void
togglePinned: () => void
width: number
storedWidth: number
maxWidth: number
setWidth: (value: number) => void
resetWidth: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
@ -80,6 +93,13 @@ function SidebarProvider({
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
const {
width,
max: maxWidth,
stored: storedWidth,
setWidth,
resetWidth,
} = useSidebarWidth()
const prevIsMobileRef = React.useRef(isMobile)
React.useEffect(() => {
@ -163,8 +183,13 @@ function SidebarProvider({
pinned,
setPinned,
togglePinned,
width,
storedWidth,
maxWidth,
setWidth,
resetWidth,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, hasPinMode, pinned, setPinned, togglePinned]
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, hasPinMode, pinned, setPinned, togglePinned, width, storedWidth, maxWidth, setWidth, resetWidth]
)
return (
@ -173,7 +198,8 @@ function SidebarProvider({
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
// The drag handle writes this same property live while resizing.
"--sidebar-width": `${width}px`,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
@ -311,11 +337,64 @@ function Sidebar({
>
{children}
</div>
<SidebarResizeHandle side={side} />
</div>
</div>
)
}
/**
* The sidebar's draggable edge, over the shared panel handle.
*/
function SidebarResizeHandle({
className,
side = "left",
}: {
className?: string
side?: "left" | "right"
}) {
const { open, toggleSidebar, width, storedWidth, maxWidth, setWidth, resetWidth } =
useSidebar()
const ref = React.useRef<HTMLDivElement>(null)
const t = useT()
return (
<div ref={ref} className="contents">
<PanelResizeHandle
edge={side === "right" ? "left" : "right"}
open={open}
width={width}
stored={storedWidth}
min={SIDEBAR_WIDTH_MIN}
max={maxWidth}
clamp={clampSidebarWidth}
setWidth={setWidth}
resetWidth={resetWidth}
onToggle={toggleSidebar}
target={() =>
ref.current?.closest<HTMLElement>('[data-slot="sidebar-wrapper"]') ?? null
}
cssVar="--sidebar-width"
// The custom titlebar renders outside the wrapper and cannot inherit it.
rootVar="--studio-sidebar-live-width"
measure={() =>
ref.current
?.closest<HTMLElement>('[data-slot="sidebar-container"]')
?.getBoundingClientRect().width ?? SIDEBAR_WIDTH_MIN
}
label={t("shell.aria.resizeSidebar")}
toggleLabel={t("shell.aria.openSidebar")}
collapseHint={t("shell.resize.collapse")}
expandHint={t("shell.resize.expand")}
dragHint={t("shell.resize.drag")}
shortcut="ModB"
dataSlot="sidebar-resize-handle"
className={className}
/>
</div>
)
}
function SidebarTrigger({
className,
onClick,
@ -777,6 +856,7 @@ export {
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarResizeHandle,
SidebarSeparator,
SidebarTrigger,
useSidebar,

View file

@ -0,0 +1,251 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { MarkdownPreview } from "@/components/markdown/markdown-preview";
import { useReleaseNotes } from "@/hooks/use-release-notes";
import { resolveChangelogLinks } from "@/lib/changelog-links";
import { releaseNotesPreview } from "@/lib/release-notes-preview";
import { cn } from "@/lib/utils";
import {
type ReactElement,
type ReactNode,
useEffect,
useMemo,
useRef,
} from "react";
interface ReleaseNotesPanelProps {
// Notes are looked up for this exact version only.
version: string;
// Collapsed previews the top bullets; expanded scrolls the full notes.
open: boolean;
// Desktop updater's body, used only if CHANGELOG.md has no section here.
fallbackMarkdown?: string | null;
releaseNotesUrl?: string | null;
className?: string;
}
const NOTES_LINK_CLASS =
"shrink-0 whitespace-nowrap text-ui-11 font-medium text-foreground underline underline-offset-2";
function NotesMessage({
children,
action,
}: {
children: ReactNode;
action?: ReactNode;
}): ReactElement {
return (
<div className="flex items-center justify-between gap-2 px-1 py-2">
<p className="text-ui-11 text-muted-foreground">{children}</p>
{action}
</div>
);
}
function ChangelogLink({ href }: { href: string }): ReactElement {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={NOTES_LINK_CLASS}
data-testid="update-release-notes-link"
>
Open changelog
</a>
);
}
export function ReleaseNotesPanel({
version,
open,
fallbackMarkdown = null,
releaseNotesUrl = null,
className,
}: ReleaseNotesPanelProps): ReactElement | null {
// Fetched with the popup: the collapsed preview needs the notes too.
const { state, notes, retry } = useReleaseNotes({ version, enabled: true });
const scrollRef = useRef<HTMLElement | null>(null);
// The fallback stands in for "no section in the changelog", which the hook
// reports as ready. An error is retryable, and the desktop fallback is the
// updater's static blurb, so taking it there would hide Retry until cache expiry.
const source = notes?.matched
? notes.markdown
: state === "error"
? null
: (fallbackMarkdown ?? null);
// Notes target the repository, so relative links must point back at it.
const markdown = useMemo(
() => (source === null ? null : resolveChangelogLinks(source)),
[source],
);
// Notes that are only a code block or a table preview as nothing.
const preview = useMemo(
() => (markdown === null ? null : releaseNotesPreview(markdown)),
[markdown],
);
// Start at the top on expand, and again once async notes land.
useEffect(() => {
if (open && markdown && scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
}, [open, markdown]);
// Caller's URL wins: the API returns only the generic changelog, while the
// desktop banner passes this version's release page.
const notesUrl = releaseNotesUrl ?? notes?.releaseNotesUrl;
const link = notesUrl ? <ChangelogLink href={notesUrl} /> : null;
// Nothing previewable yet or ever: keep the collapsed popup compact.
if (
!open &&
(!markdown ||
state === "loading" ||
state === "idle" ||
preview?.items.length === 0)
) {
return null;
}
return (
<div
className={cn("mt-3 flex min-h-0 flex-col", className)}
data-testid="update-release-notes-panel"
data-notes-state={state}
data-notes-version={version}
data-notes-open={open}
>
{/* borderless fill, lighter than the card in dark mode */}
<div className="flex min-h-0 flex-col rounded-[14px] bg-muted/40 px-3 py-1 dark:bg-white/[0.06]">
{markdown ? (
open ? (
<section
ref={scrollRef}
// biome-ignore lint/a11y/noNoninteractiveTabindex: keyboard-scrollable region
tabIndex={0}
aria-label={`Release notes for version ${version}`}
// Long notes scroll here instead of pushing the buttons off screen.
className="hover-scrollbar max-h-64 min-h-0 flex-1 overflow-y-auto overscroll-contain py-3 pr-1"
data-testid="update-release-notes-scroll"
>
<MarkdownPreview
markdown={markdown}
// Streamdown ships headings at mt-6 and code at text-sm, and
// clears max-width on descendants, so rescale and re-cap both.
className="max-h-none overflow-visible border-0 bg-transparent p-0 text-ui-11 [&_[data-streamdown=link-safety-modal]>*]:max-w-md [&_img]:h-auto [&_img]:max-w-full [&>*:first-child]:mt-0 [&>*>*:first-child]:mt-0 [&_code]:text-[0.92em] [&_h1]:mt-4 [&_h1]:font-heading [&_h1]:text-ui-13 [&_h2]:mt-4 [&_h2]:font-heading [&_h2]:text-ui-13 [&_h3]:mt-4 [&_h3]:font-heading [&_h3]:text-ui-11 [&_pre]:text-[0.92em]"
/>
{notes?.truncated ? (
<p className="mt-2 text-ui-10 text-muted-foreground/80">
Notes truncated. See the full changelog.
</p>
) : null}
</section>
) : (
<ReleaseNotesSummary preview={preview} />
)
) : (
<NotesStatus
state={state}
version={version}
link={link}
retry={retry}
/>
)}
</div>
{open && markdown && link ? (
<div className="mt-2 flex justify-end px-1">{link}</div>
) : null}
</div>
);
}
/** Collapsed view: the first few bullets, one line each where possible. */
function ReleaseNotesSummary({
preview,
}: {
preview: ReturnType<typeof releaseNotesPreview> | null;
}): ReactElement | null {
if (preview === null || preview.items.length === 0) {
return null;
}
const { items, remaining } = preview;
return (
<ul
className="space-y-1 py-2 pr-1"
data-testid="update-release-notes-summary"
>
{items.map((item, index) => (
<li
// Two releases can carry the same bullet text, so index is the key.
key={`${index}-${item.lead}`}
className="flex gap-1.5 text-ui-11 leading-snug text-muted-foreground"
>
<span aria-hidden="true" className="text-muted-foreground/60">
&bull;
</span>
<span className="line-clamp-2 min-w-0">
{/* lead sentence carries the change */}
<span className="font-medium text-foreground">{item.lead}</span>
{item.rest ? <span> {item.rest}</span> : null}
</span>
</li>
))}
{remaining > 0 ? (
<li className="pl-3 text-ui-10 text-muted-foreground/70">
+{remaining} more
</li>
) : null}
</ul>
);
}
function NotesStatus({
state,
version,
link,
retry,
}: {
state: ReturnType<typeof useReleaseNotes>["state"];
version: string;
link: ReactNode;
retry: () => void;
}): ReactElement {
if (state === "loading" || state === "idle") {
return <NotesMessage>Loading release notes...</NotesMessage>;
}
if (state === "error") {
return (
<NotesMessage
action={
// The changelog page may be reachable when the lookup is not.
<span className="flex shrink-0 items-center gap-3">
<button
type="button"
onClick={retry}
className={NOTES_LINK_CLASS}
data-testid="update-release-notes-retry"
>
Retry
</button>
{link}
</span>
}
>
Could not load release notes.
</NotesMessage>
);
}
// Matched nothing: link out rather than show another release's notes.
return (
<NotesMessage action={link}>
No release notes published for {version} yet.
</NotesMessage>
);
}

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { ReleaseNotesPanel } from "@/components/update/release-notes-panel";
import { type DeviceType, usePlatformStore } from "@/config/env";
import { useWebUpdateCheck } from "@/hooks/use-web-update-check";
import { isTauri } from "@/lib/api-base";
@ -40,6 +41,7 @@ export function WebUpdateBanner({
const deviceType = usePlatformStore((s) => s.deviceType);
const installCmd = installCommandForDevice(deviceType);
const [copiedVersion, setCopiedVersion] = useState<string | null>(null);
const [notesVersion, setNotesVersion] = useState<string | null>(null);
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
@ -68,6 +70,8 @@ export function WebUpdateBanner({
}
const copied = status != null && copiedVersion === status.latestVersion;
// Keyed by version so a new offer collapses the panel.
const notesOpen = status != null && notesVersion === status.latestVersion;
return (
<AnimatePresence>
@ -78,13 +82,14 @@ export function WebUpdateBanner({
exit={{ opacity: 0, y: 8, scale: 0.97 }}
transition={{ duration: 0.35, ease: EASE_OUT_QUART }}
className={cn(
// Wider than the other overlays: notes preview plus three buttons.
positioned
? "fixed bottom-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[400px]"
: "pointer-events-auto w-full",
? "fixed bottom-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[448px]"
: "pointer-events-auto flex min-h-0 w-[calc(100vw-2rem)] max-w-[448px] flex-col",
)}
data-testid="web-update-banner"
>
<div className="relative overflow-hidden rounded-[24px] bg-white px-5 pb-4 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]">
<div className="relative flex max-h-[calc(100dvh_-_2rem)] flex-col overflow-hidden rounded-[24px] bg-white px-5 pb-4 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]">
<button
type="button"
onClick={dismiss}
@ -127,22 +132,33 @@ export function WebUpdateBanner({
</div>
</div>
<ReleaseNotesPanel
version={status.latestVersion}
open={notesOpen}
releaseNotesUrl={RELEASE_NOTES_URL}
className="min-h-0 flex-1"
/>
{/* one row at one type size; wraps only on narrow viewports */}
<div className="mt-4 flex flex-wrap items-center justify-between gap-y-2">
<a
href={RELEASE_NOTES_URL}
target="_blank"
rel="noopener noreferrer"
className="-ml-2 whitespace-nowrap rounded-full px-2.5 py-2 text-ui-13 font-medium text-foreground transition-colors hover:bg-muted"
data-testid="web-update-release-notes-link"
<Button
size="sm"
variant="ghost"
className="-ml-2 h-auto whitespace-nowrap rounded-full px-2.5 py-2 text-ui-13 font-medium text-foreground"
onClick={() =>
setNotesVersion(notesOpen ? null : status.latestVersion)
}
aria-expanded={notesOpen}
data-testid="web-update-release-notes-toggle"
>
Release notes
</a>
{notesOpen ? "Hide release notes" : "Show release notes"}
</Button>
{/* wrap + right-align so buttons stack instead of clipping on very narrow banners */}
<div className="flex flex-wrap items-center justify-end gap-x-1 gap-y-2">
<Button
size="sm"
variant="ghost"
className="h-auto rounded-full px-3 py-2 text-ui-13 font-medium text-foreground"
className="h-auto whitespace-nowrap rounded-full px-2.5 py-2 text-ui-13 font-medium text-foreground"
onClick={snooze}
data-testid="web-update-snooze-button"
>
@ -151,7 +167,7 @@ export function WebUpdateBanner({
<Button
size="sm"
// -mr optically aligns the filled pill's edge with the card padding
className="-mr-1 h-auto rounded-full px-3.5 py-2 text-ui-13"
className="-mr-1 h-auto whitespace-nowrap rounded-full px-3 py-2 text-ui-13"
onClick={handleCopyCommand}
data-testid="web-update-copy-button"
>

View file

@ -18,6 +18,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { InfoHint } from "@/components/ui/info-hint";
import { PanelResizeHandle } from "@/components/ui/panel-resize-handle";
import {
InputGroup,
InputGroupAddon,
@ -44,7 +45,13 @@ import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { NumericValueInput, snapToStep } from "@/features/model-picker";
import { RetrievalSettingsSection } from "@/features/rag";
import { useLlamaUpdateCheck } from "@/hooks/use-llama-update-check";
import {
CHAT_SETTINGS_WIDTH_MIN,
clampChatSettingsWidth,
useChatSettingsWidth,
} from "@/hooks/use-chat-settings-width";
import { useIsMobile } from "@/hooks/use-mobile";
import { useT } from "@/i18n";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
@ -52,7 +59,7 @@ import { Edit03Icon, LayoutAlignRightIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Braces, ChevronDown, ExternalLink } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Fragment, type ReactNode } from "react";
import { type CSSProperties, Fragment, type ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { OpenAICodeExecSection } from "./components/openai-code-exec-section";
import { PermissionModeDropdown } from "./permission-mode-select";
@ -363,6 +370,15 @@ export function ChatSettingsPanel({
onExternalProviderChange,
externalProviderType = null,
}: ChatSettingsPanelProps) {
const asideRef = useRef<HTMLElement>(null);
const t = useT();
const {
width: settingsWidth,
max: settingsMax,
stored: settingsStored,
setWidth: setSettingsWidth,
resetWidth: resetSettingsWidth,
} = useChatSettingsWidth();
// Local models show every knob; providerCapabilities is only consulted when
// isExternalModel. Unknown providers fall back to the OpenAI-compat shape via
// getProviderCapabilities, so these flags never undercount support.
@ -461,6 +477,23 @@ export function ChatSettingsPanel({
// When the prompt overflows the inline box, clicking opens the popup editor.
const systemPromptBoxRef = useRef<HTMLTextAreaElement>(null);
const [systemPromptOverflows, setSystemPromptOverflows] = useState(false);
const promptObserverRef = useRef<ResizeObserver | null>(null);
const measurePromptRef = useRef<() => void>(() => {});
// The section unmounts its textarea when collapsed, so observe through a
// callback ref: a stored observer would cling to the detached node and the
// remounted one would never be measured.
const attachPromptBox = useCallback((node: HTMLTextAreaElement | null) => {
systemPromptBoxRef.current = node;
promptObserverRef.current?.disconnect();
promptObserverRef.current = null;
if (!node || typeof ResizeObserver === "undefined") return;
// Resizing rewraps the prompt, and a drag changes the width through a
// custom property without re-rendering, so watch the box itself.
const observer = new ResizeObserver(() => measurePromptRef.current());
observer.observe(node);
promptObserverRef.current = observer;
measurePromptRef.current();
}, []);
const [activePresetBaseline, setActivePresetBaseline] = useState(params);
const presets = useMemo(() => {
return getOrderedPresets(customPresets);
@ -746,15 +779,20 @@ export function ChatSettingsPanel({
}, [open]);
useEffect(() => {
const el = systemPromptBoxRef.current;
setSystemPromptOverflows(
currentSystemPrompt.length > 0 &&
el != null &&
el.clientHeight > 0 &&
el.scrollHeight > el.clientHeight + 1,
);
measurePromptRef.current = () => {
const el = systemPromptBoxRef.current;
setSystemPromptOverflows(
currentSystemPrompt.length > 0 &&
el != null &&
el.clientHeight > 0 &&
el.scrollHeight > el.clientHeight + 1,
);
};
measurePromptRef.current();
}, [currentSystemPrompt, open]);
useEffect(() => () => promptObserverRef.current?.disconnect(), []);
const settingsScrollRef = useRef<HTMLDivElement>(null);
const settingsContent = (
@ -1124,7 +1162,7 @@ export function ChatSettingsPanel({
)}
>
<textarea
ref={systemPromptBoxRef}
ref={attachPromptBox}
value={currentSystemPrompt}
onChange={(e) => set("systemPrompt")(e.target.value)}
onMouseDown={(e) => {
@ -1433,17 +1471,47 @@ export function ChatSettingsPanel({
return (
<aside
ref={asideRef}
data-tour="chat-settings"
data-slot="chat-settings-panel"
className={cn(
"relative z-50 shrink-0 overflow-hidden bg-panel-surface text-panel-surface-fg font-heading",
open ? "w-[17rem] border-l border-sidebar-border" : "w-0",
"relative z-50 shrink-0 bg-panel-surface text-panel-surface-fg font-heading",
open
? "w-(--chat-settings-width) border-l border-sidebar-border"
: "w-0 overflow-hidden",
)}
style={{
height: "calc(100% - var(--studio-custom-titlebar-height, 0px))",
marginTop: "var(--studio-custom-titlebar-height, 0px)",
}}
style={
{
"--chat-settings-width": `${settingsWidth}px`,
height: "calc(100% - var(--studio-custom-titlebar-height, 0px))",
marginTop: "var(--studio-custom-titlebar-height, 0px)",
} as CSSProperties
}
>
<div className="h-full w-full">{settingsContent}</div>
{open ? (
<PanelResizeHandle
edge="left"
open={open}
width={settingsWidth}
stored={settingsStored}
min={CHAT_SETTINGS_WIDTH_MIN}
max={settingsMax}
clamp={clampChatSettingsWidth}
setWidth={setSettingsWidth}
resetWidth={resetSettingsWidth}
onToggle={() => onOpenChange?.(!open)}
target={() => asideRef.current}
cssVar="--chat-settings-width"
measure={() => asideRef.current?.getBoundingClientRect().width ?? 0}
label={t("shell.aria.resizeRunSettings")}
toggleLabel={t("shell.aria.openRunSettings")}
collapseHint={t("shell.resize.collapse")}
expandHint={t("shell.resize.expand")}
dragHint={t("shell.resize.drag")}
dataSlot="chat-settings-resize-handle"
/>
) : null}
<div className="h-full w-full overflow-hidden">{settingsContent}</div>
</aside>
);
}

View file

@ -14,7 +14,8 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { ChevronDownIcon, XIcon } from "lucide-react";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { XIcon } from "lucide-react";
import { type KeyboardEvent, useState } from "react";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import type { ResearchWebsitePolicy } from "../types/research";
@ -24,7 +25,12 @@ function normalizeDomain(raw: string): string | null {
if (!value || /[\\\s]/.test(value)) return null;
try {
const url = new URL(value.includes("://") ? value : `https://${value}`);
if (!/^https?:$/.test(url.protocol) || url.username || url.password || url.port) {
if (
!/^https?:$/.test(url.protocol) ||
url.username ||
url.password ||
url.port
) {
return null;
}
return url.hostname
@ -99,7 +105,9 @@ function DomainList({
type="button"
className="text-muted-foreground transition-colors hover:text-foreground"
aria-label={`Remove ${domain}`}
onClick={() => onChange(values.filter((value) => value !== domain))}
onClick={() =>
onChange(values.filter((value) => value !== domain))
}
>
<XIcon className="size-3" />
</button>
@ -129,7 +137,9 @@ export function DeepResearchComposerButton({
onConfigure: () => void;
}) {
const enabled = useChatRuntimeStore((state) => state.deepResearchEnabled);
const setEnabled = useChatRuntimeStore((state) => state.setDeepResearchEnabled);
const setEnabled = useChatRuntimeStore(
(state) => state.setDeepResearchEnabled,
);
if (!enabled) return null;
@ -158,9 +168,12 @@ export function DeepResearchComposerButton({
<XIcon className="composer-pill-x" />
</span>
<span>Deep research</span>
<span className="composer-pill-caret flex items-center gap-0.5 text-primary/70">
<ChevronDownIcon className="size-3" />
</span>
{/* Same caret as the other composer pills, so the arrows match. */}
<HugeiconsIcon
icon={ChevronDownStandardIcon}
strokeWidth={1.5}
className="composer-pill-caret size-[15px] text-primary/70"
/>
</button>
);
}
@ -173,7 +186,9 @@ export function DeepResearchWebsiteAccessDialog({
onOpenChange: (open: boolean) => void;
}) {
const policy = useChatRuntimeStore((state) => state.researchWebsitePolicy);
const setPolicy = useChatRuntimeStore((state) => state.setResearchWebsitePolicy);
const setPolicy = useChatRuntimeStore(
(state) => state.setResearchWebsitePolicy,
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -201,41 +216,40 @@ function DeepResearchWebsiteAccessContent({
return (
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Website access</DialogTitle>
<DialogDescription>
Control which websites the next Deep Research run can search and
read. Limits are enforced by the server and shared with the research
model.
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<DomainList
label="Allow only"
description="When set, research can access only these domains and their subdomains."
values={draft.allowedDomains}
onChange={(allowedDomains) => setDraft({ ...draft, allowedDomains })}
/>
<DomainList
label="Always block"
description="These domains and their subdomains stay blocked. Blocking takes precedence."
values={draft.blockedDomains}
onChange={(blockedDomains) => setDraft({ ...draft, blockedDomains })}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
onClick={() => {
setPolicy(draft);
onClose();
}}
>
Save limits
</Button>
</DialogFooter>
<DialogHeader>
<DialogTitle>Website access</DialogTitle>
<DialogDescription>
Control which websites the next Deep Research run can search and read.
Limits are enforced by the server and shared with the research model.
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<DomainList
label="Allow only"
description="When set, research can access only these domains and their subdomains."
values={draft.allowedDomains}
onChange={(allowedDomains) => setDraft({ ...draft, allowedDomains })}
/>
<DomainList
label="Always block"
description="These domains and their subdomains stay blocked. Blocking takes precedence."
values={draft.blockedDomains}
onChange={(blockedDomains) => setDraft({ ...draft, blockedDomains })}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
onClick={() => {
setPolicy(draft);
onClose();
}}
>
Save limits
</Button>
</DialogFooter>
</DialogContent>
);
}

View file

@ -23,12 +23,21 @@ import {
removeScanFolder,
} from "@/features/hub";
import { FolderBrowser } from "@/features/model-picker";
import { openModelsDir } from "@/features/native-intents";
import {
openModelsDir,
pickHuggingFaceCacheDir,
} from "@/features/native-intents";
import {
type HuggingFaceCacheSettings,
loadHuggingFaceCacheSettings,
updateHuggingFaceCacheSettings,
} from "@/features/settings";
import { isTauri } from "@/lib/api-base";
import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
import {
Delete02Icon,
DownloadCircle01Icon,
FileSearchIcon,
FolderAddIcon,
FolderExportIcon,
@ -49,6 +58,12 @@ function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function formatFreeSpace(bytes: number | null): string | null {
if (bytes === null || !Number.isFinite(bytes)) return null;
const gb = bytes / 1024 ** 3;
return gb >= 10 ? `${Math.round(gb)} GB free` : `${gb.toFixed(1)} GB free`;
}
export function OnDeviceFoldersDialog({
open,
onOpenChange,
@ -68,6 +83,11 @@ export function OnDeviceFoldersDialog({
);
const refreshIdRef = useRef(0);
const mutationVersionRef = useRef(0);
const [downloadCache, setDownloadCache] =
useState<HuggingFaceCacheSettings | null>(null);
const [downloadCacheLoaded, setDownloadCacheLoaded] = useState(false);
const [downloadBrowserOpen, setDownloadBrowserOpen] = useState(false);
const [downloadSaving, setDownloadSaving] = useState(false);
const sortedFolders = useMemo(
() => [...folders].sort((a, b) => a.path.localeCompare(b.path)),
@ -108,10 +128,66 @@ export function OnDeviceFoldersDialog({
return () => window.clearTimeout(timer);
}, [open, refreshFolders]);
useEffect(() => {
if (!open) return;
let cancelled = false;
// The dialog stays mounted between opens, so re-arm the flag or a reopen
// shows the previous answer as if it were fresh.
setDownloadCacheLoaded(false);
loadHuggingFaceCacheSettings()
// Indexed locations do not depend on this. Null drops the stale path
// rather than offer Change against a location we could not confirm.
.catch(() => null)
.then((settings) => {
if (cancelled) return;
setDownloadCache(settings);
setDownloadCacheLoaded(true);
});
return () => {
cancelled = true;
};
}, [open]);
const handleInventoryChanged = useCallback(() => {
onInventoryChange?.();
}, [onInventoryChange]);
// Relocating the cache changes which repos are on disk, but
// updateHuggingFaceCacheSettings already bumps the inventory version, which
// re-fetches every source. Refreshing here too would scan twice, since the
// two rounds carry different version keys and cannot be deduplicated.
const saveDownloadLocation = useCallback(async (nextPath: string | null) => {
setDownloadSaving(true);
try {
const settings = await updateHuggingFaceCacheSettings(nextPath);
setDownloadCache(settings);
toast.success("Download location updated", {
description: settings.cacheHome,
});
} catch (err) {
toast.error("Couldn't update the download location", {
description: formatError(err),
});
} finally {
setDownloadSaving(false);
}
}, []);
const changeDownloadLocation = useCallback(async () => {
if (!isTauri) {
setDownloadBrowserOpen(true);
return;
}
try {
const picked = await pickHuggingFaceCacheDir();
if (picked) await saveDownloadLocation(picked);
} catch (err) {
toast.error("Couldn't open the folder picker", {
description: formatError(err),
});
}
}, [saveDownloadLocation]);
const handleAdd = useCallback(
async (rawPath: string) => {
const nextPath = rawPath.trim();
@ -182,10 +258,10 @@ export function OnDeviceFoldersDialog({
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="gap-0 overflow-hidden p-0 sm:max-w-[620px] lg:max-w-[660px] xl:max-w-[680px] [&_[data-slot=dialog-close]]:right-3 [&_[data-slot=dialog-close]]:top-3"
className="flex max-h-[90dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[620px] lg:max-w-[660px] xl:max-w-[680px] [&_[data-slot=dialog-close]]:right-3 [&_[data-slot=dialog-close]]:top-3"
overlayClassName="bg-black/20 backdrop-blur-none"
>
<DialogHeader className="border-b border-border/60 px-5 py-4">
<DialogHeader className="shrink-0 border-b border-border/60 px-5 py-4">
<DialogTitle className="text-ui-15">
On-device locations
</DialogTitle>
@ -195,7 +271,78 @@ export function OnDeviceFoldersDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4 px-5 py-4">
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-4">
<div className="rounded-[14px] border border-border/70 bg-muted/20 p-3">
<div className="mb-2 flex items-center gap-2 text-ui-12 font-medium text-foreground">
<HugeiconsIcon
icon={DownloadCircle01Icon}
strokeWidth={1.75}
className="size-3.5 text-muted-foreground"
/>
Download location
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
readOnly={true}
aria-label="Model download location"
value={
downloadCache?.cacheHome ??
(downloadCacheLoaded ? "Unknown" : "Loading...")
}
title={downloadCache?.cacheHome}
className="field-soft h-9 min-w-0 flex-1 rounded-full px-3 font-mono text-ui-12"
/>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void changeDownloadLocation()}
disabled={!downloadCache?.editable || downloadSaving}
className="h-9 rounded-full px-3 text-ui-12p5"
>
{downloadSaving ? (
<Spinner className="size-3.5" />
) : (
<HugeiconsIcon
icon={FolderSearchIcon}
strokeWidth={1.75}
data-icon="inline-start"
className="size-3.5"
/>
)}
Change
</Button>
{downloadCache?.isCustom ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void saveDownloadLocation(null)}
disabled={downloadSaving}
className="h-9 rounded-full px-3 text-ui-12p5 text-muted-foreground"
>
Use default
</Button>
) : null}
</div>
</div>
<p className="mt-2 text-ui-10p5 text-muted-foreground">
{downloadCache?.source === "environment"
? `Managed by the ${
downloadCache.environmentVariable ?? "HF_HOME"
} environment variable.`
: [
"New downloads only. Models already on disk stay where they are.",
formatFreeSpace(downloadCache?.freeBytes ?? null),
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
<div className="rounded-[14px] border border-border/70 bg-muted/20 p-3">
<div className="mb-2 flex items-center gap-2 text-ui-12 font-medium text-foreground">
<HugeiconsIcon
@ -425,6 +572,16 @@ export function OnDeviceFoldersDialog({
onOpenChange={setBrowserOpen}
onSelect={(selectedPath) => void handleAdd(selectedPath)}
/>
<FolderBrowser
open={!isTauri && downloadBrowserOpen}
onOpenChange={setDownloadBrowserOpen}
onSelect={(selectedPath) => void saveDownloadLocation(selectedPath)}
initialPath={downloadCache?.cacheHome}
title="Choose model download location"
confirmLabel="Use for future downloads"
showModelHints={false}
/>
</>
);
}

View file

@ -201,8 +201,10 @@ export function DownloadManagerPanel({
className={cn(
// Standalone: anchor bottom-right. In a shared stack (positioned=false)
// flow as a right-aligned row so overlays stack instead of overlapping.
// min-h-0 there: a flex item's min-height defaults to auto, so the capped
// stack would squeeze the update card instead of this list.
"pointer-events-none",
positioned ? "fixed bottom-4 right-4 z-50" : "flex justify-end",
positioned ? "fixed bottom-4 right-4 z-50" : "flex min-h-0 justify-end",
)}
>
{collapsed ? (
@ -229,7 +231,7 @@ export function DownloadManagerPanel({
</TooltipContent>
</Tooltip>
) : (
<div className="hub-download-panel pointer-events-auto w-[min(400px,calc(100vw-2rem))] overflow-hidden">
<div className="hub-download-panel pointer-events-auto flex min-h-0 w-[min(400px,calc(100vw-2rem))] flex-col overflow-hidden">
<div className="flex items-center gap-2 border-b border-foreground/[0.07] py-2 pl-4 pr-3">
<span className="min-w-0 flex-1 truncate text-ui-12p5 font-semibold text-foreground">
{headerLabel}

View file

@ -3,6 +3,11 @@
export { SettingsDialog } from "./settings-dialog";
export { loadEmbeddingModelSettings } from "./api/embedding-model";
export {
loadHuggingFaceCacheSettings,
updateHuggingFaceCacheSettings,
} from "./api/hugging-face-cache";
export type { HuggingFaceCacheSettings } from "./api/hugging-face-cache";
export {
loadPersonalization,
savePersonalization,

View file

@ -35,7 +35,10 @@ import {
useRef,
useState,
} from "react";
import { SETTINGS_SEARCH_INDEX } from "./settings-search";
import {
SETTINGS_SEARCH_INDEX,
SETTINGS_SEARCH_KEYWORDS,
} from "./settings-search";
import {
type SettingsTab,
useSettingsDialogStore,
@ -157,8 +160,12 @@ export function SettingsDialog() {
return TABS.map((tab) => {
const tabLabel = t(tab.labelKey);
const entries = SETTINGS_SEARCH_INDEX[tab.id]
.map((key) => t(key))
.filter((label) => label.toLowerCase().includes(q));
.filter((key) => {
if (t(key).toLowerCase().includes(q)) return true;
const keywordsKey = SETTINGS_SEARCH_KEYWORDS[key];
return keywordsKey ? t(keywordsKey).toLowerCase().includes(q) : false;
})
.map((key) => t(key));
const deduped = [...new Set(entries)];
return {
tab,

View file

@ -146,3 +146,15 @@ export const SETTINGS_SEARCH_INDEX: Record<SettingsTab, TranslationKey[]> = {
"settings.about.shutDownStudio",
],
};
/**
* Extra terms a row matches on, beyond its own label. The value is a
* translation key holding space-separated synonyms; it is never rendered.
* Search matched labels only, so "models folder" or "directory" found nothing.
*/
export const SETTINGS_SEARCH_KEYWORDS: Partial<
Record<TranslationKey, TranslationKey>
> = {
"settings.resources.storage.modelsFolder":
"settings.resources.storage.modelsFolderKeywords",
};

View file

@ -74,6 +74,8 @@ const PREFS_KEYS: string[] = [
LOCALE_STORAGE_KEY,
// UI state
"sidebar_pinned",
"sidebar_width",
"chat_settings_width",
"unsloth_sidebar_navigate_open",
"unsloth_settings_active_tab",
// Chat runtime prefs

View file

@ -0,0 +1,20 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { createPanelWidthStore } from "./use-panel-width.ts";
/** The previous fixed 17rem, at a 16px root font size. */
export const CHAT_SETTINGS_WIDTH_DEFAULT = 272;
/** Below this the sliders and their value pills start colliding. */
export const CHAT_SETTINGS_WIDTH_MIN = 248;
export const CHAT_SETTINGS_WIDTH_MAX = 560;
const store = createPanelWidthStore({
key: "chat_settings_width",
min: CHAT_SETTINGS_WIDTH_MIN,
max: CHAT_SETTINGS_WIDTH_MAX,
fallback: CHAT_SETTINGS_WIDTH_DEFAULT,
});
export const clampChatSettingsWidth = store.clamp;
export const useChatSettingsWidth = store.useWidth;

View file

@ -0,0 +1,138 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useCallback, useSyncExternalStore } from "react";
/** Never let one panel eat more than this share of a narrow window. */
const MAX_VIEWPORT_FRACTION = 0.4;
export type PanelWidthStore = {
/** Clamps to what the current viewport allows. */
clamp: (px: number) => number;
useWidth: () => {
width: number;
max: number;
/** The uncapped stored preference. */
stored: number;
setWidth: (value: number) => void;
resetWidth: () => void;
};
};
/**
* A persisted, viewport-aware width for a draggable panel. The preference is
* stored whole and an effective width is derived from it, so narrowing the
* window shrinks the panel without losing what the user picked.
*/
export function createPanelWidthStore({
key,
min,
max,
fallback,
}: {
key: string;
min: number;
max: number;
fallback: number;
}): PanelWidthStore {
function maxWidth(): number {
if (typeof window === "undefined") return max;
// The floor wins on a narrow window; collapsing is the escape.
return Math.max(min, Math.min(max, window.innerWidth * MAX_VIEWPORT_FRACTION));
}
/** Clamps to the absolute range, ignoring the viewport. */
function clampStored(px: number): number {
if (!Number.isFinite(px)) return fallback;
return Math.min(max, Math.max(min, Math.round(px)));
}
function clamp(px: number): number {
return Math.min(maxWidth(), clampStored(px));
}
function load(): number {
if (typeof window === "undefined") return fallback;
try {
const raw = window.localStorage.getItem(key);
if (raw === null) return fallback;
return clampStored(Number.parseFloat(raw));
} catch {
return fallback;
}
}
let storedWidth = load();
let effectiveWidth = clamp(storedWidth);
let effectiveMax = maxWidth();
const listeners = new Set<() => void>();
let lastStored = storedWidth;
function recompute() {
const nextWidth = clamp(storedWidth);
const nextMax = maxWidth();
if (
nextWidth === effectiveWidth &&
nextMax === effectiveMax &&
storedWidth === lastStored
) {
return;
}
effectiveWidth = nextWidth;
effectiveMax = nextMax;
lastStored = storedWidth;
listeners.forEach((cb) => cb());
}
function subscribe(cb: () => void) {
// With no subscribers there is no resize listener, so the cache can be
// stale after a resize on a route that hides every panel. Refresh first;
// useSyncExternalStore re-reads the snapshot right after subscribing.
recompute();
listeners.add(cb);
if (typeof window === "undefined") {
return () => listeners.delete(cb);
}
// Keep tabs in sync, same as the pin flag.
const onStorage = (e: StorageEvent) => {
if (e.key === key || e.key === null) {
storedWidth = load();
effectiveWidth = clamp(storedWidth);
effectiveMax = maxWidth();
cb();
}
};
window.addEventListener("storage", onStorage);
window.addEventListener("resize", recompute);
return () => {
listeners.delete(cb);
window.removeEventListener("storage", onStorage);
window.removeEventListener("resize", recompute);
};
}
function setWidthGlobal(next: number) {
const stored = clampStored(next);
if (stored !== storedWidth) {
storedWidth = stored;
try {
window.localStorage.setItem(key, String(stored));
} catch {}
}
recompute();
}
function useWidth() {
const width = useSyncExternalStore(subscribe, () => effectiveWidth, () => fallback);
// What the viewport actually allows right now, for aria-valuemax.
const panelMax = useSyncExternalStore(subscribe, () => effectiveMax, () => max);
// The uncapped preference, so a capped drag can avoid lowering it.
const preference = useSyncExternalStore(subscribe, () => storedWidth, () => fallback);
const setWidth = useCallback((value: number) => setWidthGlobal(value), []);
const resetWidth = useCallback(() => setWidthGlobal(fallback), []);
return { width, max: panelMax, stored: preference, setWidth, resetWidth };
}
return { clamp, useWidth };
}

View file

@ -0,0 +1,146 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { authFetch, hasAuthToken } from "@/features/auth";
import { apiUrl } from "@/lib/api-base";
import { useCallback, useEffect, useRef, useState } from "react";
// Keyed to one exact version, so a new update never pairs with older notes.
export interface ReleaseNotes {
version: string;
markdown: string | null;
matched: boolean;
truncated: boolean;
source: string | null;
releaseNotesUrl: string | null;
// Set when the lookup itself failed, as opposed to a version with no notes.
error: string | null;
}
export type ReleaseNotesState = "idle" | "loading" | "ready" | "error";
// Desktop auto-auth installs its token after first paint, so a startup popup can
// ask before one exists. Wait briefly rather than fail.
const AUTH_POLL_MS = 250;
const AUTH_POLL_LIMIT = 40;
interface UseReleaseNotesOptions {
version: string | null | undefined;
enabled?: boolean;
}
type ApiObject = Record<string, unknown>;
function stringOrNull(value: ApiObject, key: string): string | null {
const field = value[key];
return typeof field === "string" && field.length > 0 ? field : null;
}
function toReleaseNotes(value: unknown, version: string): ReleaseNotes | null {
if (!value || typeof value !== "object") {
return null;
}
const payload = value as ApiObject;
const notesVersion = stringOrNull(payload, "version");
// A response for another version is not usable here.
if (notesVersion !== version) {
return null;
}
const markdown = stringOrNull(payload, "markdown");
return {
version,
markdown,
matched: payload.matched === true && markdown !== null,
truncated: payload.truncated === true,
source: stringOrNull(payload, "source"),
releaseNotesUrl: stringOrNull(payload, "release_notes_url"),
error: stringOrNull(payload, "error"),
};
}
async function fetchReleaseNotes(
version: string,
refresh = false,
): Promise<ReleaseNotes | null> {
const query = `version=${encodeURIComponent(version)}${refresh ? "&refresh=true" : ""}`;
// authFetch, not fetch: an expired token is refreshed and retried.
const res = await authFetch(apiUrl(`/api/studio/release-notes?${query}`));
if (!res.ok) {
throw new Error(`Release notes request failed: ${res.status}`);
}
return toReleaseNotes(await res.json(), version);
}
export function useReleaseNotes({
version,
enabled = true,
}: UseReleaseNotesOptions) {
const [state, setState] = useState<ReleaseNotesState>("idle");
const [notes, setNotes] = useState<ReleaseNotes | null>(null);
// Version the current state belongs to; a change invalidates it.
const requestedVersionRef = useRef<string | null>(null);
// Identifies one request, so an earlier response cannot overwrite a later one.
const requestIdRef = useRef(0);
const load = useCallback((target: string, refresh = false) => {
requestedVersionRef.current = target;
requestIdRef.current += 1;
const requestId = requestIdRef.current;
setState("loading");
setNotes(null);
fetchReleaseNotes(target, refresh)
.then((next) => {
// A newer request owns the state now.
if (requestIdRef.current !== requestId) {
return;
}
setNotes(next);
// A reported failure is retryable; "no notes for this version" is not.
const failed = !next || (!next.matched && next.error !== null);
setState(failed ? "error" : "ready");
})
.catch(() => {
if (requestIdRef.current === requestId) {
setNotes(null);
setState("error");
}
});
}, []);
useEffect(() => {
if (!enabled || !version || requestedVersionRef.current === version) {
return;
}
if (hasAuthToken()) {
load(version);
return;
}
let attempts = 0;
const timer = window.setInterval(() => {
attempts += 1;
if (hasAuthToken() || attempts >= AUTH_POLL_LIMIT) {
window.clearInterval(timer);
// Out of patience: load anyway so the panel settles on retry.
load(version);
}
}, AUTH_POLL_MS);
return () => window.clearInterval(timer);
}, [enabled, version, load]);
const retry = useCallback(() => {
if (version) {
requestedVersionRef.current = null;
// Bypass the cached remote failure, or retry waits for it to expire.
load(version, true);
}
}, [version, load]);
// Never hand back another version's notes: state lags `version` by a render.
const matchesVersion = notes !== null && notes.version === version;
return {
state: notes !== null && !matchesVersion ? "loading" : state,
notes: matchesVersion ? notes : null,
retry,
};
}

View file

@ -0,0 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { createPanelWidthStore } from "./use-panel-width.ts";
/** The previous fixed 17.5rem, at a 16px root font size. */
export const SIDEBAR_WIDTH_DEFAULT = 280;
/** Narrowest width that still fits the wordmark. Firefox is the constraint:
* it renders the heading ~3px wider than Chromium and WebKit. */
export const SIDEBAR_WIDTH_MIN = 260;
export const SIDEBAR_WIDTH_MAX = 480;
const store = createPanelWidthStore({
key: "sidebar_width",
min: SIDEBAR_WIDTH_MIN,
max: SIDEBAR_WIDTH_MAX,
fallback: SIDEBAR_WIDTH_DEFAULT,
});
export const clampSidebarWidth = store.clamp;
export const useSidebarWidth = store.useWidth;

View file

@ -21,6 +21,8 @@ export type UpdateStatus =
export interface UpdateInfo {
version: string;
currentVersion: string;
// Backend release this build pins; CHANGELOG.md is keyed by it, not the SemVer.
pypiVersion?: string;
body?: string;
date?: string;
}
@ -42,10 +44,17 @@ interface DesktopUpdatePolicy {
interface ManualUpdateInfo {
version: string;
currentVersion: string;
pypiVersion?: string | null;
body?: string;
date?: string;
}
/** `pypi_version` from latest.json, which the updater passes through raw. */
function rawPypiVersion(raw: Record<string, unknown>): string | undefined {
const value = raw.pypi_version;
return typeof value === "string" && value.length > 0 ? value : undefined;
}
export interface RetainedUpdateFailure {
error: string;
phase: UpdatePhase;
@ -162,6 +171,7 @@ export function useTauriUpdate(isExternalServer = false) {
setInfo({
version: manualUpdate.version,
currentVersion: manualUpdate.currentVersion,
pypiVersion: manualUpdate.pypiVersion ?? undefined,
body: manualUpdate.body,
date: manualUpdate.date,
});
@ -197,6 +207,7 @@ export function useTauriUpdate(isExternalServer = false) {
setInfo({
version: update.version,
currentVersion: update.currentVersion,
pypiVersion: rawPypiVersion(update.rawJson),
body: update.body,
date: update.date,
});
@ -384,10 +395,13 @@ export function useTauriUpdate(isExternalServer = false) {
});
}
// Install target for Linux packages that cannot self-update.
const manualReleaseUrl =
updatePolicy.mode === "manual_linux_package" && info
? manualReleasePageUrl(updatePolicy, info.version)
: null;
// Release page for the offered version, on every platform, for the notes link.
const releasePageUrl = info ? manualReleasePageUrl(updatePolicy, info.version) : null;
return {
status,
@ -401,6 +415,7 @@ export function useTauriUpdate(isExternalServer = false) {
isExternalServer,
updatePolicyMode: updatePolicy.mode,
manualReleaseUrl,
releasePageUrl,
installUpdate,
retryUpdate,
skipAndRestart,

View file

@ -27,10 +27,18 @@ export const ar = {
product: "Unsloth Studio",
accountMenu: "قائمة حساب {name}",
updateAvailable: "يتوفر تحديث",
resize: {
collapse: "انقر للطي",
expand: "انقر للتوسيع",
drag: "اسحب لتغيير الحجم",
},
aria: {
home: "الصفحة الرئيسية لـ Unsloth",
closeSidebar: "إغلاق الشريط الجانبي",
openSidebar: "فتح الشريط الجانبي",
resizeSidebar: "تغيير حجم الشريط الجانبي أو طيه",
resizeRunSettings: "تغيير حجم إعدادات التشغيل أو إغلاقها",
openRunSettings: "فتح إعدادات التشغيل",
chatOptions: "خيارات المحادثة",
runOptions: "خيارات التدريب",
},
@ -317,6 +325,8 @@ export const ar = {
diskUsage: "{used} مستخدم / {total}",
diskFree: "{free} متاح",
modelsFolder: "مجلد النماذج",
modelsFolderKeywords:
"النماذج مجلد دليل مسار موقع تنزيلات التنزيل ذاكرة التخزين المؤقت تخزين قرص محرك نقل تغيير models folder path hugging face",
modelsFolderDescription: "المكان الذي تُخزَّن فيه النماذج المُنزَّلة.",
openAction: "فتح",
copyAction: "نسخ المسار",

View file

@ -27,10 +27,18 @@ export const de = {
product: "Unsloth Studio",
accountMenu: "Kontomenü von {name}",
updateAvailable: "Update verfügbar",
resize: {
collapse: "Zum Einklappen klicken",
expand: "Zum Ausklappen klicken",
drag: "Zum Ändern der Größe ziehen",
},
aria: {
home: "Unsloth Startseite",
closeSidebar: "Seitenleiste schließen",
openSidebar: "Seitenleiste öffnen",
resizeSidebar: "Seitenleiste anpassen oder einklappen",
resizeRunSettings: "Ausführungseinstellungen anpassen oder schließen",
openRunSettings: "Ausführungseinstellungen öffnen",
chatOptions: "Chat-Optionen",
runOptions: "Trainingslauf-Optionen",
},
@ -330,9 +338,23 @@ export const de = {
diskFree: "{free} frei",
modelsFolder: "Modell-Ordner",
modelsFolderDescription:
"Wo heruntergeladene Modelle gespeichert werden.",
"Wo heruntergeladene Modelle gespeichert werden. Ändern Sie ihn, um Modelle nicht auf dem Systemlaufwerk abzulegen.",
modelsFolderKeywords:
"Modelle Ordner Verzeichnis Pfad Speicherort Download Downloads Cache Speicher Festplatte Laufwerk verschieben ändern hugging face",
futureDownloads: "Nur neue Downloads",
environmentManaged:
"Wird über die Umgebungsvariable {variable} verwaltet.",
locationFree: "{free} frei",
openAction: "Öffnen",
copyAction: "Pfad kopieren",
changeAction: "Ändern",
resetAction: "Standard verwenden",
chooseTitle: "Speicherort für Modell-Downloads wählen",
chooseAction: "Für künftige Downloads verwenden",
cacheSaved: "Speicherort für Modell-Downloads aktualisiert",
cacheSaveError:
"Der Speicherort für Modell-Downloads konnte nicht geändert werden",
cachePickerError: "Die Ordnerauswahl konnte nicht geöffnet werden",
copied: "Pfad kopiert",
openError: "Der Ordner konnte nicht geöffnet werden",
copyError: "Der Pfad konnte nicht kopiert werden",

Some files were not shown because too many files have changed in this diff Show more