mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-23 15:53:46 +00:00
* Install Studio on a Windows host whose C# compiler cannot run
A first launch of the desktop app on Windows 10 died with
[ERROR] Could not create the Studio install lock: (0) : Die Quelldatei
'C:\Windows\TEMP\05po312x.0.cs' konnte nicht gefunden werden.
(1) : using System;
which is C# compiler error CS2001, not a locking problem. Get-StudioFinalPath
compiles a GetFinalPathNameByHandleW helper with Add-Type, and Windows
PowerShell 5.1 (the interpreter install.rs spawns) compiles by writing the
source into %TEMP% and running csc.exe. When that directory cannot hold a file,
or a scanner eats the source before csc opens it, Add-Type throws, and the
throw travelled up Get-StudioPathHash into Enter-StudioInstallMutex, where it
was reported as a lock failure. install.rs never sets or validates TEMP, so the
installer inherits whatever the app was started with.
Three parts:
Probe TMP and TEMP once with a write, read back and delete, and if the
inherited one cannot hold a file, point both at a per-user directory for the
rest of the run. The compiler is not the only thing that stages through there;
so do the Python, uv and VC++ downloads. It is restored on every exit path.
Split Get-StudioFinalPath into a cached native initializer and a compiler-free
resolver. The initializer skips compiling under Constrained Language Mode,
retries once with a private %TEMP%, and then remembers the answer. The fallback
resolves reparse points component-wise from the root, since a link on a parent
component is the ordinary Windows shape. Callers get an Exact flag so
Test-StudioPathEqual can answer "unknown" rather than "different", which the
runtime lock already reads as "take both locks".
Stop the in-use check failing open. Get-RunningStudioVenvProcesses called the
compiled helper inside catch { continue }, so a host that could not compile
found no running processes and would happily overwrite a venv Studio had open.
It now falls back to the process image path and Win32_Process, and says so.
With the native helper present every output is byte-identical to before: the
install mutex name, the runtime lock names, path identity and the process scan.
Fixes #9140
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Put the private temp directory where uninstall already looks
The fallback temp directory outlives the install on purpose: a Studio started
from it inherits it as its own %TEMP%. That means it has to sit somewhere
scripts/uninstall.ps1 reclaims, and LOCALAPPDATA\UnslothStudio was a folder of
my own invention that nothing would ever clean.
The USERPROFILE candidate was worse than litter. ~\.unsloth is removed only when
it is empty, so a leftover ~\.unsloth\temp would have stopped the uninstaller
clearing the directory at all.
Now LOCALAPPDATA\"Unsloth Studio"\temp, which the uninstaller deletes wholesale
as the data dir, and ~\.unsloth\.cache\temp, which is on its explicit sibling
list. Creating the data dir early cannot be mistaken for an install: the desktop
app decides that from find_unsloth_binary(), not from this directory existing.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Prove the private temp retry recovers, not just that it survives
Surviving a dead compiler is the floor. The retry is supposed to get the native
resolver back, and nothing asserted that it does. Add-Type is stubbed to behave
the way 5.1's CodeDom does, writing the source into %TEMP% and failing while that
cannot hold a file, then calling the real cmdlet once it can, so the type really
is defined on the second attempt.
Pins the whole sequence: one failure then exactly one retry, the type loaded
afterwards, no degraded warning, TMP and TEMP restored to the broken values they
came in with, and the retry directory cleaned up.
* Reclaim a stale junction in the private temp sweep instead of leaking it
Running the sweep on Windows PowerShell 5.1 showed the reparse-point branch
never removing the link: Remove-Item without -Recurse reports the junction
target's contents and refuses as "directory not empty", so a junction left
under the private temp root would sit there forever. Directory.Delete with
recursive:$false removes the reparse point itself and, unlike -Recurse on
5.1, cannot follow it into the target.
* Say what the junction branch actually guards against
Measured on windows-latest under PowerShell 5.1: Remove-Item -Recurse on a
junction removed only the link and left the target alone, while Remove-Item
without -Recurse threw a NullReferenceException that -ErrorAction did not
suppress. The comment claimed the first case as the hazard; the second is
what is reproducible today. Directory.Delete avoids both.
* Make the installer resolver tests hold on Windows too
Running them on windows-latest turned up three things the Linux-only run could
not see. The junction-alias test extracts Get-StudioFinalPath alone, which is a
dispatcher now, so every call in it was undefined and it read as "could not
resolve"; give it the whole resolver chain. The link-shape cases compared a
POSIX fixture against a GetFullPath result, which is drive-rooted and
backslashed on Windows, so compare paths rather than spellings. And utime with
follow_symlinks=False does not exist on Windows, where aging a link any other
way writes through onto the target; skip there and say why.
Resolve-StudioLinkTarget now normalises $Path before the self-reference check,
so a caller passing an unnormalised spelling still trips the guard instead of
being handed a link that points at itself.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Restore the caller's temp on every early exit, and keep a live owner's directory
Four review findings, all reachable through paths this change itself opened.
Two early throws sit before the try/finally that owns the locks: rejecting a
custom root under --tauri, and --shortcuts-only finding no managed Python.
Resolving a path can redirect TMP and TEMP, so under irm | iex both left the
caller's own session pointed at an installer-owned directory. The tauri branch
restores before it throws; the shortcuts-only block is now a try/finally, which
also covers the tauri return inside it.
The stale sweep treated age as proof a directory was unused. A Studio
autostarted by an earlier install inherits one as its %TEMP% and can outlive the
one-day cutoff without writing to it, and the sweep runs before the runtime
mutex is taken. The owner PID is in the name, so a directory whose owner is
still running is left alone.
The in-use scan resolved the venv and each process image through the same
resolver, which is enough while it is exact. Without the native helper an alias
it cannot canonicalize keeps its own spelling while the process reports the
physical one, so the prefix test found nothing and the install would overwrite a
venv Studio had open. When the identity is inexact it now also compares the two
paths below their roots, and fails closed.
* Fold SUBST aliases, fix the UNC device form, record the real temp owner
Three follow-ups, each on a path the earlier round opened rather than closed.
A SUBST drive kept its own spelling on a compiler-blocked host, so one directory
reached as X:\venv and as its physical target produced two different install
mutexes and hid a running Studio from the in-use scan. The Python runtime gate
resolves it, since Path.resolve does. Measured on windows-latest that subst.exe
is the only source available without a compiler: Get-PSDrive.DisplayRoot,
Win32_LogicalDisk.ProviderName, GetFullPath and Resolve-Path all reveal nothing.
Get-StudioLexicalPath now folds the alias before it walks components, so the
identity, the mutex and the scan all agree with the runtime gate.
Stripping the four-character device prefix from \??\UNC\server\share left
UNC\server\share, which reads as relative and was combined with the link's own
parent. It becomes \\server\share instead.
The PID baked into a ust-<pid>- name is the installer's, and the installer is
gone by the time the next sweep runs; the process that keeps using the directory
is the Studio it autostarted. That one is recorded in owner.pid when the
autostart happens, and the sweep prefers it over the name.
* Keep a mounted folder's volume GUID target rooted
Same trap as the UNC device form, in a different shape. A mounted folder
reports its target as \??\Volume{GUID}\..., and after the four-character strip
Volume{...}\... is not rooted either, so it was combined with the link's own
parent and the resulting identity named a directory that does not exist. It
becomes the extended-length spelling \\?\Volume{GUID}\... instead, which is the
same device path and stays the volume it names.
* Keep the volume GUID prefix through final normalization
The rewrite in the resolver was undone one step later. Resolve-StudioFinalPathInfo
strips a leading extended-length prefix, which is right for \\?\C:\x -- that still
names a drive afterwards -- and wrong for \\?\Volume{GUID}\x, which becomes the
unrooted Volume{GUID}\x. That hashes to a different identity than the same
directory reached by its drive letter, and leaves GetPathRoot empty so the
relaxed process comparison cannot run either. The volume GUID branch is tested
before the general one and keeps the prefix.
* Say only what was measured about the volume GUID root
The previous commit gave two reasons for keeping the prefix and one of them is
false. Measured on Windows PowerShell 5.1: GetPathRoot returns empty for both
\\?\Volume{GUID}\x and Volume{GUID}\x, so keeping the prefix does not restore a
root and does not re-enable the relaxed process comparison. The real and only
reason is IsPathRooted, which is true for the extended form and false for the
bare one, so the link resolver stops combining the target with the link's own
parent and inventing a directory that does not exist.
* Drop the root-relative fallback in the in-use scan
It was written to catch an aliased root the lexical resolver could not
canonicalize, and it was too broad to keep. Without the native helper every path
is inexact, so it compared path tails across unrelated drives: an ordinary
D:\env\python.exe matched a protected C:\env and aborted a legitimate install
as "the managed Python environment is still in use". That is every
compiler-blocked host, which is the population this change exists to serve.
The alias it was written for was SUBST, and that is now folded in
Get-StudioLexicalPath instead, which is the right place and costs no false
positives. A volume reached by GUID and the same volume reached by drive letter
still cannot be matched without the compiler, and a tail match is not a safe
price to pay for it.
* Probe TMP whenever Windows would use it
GetTempPath takes the first of TMP/TEMP that is merely non-empty, so a
whitespace-only TMP is the one Windows and every child process resolve through.
IsNullOrWhiteSpace read that as unset, probed a healthy TEMP, found it fine and
returned, leaving the compile and every later Python, uv and VC++ download
pointed at a path that cannot exist. Only an absent or empty TMP falls through
to TEMP now.
* Tighten the comments on the temp and path resolver fallbacks
Collapse the explanatory prose to one line where it still reads clearly and
drop the restatements, keeping every measured Windows PowerShell 5.1 fact the
code is shaped around. No code changes: the PowerShell token stream is
identical with comments and newlines dropped, and the two test files pass the
comments-only AST check.
* Resolve the data dir the same way the installer does when uninstalling
install.ps1 falls back to GetFolderPath("LocalApplicationData") when
LOCALAPPDATA is unset, which is the service and CI case the fallback exists for,
and puts its private temp under "Unsloth Studio\temp" there. uninstall.ps1
derived the default data dir from the variable alone and skipped the removal
when it was absent, so that tree survived an uninstall on exactly those hosts.
It uses _AppDataRoot now, the same resolver the WebView2 profile removal
already used a few lines below.
* Resolve an inherited temp path before probing it, and pin what was probed
A relative TMP or TEMP (temp, or the drive-relative C:temp) is resolved by
whoever reads it, and the install relocates out of a Windows system directory
later, so the value that was probed could afterwards name somewhere else or
nowhere. The absolute form is now pinned into both variables, and the caller's
own spelling is still what the restore hands back.
Resolving happens BEFORE the probe rather than after, because the two halves of
the probe disagree on relative paths: Test-Path is relative to PowerShell's
location while the .NET file APIs are relative to the process working directory,
and Set-Location moves only the first. Probing the absolute path checks the same
directory the writes will use.
The override now records whether the directory is one this run created, so
owner.pid is written only there and never into the host's own temp.
* Do not resolve a whitespace-only temp value before probing it
Resolving it first turns " " or a tab into the working directory plus that
name, which is creatable on some filesystems, so the probe would manufacture a
junk directory and then trust it as the host's temp. Leaving it untouched lets
the probe reject it, which is what sends the install to a private directory.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Resolve a drive-less rooted link target on the link's own drive
A directory symlink can store a target like "\real": rooted as far as
IsPathRooted is concerned, but naming no drive. The compiler-free resolver
therefore skipped anchoring it to the link and handed it to GetFullPath, which
resolves a drive-less path against the PROCESS current drive. A link on D:
could normalize to C:\real, so the fallback mutex and the in-use process scan
would guard a different directory than the one being installed into.
Windows resolves such a target on the link's own volume, so anchor it to
GetPathRoot of the link before normalizing. A volume-GUID spelling has no root
to anchor to (GetPathRoot is empty there, measured on windows-latest), and
those keep today's behaviour rather than a guess.
* Run the Windows guard tests from a file, not a 32 KB command line
The Windows job started failing every test in this file as WinError 206, "The
filename or extension is too long". These scripts embed the whole extracted
helper chain, which has grown past the 32767 character command line cap
Windows enforces, so nothing was being tested on the platform the tests exist
for. Written to a temp .ps1 and run with -File instead, with a BOM so 5.1 does
not read it as ANSI.
test_running_venv_process_is_reported also raced: a six-ping child can exit
before Windows PowerShell 5.1 has finished its cold start and the csc.exe
compile of the native helper, which reads as the in-use scan missing a running
process. Same fix already applied to the 32-bit test: a long-lived child and a
deadline that allows for a slow shell.
* Reclaim both LocalAppData spellings on uninstall
New-StudioPrivateTempDirectory tries $env:LOCALAPPDATA first and falls through
to the LocalApplicationData known folder when that path is set but not usable,
so a non-blank variable does not tell you where "Unsloth Studio\temp" actually
landed. The uninstaller resolved a single root and stopped at the first
non-blank candidate, so on those hosts it removed a directory that was never
used and left the real tree behind.
It now collects both spellings, deduplicated, and stops servers by port file,
adds stop roots and removes the data dir for each. On an ordinary host the two
agree and the list has one entry, which is exactly today's behaviour.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Sweep abandoned private temp directories on a healthy host too
The stale sweep only ran from New-StudioPrivateTempDirectory, which a run with
a usable inherited TMP/TEMP never reaches. So once a host's temp was fixed, by
an ACL correction or a cleaned environment, nothing ever collected what the
degraded runs had left, including the remains of interrupted downloads, and it
sat there until an uninstall.
The candidate roots move into Get-StudioPrivateTempRoots so both paths can
share them, and the healthy path now sweeps each one. A root that does not
exist is a no-op, which is every ordinary host.
* Require the temp probe file to actually be deleted
The usability probe created a file, read it back, then deleted it with the
error suppressed and reported success regardless. A directory that accepts a
file and refuses to give it back, a denied Delete ACE or a scanner sitting on
the handle, therefore passed as usable. That is the shape behind this whole
issue: csc.exe writes its source and its output into the temp directory and
then cleans up, so the private-temp fallback was being skipped for exactly the
hosts that needed it.
The delete is now verified. Retried up to three times first, since a scanner
holding a file for a moment is not the same as a directory that denies
deletion and only the second should cost a healthy host its own temp.
* Run every Windows guard script from a file, and decode its output as utf-8
The command line cap caught the other two spawn sites as well: the Add-Type
fallback tests and the two mutex holder processes still passed their script
inline, so seven tests died as WinError 206 on windows-latest without testing
anything. All of them now go through a temp .ps1 with -File, the same as the
rest of the file.
Output is also decoded as utf-8 with replacement rather than the console
codepage. cp1252 cannot decode what PowerShell writes into an error message,
and the reader thread raised UnicodeDecodeError from inside subprocess.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
865 lines
46 KiB
PowerShell
865 lines
46 KiB
PowerShell
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
#
|
|
# Unsloth Studio uninstaller for Windows PowerShell. Run -Help for details.
|
|
# Custom roots (UNSLOTH_STUDIO_HOME / STUDIO_HOME) come from share\studio.conf.
|
|
#
|
|
# Usage: run -Help. The web one-liner is in that help text and is not repeated here, since
|
|
# AMSI scans this file in full before any of it runs and nothing reads the header.
|
|
|
|
function Uninstall-UnslothStudio {
|
|
$ErrorActionPreference = "Continue"
|
|
|
|
# Reset at entry: a piped web run defines this function in the caller's session, so a
|
|
# second run in the same window would otherwise inherit the first run's flags.
|
|
$script:RemoveFailed = $false
|
|
$script:StudioDbRemoved = $false
|
|
|
|
function _Usage {
|
|
Write-Host @'
|
|
Unsloth Studio uninstaller (Windows PowerShell).
|
|
|
|
Usage:
|
|
irm https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.ps1 | iex
|
|
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass; .\scripts\uninstall.ps1
|
|
|
|
Stops running Unsloth Studio servers, then removes the install dir, launcher
|
|
data, CLI shim, desktop and Start Menu shortcuts, the user PATH entry and the
|
|
PathBackup registry key. In a default-mode install it also removes the shared
|
|
prebuilts that sit beside the install dir:
|
|
%USERPROFILE%\.unsloth\{llama.cpp,node,whisper.cpp,.cache}. The Hugging Face
|
|
cache is left in place, as is anything else you keep under %USERPROFILE%\.unsloth.
|
|
|
|
Options:
|
|
-Help, -h, --help, -?, /? Print this message and exit without removing anything.
|
|
|
|
Run with no arguments to uninstall. Unrecognized arguments never trigger
|
|
removal.
|
|
|
|
Environment:
|
|
UNSLOTH_STUDIO_HOME Also remove this custom install root. Set it to the value
|
|
used at install time.
|
|
STUDIO_HOME Alias for the above, ignored when both are set.
|
|
'@
|
|
}
|
|
|
|
# Reject unknown arguments before destructive work. Use throw so embedded
|
|
# invocations report failure without exiting the caller's PowerShell session.
|
|
foreach ($arg in $args) {
|
|
if ($arg -in @('-h', '-help', '--help', '-?', '/?')) { _Usage; return }
|
|
Write-Host "uninstall.ps1: unrecognized argument: $arg" -ForegroundColor Red
|
|
Write-Host "Nothing was removed. Re-run with no arguments to uninstall, or -Help."
|
|
throw "uninstall.ps1: unrecognized argument: $arg"
|
|
}
|
|
|
|
function _Step { param([string]$Msg) Write-Host $Msg }
|
|
function _Substep { param([string]$Msg, [string]$Color = "Gray") Write-Host " $Msg" -ForegroundColor $Color }
|
|
|
|
# Remove a file/dir/symlink if present. Idempotent; retries since a just-killed
|
|
# process can briefly hold a handle (Windows refuses the delete until released).
|
|
function _RemovePath {
|
|
param([string]$Path)
|
|
if ([string]::IsNullOrWhiteSpace($Path)) { return }
|
|
if (-not (Test-Path -LiteralPath $Path)) { return }
|
|
for ($attempt = 1; $attempt -le 4; $attempt++) {
|
|
try {
|
|
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop
|
|
} catch {
|
|
if ($attempt -lt 4) { Start-Sleep -Milliseconds 700; continue }
|
|
_Substep "could not remove: $Path ($($_.Exception.Message))" "Yellow"
|
|
# The closing summary must not promise the data is gone.
|
|
$script:RemoveFailed = $true
|
|
return
|
|
}
|
|
# Remove-Item -Recurse can report success yet leave a transiently-locked
|
|
# child (e.g. unsloth.ico in Explorer's icon cache); verify + retry so we
|
|
# never falsely claim "removed" or orphan the dir.
|
|
if (-not (Test-Path -LiteralPath $Path)) {
|
|
_Substep "removed: $Path" "Green"
|
|
return
|
|
}
|
|
if ($attempt -lt 4) { Start-Sleep -Milliseconds 700; continue }
|
|
_Substep "still present (files held open): $Path" "Yellow"
|
|
$script:RemoveFailed = $true
|
|
}
|
|
}
|
|
|
|
# LOCALAPPDATA / APPDATA are dropped in service and CI contexts, so fall back to the known
|
|
# folder; $null only if that fails too, which callers treat as incomplete cleanup.
|
|
function _AppDataRoot {
|
|
param([string]$Var, [string]$Folder)
|
|
if (-not [string]::IsNullOrWhiteSpace($Var)) { return $Var }
|
|
$p = try { [Environment]::GetFolderPath($Folder) } catch { $null }
|
|
if ([string]::IsNullOrWhiteSpace($p)) { return $null }
|
|
return $p
|
|
}
|
|
|
|
# Remove an install root and record whether its studio.db really went with it. That file
|
|
# holds chat_threads/chat_messages (backend/storage/studio_db.py), not the provider API
|
|
# keys, which providers_db.py keeps in the browser's localStorage only. It sits under the
|
|
# install root, so an env-mode install keeps it in a custom root a bare run cannot find.
|
|
# The check runs on the RESOLVED target: a relocated install (junction or symlink to
|
|
# another disk) passes the before-check through the link, but the delete unlinks only the
|
|
# reparse point, and afterwards the path stops resolving and reads as absent either way.
|
|
# Verifying rather than chasing the link is deliberate: following a reparse point out of
|
|
# the expected location to delete its target is what the deny list exists to stop.
|
|
function _RemoveRootRecordingDb {
|
|
param([string]$Path)
|
|
if ([string]::IsNullOrWhiteSpace($Path)) { return }
|
|
# Anchor a relative reparse-point target to the link's own parent, or Join-Path
|
|
# resolves it from the uninstaller's working directory and the db test reads false.
|
|
$resolveTarget = {
|
|
param($Item, $Fallback)
|
|
if (-not $Item -or -not $Item.Target) { return $Fallback }
|
|
$t = @($Item.Target)[0]
|
|
if ([string]::IsNullOrWhiteSpace($t)) { return $Fallback }
|
|
if (-not [System.IO.Path]::IsPathRooted($t)) {
|
|
$t = Join-Path (Split-Path -LiteralPath $Item.FullName -Parent) $t
|
|
}
|
|
return $t
|
|
}
|
|
$real = $Path
|
|
try {
|
|
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
|
|
$real = & $resolveTarget $item $Path
|
|
} catch { }
|
|
# The db itself can be a reparse point out of the tree: the target survives the delete.
|
|
$dbPath = Join-Path $real "studio.db"
|
|
$hadDb = Test-Path -LiteralPath $dbPath -PathType Leaf
|
|
if ($hadDb) {
|
|
try {
|
|
$dbItem = Get-Item -LiteralPath $dbPath -Force -ErrorAction SilentlyContinue
|
|
$dbPath = & $resolveTarget $dbItem $dbPath
|
|
} catch { }
|
|
}
|
|
_RemovePath $Path
|
|
if ($hadDb) {
|
|
if (Test-Path -LiteralPath $dbPath -PathType Leaf) {
|
|
$script:RemoveFailed = $true
|
|
} else {
|
|
$script:StudioDbRemoved = $true
|
|
}
|
|
}
|
|
}
|
|
|
|
# Remove the shared data dir, but keep unsloth.ico if a WSL shortcut still points
|
|
# at it (else that shortcut blanks); uninstall.sh drops it when WSL is removed.
|
|
function _RemoveDataDirKeepingWslIcon {
|
|
param(
|
|
[string]$DataDir,
|
|
# WSL-shortcut search dirs; default Start Menu + Desktop, overridable for tests.
|
|
[string[]]$ShortcutDirs = $null
|
|
)
|
|
if ([string]::IsNullOrWhiteSpace($DataDir)) { return }
|
|
if (-not (Test-Path -LiteralPath $DataDir)) { return }
|
|
# $null = not passed (use defaults); test $null not truthiness so an explicit
|
|
# @() is honored (-not @() is $true).
|
|
if ($null -eq $ShortcutDirs) {
|
|
# Guard $env:APPDATA: it can be unset in service/CI Windows contexts, where
|
|
# an unguarded Join-Path emits a noisy parameter-binding error.
|
|
$ShortcutDirs = @()
|
|
if (-not [string]::IsNullOrWhiteSpace($env:APPDATA)) {
|
|
$ShortcutDirs += Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs"
|
|
}
|
|
try {
|
|
$desktop = [Environment]::GetFolderPath("Desktop")
|
|
if (-not [string]::IsNullOrWhiteSpace($desktop)) { $ShortcutDirs += $desktop }
|
|
} catch {}
|
|
}
|
|
$wslShortcuts = @()
|
|
foreach ($d in $ShortcutDirs) {
|
|
if ($d -and (Test-Path -LiteralPath $d)) {
|
|
$wslShortcuts += Get-ChildItem -LiteralPath $d -Filter "Unsloth Studio (WSL*.lnk" -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
if (@($wslShortcuts).Count -eq 0) {
|
|
_RemovePath $DataDir
|
|
return
|
|
}
|
|
# A WSL shortcut survives: drop everything except its shared icon.
|
|
_Substep "keeping $(Join-Path $DataDir 'unsloth.ico') for the WSL shortcut" "Gray"
|
|
Get-ChildItem -LiteralPath $DataDir -Force -ErrorAction SilentlyContinue | ForEach-Object {
|
|
if ($_.Name -ne "unsloth.ico") { _RemovePath $_.FullName }
|
|
}
|
|
}
|
|
|
|
# Is this bin\unsloth.cmd the launcher install.ps1 wrote, or just a file with that
|
|
# name? The distinction decides whether a directory gets deleted recursively, so a
|
|
# name alone is not enough -- `unsloth.cmd` is a plausible wrapper for anyone who
|
|
# ships an unsloth-based tool, and pointing UNSLOTH_STUDIO_HOME at such a project
|
|
# must not hand its whole tree to _RemovePath.
|
|
#
|
|
# The trampoline is the marker: install.ps1 bakes that exact expression into the
|
|
# shim, no other file has a reason to carry it, and it survives every layout the
|
|
# shim has (relative %~dp0 or an absolute cross-volume path, unsloth_studio or the
|
|
# legacy .venv). Bounded read: the real shim is a few hundred bytes.
|
|
function _IsUnslothCmdShim {
|
|
param([string]$Path)
|
|
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false }
|
|
try {
|
|
$item = Get-Item -LiteralPath $Path -ErrorAction Stop
|
|
if ($item.Length -gt 8192) { return $false }
|
|
$text = [System.IO.File]::ReadAllText($Path)
|
|
} catch {
|
|
# Unreadable proves nothing, and "proves nothing" must not mean "delete it".
|
|
return $false
|
|
}
|
|
return ($text -like "*unsloth-studio-managed-launcher*" -and $text -like "*from unsloth_cli import app*")
|
|
}
|
|
|
|
# A path is an Unsloth-owned root iff one of install.ps1's sentinels exists:
|
|
# <root>\share\studio.conf, <root>\unsloth_studio\.unsloth-studio-owned,
|
|
# <root>\bin\unsloth.exe, or a <root>\bin\unsloth.cmd this installer wrote.
|
|
# The .cmd is the interpreter-based launcher install.ps1 writes beside the .exe for
|
|
# machines whose Application Control policy denies the generated console script. An
|
|
# install whose .exe was removed by that policy's quarantine still owns its root.
|
|
function _IsStudioRoot {
|
|
param([string]$Path)
|
|
if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
|
|
if (Test-Path -LiteralPath (Join-Path $Path "share\studio.conf") -PathType Leaf) { return $true }
|
|
if (Test-Path -LiteralPath (Join-Path $Path "unsloth_studio\.unsloth-studio-owned") -PathType Leaf) { return $true }
|
|
if (Test-Path -LiteralPath (Join-Path $Path "bin\unsloth.exe") -PathType Leaf) { return $true }
|
|
if (_IsUnslothCmdShim (Join-Path $Path "bin\unsloth.cmd")) { return $true }
|
|
return $false
|
|
}
|
|
|
|
# Hard deny list. Refuse to recursively delete drive roots, USERPROFILE
|
|
# itself, parent of USERPROFILE, or system directories.
|
|
function _IsUnsafeRoot {
|
|
param([string]$Path)
|
|
if ([string]::IsNullOrWhiteSpace($Path)) { return $true }
|
|
$norm = $null
|
|
try { $norm = [System.IO.Path]::GetFullPath($Path).TrimEnd('\','/') } catch { return $true }
|
|
if ([string]::IsNullOrWhiteSpace($norm)) { return $true }
|
|
# Drive root, e.g. C:\
|
|
if ($norm -match '^[A-Za-z]:[\\/]?$') { return $true }
|
|
$userProfile = $env:USERPROFILE
|
|
if ($userProfile) {
|
|
$userProfile = $userProfile.TrimEnd('\','/')
|
|
if ($norm -ieq $userProfile) { return $true }
|
|
try {
|
|
$parent = Split-Path -LiteralPath $userProfile -Parent
|
|
if ($parent -and ($norm -ieq $parent.TrimEnd('\','/'))) { return $true }
|
|
} catch { }
|
|
}
|
|
$systemRoots = @(
|
|
$env:SystemRoot, $env:windir, $env:ProgramFiles, ${env:ProgramFiles(x86)},
|
|
$env:ProgramData, $env:APPDATA, $env:LOCALAPPDATA
|
|
)
|
|
foreach ($s in $systemRoots) {
|
|
if (-not [string]::IsNullOrWhiteSpace($s)) {
|
|
$s2 = $s.TrimEnd('\','/')
|
|
if ($norm -ieq $s2) { return $true }
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
# Parse UNSLOTH_EXE='<path>' out of a share\studio.conf and return the
|
|
# implied install root (three dirnames up from the venv exe).
|
|
function _RootFromConf {
|
|
param([string]$ConfFile)
|
|
if (-not (Test-Path -LiteralPath $ConfFile -PathType Leaf)) { return $null }
|
|
$line = Get-Content -LiteralPath $ConfFile -ErrorAction SilentlyContinue |
|
|
Where-Object { $_ -match "^UNSLOTH_EXE\s*=" } | Select-Object -First 1
|
|
if (-not $line) { return $null }
|
|
# Tolerate ' value ' single-quoted with '' -> ' apostrophe escape.
|
|
if ($line -match "^UNSLOTH_EXE\s*=\s*'(.*)'\s*$") {
|
|
$exe = $Matches[1] -replace "''", "'"
|
|
try {
|
|
$bin = Split-Path -LiteralPath $exe -Parent
|
|
$studio = Split-Path -LiteralPath $bin -Parent
|
|
$root = Split-Path -LiteralPath $studio -Parent
|
|
if ($root) { return $root }
|
|
} catch { }
|
|
}
|
|
return $null
|
|
}
|
|
|
|
# Expand a leading ~ or ~/ ~\ to $env:USERPROFILE so env-mode roots
|
|
# written with the tilde shape install.ps1 supports (lines 152-154) are
|
|
# found here too.
|
|
function _ExpandTilde {
|
|
param([string]$Path)
|
|
if ([string]::IsNullOrWhiteSpace($Path)) { return $Path }
|
|
$p = $Path.Trim()
|
|
if ($p -eq '~') { return $env:USERPROFILE }
|
|
if ($p.StartsWith('~/') -or $p.StartsWith('~\')) {
|
|
if ($env:USERPROFILE) {
|
|
return (Join-Path $env:USERPROFILE $p.Substring(2).TrimStart('/','\'))
|
|
}
|
|
}
|
|
return $p
|
|
}
|
|
|
|
# Discover non-default Unsloth roots from env vars + studio.conf files.
|
|
# Mirrors install.ps1's precedence: UNSLOTH_STUDIO_HOME wins, STUDIO_HOME
|
|
# is ignored when both are set, so uninstalling install A doesn't also
|
|
# delete install B if the user has a stale STUDIO_HOME pointing at B.
|
|
function _CustomStudioRoots {
|
|
$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
|
$defaultRoot = $null
|
|
if ($env:USERPROFILE) {
|
|
$defaultRoot = (Join-Path $env:USERPROFILE ".unsloth\studio")
|
|
}
|
|
|
|
$emit = {
|
|
param($Path)
|
|
if ([string]::IsNullOrWhiteSpace($Path)) { return }
|
|
$expanded = _ExpandTilde $Path
|
|
$norm = $null
|
|
try { $norm = [System.IO.Path]::GetFullPath($expanded).TrimEnd('\','/') } catch { return }
|
|
if (-not $norm) { return }
|
|
if ($defaultRoot -and ($norm -ieq $defaultRoot.TrimEnd('\','/'))) { return }
|
|
if ($seen.Add($norm)) { Write-Output $norm }
|
|
}
|
|
|
|
$envRoot = $null
|
|
if ($env:UNSLOTH_STUDIO_HOME) {
|
|
$envRoot = $env:UNSLOTH_STUDIO_HOME
|
|
} elseif ($env:STUDIO_HOME) {
|
|
$envRoot = $env:STUDIO_HOME
|
|
}
|
|
if ($envRoot) {
|
|
$expandedEnv = _ExpandTilde $envRoot
|
|
& $emit $expandedEnv
|
|
$confRoot = _RootFromConf (Join-Path $expandedEnv "share\studio.conf")
|
|
if ($confRoot) { & $emit $confRoot }
|
|
}
|
|
# Default-mode conf at LOCALAPPDATA\Unsloth Studio.
|
|
if ($env:LOCALAPPDATA) {
|
|
$confRoot = _RootFromConf (Join-Path $env:LOCALAPPDATA "Unsloth Studio\studio.conf")
|
|
if ($confRoot) { & $emit $confRoot }
|
|
}
|
|
}
|
|
|
|
# Return $true iff the PID's image path lives under one of $KnownRoots.
|
|
# Prevents killing an unrelated process that happens to listen on a stale
|
|
# Unsloth port.
|
|
function _PidUnderKnownRoot {
|
|
param([int]$Pid_, [string[]]$KnownRoots)
|
|
if (-not $KnownRoots -or $KnownRoots.Count -eq 0) { return $false }
|
|
try {
|
|
$proc = Get-CimInstance Win32_Process -Filter "ProcessId=$Pid_" -ErrorAction SilentlyContinue
|
|
if (-not $proc) { return $false }
|
|
$exe = $proc.ExecutablePath
|
|
if (-not $exe) { return $false }
|
|
foreach ($r in $KnownRoots) {
|
|
if ($r -and ($exe -ilike "$r\*")) { return $true }
|
|
}
|
|
} catch { }
|
|
return $false
|
|
}
|
|
|
|
# Stop an Unsloth backend whose port is recorded in <DataDir>\studio.port.
|
|
# Only kills if the listening PID's exe path is under a known Unsloth root.
|
|
function _StopByPortFile {
|
|
param([string]$PortFile, [string[]]$KnownRoots)
|
|
if (-not (Test-Path -LiteralPath $PortFile -PathType Leaf)) { return }
|
|
$port = Get-Content -LiteralPath $PortFile -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
if ($port) { $port = $port.Trim() }
|
|
if (-not ($port -match '^[0-9]+$')) {
|
|
Remove-Item -LiteralPath $PortFile -Force -ErrorAction SilentlyContinue
|
|
return
|
|
}
|
|
try {
|
|
$conns = Get-NetTCPConnection -State Listen -LocalPort ([int]$port) -ErrorAction SilentlyContinue
|
|
foreach ($c in $conns) {
|
|
if (-not (_PidUnderKnownRoot -Pid_ ([int]$c.OwningProcess) -KnownRoots $KnownRoots)) { continue }
|
|
try {
|
|
Stop-Process -Id $c.OwningProcess -Force -ErrorAction SilentlyContinue
|
|
} catch { }
|
|
}
|
|
} catch {
|
|
# netstat fallback for older PowerShell. Require LISTENING state so
|
|
# we never kill a process whose remote endpoint just happens to be
|
|
# the cached port (browser -> :443 etc.).
|
|
try {
|
|
$lines = & netstat.exe -ano 2>$null |
|
|
Select-String -Pattern "LISTENING" |
|
|
Select-String -Pattern ":$port\s"
|
|
foreach ($l in $lines) {
|
|
$parts = ($l.ToString() -split '\s+') | Where-Object { $_ }
|
|
$pid_ = $parts[-1]
|
|
if ($pid_ -match '^\d+$') {
|
|
if (-not (_PidUnderKnownRoot -Pid_ ([int]$pid_) -KnownRoots $KnownRoots)) { continue }
|
|
try { Stop-Process -Id ([int]$pid_) -Force -ErrorAction SilentlyContinue } catch { }
|
|
}
|
|
}
|
|
} catch { }
|
|
}
|
|
Remove-Item -LiteralPath $PortFile -Force -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
# Stop processes whose ExecutablePath lives under an unsloth_studio venv.
|
|
# Anchoring on the venv path avoids matching unrelated python.exe / studio.exe.
|
|
function _StopStudioProcesses {
|
|
param([string[]]$KnownRoots)
|
|
try {
|
|
$procs = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
|
|
Where-Object {
|
|
$_.ExecutablePath -and ($_.ExecutablePath -match '\\unsloth_studio\\.*\\(unsloth|python|studio)\.exe$') -and
|
|
$_.CommandLine -and ($_.CommandLine -match 'studio')
|
|
}
|
|
foreach ($p in $procs) {
|
|
# Optional scope: only kill if the exe is under a known root.
|
|
if ($KnownRoots) {
|
|
$match = $false
|
|
foreach ($r in $KnownRoots) {
|
|
if ($p.ExecutablePath -and ($p.ExecutablePath -ilike "$r\*")) { $match = $true; break }
|
|
}
|
|
if (-not $match) { continue }
|
|
}
|
|
try {
|
|
Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
|
|
} catch { }
|
|
}
|
|
} catch { }
|
|
}
|
|
|
|
# The Studio-managed subtrees underneath the reparse-point TARGET of each Studio home, for the
|
|
# stop scan only.
|
|
#
|
|
# A junction or directory symlink Studio home runs its native binaries out of the PHYSICAL
|
|
# path: the backend resolves the home (Path.resolve) before deriving <home>\stable-diffusion.cpp
|
|
# and launching sd-server there, while _CustomStudioRoots only normalizes the string --
|
|
# System.IO.Path.GetFullPath is lexical and never touches the filesystem, so it leaves a
|
|
# reparse point untouched. The prefix scan below reads Win32_Process.ExecutablePath, the real
|
|
# image path, so without the target the running server never matches and survives an uninstall
|
|
# that took its tree.
|
|
#
|
|
# The SUBTREES, never the bare target. The delete unlinks only the reparse point and leaves the
|
|
# target standing, so anything there that is not ours is neither locking nor being removed --
|
|
# a home relocated onto a directory that holds other software must not have those force-stopped.
|
|
# Homes only, for the same reason: the component dirs ($defaultNode, $defaultLlamaCpp, ...) can
|
|
# themselves be links onto a shared runtime, and resolving those would put every process out of
|
|
# it in scope.
|
|
#
|
|
# Stop scan only, deliberately. _RemoveRootRecordingDb and the deletes still refuse to chase a
|
|
# link out of the expected location -- following one to delete its target is exactly what the
|
|
# deny list exists to prevent. Ending our own process under the target is not destructive.
|
|
function _ManagedPathsUnderReparseTargets {
|
|
param([string[]]$Roots)
|
|
# Everything setup.ps1 / the prebuilt installers place inside a Studio home.
|
|
$managed = @(
|
|
"unsloth_studio", "share", "bin", "llama.cpp", "whisper.cpp", "node",
|
|
"stable-diffusion.cpp", ".cache", ".venv_t5_510", ".venv_t5_530", ".venv_t5_550"
|
|
)
|
|
$out = @()
|
|
foreach ($r in @($Roots | Where-Object { $_ })) {
|
|
try {
|
|
$item = Get-Item -LiteralPath $r -Force -ErrorAction SilentlyContinue
|
|
if (-not $item -or -not $item.Target) { continue }
|
|
$t = @($item.Target)[0]
|
|
if ([string]::IsNullOrWhiteSpace($t)) { continue }
|
|
# A symlink target may be relative; a junction's never is. Anchor it on the link's
|
|
# own parent, or GetFullPath would read it from the uninstaller's working directory.
|
|
if (-not [System.IO.Path]::IsPathRooted($t)) {
|
|
$t = Join-Path (Split-Path -LiteralPath $item.FullName -Parent) $t
|
|
}
|
|
$t = [System.IO.Path]::GetFullPath($t).TrimEnd('\', '/')
|
|
if (-not $t) { continue }
|
|
foreach ($sub in $managed) {
|
|
$p = (Join-Path $t $sub).TrimEnd('\', '/')
|
|
if ($out -notcontains $p) { $out += $p }
|
|
}
|
|
} catch { }
|
|
}
|
|
return $out
|
|
}
|
|
|
|
# Stop processes that would block deleting the paths we remove. Unlike
|
|
# _StopStudioProcesses (venv exe only), this also catches llama-server/llama-cli,
|
|
# the unsloth.exe shim, and orphaned mp workers under SYSTEM python holding a
|
|
# venv DLL (an open DLL handle blocks the dir delete) -- found by scanning each
|
|
# candidate's loaded modules, not just its image path.
|
|
function _StopProcessesLockingRoots {
|
|
param([string[]]$Roots)
|
|
$clean = @($Roots | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\','/') })
|
|
if ($clean.Count -eq 0) { return }
|
|
$underRoot = {
|
|
param($p)
|
|
if (-not $p) { return $false }
|
|
foreach ($r in $clean) { if ($p -ieq $r -or $p -ilike "$r\*") { return $true } }
|
|
return $false
|
|
}
|
|
# 1. Image path under a target root (venv python, shim, llama-server).
|
|
try {
|
|
foreach ($proc in (Get-CimInstance Win32_Process -ErrorAction SilentlyContinue)) {
|
|
if ((& $underRoot $proc.ExecutablePath)) {
|
|
try { Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue } catch { }
|
|
}
|
|
}
|
|
} catch { }
|
|
# 2. A loaded module under a target root (orphaned mp-fork python holding a
|
|
# venv DLL). Scoped to names that load our DLLs to keep the scan fast.
|
|
try {
|
|
$cands = Get-Process -Name python, pythonw, unsloth, llama-server, llama-cli, sd-cli, sd-server -ErrorAction SilentlyContinue
|
|
foreach ($proc in $cands) {
|
|
$hit = $false
|
|
try {
|
|
foreach ($m in $proc.Modules) { if ((& $underRoot $m.FileName)) { $hit = $true; break } }
|
|
} catch { } # access denied enumerating modules -> skip
|
|
if ($hit) { try { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } catch { } }
|
|
}
|
|
} catch { }
|
|
}
|
|
|
|
# Default install root + default data dir.
|
|
$defaultStudioHome = if ($env:USERPROFILE) { Join-Path $env:USERPROFILE ".unsloth\studio" } else { $null }
|
|
# BOTH LocalAppData spellings, not just the first that answers. The variable is
|
|
# dropped entirely in service and CI contexts, and install.ps1 also falls through
|
|
# from it to the known folder when the variable is merely SET but not usable, so
|
|
# a non-blank $env:LOCALAPPDATA does not mean that is where "Unsloth Studio\temp"
|
|
# ended up. Reading one candidate leaves the other behind on exactly the hosts
|
|
# that needed the fallback.
|
|
$knownLocalAppData = $null
|
|
try { $knownLocalAppData = [Environment]::GetFolderPath('LocalApplicationData') } catch { $knownLocalAppData = $null }
|
|
$defaultDataDirs = @()
|
|
foreach ($root in @($env:LOCALAPPDATA, $knownLocalAppData)) {
|
|
if ([string]::IsNullOrWhiteSpace($root)) { continue }
|
|
$candidate = Join-Path $root "Unsloth Studio"
|
|
if ($defaultDataDirs -notcontains $candidate) { $defaultDataDirs += $candidate }
|
|
}
|
|
# Default-mode ~/.unsloth holds a SHARED llama.cpp build + .cache that are
|
|
# siblings of studio (not under it), so deleting <studio> misses them -- handle
|
|
# explicitly. No-op in env/custom mode (nested under the custom root, removed
|
|
# with it). A user-set UNSLOTH_LLAMA_CPP_PATH is left alone.
|
|
$defaultUnslothHome = if ($env:USERPROFILE) { Join-Path $env:USERPROFILE ".unsloth" } else { $null }
|
|
$defaultLlamaCpp = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome "llama.cpp" } else { $null }
|
|
# Default-mode native diffusion build (install_sd_cpp_prebuilt.default_install_dir()),
|
|
# a sibling of studio like llama.cpp. No-op in env/custom mode and when absent. A
|
|
# user-set UNSLOTH_SD_CPP_PATH is left alone.
|
|
$defaultSdCpp = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome "stable-diffusion.cpp" } else { $null }
|
|
$defaultCache = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome ".cache" } else { $null }
|
|
# Isolated Node.js runtime (install_node_prebuilt.py), a sibling of studio in
|
|
# default mode. No-op in env/custom mode (nested under the custom root) and absent.
|
|
$defaultNode = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome "node" } else { $null }
|
|
# llama.cpp atomic-install staging root (install_llama_prebuilt.py .staging,
|
|
# sibling of the install dir). Usually pruned after activate, but an interrupted
|
|
# build can leave a "<name>.staging-XXXX" tree; removing it lets the empty-dir
|
|
# cleanup of ~/.unsloth below succeed. No-op in env/custom mode and when absent.
|
|
$defaultStaging = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome ".staging" } else { $null }
|
|
# Managed whisper.cpp dictation engine (setup.ps1 PHASE 3.4 installs it at
|
|
# $UnslothHome\whisper.cpp), a sibling of studio in default mode. No-op in
|
|
# env/custom mode (nested under the custom root) and when absent.
|
|
$defaultWhisperCpp = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome "whisper.cpp" } else { $null }
|
|
|
|
# Build known-root list FIRST so the port-file kill can verify ownership.
|
|
$customRoots = @(_CustomStudioRoots)
|
|
$knownRoots = @()
|
|
if ($defaultStudioHome) { $knownRoots += $defaultStudioHome }
|
|
$knownRoots += $customRoots
|
|
|
|
# ── Stop running servers ──
|
|
_Step "Stopping any running Unsloth Studio servers..."
|
|
foreach ($d in $defaultDataDirs) {
|
|
_StopByPortFile -PortFile (Join-Path $d "studio.port") -KnownRoots $knownRoots
|
|
}
|
|
foreach ($r in $customRoots) {
|
|
_StopByPortFile -PortFile (Join-Path $r "share\studio.port") -KnownRoots $knownRoots
|
|
}
|
|
_StopStudioProcesses -KnownRoots $knownRoots
|
|
# The app and the WebView2 helpers holding its profile open must both exit before the
|
|
# EBWebView delete below. Same resolver as that removal, or the sweep misses the profile
|
|
# we then try to delete and the helpers keep holding locks.
|
|
$localAppRoot = _AppDataRoot $env:LOCALAPPDATA 'LocalApplicationData'
|
|
$webviewProfile = if ($localAppRoot) { Join-Path $localAppRoot "ai.unsloth.studio" } else { $null }
|
|
# Account-scoped, like every other kill here. installMode is currentUser, so the profile is
|
|
# this account's and shared by all its sessions: a second console or RDS session must die
|
|
# too or it re-creates the profile mid-delete, while another user's Studio must not be
|
|
# touched. SIDs, so domain and locale do not matter; an unreadable owner is skipped.
|
|
$meSid = try { [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value } catch { $null }
|
|
$studioPids = @()
|
|
if ($meSid) {
|
|
try {
|
|
# Unsloth.exe too: older releases used the product name as MAINBINARYNAME, and this
|
|
# script is always fetched fresh from main, so it meets those installs. A missed
|
|
# process re-creates the profile. The owner-SID filter keeps the broader name safe.
|
|
$filter = "Name = 'unsloth-studio.exe' OR Name = 'Unsloth.exe'"
|
|
foreach ($sp in (Get-CimInstance Win32_Process -Filter $filter -ErrorAction SilentlyContinue)) {
|
|
$spSid = try { (Invoke-CimMethod -InputObject $sp -MethodName GetOwnerSid -ErrorAction SilentlyContinue).Sid } catch { $null }
|
|
if ($spSid -eq $meSid) { $studioPids += [int]$sp.ProcessId }
|
|
}
|
|
} catch { }
|
|
}
|
|
if ($webviewProfile) {
|
|
# Escape "[", a wildcard class, or we miss our profile and match others. Trailing \
|
|
# spares "<bid>2\EBWebView".
|
|
$pattern = "*" + [System.Management.Automation.WildcardPattern]::Escape($webviewProfile) + "\*"
|
|
try {
|
|
$wvProcs = @(Get-CimInstance Win32_Process -Filter "Name = 'msedgewebview2.exe'" -ErrorAction SilentlyContinue)
|
|
# Parent lookup for the ancestor walk; WebView2 procs are all the chain passes through.
|
|
$parentOf = @{}
|
|
foreach ($p in $wvProcs) { $parentOf[[int]$p.ProcessId] = [int]$p.ParentProcessId }
|
|
# Chromium process model: the renderer, GPU and utility helpers are children of the
|
|
# browser process, not of unsloth-studio.exe, so an immediate-parent check misses
|
|
# them all and they keep EBWebView locked (Stop-Process is not recursive).
|
|
# Depth-capped so a PID-reuse cycle cannot spin here.
|
|
$isOurs = {
|
|
param($StartPid)
|
|
$cur = $StartPid
|
|
for ($hop = 0; $hop -lt 12; $hop++) {
|
|
if (-not $parentOf.ContainsKey($cur)) { return $false }
|
|
$parent = $parentOf[$cur]
|
|
if ($studioPids -contains $parent) { return $true }
|
|
if ($parent -eq $cur -or $parent -le 0) { return $false }
|
|
$cur = $parent
|
|
}
|
|
return $false
|
|
}
|
|
foreach ($proc in $wvProcs) {
|
|
# CommandLine is null across an elevation boundary: fall back to the parent chain.
|
|
$mine = if ($proc.CommandLine) { $proc.CommandLine -ilike $pattern }
|
|
else { & $isOurs ([int]$proc.ProcessId) }
|
|
if ($mine) { try { Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue } catch { } }
|
|
}
|
|
} catch { }
|
|
}
|
|
if ($studioPids) {
|
|
try { Stop-Process -Id $studioPids -Force -ErrorAction SilentlyContinue } catch { }
|
|
# WebView2 releases the profile only once its browser processes have exited.
|
|
Wait-Process -Id $studioPids -Timeout 10 -ErrorAction SilentlyContinue
|
|
}
|
|
# Only stop the default sd.cpp dir when it carries our owner marker, so stop matches the
|
|
# marker-gated delete and a user's own sd-server at this default path is left running.
|
|
$defaultSdCppToStop = $null
|
|
if ($defaultSdCpp -and (Test-Path -LiteralPath $defaultSdCpp) -and (Test-Path -LiteralPath (Join-Path $defaultSdCpp ".unsloth-studio-owned") -PathType Leaf)) {
|
|
$defaultSdCppToStop = $defaultSdCpp
|
|
}
|
|
# A custom/env-mode sd.cpp build now sits UNDER its root at <root>\stable-diffusion.cpp, which
|
|
# the $knownRoots prefix match below already covers. Older builds put it BESIDE the root at
|
|
# <parent>\stable-diffusion.cpp, outside $knownRoots. We delete those marker-owned dirs below,
|
|
# so add them to the handle scan too, gated on the same owner marker.
|
|
$customSdCppToStop = @()
|
|
foreach ($r in $customRoots) {
|
|
$sdc = Join-Path (Split-Path -LiteralPath $r -Parent) "stable-diffusion.cpp"
|
|
if ((Test-Path -LiteralPath $sdc) -and (Test-Path -LiteralPath (Join-Path $sdc ".unsloth-studio-owned") -PathType Leaf)) {
|
|
$customSdCppToStop += $sdc
|
|
}
|
|
}
|
|
# Also stop anything holding a handle on the exact paths we delete (llama-server,
|
|
# the CLI shim, an mp-fork python with a venv DLL) so the dir delete isn't refused.
|
|
$stopRoots = @($knownRoots) + @($defaultDataDirs) + @($defaultLlamaCpp, $defaultCache, $defaultNode, $defaultWhisperCpp) + @($defaultSdCppToStop | Where-Object { $_ }) + @($customSdCppToStop)
|
|
_StopProcessesLockingRoots -Roots ($stopRoots + @(_ManagedPathsUnderReparseTargets $knownRoots))
|
|
|
|
# ── Remove custom-root install trees ──
|
|
_Step "Removing data and install directories..."
|
|
foreach ($r in $customRoots) {
|
|
if (_IsUnsafeRoot $r) {
|
|
_Substep "refusing to remove unsafe path: $r" "Yellow"
|
|
# install.ps1 accepts any writable root, so a real install can sit under a deny-listed
|
|
# path. Nothing is deleted and it holds studio.db, so say so. Mirrors uninstall.sh.
|
|
if (Test-Path -LiteralPath $r) { $script:RemoveFailed = $true }
|
|
continue
|
|
}
|
|
if (-not (_IsStudioRoot $r)) {
|
|
_Substep "refusing to remove non-Unsloth path: $r" "Yellow"
|
|
continue
|
|
}
|
|
_RemoveRootRecordingDb $r
|
|
# Native diffusion (stable-diffusion.cpp) now installs UNDER the custom root, at
|
|
# <root>\stable-diffusion.cpp, so the removal above already took it. Older builds put it
|
|
# BESIDE the root at <parent>\stable-diffusion.cpp (find_sd_cpp_binary derived it from
|
|
# UNSLOTH_STUDIO_HOME.parent), and removing only the root would leave that build behind.
|
|
# Only remove a sibling Studio installed: <parent> is a user-chosen dir and
|
|
# "stable-diffusion.cpp" is exactly what a git clone of the upstream project produces, so
|
|
# require our owner marker (written by install_sd_cpp_prebuilt) before rm, and keep any
|
|
# unowned checkout. Guard the derived parent path the same way.
|
|
$customSdCpp = Join-Path (Split-Path -LiteralPath $r -Parent) "stable-diffusion.cpp"
|
|
if (_IsUnsafeRoot $customSdCpp) {
|
|
_Substep "refusing to remove unsafe path: $customSdCpp" "Yellow"
|
|
} elseif ((Test-Path -LiteralPath $customSdCpp) -and -not (Test-Path -LiteralPath (Join-Path $customSdCpp ".unsloth-studio-owned") -PathType Leaf)) {
|
|
_Substep "keeping sd.cpp without Studio owner marker: $customSdCpp" "Yellow"
|
|
} else {
|
|
_RemovePath $customSdCpp
|
|
}
|
|
}
|
|
# Default install dir (always at %USERPROFILE%\.unsloth\studio when present).
|
|
if ($defaultStudioHome) { _RemoveRootRecordingDb $defaultStudioHome }
|
|
# Default data dir.
|
|
foreach ($d in $defaultDataDirs) { _RemoveDataDirKeepingWslIcon $d }
|
|
# Default-mode shared llama.cpp build + cache (siblings of studio under
|
|
# ~/.unsloth). No-op in env/custom mode and when absent.
|
|
if ($defaultLlamaCpp) { _RemovePath $defaultLlamaCpp }
|
|
# "stable-diffusion.cpp" is exactly what a git clone of leejet/stable-diffusion.cpp produces,
|
|
# so a user may keep their own checkout (or point UNSLOTH_SD_CPP_PATH) at this default path;
|
|
# require our owner marker (written by install_sd_cpp_prebuilt) before rm, mirroring the
|
|
# custom-root guard above, so a user's own checkout or a pre-marker Studio build is kept.
|
|
if ($defaultSdCpp -and (Test-Path -LiteralPath $defaultSdCpp) -and -not (Test-Path -LiteralPath (Join-Path $defaultSdCpp ".unsloth-studio-owned") -PathType Leaf)) {
|
|
_Substep "keeping sd.cpp without Studio owner marker: $defaultSdCpp" "Yellow"
|
|
} elseif ($defaultSdCpp) {
|
|
_RemovePath $defaultSdCpp
|
|
}
|
|
if ($defaultCache) { _RemovePath $defaultCache }
|
|
# Isolated Node.js runtime (sibling of studio under ~/.unsloth). No-op in env/
|
|
# custom mode (nested under the custom root, removed with it) and when absent.
|
|
if ($defaultNode) { _RemovePath $defaultNode }
|
|
if ($defaultStaging) { _RemovePath $defaultStaging }
|
|
# Managed whisper.cpp prebuilt (sibling of studio under ~/.unsloth). Only
|
|
# present when a whisper prebuilt matching the pinned llama.cpp build existed
|
|
# at install time, so many installs lack it.
|
|
if ($defaultWhisperCpp) { _RemovePath $defaultWhisperCpp }
|
|
# Prebuilt install locks. Every prebuilt serializes on
|
|
# <parent>\.<name>.install.lock (prebuilt_core.py install_lock_path), so
|
|
# llama.cpp, node and whisper.cpp each leave one; a stray lock keeps
|
|
# ~/.unsloth from being pruned below. No-op in env/custom mode and when absent.
|
|
if ($defaultUnslothHome) {
|
|
foreach ($lockName in @(".llama.cpp.install.lock", ".node.install.lock", ".whisper.cpp.install.lock")) {
|
|
_RemovePath (Join-Path $defaultUnslothHome $lockName)
|
|
}
|
|
# Taking over an abandoned lock renames it to .stale.<pid> before unlinking
|
|
# (install_node_prebuilt.py); a crash between the two steps strands the
|
|
# rename, so sweep any leftovers. -Force to see the dot-prefixed names.
|
|
if (Test-Path -LiteralPath $defaultUnslothHome) {
|
|
# -like, not -Filter: the provider's Win32 filter is unreliable for
|
|
# dot-leading names with several dots.
|
|
foreach ($stale in @(Get-ChildItem -LiteralPath $defaultUnslothHome -Force -ErrorAction SilentlyContinue |
|
|
Where-Object { $_.Name -like "*.install.lock.stale.*" })) {
|
|
_RemovePath $stale.FullName
|
|
}
|
|
}
|
|
}
|
|
# Drop ~/.unsloth itself, but ONLY if now empty -- never nuke unrelated content.
|
|
if ($defaultUnslothHome -and (Test-Path -LiteralPath $defaultUnslothHome) -and
|
|
-not (Get-ChildItem -LiteralPath $defaultUnslothHome -Force -ErrorAction SilentlyContinue)) {
|
|
_RemovePath $defaultUnslothHome
|
|
}
|
|
|
|
# Runtime data, created at first launch rather than by install.ps1: LOCALAPPDATA holds the
|
|
# EBWebView profile (a leftover copy serves a stale frontend), APPDATA the app config dir.
|
|
_Step "Removing WebView caches and app data (ai.unsloth.studio)..."
|
|
# Count an unresolvable root as incomplete cleanup: skipping it silently would leave the
|
|
# profile on disk while the summary reports the session as gone.
|
|
foreach ($known in @(
|
|
@{ Var = $env:LOCALAPPDATA; Folder = 'LocalApplicationData' },
|
|
@{ Var = $env:APPDATA; Folder = 'ApplicationData' }
|
|
)) {
|
|
$base = _AppDataRoot $known.Var $known.Folder
|
|
if ([string]::IsNullOrWhiteSpace($base)) {
|
|
_Substep "could not resolve $($known.Folder); WebView data may remain" "Yellow"
|
|
$script:RemoveFailed = $true
|
|
continue
|
|
}
|
|
_RemovePath (Join-Path $base "ai.unsloth.studio")
|
|
}
|
|
|
|
# ── Remove desktop and Start Menu shortcuts ──
|
|
_Step "Removing desktop and Start Menu shortcuts..."
|
|
try {
|
|
$desktop = [Environment]::GetFolderPath("Desktop")
|
|
if ($desktop) { _RemovePath (Join-Path $desktop "Unsloth Studio.lnk") }
|
|
} catch { }
|
|
if ($env:APPDATA) {
|
|
_RemovePath (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Unsloth Studio.lnk")
|
|
}
|
|
# Invalidate the Win11 Start Menu tile cache so the removed shortcut's tile
|
|
# disappears promptly instead of lingering stale (mirrors install.ps1's
|
|
# New-StudioShortcuts). Preserves start2.bin (the pin layout).
|
|
try {
|
|
$smehTemp = Join-Path $env:LOCALAPPDATA "Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\TempState"
|
|
if (Test-Path -LiteralPath $smehTemp) {
|
|
Get-ChildItem -LiteralPath $smehTemp -Filter "TileCache_*" -ErrorAction SilentlyContinue |
|
|
Remove-Item -Force -ErrorAction SilentlyContinue
|
|
Remove-Item -LiteralPath (Join-Path $smehTemp "StartUnifiedTileModelCache.dat") -Force -ErrorAction SilentlyContinue
|
|
Stop-Process -Name StartMenuExperienceHost -Force -ErrorAction SilentlyContinue
|
|
}
|
|
} catch { }
|
|
|
|
# Re-sweep: the first pass may have left unsloth.ico locked by Explorer/SMEH for
|
|
# the native shortcut; that handle is now freed. (A surviving WSL shortcut still
|
|
# keeps the icon -- see the helper.)
|
|
foreach ($d in $defaultDataDirs) {
|
|
if (Test-Path -LiteralPath $d) { _RemoveDataDirKeepingWslIcon $d }
|
|
}
|
|
|
|
# ── Clean user PATH and registry backup ──
|
|
_Step "Cleaning user PATH and registry..."
|
|
try {
|
|
$regKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
|
|
if ($regKey) {
|
|
try {
|
|
$rawPath = $regKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
|
if ($rawPath) {
|
|
$entries = $rawPath -split ';'
|
|
$kept = New-Object System.Collections.ArrayList
|
|
$removedAny = $false
|
|
# Only remove PATH entries that live inside an Unsloth root we
|
|
# actually own (default or env-mode). A literal substring
|
|
# match on `unsloth_studio` would clobber unrelated user
|
|
# virtualenvs that happen to share the name.
|
|
foreach ($e in $entries) {
|
|
if ([string]::IsNullOrWhiteSpace($e)) { continue }
|
|
$expanded = [Environment]::ExpandEnvironmentVariables($e).TrimEnd('\','/')
|
|
$isStudio = $false
|
|
foreach ($r in $knownRoots) {
|
|
if (-not $r) { continue }
|
|
$rNorm = $r.TrimEnd('\','/')
|
|
if ($expanded -ieq $rNorm -or $expanded -ilike "$rNorm\*") {
|
|
$isStudio = $true; break
|
|
}
|
|
}
|
|
if ($isStudio) {
|
|
_Substep "removed PATH entry: $e" "Green"
|
|
$removedAny = $true
|
|
continue
|
|
}
|
|
[void]$kept.Add($e)
|
|
}
|
|
if ($removedAny) {
|
|
$newPath = ($kept -join ';')
|
|
$regKey.SetValue('Path', $newPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
|
|
try {
|
|
$d = "UnslothPathRefresh_" + ([guid]::NewGuid().ToString('N').Substring(0, 8))
|
|
[Environment]::SetEnvironmentVariable($d, '1', 'User')
|
|
[Environment]::SetEnvironmentVariable($d, [NullString]::Value, 'User')
|
|
} catch { }
|
|
}
|
|
}
|
|
} finally {
|
|
$regKey.Close()
|
|
}
|
|
}
|
|
} catch {
|
|
_Substep "could not update user PATH: $($_.Exception.Message)" "Yellow"
|
|
}
|
|
# Remove HKCU\Software\Unsloth (PathBackup lives here; install.ps1 owns it).
|
|
try {
|
|
Remove-Item -LiteralPath 'HKCU:\Software\Unsloth' -Recurse -Force -ErrorAction SilentlyContinue
|
|
} catch { }
|
|
|
|
Write-Host ""
|
|
Write-Host "Unsloth Studio uninstalled."
|
|
if ($script:RemoveFailed) {
|
|
Write-Host "Note: some paths could not be removed (see 'could not remove:' above), so the"
|
|
Write-Host " signed-in session and local chat history may still be on disk. Remove"
|
|
Write-Host " those paths by hand to clear them."
|
|
} elseif ($script:StudioDbRemoved) {
|
|
# Scoped to what was removed: a bare run never discovers a coexisting env-mode root.
|
|
Write-Host "Note: this also removed the app's WebView data and the studio.db it found, so"
|
|
Write-Host " the desktop app's session and the chat history in the install(s) removed"
|
|
Write-Host " above are gone."
|
|
} else {
|
|
# No studio.db was deleted, so only the WebView-local data is accounted for: an env-mode
|
|
# install this run never discovered still has its keys and history.
|
|
Write-Host "Note: this also removed the app's WebView data, so the desktop app's session"
|
|
Write-Host " is gone. A browser session is not affected: its tokens live in the same"
|
|
Write-Host " localStorage as the API keys below."
|
|
Write-Host " No studio.db was found, so any chat history in an install root this run"
|
|
Write-Host " did not see is still on disk."
|
|
}
|
|
Write-Host "Note: provider API keys are kept in the browser's localStorage, not in studio.db."
|
|
Write-Host " Unless you ran Studio as the desktop app, clear site data for the"
|
|
Write-Host " http://localhost:<port> origin you used to remove them."
|
|
Write-Host "Note: Hugging Face model cache at %USERPROFILE%\.cache\huggingface was left in place."
|
|
Write-Host "Remove it manually with 'Remove-Item -Recurse -Force `"$env:USERPROFILE\.cache\huggingface\hub`"' if desired."
|
|
if (-not $env:UNSLOTH_STUDIO_HOME -and -not $env:STUDIO_HOME) {
|
|
Write-Host ""
|
|
Write-Host "If you installed Unsloth Studio with UNSLOTH_STUDIO_HOME or STUDIO_HOME"
|
|
Write-Host "pointing at a custom directory, re-run this script with the same variable"
|
|
Write-Host "set to also remove that install tree, e.g.:"
|
|
Write-Host " `$env:UNSLOTH_STUDIO_HOME = 'C:\your\path'; irm https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.ps1 | iex"
|
|
}
|
|
}
|
|
|
|
Uninstall-UnslothStudio @args
|