From 6944856256dce13da7502d3511c221714f13c214 Mon Sep 17 00:00:00 2001 From: Lionel Colaso Date: Sat, 1 Aug 2026 19:17:48 +0530 Subject: [PATCH] ci: build and release LocalAI for Windows - goreleaser: add windows (amd64/arm64) to the release targets - Makefile: download the win64 protoc zip and rename protoc.exe to protoc, resolve code-gen plugins via --plugin instead of PATH, force SHELL=sh and name the binary local-ai.exe on Windows, ignore protoc.exe - build-test.yaml: add a native windows-latest build gate that installs GNU make via choco, adds Git for Windows' usr/bin to PATH and builds with CGO_ENABLED=0 - pkg/downloader: close the write handle before removing or renaming the partial so Windows file locks do not break resume and error paths; guard the POSIX-permission and symlink tests on non-Windows - tests: Windows guards and path fixes for core/gallery, video_internal, loader and the testcontainers database setup - docs: document building LocalAI on Windows (Go, Git for Windows, GNU make, Node.js and CGO_ENABLED=0) --- .github/workflows/build-test.yaml | 29 ++++++++++++ .gitignore | 5 ++ .goreleaser.yaml | 2 +- Makefile | 46 +++++++++++++++---- core/gallery/gallery_test.go | 2 +- core/gallery/model_artifacts_test.go | 2 +- .../endpoints/localai/video_internal_test.go | 7 ++- core/services/testutil/testdb.go | 3 ++ docs/content/getting-started/build.md | 34 ++++++++++++++ docs/content/getting-started/models.md | 2 +- .../getting-started/troubleshooting.md | 2 +- docs/content/reference/binaries.md | 16 ++++--- pkg/downloader/auth_progress_test.go | 7 ++- pkg/downloader/partial_resume_test.go | 7 +++ pkg/downloader/uri.go | 11 +++++ pkg/downloader/uri_test.go | 10 +++- pkg/model/loader_test.go | 2 +- 17 files changed, 162 insertions(+), 25 deletions(-) diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index 08fb2d0842a3..d875b31483c2 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -53,6 +53,35 @@ jobs: - name: Run GoReleaser run: | make ${{ github.event_name == 'pull_request' && 'dev-dist-single' || 'dev-dist' }} + # Windows ships no GNU toolchain on the default runner: make is not + # installed there and Git for Windows does not ship it (it bundles sh, uname, + # unzip, grep, awk, sed only), so it is installed via Chocolatey below. The + # Makefile finds Git for Windows' sh itself and runs recipes through it (see + # the SHELL setup at the top of the Makefile), so the explicit usr/bin PATH + # step below is belt-and-suspenders for environments where make must locate + # sh before the makefile is read. The server builds with CGO_ENABLED=0, so no + # mingw/gcc is needed; the Makefile downloads protoc and installs the Go + # protobuf plugins itself (see the protoc/protogen-go targets). + build-test-windows: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 1.25 + - name: Add Git usr/bin to PATH + run: | + echo "$env:ProgramFiles\Git\usr\bin" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH + - name: Install GNU Make + run: choco install make -y + - name: Build LocalAI (CGO disabled, mirrors release config) + env: + CGO_ENABLED: '0' + run: make build launcher-build-darwin: runs-on: macos-latest steps: diff --git a/.gitignore b/.gitignore index a4c84a0e8dfe..b1fe53accc10 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ prepare-sources /backend-images /result.yaml protoc +protoc.exe *.log @@ -25,6 +26,7 @@ go-bert # LocalAI build binary LocalAI /local-ai +/local-ai.exe /local-ai-launcher # Root-level build artifacts when running `go build ./...` against # Go backend packages whose main lives under backend/go/. @@ -129,3 +131,6 @@ formal-verification/out/ # root, which is what a contributor testing a build does. Nothing under here is # source: it is the instance's own models, outputs, traces and identity. /data/ + +# Model gallery index cache fetched by `local-ai` at runtime (cache/gallery/). +/cache/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 88d6e9ecd9e4..67bc8cf33b0e 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -20,7 +20,7 @@ builds: goos: - linux - darwin - #- windows + - windows goarch: - amd64 - arm64 diff --git a/Makefile b/Makefile index 1f25e246c423..67c7872ead77 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,28 @@ # Disable parallel execution for backend builds .NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/bonsai backends/outetts backends/piper backends/stablediffusion-ggml backends/trellis2cpp backends/trellis2cpp-darwin backends/whisper backends/crispasr backends/parakeet-cpp backends/moss-transcribe-cpp backends/nemo-speech-cpp backends/faster-whisper backends/silero-vad backends/local-store backends/valkey-store backends/cloud-proxy backends/huggingface backends/rfdetr backends/rfdetr-cpp backends/insightface backends/speaker-recognition backends/kitten-tts backends/kokoro backends/chatterbox backends/llama-cpp-darwin backends/neutts build-darwin-python-backend build-darwin-go-backend backends/mlx backends/diffuser-darwin backends/mlx-vlm backends/mlx-audio backends/mlx-distributed backends/stablediffusion-ggml-darwin backends/vllm backends/vllm-omni backends/longcat-video backends/sglang backends/moonshine backends/pocket-tts backends/qwen-tts backends/faster-qwen3-tts backends/qwen-asr backends/nemo backends/voxcpm backends/whisperx backends/ace-step backends/acestep-cpp backends/fish-speech backends/voxtral backends/opus backends/trl backends/llama-cpp-quantization backends/kokoros backends/sam3-cpp backends/qwen3-tts-cpp backends/moss-tts-cpp backends/magpie-tts-cpp backends/vllm-cpp backends/omnivoice-cpp backends/vibevoice-cpp backends/localvqe backends/tinygrad backends/sherpa-onnx backends/ds4 backends/ds4-darwin backends/liquid-audio backends/supertonic backends/depth-anything-cpp backends/privacy-filter backends/privacy-filter-darwin backends/audio-cpp backends/audio-cpp-darwin +# Native GNU make for Windows (e.g. ezwinports) only behaves like the Unix +# make when it can find sh.exe: otherwise recipe lines run under cmd.exe and +# $(shell ...) degrades to bare CreateProcess, which breaks every POSIX +# recipe and the uname/tput expansion below. Seed the exported PATH with the +# sh directories from the standard Git-for-Windows / MSYS2 installs (missing +# entries are harmless in a Windows PATH) and force SHELL to sh, mirroring +# what CI does. Detection uses the cmd environment OS variable (Windows_NT); +# OS is re-derived from uname further down. Nothing matches on +# Linux/macOS/WSL, so the block is inert there. +ifeq ($(findstring Windows,$(OS)),Windows) + export PATH := C:/Program Files/Git/usr/bin;$(if $(LOCALAPPDATA),$(LOCALAPPDATA)/Programs/Git/usr/bin,);C:/msys64/usr/bin;$(PATH) + export SHELL := sh +endif + GOCMD=go GOTEST=$(GOCMD) test GOVET=$(GOCMD) vet -BINARY_NAME=local-ai +# Windows builds get the .exe suffix so the artifact is runnable from cmd / +# PowerShell (an extensionless PE needs a POSIX shell to launch it). OS is +# assigned below via uname; this is a recursive = so the suffix is expanded +# at use time, after OS exists. Empty on Linux/macOS, so nothing else changes. +BINARY_NAME=local-ai$(if $(findstring NT,$(OS)),.exe) LAUNCHER_BINARY_NAME=local-ai-launcher UBUNTU_VERSION?=2404 @@ -532,10 +550,18 @@ help: ## Show this help. .PHONY: protogen protogen: protogen-go +# The win64 protoc zip ships bin/protoc.exe while the unix zips ship +# bin/protoc. protogen-go invokes a bare ./protoc, and the protoc target must +# stay up-to-date once the binary is in place, so on Windows we extract the +# .exe and rename it to ./protoc (MSYS sh runs extensionless PE binaries). +PROTOC_MEMBER := bin/protoc$(if $(findstring NT,$(OS)),.exe) + protoc: @OS_NAME=$$(uname -s | tr '[:upper:]' '[:lower:]'); \ ARCH_NAME=$$(uname -m); \ - if [ "$$OS_NAME" = "darwin" ]; then \ + if echo "$$OS_NAME" | grep -qE 'mingw|msys|cygwin'; then \ + FILE=protoc-31.1-win64.zip; \ + elif [ "$$OS_NAME" = "darwin" ]; then \ if [ "$$ARCH_NAME" = "arm64" ]; then \ FILE=protoc-31.1-osx-aarch_64.zip; \ elif [ "$$ARCH_NAME" = "x86_64" ]; then \ @@ -562,18 +588,20 @@ protoc: fi; \ URL=https://github.com/protocolbuffers/protobuf/releases/download/v31.1/$$FILE; \ curl -L $$URL -o protoc.zip && \ - unzip -j -d $(CURDIR) protoc.zip bin/protoc && rm protoc.zip + unzip -o -j -d $(CURDIR) protoc.zip $(PROTOC_MEMBER) && \ + rm -f protoc.zip && \ + [ ! -f ./protoc.exe ] || mv -f ./protoc.exe ./protoc .PHONY: protogen-go protogen-go: protoc install-go-tools mkdir -p pkg/grpc/proto # install-go-tools writes protoc-gen-go and protoc-gen-go-grpc into - # $(shell go env GOPATH)/bin, which isn't on every dev's PATH. protoc - # resolves its code-gen plugins via PATH, so without this prefix the - # generate step fails with "protoc-gen-go: program not found". Prepend - # GOPATH/bin so the freshly-installed plugins win without requiring a - # shell-profile change. - PATH="$$(go env GOPATH)/bin:$$PATH" ./protoc --experimental_allow_proto3_optional -Ibackend/ --go_out=pkg/grpc/proto/ --go_opt=paths=source_relative --go-grpc_out=pkg/grpc/proto/ --go-grpc_opt=paths=source_relative \ + # $(shell go env GOPATH)/bin, which isn't on every dev's PATH. Point + # protoc at the plugins explicitly (--plugin) so discovery doesn't depend + # on PATH separator conventions (POSIX ':' vs Windows ';'). + ./protoc --experimental_allow_proto3_optional -Ibackend/ --go_out=pkg/grpc/proto/ --go_opt=paths=source_relative --go-grpc_out=pkg/grpc/proto/ --go-grpc_opt=paths=source_relative \ + --plugin=protoc-gen-go="$$(go env GOPATH)/bin/protoc-gen-go$(if $(findstring NT,$(OS)),.exe,)" \ + --plugin=protoc-gen-go-grpc="$$(go env GOPATH)/bin/protoc-gen-go-grpc$(if $(findstring NT,$(OS)),.exe,)" \ backend/backend.proto core/config/inference_defaults.json: ## Fetch inference defaults from unsloth (only if missing) diff --git a/core/gallery/gallery_test.go b/core/gallery/gallery_test.go index cb2070c1c6eb..1455f7e87360 100644 --- a/core/gallery/gallery_test.go +++ b/core/gallery/gallery_test.go @@ -510,7 +510,7 @@ var _ = Describe("Gallery", func() { }, }, } - result := FindGalleryElement(modelsWithPath, "bert/embeddings") + result := FindGalleryElement(modelsWithPath, "bert"+string(os.PathSeparator)+"embeddings") Expect(result).NotTo(BeNil()) Expect(result.GetName()).To(Equal("bert__embeddings")) }) diff --git a/core/gallery/model_artifacts_test.go b/core/gallery/model_artifacts_test.go index cd9d4cdd9866..bc31ddb5b095 100644 --- a/core/gallery/model_artifacts_test.go +++ b/core/gallery/model_artifacts_test.go @@ -67,7 +67,7 @@ unknown_extension: Expect(err).NotTo(HaveOccurred()) Expect(fake.seen).To(HaveLen(1)) Expect(installed.Model).To(Equal("owner/repo")) - Expect(installed.ModelFileName()).To(Equal(fake.result.RelativePath)) + Expect(installed.ModelFileName()).To(Equal(filepath.FromSlash(fake.result.RelativePath))) data, err := os.ReadFile(filepath.Join(modelsPath, "managed.yaml")) Expect(err).NotTo(HaveOccurred()) diff --git a/core/http/endpoints/localai/video_internal_test.go b/core/http/endpoints/localai/video_internal_test.go index 383ae5fe4115..848f945c9958 100644 --- a/core/http/endpoints/localai/video_internal_test.go +++ b/core/http/endpoints/localai/video_internal_test.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "os" "path/filepath" + "runtime" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -23,7 +24,11 @@ var _ = Describe("video media staging", func() { Expect(os.ReadFile(path)).To(Equal(content)) info, err := os.Stat(path) Expect(err).NotTo(HaveOccurred()) - Expect(info.Mode().Perm()).To(Equal(os.FileMode(0o600))) + // POSIX permission bits are not enforceable on Windows, where the file + // is reported with the default 0666 mode regardless of chmod. + if runtime.GOOS != "windows" { + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0o600))) + } }) It("accepts browser data URIs with codec parameters", func() { diff --git a/core/services/testutil/testdb.go b/core/services/testutil/testdb.go index 80e511201b7d..ebecc3c8bc79 100644 --- a/core/services/testutil/testdb.go +++ b/core/services/testutil/testdb.go @@ -22,6 +22,9 @@ func SetupTestDB() *gorm.DB { if runtime.GOOS == "darwin" { Skip("testcontainers requires Docker, not available on macOS CI") } + if runtime.GOOS == "windows" { + Skip("testcontainers requires Docker, not available on Windows CI") + } ctx := context.Background() pgC, err := tcpostgres.Run(ctx, "postgres:16", tcpostgres.WithDatabase("testdb"), diff --git a/docs/content/getting-started/build.md b/docs/content/getting-started/build.md index 77884a4126b8..3a66be6d505e 100644 --- a/docs/content/getting-started/build.md +++ b/docs/content/getting-started/build.md @@ -51,6 +51,17 @@ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@1958fcbe2ca8bd93af633f1 ``` +{{% /tab %}} +{{% tab title="Windows" %}} + +The `make` recipes are POSIX shell. Git for Windows bundles the POSIX tools they need (`sh`, `uname`, `unzip`, `grep`, `awk`, `sed`) — but **not** `make` itself — so install a GNU make for Windows as well: + +```powershell +winget install ezwinports.make +``` + +The Makefile locates Git for Windows' `sh` on its own and runs every recipe through it, downloads `protoc` and installs the Go protobuf plugins itself — so no PATH edits or extra toolchain are needed. `make build` also compiles the React UI, so install [Node.js](https://nodejs.org) (with npm) as well. + {{% /tab %}} {{% tab title="From source" %}} @@ -72,6 +83,8 @@ make build This should produce the binary `local-ai` +On Windows, `make build` produces `local-ai.exe` instead. + #### Container image Requirements: @@ -143,6 +156,27 @@ make clean make build ``` +### Example: Build on Windows + +Building the server binary on Windows needs no C toolchain: the release config compiles with `CGO_ENABLED=0`. The `make` recipes are POSIX shell, so you need Git for Windows (which bundles `sh` and the POSIX tools) plus a GNU make for Windows — Git for Windows does not ship `make`. The Makefile finds `sh` on its own, so no PATH edits are required. + +```powershell +# Install Go (https://go.dev/dl), Git for Windows (https://git-scm.com/downloads), +# Node.js for the React UI build (https://nodejs.org), then a GNU make for +# Windows, e.g.: +winget install ezwinports.make + +# Build with CGO disabled like the release config. +$env:CGO_ENABLED = '0' + +git clone https://github.com/mudler/LocalAI.git +cd LocalAI + +make build +``` + +This produces the binary `local-ai.exe`. Backends such as `llama-cpp` still build as Linux container images and are not distributed for Windows. + ## Build backends LocalAI have several backends available for installation in the backend gallery. The backends can be also built by source. As backends might vary from language and dependencies that they require, the documentation will provide generic guidance for few of the backends, which can be applied with some slight modifications also to the others. diff --git a/docs/content/getting-started/models.md b/docs/content/getting-started/models.md index 116b76e65ded..cffd10f2dc18 100644 --- a/docs/content/getting-started/models.md +++ b/docs/content/getting-started/models.md @@ -282,7 +282,7 @@ curl http://localhost:8080/v1/completions -H "Content-Type: application/json" -d For other Docker images, please refer to the table in [Getting Started](https://localai.io/basics/getting_started/#container-images). {{% /notice %}} -Note: If you are on Windows, ensure the project is on the Linux filesystem to avoid slow model loading. For more information, see the [Microsoft Docs](https://learn.microsoft.com/en-us/windows/wsl/filesystems). +Note: When running LocalAI under Docker/WSL2 on Windows, ensure the project is on the Linux filesystem to avoid slow model loading. For more information, see the [Microsoft Docs](https://learn.microsoft.com/en-us/windows/wsl/filesystems). {{% /tab %}} {{% tab title="Kubernetes" %}} diff --git a/docs/content/getting-started/troubleshooting.md b/docs/content/getting-started/troubleshooting.md index 3e925125be4e..1330d829e1b4 100644 --- a/docs/content/getting-started/troubleshooting.md +++ b/docs/content/getting-started/troubleshooting.md @@ -51,7 +51,7 @@ docker run --rm --gpus all nvidia/cuda:12.8.0-base-ubuntu24.04 nvidia-smi ```bash chmod +x local-ai-* -./local-ai-Linux-x86_64 run +./local-ai--linux-amd64 run ``` If you see "cannot execute binary file: Exec format error", you downloaded the wrong architecture. Verify with: diff --git a/docs/content/reference/binaries.md b/docs/content/reference/binaries.md index 178f311277c2..18b2677b62c1 100644 --- a/docs/content/reference/binaries.md +++ b/docs/content/reference/binaries.md @@ -5,7 +5,7 @@ title = "LocalAI binaries" weight = 26 +++ -LocalAI binaries are available for both Linux and MacOS platforms and can be executed directly from your command line. These binaries are continuously updated and hosted on [our GitHub Releases page](https://github.com/mudler/LocalAI/releases). This method also supports Windows users via the Windows Subsystem for Linux (WSL). +LocalAI binaries are available for Linux, macOS, and Windows platforms and can be executed directly from your command line. These binaries are continuously updated and hosted on [our GitHub Releases page](https://github.com/mudler/LocalAI/releases). ### macOS Download @@ -17,19 +17,22 @@ You can download the DMG and install the application: > Note: the DMGs are not signed by Apple as quarantined. See https://github.com/mudler/LocalAI/issues/6268 for a workaround, fix is tracked here: https://github.com/mudler/LocalAI/issues/6244 -Otherwise, use the following one-liner command in your terminal to download and run LocalAI on Linux or MacOS: +Otherwise, use the following one-liner command in your terminal to download and run LocalAI on Linux or MacOS (set `VERSION` to the current tag, e.g. `v4.8.2`): ```bash -curl -Lo local-ai "https://github.com/mudler/LocalAI/releases/download/{{< version >}}/local-ai-$(uname -s)-$(uname -m)" && chmod +x local-ai && ./local-ai +VERSION=v4.8.2; ARCH=$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/'); \ +curl -Lo local-ai "https://github.com/mudler/LocalAI/releases/download/$VERSION/local-ai-$VERSION-$(uname -s | tr '[:upper:]' '[:lower:]')-$ARCH" \ + && chmod +x local-ai && ./local-ai ``` Otherwise, here are the links to the binaries: | OS | Link | | --- | --- | -| Linux (amd64) | [Download](https://github.com/mudler/LocalAI/releases/download/{{< version >}}/local-ai-Linux-x86_64) | -| Linux (arm64) | [Download](https://github.com/mudler/LocalAI/releases/download/{{< version >}}/local-ai-Linux-arm64) | -| MacOS (arm64) | [Download](https://github.com/mudler/LocalAI/releases/download/{{< version >}}/local-ai-Darwin-arm64) | +| Linux (amd64) | [Download](https://github.com/mudler/LocalAI/releases/download/{{< version >}}/local-ai-{{< version >}}-linux-amd64) | +| Linux (arm64) | [Download](https://github.com/mudler/LocalAI/releases/download/{{< version >}}/local-ai-{{< version >}}-linux-arm64) | +| MacOS (arm64) | [Download](https://github.com/mudler/LocalAI/releases/download/{{< version >}}/local-ai-{{< version >}}-darwin-arm64) | +| Windows (amd64) | [Download](https://github.com/mudler/LocalAI/releases/download/{{< version >}}/local-ai-{{< version >}}-windows-amd64.exe) | {{% notice icon="⚡" context="warning" %}} @@ -38,4 +41,5 @@ Binaries do have limited support compared to container images: - Python-based backends are not shipped with binaries (e.g. `diffusers` or `transformers`) - MacOS binaries and Linux-arm64 do not ship TTS nor `stablediffusion-cpp` backends - Linux binaries do not ship `stablediffusion-cpp` backend +- The Windows binary ships only the `llama-cpp` backend (native, no WSL required); see the [Windows guide]({{% relref "getting-started/windows" %}}) for details {{% /notice %}} diff --git a/pkg/downloader/auth_progress_test.go b/pkg/downloader/auth_progress_test.go index debf45f9e79d..622e652720e5 100644 --- a/pkg/downloader/auth_progress_test.go +++ b/pkg/downloader/auth_progress_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "sync" . "github.com/onsi/ginkgo/v2" @@ -141,7 +142,11 @@ var _ = Describe("authenticated HTTP downloads", func() { Expect(errors.Is(err, context.Canceled)).To(BeTrue()) info, statErr := os.Stat(target + ".partial") Expect(statErr).NotTo(HaveOccurred()) - Expect(info.Mode().Perm()).To(Equal(os.FileMode(0o600))) + // POSIX permission bits are not enforceable on Windows, where the file + // is reported with the default 0666 mode regardless of chmod. + if runtime.GOOS != "windows" { + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0o600))) + } }) It("keeps the legacy total empty when the response length is unknown", func() { diff --git a/pkg/downloader/partial_resume_test.go b/pkg/downloader/partial_resume_test.go index 9cf953dee2ef..42e213011477 100644 --- a/pkg/downloader/partial_resume_test.go +++ b/pkg/downloader/partial_resume_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "strconv" "strings" "sync" @@ -157,6 +158,12 @@ var _ = Describe("DownloadFile with a leftover .partial", func() { }) It("reports an informative error when the partial cannot be stat'd", func() { + if runtime.GOOS == "windows" { + // A self-referencing symlink is the portable trick that makes + // os.Stat fail with ELOOP, but creating symlinks on Windows needs + // admin rights or Developer Mode, so the setup itself errors first. + Skip("symlink creation requires privileges on Windows") + } server := rangeServer(true, nil, nil) defer server.Close() diff --git a/pkg/downloader/uri.go b/pkg/downloader/uri.go index 0239781304fb..425244b75eeb 100644 --- a/pkg/downloader/uri.go +++ b/pkg/downloader/uri.go @@ -756,6 +756,10 @@ func (uri URI) DownloadFileWithContext(ctx context.Context, filePath, sha string if startPos > 0 && resp.StatusCode != http.StatusPartialContent { _ = resp.Body.Close() + // The origin ignored the resume range, so the partial is garbage; + // a retry must start clean. Drop the write handle first — Windows + // cannot delete a file that is still open (no FILE_SHARE_DELETE). + _ = outFile.Close() _ = removePartialFile(tmpFilePath) // The partial has just been discarded, so a further attempt starts // clean and no longer needs the server to honour the range. @@ -805,6 +809,13 @@ func (uri URI) DownloadFileWithContext(ctx context.Context, filePath, sha string // after filesystem permissions while the disk was perfectly healthy. tracked := &readErrorRecorder{r: source} _, err = xio.Copy(ctx, io.MultiWriter(outFile, progress), tracked) + // Windows cannot rename or remove a file that still has an open handle: + // Go opens files without FILE_SHARE_DELETE, so MoveFileEx / DeleteFile fail + // with a sharing violation while outFile is live. The copy is the last use + // of the handle, so close it before any of the removal/rename paths below — + // the error paths (non-206 resume, user cancel, SHA mismatch) remove the + // partial too, and POSIX unlink works on open files while Windows does not. + _ = outFile.Close() if err != nil { // Detect cancellation via the context (a cause-cancelled read surfaces // the cause, not context.Canceled). Keep the .partial for resume, diff --git a/pkg/downloader/uri_test.go b/pkg/downloader/uri_test.go index 9cb667b57864..d4e9c28a90c9 100644 --- a/pkg/downloader/uri_test.go +++ b/pkg/downloader/uri_test.go @@ -281,12 +281,15 @@ var _ = Describe("Download Test", func() { mockServer := getMockServer(true) defer mockServer.Close() uri := URI(mockServer.URL) - // Create a partial file + // Create a partial file. The handle must be released before the + // download runs: DownloadFile renames the partial over the target, + // and Windows cannot rename a file with an open handle. tmpFilePath := filePath + ".partial" file, err := os.OpenFile(tmpFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) Expect(err).ToNot(HaveOccurred()) _, err = file.Write(mockData[0:10000]) Expect(err).ToNot(HaveOccurred()) + Expect(file.Close()).To(Succeed()) err = uri.DownloadFile(filePath, mockDataSha, 1, 1, func(s1, s2, s3 string, f float64) {}) Expect(err).ToNot(HaveOccurred()) }) @@ -295,12 +298,15 @@ var _ = Describe("Download Test", func() { mockServer := getMockServer(false) defer mockServer.Close() uri := URI(mockServer.URL) - // Create a partial file + // Create a partial file. The handle must be released before the + // download runs: DownloadFile removes the partial and Windows cannot + // delete a file with an open handle. tmpFilePath := filePath + ".partial" file, err := os.OpenFile(tmpFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) Expect(err).ToNot(HaveOccurred()) _, err = file.Write(mockData[0:10000]) Expect(err).ToNot(HaveOccurred()) + Expect(file.Close()).To(Succeed()) err = uri.DownloadFile(filePath, mockDataSha, 1, 1, func(s1, s2, s3 string, f float64) {}) Expect(err).ToNot(HaveOccurred()) }) diff --git a/pkg/model/loader_test.go b/pkg/model/loader_test.go index 1a882943127f..0182ad23d1ef 100644 --- a/pkg/model/loader_test.go +++ b/pkg/model/loader_test.go @@ -65,7 +65,7 @@ var _ = Describe("ModelLoader", func() { BeforeEach(func() { // Setup the model loader with a test directory - modelPath = "/tmp/test_model_path" + modelPath = filepath.Join(os.TempDir(), "test_model_path") os.Mkdir(modelPath, 0755) systemState, err := system.GetSystemState(