Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 71 additions & 49 deletions launcher/uv.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"fmt"
"io"
Expand Down Expand Up @@ -35,12 +36,19 @@ const (
uvLatestBase = "https://github.com/astral-sh/uv/releases/latest/download/"
)

// uvEnv returns the process environment with all uv state redirected under the
// runtime root, so nothing leaks into the user's global uv cache/tools.
// uvEnv returns the process environment with uv's tool and cache state redirected
// under the runtime root, so nothing leaks into the user's global uv dirs.
//
// It deliberately does NOT set UV_PYTHON_INSTALL_DIR. The managed-Python download
// is run by ensurePython, which sets that variable itself; the `uv tool install`
// step must run *without* it so that the explicit interpreter we pass via
// --python is treated as an external interpreter. If UV_PYTHON_INSTALL_DIR were
// set there, uv would treat that interpreter as managed and try to (re)create its
// minor-version directory junction — the exact step that fails with os error 448
// on Windows under OneDrive Files-On-Demand.
func (l layout) uvEnv() []string {
env := os.Environ()
env = append(env,
"UV_PYTHON_INSTALL_DIR="+l.pythonDir,
"UV_TOOL_DIR="+l.toolDir,
"UV_TOOL_BIN_DIR="+l.toolBin,
"UV_CACHE_DIR="+l.cacheDir,
Expand All @@ -50,45 +58,6 @@ func (l layout) uvEnv() []string {
return env
}

// installStepAttempts is how many times each uv install command is tried before
// giving up. On Windows with OneDrive Files-On-Demand, the cloud-filter driver
// (cldflt) can transiently fail uv's creation of the managed-Python
// minor-version junction with "untrusted mount point" (os error 448) while it
// engages a freshly created folder tree on the very first run. The failure
// clears itself once the filter settles, so a short retry turns what was a hard
// first-run abort into a brief pause. Retrying is cheap: uv caches its
// downloads, so a second attempt reuses the already-fetched Python/wheels.
const installStepAttempts = 3

// withRetry calls fn up to attempts times, returning nil on the first success
// and the last error once attempts are exhausted. Between failed tries it calls
// backoff(attempt) (1-based) so callers control the delay (and tests can make it
// a no-op). Pulled out as a free function so the retry logic is unit-testable
// without spawning a real uv.
func withRetry(attempts int, backoff func(attempt int), fn func() error) error {
var err error
for attempt := 1; ; attempt++ {
if err = fn(); err == nil || attempt >= attempts {
return err
}
backoff(attempt)
}
}

// runUVRetry runs uv like runUV but retries a few times with a short, growing
// backoff so a transient first-run filesystem error (see installStepAttempts)
// self-heals instead of aborting setup.
func (l layout) runUVRetry(args ...string) error {
return withRetry(installStepAttempts, func(attempt int) {
delay := time.Duration(attempt*3) * time.Second
fmt.Printf("\nThat step didn't complete (attempt %d of %d). Retrying in %s...\n",
attempt, installStepAttempts, delay)
time.Sleep(delay)
}, func() error {
return l.runUV(args...)
})
}

// runUV runs uv with the given arguments, streaming its output to our console.
func (l layout) runUV(args ...string) error {
cmd := exec.Command(l.uvBin, args...)
Expand All @@ -99,6 +68,57 @@ func (l layout) runUV(args ...string) error {
return cmd.Run()
}

// ensurePython makes a managed CPython available on disk and returns the path to
// its interpreter executable.
//
// It runs uv's managed-Python install (download + extract into l.pythonDir) but
// treats a nonzero exit as success *as long as a usable interpreter landed on
// disk*. The reason: on Windows with OneDrive Files-On-Demand, the final step
// where uv creates a "minor version" directory junction fails with os error 448
// ("untrusted mount point") even though the interpreter itself is fully extracted
// and runnable. We don't need that junction — install() points uv at the
// interpreter directly via --python — so a junction failure is harmless here. uv
// output is captured and only surfaced if we end up with no usable interpreter,
// to avoid alarming the user with a 448 we deliberately ignore.
func (l layout) ensurePython() (string, error) {
var out bytes.Buffer
cmd := exec.Command(l.uvBin, "python", "install", pythonVersion, "--no-bin")
// Set UV_PYTHON_INSTALL_DIR only for this step (see uvEnv). --no-bin keeps uv
// from dropping a python shim into the user's ~/.local/bin.
cmd.Env = append(l.uvEnv(), "UV_PYTHON_INSTALL_DIR="+l.pythonDir)
cmd.Stdout = &out
cmd.Stderr = &out
runErr := cmd.Run()

if pyExe, ok := findExtractedPython(l); ok {
return pyExe, nil
}
if runErr != nil {
return "", fmt.Errorf("%w\n%s", runErr, strings.TrimSpace(out.String()))
}
return "", fmt.Errorf("Python %s was not found under %s after install", pythonVersion, l.pythonDir)
}

// findExtractedPython locates the interpreter inside an extracted managed-CPython
// directory under l.pythonDir (e.g. cpython-3.12.13-windows-x86_64-none),
// returning the path and whether one was found. The glob requires a patch
// component (`3.12.`), so it matches the real install directory and not uv's
// `cpython-3.12-…` minor-version junction.
func findExtractedPython(l layout) (string, bool) {
dirs, _ := filepath.Glob(filepath.Join(l.pythonDir, "cpython-"+pythonVersion+".*"))
for _, dir := range dirs {
for _, exe := range []string{
filepath.Join(dir, "python.exe"), // Windows
filepath.Join(dir, "bin", "python3"), // macOS / Linux
} {
if fileExists(exe) {
return exe, true
}
}
}
return "", false
}

// ensureUV makes sure a usable uv binary exists at l.uvBin. It prefers an
// already-materialized copy, then the embedded binary (release builds), then a
// download. A uv that happens to be on PATH is deliberately NOT used: it may be
Expand Down Expand Up @@ -304,23 +324,25 @@ func install(l layout, pkgSpec, torchBackend string, reinstall bool) error {

warnIfXcodeToolsMissing()

// --no-bin: the launcher runs start_photomap from the tool venv directly, so
// we don't need uv to drop a python3.12 shim into the user's ~/.local/bin
// (which it can't manage on reinstall, and which is one more cross-folder
// write outside our runtime root).
if err := l.runUVRetry("python", "install", pythonVersion, "--no-bin"); err != nil {
// Get a managed interpreter on disk. We pass its explicit path to
// `uv tool install` below rather than `--python 3.12`, so uv never runs its
// managed-install machinery for the tool venv — and therefore never tries to
// create the minor-version junction that fails with os error 448 on Windows
// under OneDrive Files-On-Demand. See ensurePython / uvEnv for the details.
pyExe, err := l.ensurePython()
if err != nil {
return fmt.Errorf("installing Python: %w", err)
}

args := []string{
"tool", "install", pkgSpec,
"--python", pythonVersion,
"--python", pyExe,
"--torch-backend", torchBackend,
}
if reinstall {
args = append(args, "--reinstall")
}
if err := l.runUVRetry(args...); err != nil {
if err := l.runUV(args...); err != nil {
return fmt.Errorf("installing %s: %w", pkgSpec, err)
}
return writeMarker(l, torchBackend)
Expand Down
90 changes: 41 additions & 49 deletions launcher/uv_test.go
Original file line number Diff line number Diff line change
@@ -1,60 +1,47 @@
package main

import (
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

func TestWithRetrySucceedsAfterTransientFailures(t *testing.T) {
calls, backoffs := 0, 0
err := withRetry(3, func(int) { backoffs++ }, func() error {
calls++
if calls < 3 {
return errors.New("transient 448")
}
return nil
})
if err != nil {
t.Fatalf("withRetry = %v, want nil after recovering", err)
func TestFindExtractedPython(t *testing.T) {
dir := t.TempDir()
l := layout{pythonDir: dir}

if _, ok := findExtractedPython(l); ok {
t.Fatal("findExtractedPython found an interpreter in an empty dir")
}
if calls != 3 {
t.Errorf("fn called %d times, want 3", calls)

// A bare minor-version junction name (no patch) must NOT match — only the
// real, patch-versioned install directory should.
if err := os.MkdirAll(filepath.Join(dir, "cpython-3.12-windows-x86_64-none"), 0o755); err != nil {
t.Fatal(err)
}
if backoffs != 2 {
t.Errorf("backoff called %d times, want 2 (between the 3 tries)", backoffs)
if _, ok := findExtractedPython(l); ok {
t.Fatal("findExtractedPython matched the patch-less junction directory")
}
}

func TestWithRetryReturnsLastErrorWhenExhausted(t *testing.T) {
calls := 0
want := errors.New("persistent failure")
err := withRetry(3, func(int) {}, func() error {
calls++
return want
})
if !errors.Is(err, want) {
t.Fatalf("withRetry = %v, want %v", err, want)
}
if calls != 3 {
t.Errorf("fn called %d times, want 3", calls)
// Simulate an extracted interpreter. Create both candidate layouts (Windows
// python.exe at the root, Unix bin/python3) so the test is host-OS-agnostic.
pdir := filepath.Join(dir, "cpython-3.12.13-windows-x86_64-none")
if err := os.MkdirAll(filepath.Join(pdir, "bin"), 0o755); err != nil {
t.Fatal(err)
}
}

func TestWithRetryStopsOnFirstSuccess(t *testing.T) {
calls := 0
err := withRetry(3, func(int) { t.Error("backoff should not be called on immediate success") }, func() error {
calls++
return nil
})
if err != nil {
t.Fatalf("withRetry = %v, want nil", err)
for _, exe := range []string{filepath.Join(pdir, "python.exe"), filepath.Join(pdir, "bin", "python3")} {
if err := os.WriteFile(exe, []byte("stub"), 0o755); err != nil {
t.Fatal(err)
}
}
if calls != 1 {
t.Errorf("fn called %d times, want 1", calls)
got, ok := findExtractedPython(l)
if !ok {
t.Fatal("findExtractedPython did not find the extracted interpreter")
}
if !strings.HasPrefix(got, pdir) {
t.Errorf("findExtractedPython = %q, want a path under %q", got, pdir)
}
}

Expand Down Expand Up @@ -108,25 +95,30 @@ func TestUVEnvRedirectsState(t *testing.T) {
toolBin: "/tmp/pm/bin",
cacheDir: "/tmp/pm/cache",
}
// All uv state must be redirected under the runtime root so nothing leaks
// into the user's global uv cache/tools.
want := map[string]string{
"UV_PYTHON_INSTALL_DIR": l.pythonDir,
"UV_TOOL_DIR": l.toolDir,
"UV_TOOL_BIN_DIR": l.toolBin,
"UV_CACHE_DIR": l.cacheDir,
}
got := map[string]string{}
for _, kv := range l.uvEnv() {
if k, v, ok := strings.Cut(kv, "="); ok {
got[k] = v
}
}
// Tool + cache state must be redirected under the runtime root so nothing
// leaks into the user's global uv dirs.
want := map[string]string{
"UV_TOOL_DIR": l.toolDir,
"UV_TOOL_BIN_DIR": l.toolBin,
"UV_CACHE_DIR": l.cacheDir,
}
for k, v := range want {
if got[k] != v {
t.Errorf("uvEnv()[%q] = %q, want %q", k, got[k], v)
}
}
// uvEnv must NOT set UV_PYTHON_INSTALL_DIR: only ensurePython sets it, and the
// `uv tool install` step must run without it so the explicit --python
// interpreter is treated as external (no minor-version junction → no os 448).
if v, set := got["UV_PYTHON_INSTALL_DIR"]; set {
t.Errorf("uvEnv() set UV_PYTHON_INSTALL_DIR=%q, want it unset", v)
}
}

// TestDownloadUVIntegration exercises the download + archive-extraction + exec
Expand Down
Loading