7 Commits
Author SHA1 Message Date
Cheviiot de49e80cb7 Resume interrupted downloads instead of restarting from scratch
MSVC/WinSDK/WDK/DXSDK payloads run into the hundreds of MB to several
GB, so a dropped connection or a retry after a transient error used to
mean throwing away everything already fetched and starting over from
byte 0. Track progress in a dest+".part" file and resume it via an
HTTP Range request, falling back to a full restart when the server
doesn't honor Range (200 instead of 206) or the local part is stale
(416).
2026-07-25 18:17:59 +10:00
Cheviiot 98bac75767 Make the wine-not-found error actionable
FindWine's error used to just say wine64/wine weren't on PATH, with no
next step - surface the exact install fix ("install the wine
package") instead of leaving the reader to figure that out
themselves.
2026-07-25 18:17:59 +10:00
Cheviiot 5cf52c9f9f Add vintner doctor for diagnosing a broken wine/toolchain setup
A misconfigured environment (wine missing, msitools not installed, a
partially-built toolchain) otherwise only surfaces as a wine-specific
error buried deep inside a build. `vintner doctor` checks wine itself
(found and actually runs), the optional extraction tools download
needs, and every installed <dest>/bin/<arch> toolchain's on-disk
layout, printing a pass/fail checklist and exiting non-zero if
anything's broken.
2026-07-25 18:14:47 +10:00
Cheviiot b366dfa9ac Add a concurrency lock for download/install against the same destination
combineDirTrees' merge logic assumes it's the only thing moving files
into a given target at a time; two `vintner download`/`install` runs
racing against the same --dest could otherwise interleave os.Rename
calls and corrupt the tree instead of erroring cleanly. Take an
exclusive, non-blocking flock(2) on the destination for the duration
of each run, so a second invocation fails immediately with a clear
message instead of silently colliding with the first.
2026-07-25 18:10:56 +10:00
Cheviiot 738234186d Run wrapped tools directly as vintner <tool> ..., no PATH needed
`vintner cl ...`, `vintner msbuild ...`, etc. now work without adding
<dest>/bin/<arch> to PATH or relying on the same-directory symlinks
`install` sets up there. wrapper.Run gained an explicit binDir
parameter (empty string preserves the existing os.Executable()-based
self-location for the ordinary multi-call/symlink case) so
cmd/vintner's new runTool can point it at a resolved toolchain
directory instead.

Resolution order: VINTNER_BIN if set (same meaning as `env --bin` -
point it at a <dest>/bin/<arch> directory directly, for a non-default
--dest or a specific architecture), else <defaultToolchainDir>/bin/
<hostArch> - the layout a plain `vintner download && vintner install`
with no --dest override produces. A missing toolchain gets a clear
error pointing at both fixes, rather than bubbling up whatever
wineenv.Load's env.json error looks like.

Also fixed shell completion falling out of sync with the actual tool
list: the bash/zsh scripts previously hand-copied tool/flag names
(and had already gone stale once - --with-dxsdk was missing from the
download flag completions since it was added). The tool name list is
now generated from wrapper.ToolNames() instead of hand-maintained,
and both scripts now complete tool names too, so `vintner <TAB>`
suggests `cl`, `link`, `msbuild`, etc. alongside the management
subcommands.

Verified end-to-end with a minimal PATH (/usr/bin:/bin only, no
toolchain dir on it at all): `vintner cl /nologo hello.c` compiled
successfully, and `vintner msbuild -t:Rebuild ...` rebuilt the same
real KMDF driver verified earlier this session - both via the default
~/.vintner/bin/<hostArch> resolution, no VINTNER_BIN override needed.
2026-07-25 16:23:46 +10:00
Cheviiot f6b9a0811a Prevent and recover from wedged Wine-hosted processes
Prompted by a real incident: an MSBuild node-reuse worker (its own
/nodeReuse:true default) survived a build getting interrupted, came
back deadlocked, and got reused by the next `msbuild` invocation -
which then failed with a confusing, unrelated-looking
`System.TypeLoadException` on Microsoft.VisualStudio.Telemetry on
every call for hours, until the stale process was killed by hand.
That's exactly the "unrelated blocker" noted in this repo's own
earlier session notes (CLAUDE.md) while debugging a real project's
build - it wasn't a missing dependency, it was a corrupted reused
process.

Two changes:

- vintner now forces /nodeReuse:false on every msbuild invocation
  (unless the caller already passed their own /nodeReuse or /nr
  switch), so a wedged worker can never poison a later, unrelated
  build in the first place. Costs each invocation the couple-hundred-
  ms/node startup time node reuse exists to save.

- VINTNER_TIMEOUT (a duration string, e.g. "30m") bounds how long any
  single tool invocation is allowed to run, for the case something
  wedges that isn't MSBuild-specific. Every exec.Command site in
  internal/wrapper now goes through a shared newToolCommand
  constructor that, when the timeout is set, kills the *whole*
  process group (not just the immediate `wine` process - a wedged
  child surviving under it is exactly the scenario this needs to
  reach) via a context deadline, and reports a clear "timed out after
  Xm" message (exit 124, matching the timeout(1) convention) instead
  of a bare "signal: killed". Unset by default - every real build
  observed stays unbounded, matching Windows' own behavior.

Verified end-to-end, not just at the unit level: a real `sleep 30`
through the `cmd` native wrapper with VINTNER_TIMEOUT=1s was killed
within the deadline and reported the timeout clearly (exit 124); a
real `cl` invocation with the same 1s timeout finished normally
(0.26s) without being mistaken for a hang.
2026-07-25 15:58:22 +10:00
Cheviiot bdea270d71 docs: list cabextract as a prerequisite for --with-dxsdk 2026-07-25 15:44:36 +10:00
25 changed files with 1335 additions and 86 deletions
+65 -4
View File
@@ -21,8 +21,11 @@ approach: download the real MSVC/WinSDK, wrap the compiler under Wine.
- [Installation](#installation)
- [Quick start](#quick-start)
- [Commands](#commands)
- [Invoking tools without PATH](#invoking-tools-without-path)
- [Diagnosing problems (vintner doctor)](#diagnosing-problems-vintner-doctor)
- [Building drivers (WDK)](#building-drivers-wdk)
- [Building against D3DX9 (DirectX SDK)](#building-against-d3dx9-directx-sdk)
- [Automated/scripted builds](#automatedscripted-builds)
- [Language](#language)
- [Shell completion](#shell-completion)
- [Using clang-cl/lld-link instead of Wine](#using-clang-cllld-link-instead-of-wine)
@@ -44,9 +47,9 @@ depending on the name it's invoked as.
then runs the real `.exe` under `wine`/`wine64`, and rewrites `z:\...`
paths back to Unix paths in the output, so your build system's error
parsing keeps working.
- As `vintner`: it exposes the `download`, `install`, `env`, `version`
and `completion` subcommands below (short aliases: `dl`, `i`, `e`, `v`;
`help`/`h` prints usage).
- As `vintner`: it exposes the `download`, `install`, `env`, `version`,
`doctor` and `completion` subcommands below (short aliases: `dl`, `i`,
`e`, `v`; `help`/`h` prints usage).
## Installation
@@ -76,11 +79,13 @@ as package dependencies.
- `msitools` (`msiextract`) — unpacks the `.msi` payloads MSVC/WinSDK ship as.
- `git` — applies the compatibility patches bundled with `download` (see
[Compatibility patches](#compatibility-patches)).
- `cabextract` — only needed for `download --with-dxsdk` (see
[Building against D3DX9](#building-against-d3dx9-directx-sdk)).
On ALT Linux:
```bash
pkcon install wine msitools git
pkcon install wine msitools git cabextract
```
## Quick start
@@ -99,6 +104,9 @@ export PATH=~/.vintner/bin/x64:$PATH
cl /nologo /EHsc hello.cpp
```
Don't want to touch PATH? Skip step 3 and run tools through vintner
directly instead - see [Invoking tools without PATH](#invoking-tools-without-path).
## Commands
```
@@ -106,6 +114,7 @@ vintner download (dl) --accept-license [--dest <dir>] [options] fetch and unpa
vintner install (i) [dir] wire up wrappers for a downloaded MSVC
vintner env (e) --bin <dir>/bin/<arch> print INCLUDE/LIB for native clang-cl/lld-link use
vintner version (v) print the version
vintner doctor check wine/toolchain setup
vintner help (h) print usage
vintner completion bash|zsh print a shell completion script
```
@@ -129,6 +138,38 @@ package id or through `--with-*`. `--print-deps-tree` prints the
dependency tree of whatever would actually be selected — honoring every
other flag — without downloading anything.
## Invoking tools without PATH
`cl`, `link`, `msbuild`, and the rest also work as `vintner <tool>
[args...]`, with no need to add `<dest>/bin/<arch>` to `PATH` or rely on
the symlinks `install` sets up there:
```bash
vintner cl /nologo /EHsc hello.cpp
vintner msbuild MyProject.sln
```
Resolves the toolchain to use from `VINTNER_BIN` if set (same meaning as
`env --bin`: point it at a `<dest>/bin/<arch>` directory directly — useful
for a non-default `--dest`, or to pick a specific architecture when more
than one is installed), otherwise defaults to
`~/.vintner/bin/<host-arch>`, the layout a plain `vintner download &&
vintner install` with no `--dest` override produces.
## Diagnosing problems (vintner doctor)
```bash
vintner doctor
```
Checks the things vintner actually needs at runtime — that `wine`/`wine64`
is on `PATH` and actually runs, that `msitools`/`cabextract` are present,
and that every installed `<dest>/bin/<arch>` toolchain (or the one
`VINTNER_BIN` points at) has its MSVC/SDK/MSBuild directories in place —
and prints a pass/fail checklist. Exits non-zero if anything failed.
Useful before filing a bug, or after a `download`/`install` that seemed to
finish but left tools failing in confusing ways.
## Building drivers (WDK)
`--with-wdk` also fetches the Windows Driver Kit: headers, import libs,
@@ -162,6 +203,26 @@ Point your project's `IncludePath`/`LibraryPath` at
Requires `cabextract` on `PATH` (the installer is a self-extracting CAB
archive).
## Automated/scripted builds
Every tool invocation runs unbounded by default, same as the real thing on
Windows. Set `VINTNER_TIMEOUT` (a `time.ParseDuration` string, e.g. `30m`,
`2h`) to have vintner kill and fail a build that runs longer than that
instead of hanging forever - meant for CI and other unattended callers, not
interactive use. This guards against one confirmed failure mode: an
MSBuild node-reuse worker (`/nodeReuse:true` is MSBuild's own default) can
survive its parent process under Wine and, if a prior build was
interrupted mid-compile, come back wedged - reused by the next `msbuild`
call and failing every subsequent build with a confusing, unrelated-looking
error, indefinitely, until it's killed by hand. vintner already forces
`/nodeReuse:false` on every `msbuild` invocation to prevent this in the
first place; `VINTNER_TIMEOUT` is the backstop for whatever else might
wedge under Wine that isn't MSBuild-specific.
```bash
VINTNER_TIMEOUT=30m msbuild MyProject.sln
```
## Language
CLI text (usage, progress lines, prompts) defaults to English. Set
+35 -8
View File
@@ -1,6 +1,11 @@
package main
import "fmt"
import (
"fmt"
"strings"
"github.com/Cheviiot/vintner/internal/wrapper"
)
// runCompletion prints a shell completion script for shell ("bash" or
// "zsh") to stdout, meant to be sourced directly:
@@ -11,6 +16,11 @@ import "fmt"
// The flag lists below are hand-maintained alongside download.go/env.go's
// flag.FlagSet definitions rather than generated from them - there's no
// reflection-friendly registry to walk, and the flag set rarely changes.
// The tool name list isn't hand-maintained, though (see wrapper.ToolNames):
// a hand-copied one already went stale once already for a flag
// (--with-dxsdk missing from here after being added to download.go), and a
// list of every wrapped tool has more entries and changes for the same
// reasons the flag lists do, so it's worth generating for real.
func runCompletion(args []string) int {
if len(args) != 1 {
fmt.Println("usage: vintner completion bash|zsh")
@@ -18,10 +28,10 @@ func runCompletion(args []string) int {
}
switch args[0] {
case "bash":
fmt.Print(bashCompletionScript)
fmt.Print(bashCompletionScript())
return 0
case "zsh":
fmt.Print(zshCompletionScript)
fmt.Print(zshCompletionScript())
return 0
default:
fmt.Printf("vintner completion: unsupported shell %q (want bash or zsh)\n", args[0])
@@ -33,9 +43,16 @@ const downloadFlags = "--dest --cache --major --preview --manifest --accept-lice
"--msvc-version --sdk-version --host-arch --only-host --language " +
"--include-optional --skip-recommended --only-download --only-unpack " +
"--keep-unpack --skip-patch --list-workloads --list-components " +
"--print-deps-tree --with-wdk --architecture --ignore -h --help"
"--print-deps-tree --with-wdk --with-dxsdk --architecture --ignore -h --help"
var bashCompletionScript = `# vintner bash completion - eval "$(vintner completion bash)"
// subcommandNames lists vintner's own management subcommands (long form
// plus every short alias) - unlike the wrapped-tool names, these really are
// fixed enough to hand-maintain: adding one is rare and always touches
// main.go's dispatch switch right next to this file anyway.
const subcommandNames = "download dl install i env e version v doctor help h completion"
func bashCompletionScript() string {
return `# vintner bash completion - eval "$(vintner completion bash)"
_vintner_complete() {
local cur cmd
COMPREPLY=()
@@ -43,7 +60,7 @@ _vintner_complete() {
cmd="${COMP_WORDS[1]}"
if [ "$COMP_CWORD" -eq 1 ]; then
COMPREPLY=($(compgen -W "download dl install i env e version v help h completion" -- "$cur"))
COMPREPLY=($(compgen -W "` + subcommandNames + ` ` + strings.Join(wrapper.ToolNames(), " ") + `" -- "$cur"))
return 0
fi
@@ -65,8 +82,15 @@ _vintner_complete() {
}
complete -F _vintner_complete vintner
`
}
var zshCompletionScript = `#compdef vintner
func zshCompletionScript() string {
var toolEntries strings.Builder
for _, name := range wrapper.ToolNames() {
fmt.Fprintf(&toolEntries, " %q\n", name+":run this tool directly, e.g. \"vintner "+name+" ...\"")
}
return `#compdef vintner
# vintner zsh completion - source <(vintner completion zsh)
_vintner() {
@@ -80,10 +104,11 @@ _vintner() {
'e:alias for env'
'version:print the version'
'v:alias for version'
'doctor:check wine/toolchain setup'
'help:print usage'
'h:alias for help'
'completion:print a shell completion script'
)
` + toolEntries.String() + ` )
if (( CURRENT == 2 )); then
_describe 'command' subcommands
@@ -115,6 +140,7 @@ _vintner() {
'--list-components[list available components and exit]'
'--print-deps-tree[print the dependency tree and exit]'
'--with-wdk[also fetch the Windows Driver Kit]'
'--with-dxsdk[also fetch the DirectX SDK]'
'--architecture[target architecture]:arch:(x86 x64 arm arm64 host)'
'--ignore[package id to skip]:package id:'
'-h[show help]'
@@ -138,3 +164,4 @@ _vintner() {
_vintner "$@"
`
}
+22 -2
View File
@@ -4,6 +4,8 @@ import (
"os/exec"
"strings"
"testing"
"github.com/Cheviiot/vintner/internal/wrapper"
)
// TestCompletionScriptsAreSyntacticallyValid catches the easy way to break
@@ -15,8 +17,8 @@ func TestCompletionScriptsAreSyntacticallyValid(t *testing.T) {
shell string
script string
}{
{"bash", bashCompletionScript},
{"zsh", zshCompletionScript},
{"bash", bashCompletionScript()},
{"zsh", zshCompletionScript()},
} {
t.Run(tc.shell, func(t *testing.T) {
if _, err := exec.LookPath(tc.shell); err != nil {
@@ -31,6 +33,24 @@ func TestCompletionScriptsAreSyntacticallyValid(t *testing.T) {
}
}
// TestCompletionScriptsListEveryTool guards against the exact staleness bug
// found and fixed alongside this test: a hand-copied tool/flag list here
// drifting from the real set in internal/wrapper (or download.go's flags)
// as tools/flags get added. Every current tool name must appear in both
// generated scripts.
func TestCompletionScriptsListEveryTool(t *testing.T) {
bash := bashCompletionScript()
zsh := zshCompletionScript()
for _, name := range wrapper.ToolNames() {
if !strings.Contains(bash, name) {
t.Errorf("bash completion script doesn't mention tool %q", name)
}
if !strings.Contains(zsh, name+":") {
t.Errorf("zsh completion script doesn't mention tool %q", name)
}
}
}
func TestRunCompletionUnknownShell(t *testing.T) {
if code := runCompletion([]string{"fish"}); code != 1 {
t.Errorf("runCompletion([\"fish\"]) = %d, want 1", code)
+176
View File
@@ -0,0 +1,176 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/Cheviiot/vintner/internal/i18n"
"github.com/Cheviiot/vintner/internal/wineenv"
)
// runDoctor checks the pieces vintner actually needs at runtime - wine
// itself, the optional extraction tools download needs, and every
// installed <dest>/bin/<arch> toolchain's on-disk layout - and prints a
// pass/fail checklist. The point is surfacing a broken setup as a short,
// readable report instead of a wine-specific error buried deep inside a
// build (see internal/wineenv.FindWine's own error, which this reuses).
func runDoctor(args []string) int {
if len(args) > 0 && (args[0] == "-h" || args[0] == "--help") {
fmt.Fprintln(os.Stderr, i18n.T("doctor.usage"))
return 1
}
d := &doctorReport{}
d.checkWine()
d.checkExtractionTools()
d.checkToolchains()
if d.failed {
fmt.Println(i18n.T("doctor.summary_fail"))
return 1
}
fmt.Println(i18n.T("doctor.summary_ok"))
return 0
}
type doctorReport struct {
failed bool
}
func (d *doctorReport) ok(format string, args ...any) {
fmt.Printf(" [ok] "+format+"\n", args...)
}
func (d *doctorReport) warn(format string, args ...any) {
fmt.Printf(" [warn] "+format+"\n", args...)
}
func (d *doctorReport) fail(format string, args ...any) {
fmt.Printf(" [FAIL] "+format+"\n", args...)
d.failed = true
}
func (d *doctorReport) checkWine() {
fmt.Println(i18n.T("doctor.section_wine"))
wineBin, err := wineenv.FindWine()
if err != nil {
d.fail("%s", err)
return
}
d.ok("found: %s", wineBin)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, wineBin, "--version").Output()
if err != nil {
d.fail("%s --version failed: %v", wineBin, err)
return
}
d.ok("runs: %s", trimNewline(string(out)))
}
func (d *doctorReport) checkExtractionTools() {
fmt.Println(i18n.T("doctor.section_extract"))
if _, err := exec.LookPath("msiextract"); err != nil {
d.warn(i18n.T("doctor.msitools_missing"))
} else {
d.ok("msitools: found (needed by `vintner download`)")
}
if _, err := exec.LookPath("cabextract"); err != nil {
d.warn(i18n.T("doctor.cabextract_missing"))
} else {
d.ok("cabextract: found (needed by --with-wdk/--with-dxsdk)")
}
}
func (d *doctorReport) checkToolchains() {
fmt.Println(i18n.T("doctor.section_toolchain"))
if binDir := os.Getenv("VINTNER_BIN"); binDir != "" {
d.checkToolchainAt(binDir, "VINTNER_BIN="+binDir)
return
}
def, err := defaultToolchainDir()
if err != nil {
d.fail("%s", err)
return
}
destBin := filepath.Join(def, "bin")
archDirs := installedArchDirs(destBin)
if len(archDirs) == 0 {
d.fail(i18n.T("doctor.no_toolchain", destBin))
return
}
for _, arch := range archDirs {
d.checkToolchainAt(filepath.Join(destBin, arch), arch)
}
}
// installedArchDirs returns the subdirectories of destBin that carry their
// own env.json, i.e. every architecture `vintner install` actually set up
// (there can be more than one - e.g. x86 and x64 side by side).
func installedArchDirs(destBin string) []string {
entries, err := os.ReadDir(destBin)
if err != nil {
return nil
}
var dirs []string
for _, e := range entries {
if !e.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(destBin, e.Name(), wineenv.ConfigFileName)); err == nil {
dirs = append(dirs, e.Name())
}
}
return dirs
}
func (d *doctorReport) checkToolchainAt(binDir, label string) {
cfg, err := wineenv.Load(binDir)
if err != nil {
d.fail("%s: %s", label, err)
return
}
baseUnix, err := wineenv.FindBaseUnix(binDir)
if err != nil {
d.fail("%s: %s", label, err)
return
}
d.ok("%s: MSVC %s, SDK %s, root %s", label, cfg.MSVCVer, cfg.SDKVer, baseUnix)
paths := wineenv.NewPaths(cfg, baseUnix)
for _, dir := range []struct{ name, path string }{
{"MSVC bin", paths.BinDir},
{"SDK bin", paths.SDKBinDir},
{"MSBuild bin", paths.MSBuildBinDir},
} {
if fi, err := os.Stat(dir.path); err != nil || !fi.IsDir() {
d.fail("%s: %s missing: %s", label, dir.name, dir.path)
} else {
d.ok("%s: %s: %s", label, dir.name, dir.path)
}
}
relay := filepath.Join(baseUnix, "bin", "toolrelay.exe")
if fi, err := os.Stat(relay); err != nil || fi.IsDir() {
d.warn("%s: toolrelay.exe not built (mt.exe's CMake exit-code translation won't apply; re-run `vintner install` to retry)", label)
} else {
d.ok("%s: toolrelay.exe: %s", label, relay)
}
}
func trimNewline(s string) string {
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
s = s[:len(s)-1]
}
return s
}
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"os"
"path/filepath"
"testing"
"github.com/Cheviiot/vintner/internal/wineenv"
)
func TestRunDoctorUsage(t *testing.T) {
for _, flag := range []string{"-h", "--help"} {
if code := runDoctor([]string{flag}); code != 1 {
t.Errorf("runDoctor([%q]) = %d, want 1", flag, code)
}
}
}
func TestDoctorReportOkWarnFail(t *testing.T) {
d := &doctorReport{}
d.ok("fine")
d.warn("meh")
if d.failed {
t.Fatal("ok/warn must not mark the report as failed")
}
d.fail("broken")
if !d.failed {
t.Fatal("fail must mark the report as failed")
}
}
func TestInstalledArchDirsFindsOnlyDirsWithEnvJSON(t *testing.T) {
destBin := t.TempDir()
for _, dir := range []string{"x64", "x86", "not-a-toolchain"} {
if err := os.MkdirAll(filepath.Join(destBin, dir), 0o755); err != nil {
t.Fatal(err)
}
}
for _, dir := range []string{"x64", "x86"} {
if err := os.WriteFile(filepath.Join(destBin, dir, wineenv.ConfigFileName), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
}
got := installedArchDirs(destBin)
want := map[string]bool{"x64": true, "x86": true}
if len(got) != len(want) {
t.Fatalf("installedArchDirs = %v, want exactly %v", got, want)
}
for _, arch := range got {
if !want[arch] {
t.Errorf("installedArchDirs returned unexpected entry %q", arch)
}
}
}
func TestInstalledArchDirsMissingDir(t *testing.T) {
if got := installedArchDirs(filepath.Join(t.TempDir(), "does-not-exist")); got != nil {
t.Errorf("installedArchDirs on a missing dir = %v, want nil", got)
}
}
func TestCheckToolchainAtMissingEnvJSON(t *testing.T) {
d := &doctorReport{}
d.checkToolchainAt(t.TempDir(), "test")
if !d.failed {
t.Error("checkToolchainAt against a dir with no env.json should fail the report")
}
}
func TestTrimNewline(t *testing.T) {
cases := map[string]string{
"wine-9.0\n": "wine-9.0",
"wine-9.0\r\n": "wine-9.0",
"wine-9.0": "wine-9.0",
"": "",
}
for in, want := range cases {
if got := trimNewline(in); got != want {
t.Errorf("trimNewline(%q) = %q, want %q", in, got, want)
}
}
}
+7
View File
@@ -10,6 +10,7 @@ import (
"github.com/Cheviiot/vintner/internal/download"
"github.com/Cheviiot/vintner/internal/i18n"
"github.com/Cheviiot/vintner/internal/lock"
)
func runDownload(args []string) int {
@@ -177,6 +178,12 @@ func runDownload(args []string) int {
fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1
}
unlock, err := lock.Acquire(destAbs)
if err != nil {
fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1
}
defer unlock()
unpack := destAbs
if !*onlyUnpack {
+11 -8
View File
@@ -1,9 +1,9 @@
// Command vintner cross compiles with the real MSVC toolchain on Linux
// via Wine. It's a multi-call binary that behaves as `cl`, `link`, `lib`,
// `rc`, `midl`, `mt`, `dumpbin`, `msbuild`, etc. when invoked under one of
// those names (via symlinks set up by `vintner install`), and
// otherwise exposes the `download`/`install`/`env`/`version` management
// subcommands.
// those names (via symlinks set up by `vintner install`) or as
// `vintner <tool> ...` directly (see runTool), and otherwise exposes the
// `download`/`install`/`env`/`version` management subcommands.
package main
import (
@@ -24,11 +24,8 @@ func main() {
base := filepath.Base(os.Args[0])
name := strings.TrimSuffix(strings.ToLower(base), ".exe")
if _, ok := wrapper.Tools[name]; ok {
os.Exit(wrapper.Run(name, os.Args[1:]))
}
if name == "cmd" || name == "findstr" {
os.Exit(wrapper.Run(name, os.Args[1:]))
if wrapper.IsTool(name) {
os.Exit(wrapper.Run(name, os.Args[1:], ""))
}
os.Exit(runCLI(os.Args[1:]))
@@ -40,6 +37,10 @@ func runCLI(args []string) int {
return 1
}
if wrapper.IsTool(args[0]) {
return runTool(args[0], args[1:])
}
switch args[0] {
case "download", "dl":
return runDownload(args[1:])
@@ -49,6 +50,8 @@ func runCLI(args []string) int {
return runEnv(args[1:])
case "completion":
return runCompletion(args[1:])
case "doctor":
return runDoctor(args[1:])
case "version", "v", "--version":
fmt.Println(versionString())
return 0
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"fmt"
"os"
"path/filepath"
"github.com/Cheviiot/vintner/internal/wrapper"
)
// runTool dispatches `vintner <tool> [args...]` (cl, link, msbuild, ...)
// directly, without needing <dest>/bin/<arch> on PATH or a same-directory
// symlink pointing back at this binary.
//
// Resolves which toolchain bin dir to use from VINTNER_BIN if set (same
// meaning as `env --bin`: point it directly at a <dest>/bin/<arch>
// directory - useful for a non-default --dest, or to pick a specific
// architecture when more than one is installed), else defaults to
// <defaultToolchainDir>/bin/<hostArch>, the layout a plain `vintner
// download && vintner install` with no --dest override produces.
func runTool(tool string, args []string) int {
binDir := os.Getenv("VINTNER_BIN")
if binDir == "" {
def, err := defaultToolchainDir()
if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err)
return 1
}
binDir = filepath.Join(def, "bin", detectHostArch())
}
if fi, err := os.Stat(binDir); err != nil || !fi.IsDir() {
fmt.Fprintf(os.Stderr,
"vintner: no installed toolchain found at %s\n"+
"Run `vintner download --accept-license && vintner install` first, "+
"or set VINTNER_BIN to an existing <dest>/bin/<arch> directory.\n",
binDir)
return 1
}
return wrapper.Run(tool, args, binDir)
}
+24
View File
@@ -0,0 +1,24 @@
package main
import (
"path/filepath"
"testing"
)
func TestRunToolReportsMissingToolchain(t *testing.T) {
t.Setenv("VINTNER_BIN", filepath.Join(t.TempDir(), "does-not-exist"))
if code := runTool("cl", nil); code != 1 {
t.Errorf("runTool with a nonexistent VINTNER_BIN = %d, want 1", code)
}
}
func TestRunToolUsesVINTNERBinOverDefault(t *testing.T) {
// A directory that exists but has no env.json - past the "toolchain
// found at all" check, into wrapper.Run's own (already-tested)
// env.json-loading error path. Confirms VINTNER_BIN is actually being
// read and passed through, without needing a full fake toolchain.
t.Setenv("VINTNER_BIN", t.TempDir())
if code := runTool("cl", nil); code != 1 {
t.Errorf("runTool with an empty VINTNER_BIN dir = %d, want 1 (from the missing env.json)", code)
}
}
+42 -6
View File
@@ -149,27 +149,63 @@ func tryDownloadPayload(payload Payload, dest, fileID string, allowHashMismatch
var downloadHTTPClient = &http.Client{Timeout: 30 * time.Minute}
// httpDownloadFile downloads url to dest via a dest+".part" temp file,
// resuming from wherever a previous attempt left off if one exists - MSVC/
// WinSDK/WDK/DXSDK payloads run into the hundreds of MB to multiple GB, so
// restarting an interrupted download from byte 0 (a dropped connection, a
// retry after this same function returned an error) wastes real time and
// bandwidth on a flaky connection. Requests a byte Range starting at the
// existing .part file's size, if any; a server that doesn't honor Range
// (responds 200 instead of 206) gets treated as sending the whole file
// again from byte 0, so the .part is truncated and started over rather
// than getting byte-0 content appended onto existing bytes.
func httpDownloadFile(url, dest string) error {
resp, err := downloadHTTPClient.Get(url)
tmp := dest + ".part"
var offset int64
if fi, err := os.Stat(tmp); err == nil {
offset = fi.Size()
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
if offset > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset))
}
resp, err := downloadHTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var out *os.File
switch resp.StatusCode {
case http.StatusOK:
// No partial-content support (or nothing to resume from): the body
// is the whole file from byte 0.
out, err = os.Create(tmp)
case http.StatusPartialContent:
out, err = os.OpenFile(tmp, os.O_WRONLY|os.O_APPEND, 0o644)
case http.StatusRequestedRangeNotSatisfiable:
// Our .part is already >= the real file size - stale or corrupt.
// Discard it; the next retry starts clean with no Range header.
os.Remove(tmp)
return fmt.Errorf("GET %s: range not satisfiable, discarding partial download and retrying from scratch", url)
default:
return fmt.Errorf("GET %s: %s", url, resp.Status)
}
tmp := dest + ".part"
out, err := os.Create(tmp)
if err != nil {
return err
}
if _, err := io.Copy(out, resp.Body); err != nil {
out.Close()
os.Remove(tmp)
// Deliberately not removing tmp here: whatever bytes made it to
// disk are exactly what the next attempt should resume from.
return err
}
if err := out.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, dest)
+173
View File
@@ -0,0 +1,173 @@
package download
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
func TestHTTPDownloadFileFullDownload(t *testing.T) {
const body = "the quick brown fox jumps over the lazy dog"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, body)
}))
defer srv.Close()
dest := filepath.Join(t.TempDir(), "out")
if err := httpDownloadFile(srv.URL, dest); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if string(got) != body {
t.Errorf("downloaded content = %q, want %q", got, body)
}
}
// rangeServer serves a fixed body and honors byte-range requests, exactly
// like a real payload host (GitHub Releases, nuget.org, etc.) would.
func rangeServer(body string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rng := r.Header.Get("Range")
if rng == "" {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, body)
return
}
var start int
if _, err := fmt.Sscanf(rng, "bytes=%d-", &start); err != nil || start < 0 || start > len(body) {
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
return
}
w.Header().Set("Content-Range", "bytes "+strconv.Itoa(start)+"-"+strconv.Itoa(len(body)-1)+"/"+strconv.Itoa(len(body)))
w.WriteHeader(http.StatusPartialContent)
fmt.Fprint(w, body[start:])
}))
}
func TestHTTPDownloadFileResumesFromExistingPart(t *testing.T) {
const body = "the quick brown fox jumps over the lazy dog"
srv := rangeServer(body)
defer srv.Close()
dest := filepath.Join(t.TempDir(), "out")
partial := body[:10]
if err := os.WriteFile(dest+".part", []byte(partial), 0o644); err != nil {
t.Fatal(err)
}
if err := httpDownloadFile(srv.URL, dest); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if string(got) != body {
t.Errorf("resumed download content = %q, want %q (partial %q should have been continued, not duplicated or lost)", got, body, partial)
}
}
func TestHTTPDownloadFileRestartsWhenServerIgnoresRange(t *testing.T) {
const body = "the quick brown fox jumps over the lazy dog"
// Always answers 200 with the full body, regardless of Range - some
// servers/CDNs genuinely don't support partial content.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, body)
}))
defer srv.Close()
dest := filepath.Join(t.TempDir(), "out")
// A stale/bogus .part that must NOT end up prepended to the real
// content - if httpDownloadFile appended instead of truncating here,
// the result would start with this garbage.
if err := os.WriteFile(dest+".part", []byte("GARBAGE-FROM-A-STALE-ATTEMPT"), 0o644); err != nil {
t.Fatal(err)
}
if err := httpDownloadFile(srv.URL, dest); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if string(got) != body {
t.Errorf("content = %q, want exactly %q (no leftover garbage prepended)", got, body)
}
}
func TestHTTPDownloadFileKeepsPartOnMidTransferFailure(t *testing.T) {
const fullBody = "0123456789"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, fullBody[:5])
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
// Simulate a dropped connection partway through by closing the
// underlying connection abruptly instead of finishing the body.
hj, ok := w.(http.Hijacker)
if !ok {
return
}
conn, _, err := hj.Hijack()
if err == nil {
conn.Close()
}
}))
defer srv.Close()
dest := filepath.Join(t.TempDir(), "out")
err := httpDownloadFile(srv.URL, dest)
if err == nil {
t.Fatal("expected an error from the truncated connection")
}
partial, err := os.ReadFile(dest + ".part")
if err != nil {
t.Fatalf("expected the .part file with the bytes received so far to survive a failed download: %v", err)
}
if !strings.HasPrefix(fullBody, string(partial)) || len(partial) == 0 {
t.Errorf(".part content = %q, want a non-empty prefix of %q", partial, fullBody)
}
}
func TestHTTPDownloadFileRangeNotSatisfiableDiscardsPart(t *testing.T) {
const body = "short"
srv := rangeServer(body)
defer srv.Close()
dest := filepath.Join(t.TempDir(), "out")
// .part is already longer than the real file - triggers 416 from
// rangeServer's own bounds check.
if err := os.WriteFile(dest+".part", []byte("this partial file is way too long"), 0o644); err != nil {
t.Fatal(err)
}
if err := httpDownloadFile(srv.URL, dest); err == nil {
t.Fatal("expected an error on the first (416) attempt")
}
if _, err := os.Stat(dest + ".part"); !os.IsNotExist(err) {
t.Error("expected the stale .part to be discarded after a 416 response")
}
// The retry (a fresh caller, no Range header since .part is gone) should
// now succeed cleanly.
if err := httpDownloadFile(srv.URL, dest); err != nil {
t.Fatalf("retry after discarding the stale .part failed: %v", err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if string(got) != body {
t.Errorf("content = %q, want %q", got, body)
}
}
+50 -4
View File
@@ -73,6 +73,7 @@ Usage:
vintner install (i) [dir] wire up wrappers for a downloaded MSVC
vintner env (e) --bin <dir/bin/arch> print INCLUDE/LIB for native clang-cl/lld-link use
vintner version (v) print the version
vintner doctor check wine/toolchain setup
vintner help (h) show this message
vintner completion bash|zsh print a shell completion script
@@ -84,7 +85,10 @@ has many, including --with-wdk, --with-dxsdk, --list-workloads,
Language: set VINTNER_LANG=ru (or LANG=ru_RU...) for Russian output.
Completion: source <(vintner completion bash) # or zsh
Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly:
Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly,
or skip PATH and run them through vintner itself - "vintner cl ...",
"vintner msbuild ...", etc. (set VINTNER_BIN to a <dir>/bin/<arch> if it's
not the default ~/.vintner/bin/<host-arch>):
cl, link, lib, ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd, findstr
`,
RU: `vintner — кросс-компиляция настоящим MSVC на Linux через Wine
@@ -95,6 +99,7 @@ Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly:
vintner install (i) [каталог] настроить обёртки для скачанного MSVC
vintner env (e) --bin <dir/bin/arch> вывести INCLUDE/LIB для clang-cl/lld-link напрямую
vintner version (v) показать версию
vintner doctor проверить настройку wine/toolchain
vintner help (h) показать эту справку
vintner completion bash|zsh вывести скрипт автодополнения для оболочки
@@ -104,9 +109,13 @@ Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly:
--dest/[каталог] по умолчанию — ~/.vintner.
Язык: установите VINTNER_LANG=en (или LANG=en_US...) для вывода на английском.
Можно не трогать PATH: "vintner cl ...", "vintner msbuild ..." и т.д. работают
напрямую (VINTNER_BIN — если каталог не стандартный ~/.vintner/bin/<hostarch>).
Автодополнение: source <(vintner completion bash) # или zsh
После установки добавьте <dir>/bin/<arch> в PATH и вызывайте инструменты напрямую:
После установки добавьте <dir>/bin/<arch> в PATH и вызывайте инструменты
напрямую, либо не трогая PATH — «vintner cl ...», «vintner msbuild ...»
и т.д.:
cl, link, lib, ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd, findstr
`,
},
@@ -124,8 +133,8 @@ Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly:
RU: "Каталог не указан, используется значение по умолчанию: %s",
},
"install.done": {
EN: "Done. Add %s to PATH to use cl, link, lib, ...",
RU: "Готово. Добавьте %s в PATH, чтобы использовать cl, link, lib и т.д.",
EN: "Done. Add %s to PATH to use cl, link, lib, ... directly, or run them as \"vintner cl\", \"vintner link\", etc. without touching PATH.",
RU: "Готово. Добавьте %s в PATH, чтобы использовать cl, link, lib и т.д. напрямую, либо запускайте их как «vintner cl», «vintner link» и т.д., не трогая PATH.",
},
"env.usage": {
@@ -181,4 +190,41 @@ Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly:
EN: "Do you accept the license? Answer \"yes\" or \"no\": ",
RU: "Вы принимаете лицензию? Ответьте «yes» или «no»: ",
},
"doctor.usage": {
EN: "usage: vintner doctor",
RU: "использование: vintner doctor",
},
"doctor.section_wine": {
EN: "Wine:",
RU: "Wine:",
},
"doctor.section_extract": {
EN: "Extraction tools:",
RU: "Инструменты распаковки:",
},
"doctor.section_toolchain": {
EN: "Installed toolchain:",
RU: "Установленный набор инструментов:",
},
"doctor.msitools_missing": {
EN: "msitools: not found (install the msitools package - needed by `vintner download`)",
RU: "msitools: не найден (установите пакет msitools — нужен для `vintner download`)",
},
"doctor.cabextract_missing": {
EN: "cabextract: not found (install the cabextract package - only needed for --with-wdk/--with-dxsdk)",
RU: "cabextract: не найден (установите пакет cabextract — нужен только для --with-wdk/--with-dxsdk)",
},
"doctor.no_toolchain": {
EN: "no installed toolchain found under %s (run `vintner download --accept-license && vintner install` first, or set VINTNER_BIN)",
RU: "установленный набор инструментов не найден в %s (сначала выполните `vintner download --accept-license && vintner install`, либо задайте VINTNER_BIN)",
},
"doctor.summary_ok": {
EN: "\nAll checks passed.",
RU: "\nВсе проверки пройдены.",
},
"doctor.summary_fail": {
EN: "\nSome checks failed - see [FAIL] lines above.",
RU: "\nНекоторые проверки не пройдены — см. строки [FAIL] выше.",
},
}
+7
View File
@@ -15,6 +15,7 @@ import (
"strings"
"github.com/Cheviiot/vintner/assets"
"github.com/Cheviiot/vintner/internal/lock"
"github.com/Cheviiot/vintner/internal/wineenv"
)
@@ -34,6 +35,12 @@ func Install(dest, selfBinary string) error {
return fmt.Errorf("destination %q is not a directory", dest)
}
unlock, err := lock.Acquire(dest)
if err != nil {
return err
}
defer unlock()
// Targets are relative so the whole installed tree stays relocatable -
// moving or renaming dest doesn't break these symlinks the way an
// absolute target baked in at install time would.
+49
View File
@@ -0,0 +1,49 @@
// Package lock guards a destination directory against two `vintner
// download`/`install` runs mutating it at the same time.
package lock
import (
"fmt"
"os"
"path/filepath"
"syscall"
)
// FileName is the advisory lock file `download` and `install` both take
// out against their (usually shared) destination directory before
// touching anything in it - concurrent download+install, or two
// downloads, against the same dest could otherwise interleave badly:
// combineDirTrees' merge logic assumes it's the only thing moving files
// into a given target at a time, and two `os.Rename` calls racing for the
// same destination path is exactly the kind of thing that corrupts a tree
// instead of erroring cleanly.
const FileName = ".vintner.lock"
// Acquire takes an exclusive, non-blocking lock on dest (creating dest if
// it doesn't exist yet) and returns a func to release it, which the caller
// must defer. If another vintner process already holds the lock, returns
// an error immediately instead of blocking - there's no reason a second
// invocation should silently queue up and wait for the first to finish
// touching the same directory; the caller should simply not have started
// it yet. Uses flock(2), so a crashed holder's lock is released
// automatically by the kernel when its file descriptor closes - never
// needs manual cleanup, unlike a plain "does a file exist" lock
// convention would.
func Acquire(dest string) (unlock func(), err error) {
if err := os.MkdirAll(dest, 0o755); err != nil {
return nil, err
}
path := filepath.Join(dest, FileName)
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
f.Close()
return nil, fmt.Errorf("another vintner download/install is already running against %s", dest)
}
return func() {
syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
f.Close()
}, nil
}
+124
View File
@@ -0,0 +1,124 @@
package lock
import (
"io"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
func TestAcquireAndRelease(t *testing.T) {
dest := t.TempDir()
unlock, err := Acquire(dest)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dest, FileName)); err != nil {
t.Errorf("expected the lock file to exist while held: %v", err)
}
unlock()
// Released - a second Acquire against the same dest must now succeed.
unlock2, err := Acquire(dest)
if err != nil {
t.Fatalf("Acquire after release failed: %v", err)
}
unlock2()
}
func TestAcquireCreatesDestIfMissing(t *testing.T) {
dest := filepath.Join(t.TempDir(), "does", "not", "exist", "yet")
unlock, err := Acquire(dest)
if err != nil {
t.Fatal(err)
}
defer unlock()
if fi, err := os.Stat(dest); err != nil || !fi.IsDir() {
t.Errorf("expected Acquire to create %s, stat err: %v", dest, err)
}
}
func TestAcquireFailsWhileAlreadyHeld(t *testing.T) {
dest := t.TempDir()
unlock, err := Acquire(dest)
if err != nil {
t.Fatal(err)
}
defer unlock()
if _, err := Acquire(dest); err == nil {
t.Fatal("expected a second Acquire against the same dest, while the first is still held, to fail")
}
}
func TestAcquireSucceedsAfterHolderReleases(t *testing.T) {
dest := t.TempDir()
unlock1, err := Acquire(dest)
if err != nil {
t.Fatal(err)
}
unlock1()
unlock2, err := Acquire(dest)
if err != nil {
t.Fatalf("Acquire should succeed once the first holder released: %v", err)
}
unlock2()
}
// TestAcquireFailsAcrossRealProcesses is the real end-to-end check: flock
// is per-open-file-description, not per-process or per-thread, so a lock
// held by *this* test process via one fd could in principle still be
// re-acquirable by another fd in the same process depending on the
// platform's exact semantics. Spawning this test binary as a genuinely
// separate child process (via the standard TestMain re-exec trick) and
// having it hold the lock while the parent tries to acquire it is what
// actually proves two independent `vintner download`/`install` processes
// contend correctly, not just two Go-level calls in one process.
func TestAcquireFailsAcrossRealProcesses(t *testing.T) {
if os.Getenv("VINTNER_LOCK_TEST_HOLD") != "" {
unlock, err := Acquire(os.Getenv("VINTNER_LOCK_TEST_HOLD"))
if err != nil {
os.Exit(2)
}
defer unlock()
// Signal readiness, then wait to be killed by the parent. A plain
// `select {}` here would have zero other goroutines able to ever
// wake it, which Go's runtime provably detects as a deadlock and
// crashes on ("fatal error: all goroutines are asleep") - a real
// timer avoids that.
os.Stdout.WriteString("locked\n")
time.Sleep(time.Minute)
}
dest := t.TempDir()
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
cmd := exec.Command(exe, "-test.run=TestAcquireFailsAcrossRealProcesses")
cmd.Env = append(os.Environ(), "VINTNER_LOCK_TEST_HOLD="+dest)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
defer cmd.Process.Kill()
buf := make([]byte, len("locked\n"))
if _, err := io.ReadFull(stdout, buf); err != nil || string(buf) != "locked\n" {
t.Fatalf("child process didn't report holding the lock: %v (%q)", err, buf)
}
if _, err := Acquire(dest); err == nil {
t.Fatal("expected Acquire to fail while a separate process holds the lock")
}
}
+1 -1
View File
@@ -14,5 +14,5 @@ func FindWine() (string, error) {
if p, err := exec.LookPath("wine"); err == nil {
return p, nil
}
return "", fmt.Errorf("neither wine64 nor wine found in PATH")
return "", fmt.Errorf("neither wine64 nor wine found in PATH (install the wine package)")
}
+60
View File
@@ -0,0 +1,60 @@
package wineenv
import (
"os"
"path/filepath"
"strings"
"testing"
)
func fakeBinary(t *testing.T, name string) {
t.Helper()
bin := t.TempDir()
path := filepath.Join(bin, name)
if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin)
}
func TestFindWinePrefersWine64(t *testing.T) {
bin := t.TempDir()
for _, name := range []string{"wine64", "wine"} {
if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("PATH", bin)
got, err := FindWine()
if err != nil {
t.Fatal(err)
}
if filepath.Base(got) != "wine64" {
t.Errorf("FindWine() = %q, want wine64 to be preferred over wine", got)
}
}
func TestFindWineFallsBackToWine(t *testing.T) {
fakeBinary(t, "wine")
got, err := FindWine()
if err != nil {
t.Fatal(err)
}
if filepath.Base(got) != "wine" {
t.Errorf("FindWine() = %q, want wine", got)
}
}
func TestFindWineErrorIsActionable(t *testing.T) {
t.Setenv("PATH", t.TempDir()) // empty dir, neither binary present
_, err := FindWine()
if err == nil {
t.Fatal("expected an error when neither wine64 nor wine is on PATH")
}
if !strings.Contains(err.Error(), "install") {
t.Errorf("FindWine() error = %q, want it to say what to install (matching msiextract/cabextract's error style)", err)
}
}
+33
View File
@@ -218,6 +218,39 @@ func msbuildGlobalArgs(cfg *wineenv.Config, args []string) []string {
return out
}
var reNodeReuse = regexp.MustCompile(`(?i)^[-/](nodereuse|nr):`)
// msbuildNodeReuseArgs returns ["/nodeReuse:false"] unless args already pins
// node reuse one way or the other.
//
// MSBuild's node-reuse worker processes (its own /nodeReuse:true default)
// don't behave like a normal child process here: they're meant to outlive
// the parent msbuild.exe invocation that spawned them, waiting around under
// Wine for the *next* msbuild call to reuse them - so nothing about
// vintner's own process-lifetime handling (see signals.go) touches them,
// and there's no parent process left to notice if one wedges. If a build is
// interrupted (Ctrl-C, a killed session, a crashed Wine transport) mid-
// compile, the worker can be left holding a half-open pipe/mutex,
// permanently deadlocked rather than exited - confirmed in practice: a
// stale reused node kept throwing an unrelated-looking
// `System.TypeLoadException` on Microsoft.VisualStudio.Telemetry on every
// subsequent build, for hours, until it was killed by hand and the next
// build got a fresh node. Forcing node reuse off means every invocation
// gets a clean process, so a wedged one can never poison a later,
// unrelated build - at the cost of the couple-hundred-ms/node startup time
// node reuse exists to save. Callers who deliberately want reuse (e.g.
// running many builds back to back and are prepared to clean up wedged
// nodes themselves) can still pass their own /nodeReuse or /nr switch to
// override this.
func msbuildNodeReuseArgs(args []string) []string {
for _, a := range args {
if reNodeReuse.MatchString(a) {
return nil
}
}
return []string{"/nodeReuse:false"}
}
func msbuildPlatform(arch string) string {
switch arch {
case "x86":
+22
View File
@@ -198,3 +198,25 @@ func TestMsbuildEnvPreferredToolArchitecture(t *testing.T) {
t.Errorf(`with DotnetHost=arm64, PreferredToolArchitecture = %q, want unset`, env["PreferredToolArchitecture"])
}
}
func TestMsbuildNodeReuseArgsForcesOffByDefault(t *testing.T) {
got := msbuildNodeReuseArgs([]string{"Foo.sln", "/p:Configuration=Release"})
want := []string{"/nodeReuse:false"}
if len(got) != 1 || got[0] != want[0] {
t.Errorf("msbuildNodeReuseArgs(...) = %v, want %v", got, want)
}
}
func TestMsbuildNodeReuseArgsRespectsExplicitOverride(t *testing.T) {
for _, explicit := range []string{
"/nodeReuse:true",
"-nodeReuse:true",
"/nr:true",
"/NODEREUSE:FALSE", // caller explicitly wanting it off too - still shouldn't double up
} {
got := msbuildNodeReuseArgs([]string{"Foo.sln", explicit})
if got != nil {
t.Errorf("msbuildNodeReuseArgs with explicit %q = %v, want nil (left alone)", explicit, got)
}
}
}
+13 -8
View File
@@ -1,6 +1,7 @@
package wrapper
import (
"fmt"
"os"
"os/exec"
)
@@ -25,17 +26,21 @@ func execInherit(args []string) int {
if len(args) == 0 {
return 0
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
setNewProcessGroup(cmd)
if err := cmd.Start(); err != nil {
tc, cleanup := newToolCommand(args[0], args[1:]...)
defer cleanup()
tc.Stdin = os.Stdin
tc.Stdout = os.Stdout
tc.Stderr = os.Stderr
if err := tc.Start(); err != nil {
return 127
}
stopSignals := forwardSignals(cmd.Process)
stopSignals := forwardSignals(tc.Process)
defer stopSignals()
if err := cmd.Wait(); err != nil {
if err := tc.Wait(); err != nil {
if tc.timedOut() {
fmt.Fprintln(os.Stderr, tc.timeoutMessage())
return 124
}
if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode()
}
+70 -44
View File
@@ -30,7 +30,15 @@ const toolRelayName = "toolrelay.exe"
// Run executes the named multi-call tool with args, exactly as the original
// bash wrappers would, and returns the process exit code.
func Run(tool string, args []string) int {
//
// binDir is the <dest>/bin/<arch> directory holding env.json for this
// invocation. Pass "" for the ordinary multi-call case (invoked as `cl`,
// `link`, etc. via a same-directory symlink) to have it resolved from the
// running binary's own location; a non-empty value is for `vintner <tool>
// ...` direct dispatch (see cmd/vintner's runTool), which isn't running
// from inside any particular <dest>/bin/<arch> and so has nothing to
// resolve on its own.
func Run(tool string, args []string, binDir string) int {
if nativeTools[tool] {
return runNative(tool, args)
}
@@ -41,19 +49,23 @@ func Run(tool string, args []string) int {
return 127
}
// os.Executable() (backed by /proc/self/exe on Linux) fully resolves
// symlinks, unlike os.Args[0]: not every shell passes a PATH-resolved
// absolute path as argv[0] (some just pass the bare command name), which
// would make an argv[0]-based lookup resolve against the caller's cwd
// instead of the actual install dir. `install` sets each arch dir up
// with its own local copy of the binary precisely so this resolves to
// <dest>/bin/<arch>, not <dest>/bin.
exePath, err := os.Executable()
if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err)
return 1
scriptDir := binDir
if scriptDir == "" {
// os.Executable() (backed by /proc/self/exe on Linux) fully resolves
// symlinks, unlike os.Args[0]: not every shell passes a
// PATH-resolved absolute path as argv[0] (some just pass the bare
// command name), which would make an argv[0]-based lookup resolve
// against the caller's cwd instead of the actual install dir.
// `install` sets each arch dir up with its own local copy of the
// binary precisely so this resolves to <dest>/bin/<arch>, not
// <dest>/bin.
exePath, err := os.Executable()
if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err)
return 1
}
scriptDir = filepath.Dir(exePath)
}
scriptDir := filepath.Dir(exePath)
cfg, err := wineenv.Load(scriptDir)
if err != nil {
@@ -84,27 +96,29 @@ func Run(tool string, args []string) int {
// read as-is), and add the extra environment MSBuild's own
// toolset/SDK-detection props need on top of the generic
// INCLUDE/LIB/WINEPATH, plus any global properties a project file
// itself could otherwise override (see msbuildGlobalArgs).
// itself could otherwise override (see msbuildGlobalArgs) and a
// forced /nodeReuse:false (see msbuildNodeReuseArgs).
msArgs := append(msbuildGlobalArgs(cfg, rewritten), rewritten...)
cmd := exec.Command(wineBin, append([]string{toolExePath}, msArgs...)...)
msArgs = append(msbuildNodeReuseArgs(rewritten), msArgs...)
tc, cleanup := newToolCommand(wineBin, append([]string{toolExePath}, msArgs...)...)
defer cleanup()
env := buildEnv(paths)
for k, v := range msbuildEnv(cfg, paths) {
env = append(env, k+"="+v)
}
cmd.Env = env
cmd.Stdin = os.Stdin
setNewProcessGroup(cmd)
exitCode = runRawStdout(cmd)
tc.Env = env
tc.Stdin = os.Stdin
exitCode = runRawStdout(tc)
default:
relay := filepath.Join(paths.BaseUnix, "bin", toolRelayName)
if fi, err := os.Stat(relay); err == nil && !fi.IsDir() {
exitCode = runViaToolRelay(wineBin, relay, toolExePath, rewritten, paths, s.stdoutFilter, s.stderrFilter)
} else {
cmd := exec.Command(wineBin, append([]string{toolExePath}, rewritten...)...)
cmd.Env = buildEnv(paths)
cmd.Stdin = os.Stdin
setNewProcessGroup(cmd)
exitCode = runFiltered(cmd, s.stdoutFilter, s.stderrFilter)
tc, cleanup := newToolCommand(wineBin, append([]string{toolExePath}, rewritten...)...)
defer cleanup()
tc.Env = buildEnv(paths)
tc.Stdin = os.Stdin
exitCode = runFiltered(tc, s.stdoutFilter, s.stderrFilter)
}
}
@@ -139,20 +153,20 @@ func runViaToolRelay(wineBin, relayExe, exePath string, args []string, paths *wi
defer os.Remove(stderrFifo)
cmdArgs := append([]string{relayExe, exePath}, args...)
cmd := exec.Command(wineBin, cmdArgs...)
cmd.Env = append(buildEnv(paths), "MSVCGOWINE_STDOUT="+stdoutFifo, "MSVCGOWINE_STDERR="+stderrFifo)
setNewProcessGroup(cmd)
tc, cleanup := newToolCommand(wineBin, cmdArgs...)
defer cleanup()
tc.Env = append(buildEnv(paths), "MSVCGOWINE_STDOUT="+stdoutFifo, "MSVCGOWINE_STDERR="+stderrFifo)
if devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0); err == nil {
defer devNull.Close()
cmd.Stdout = devNull
cmd.Stderr = devNull
tc.Stdout = devNull
tc.Stderr = devNull
}
if err := cmd.Start(); err != nil {
if err := tc.Start(); err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err)
return 1
}
stopSignals := forwardSignals(cmd.Process)
stopSignals := forwardSignals(tc.Process)
defer stopSignals()
var wg sync.WaitGroup
@@ -176,10 +190,14 @@ func runViaToolRelay(wineBin, relayExe, exePath string, args []string, paths *wi
pumpLines(f, os.Stderr, stderrF)
}()
err := cmd.Wait()
err := tc.Wait()
wg.Wait()
if err != nil {
if tc.timedOut() {
fmt.Fprintln(os.Stderr, tc.timeoutMessage())
return 124
}
if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode()
}
@@ -195,23 +213,23 @@ func runViaToolRelay(wineBin, relayExe, exePath string, args []string, paths *wi
// own copy goroutines - not the caller's terminal or pipe - are exposed to
// Wine's background processes holding those descriptors open; see
// pipeDrainGrace.
func runRawStdout(cmd *exec.Cmd) int {
stdout, err := cmd.StdoutPipe()
func runRawStdout(tc *toolCommand) int {
stdout, err := tc.StdoutPipe()
if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err)
return 1
}
stderr, err := cmd.StderrPipe()
stderr, err := tc.StderrPipe()
if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err)
return 1
}
if err := cmd.Start(); err != nil {
if err := tc.Start(); err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err)
return 1
}
stopSignals := forwardSignals(cmd.Process)
stopSignals := forwardSignals(tc.Process)
defer stopSignals()
doneOut := make(chan struct{})
@@ -219,11 +237,15 @@ func runRawStdout(cmd *exec.Cmd) int {
go func() { io.Copy(os.Stdout, stdout); close(doneOut) }()
go func() { io.Copy(os.Stderr, stderr); close(doneErr) }()
err = cmd.Wait()
err = tc.Wait()
drain(doneOut)
drain(doneErr)
if err != nil {
if tc.timedOut() {
fmt.Fprintln(os.Stderr, tc.timeoutMessage())
return 124
}
if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode()
}
@@ -271,23 +293,23 @@ func buildEnv(p *wineenv.Paths) []string {
// runFiltered streams stdout/stderr line by line through the tool's
// filters (CR-stripping always applied first), then waits for completion.
func runFiltered(cmd *exec.Cmd, stdoutF, stderrF lineFilter) int {
stdout, err := cmd.StdoutPipe()
func runFiltered(tc *toolCommand, stdoutF, stderrF lineFilter) int {
stdout, err := tc.StdoutPipe()
if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err)
return 1
}
stderr, err := cmd.StderrPipe()
stderr, err := tc.StderrPipe()
if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err)
return 1
}
if err := cmd.Start(); err != nil {
if err := tc.Start(); err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err)
return 1
}
stopSignals := forwardSignals(cmd.Process)
stopSignals := forwardSignals(tc.Process)
defer stopSignals()
doneOut := make(chan struct{})
@@ -295,11 +317,15 @@ func runFiltered(cmd *exec.Cmd, stdoutF, stderrF lineFilter) int {
go func() { pumpLines(stdout, os.Stdout, stdoutF); close(doneOut) }()
go func() { pumpLines(stderr, os.Stderr, stderrF); close(doneErr) }()
err = cmd.Wait()
err = tc.Wait()
drain(doneOut)
drain(doneErr)
if err != nil {
if tc.timedOut() {
fmt.Fprintln(os.Stderr, tc.timeoutMessage())
return 124
}
if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode()
}
+97
View File
@@ -0,0 +1,97 @@
package wrapper
import (
"context"
"fmt"
"os"
"os/exec"
"syscall"
"time"
)
// waitDelay bounds how long cmd.Wait() itself may block after the process
// group has already been told to die (by a timeout or a forwarded signal) -
// usually resolved immediately, but a wedged Wine transport is exactly the
// case that isn't guaranteed to notice a plain SIGKILL right away.
const waitDelay = 5 * time.Second
// commandTimeout returns how long a single tool invocation may run before
// vintner kills it and reports a timeout, from VINTNER_TIMEOUT (a
// time.ParseDuration string, e.g. "30m", "2h"). Unset, empty, or invalid
// all mean "no timeout" (0) - the default stays a plain, unbounded build,
// matching every real build observed so far (Ogre3D's from-scratch build
// alone ran several minutes). This exists for exactly one failure mode: a
// wedged Wine-hosted process (a corrupted MSBuild node-reuse worker in the
// one confirmed case so far, but nothing about the mechanism is
// MSBuild-specific) that will otherwise never exit on its own, hanging
// vintner - and whatever's waiting on vintner - forever with no feedback.
// Automated/scripted callers that would rather fail loudly after N minutes
// than risk hanging indefinitely can set this; interactive use is
// unaffected unless it's set.
func commandTimeout() time.Duration {
v := os.Getenv("VINTNER_TIMEOUT")
if v == "" {
return 0
}
d, err := time.ParseDuration(v)
if err != nil || d <= 0 {
return 0
}
return d
}
// toolCommand wraps the exec.Cmd every wine-hosted tool invocation is built
// from, plus enough state to tell a VINTNER_TIMEOUT kill apart from every
// other failure once Wait() returns.
type toolCommand struct {
*exec.Cmd
ctx context.Context
cancel context.CancelFunc
timeout time.Duration // 0 if VINTNER_TIMEOUT wasn't set
}
// newToolCommand builds a toolCommand: its own process group
// (setNewProcessGroup) and, when VINTNER_TIMEOUT is set, a deadline that
// kills the *whole group* - not just the immediate `wine` process, since a
// wedged Wine-hosted child surviving past its parent is exactly the
// scenario this needs to reach - if the tool hasn't finished in time.
//
// Callers must defer the returned cleanup func, and should call
// timedOut() after Wait() returns to tell a timeout-triggered kill apart
// from every other failure.
func newToolCommand(name string, args ...string) (tc *toolCommand, cleanup func()) {
timeout := commandTimeout()
if timeout <= 0 {
cmd := exec.Command(name, args...)
setNewProcessGroup(cmd)
return &toolCommand{Cmd: cmd, ctx: context.Background()}, func() {}
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
cmd := exec.CommandContext(ctx, name, args...)
setNewProcessGroup(cmd)
// cmd.Cancel's default (Go 1.20+) only signals the immediate child;
// override it to reach the whole process group, same as
// forwardSignals - the wedged process a timeout exists to clean up is
// typically under wine, not wine itself.
cmd.Cancel = func() error {
if cmd.Process == nil {
return nil
}
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
cmd.WaitDelay = waitDelay
tc = &toolCommand{Cmd: cmd, ctx: ctx, cancel: cancel, timeout: timeout}
return tc, cancel
}
// timedOut reports whether this command was killed by its own
// VINTNER_TIMEOUT deadline rather than exiting (however it exited) on its
// own - call after Wait() returns a non-nil error.
func (tc *toolCommand) timedOut() bool {
return tc.ctx.Err() == context.DeadlineExceeded
}
func (tc *toolCommand) timeoutMessage() string {
return fmt.Sprintf("vintner: %s: timed out after %s (VINTNER_TIMEOUT), killed", tc.Path, tc.timeout)
}
+82
View File
@@ -0,0 +1,82 @@
package wrapper
import (
"syscall"
"testing"
"time"
)
func TestCommandTimeout(t *testing.T) {
for _, tc := range []struct {
name string
env string
want time.Duration
}{
{"unset", "", 0},
{"valid", "30m", 30 * time.Minute},
{"invalid unit-less number", "30", 0},
{"zero", "0s", 0},
{"negative", "-5m", 0},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("VINTNER_TIMEOUT", tc.env)
if got := commandTimeout(); got != tc.want {
t.Errorf("commandTimeout() with VINTNER_TIMEOUT=%q = %v, want %v", tc.env, got, tc.want)
}
})
}
}
// TestNewToolCommandKillsOnTimeout is the real end-to-end check: start a
// process that would otherwise run far longer than the timeout (mimicking
// a wedged Wine-hosted tool), and confirm newToolCommand's deadline
// actually kills it - not just that timedOut() would report true in
// principle, but that Wait() actually returns, promptly, with the process
// gone.
func TestNewToolCommandKillsOnTimeout(t *testing.T) {
t.Setenv("VINTNER_TIMEOUT", "300ms")
tc, cleanup := newToolCommand("sleep", "30")
defer cleanup()
if err := tc.Start(); err != nil {
t.Fatalf("starting sleep: %v", err)
}
pid := tc.Process.Pid
done := make(chan error, 1)
go func() { done <- tc.Wait() }()
select {
case err := <-done:
if err == nil {
t.Fatal("expected sleep 30 to be killed by the timeout, but it exited successfully")
}
if !tc.timedOut() {
t.Errorf("Wait() returned an error (%v) but timedOut() = false", err)
}
case <-time.After(5 * time.Second):
t.Fatal("newToolCommand's timeout did not kill the process within 5s of a 300ms deadline")
}
// Belt-and-suspenders: the process should genuinely be gone, not just
// reported as such. Signal 0 sends nothing but still fails with ESRCH
// once the pid is gone - the standard Unix way to probe existence.
if err := syscall.Kill(pid, 0); err == nil {
t.Errorf("pid %d still exists after the timeout killed it", pid)
}
}
func TestNewToolCommandNoTimeoutByDefault(t *testing.T) {
t.Setenv("VINTNER_TIMEOUT", "")
tc, cleanup := newToolCommand("true")
defer cleanup()
if err := tc.Run(); err != nil {
t.Fatalf("running `true` with no VINTNER_TIMEOUT set: %v", err)
}
if tc.timedOut() {
t.Error("timedOut() = true for a command that finished well within any reasonable time, with no timeout configured")
}
}
+31 -1
View File
@@ -4,7 +4,11 @@
// filtering its output.
package wrapper
import "github.com/Cheviiot/vintner/internal/wineenv"
import (
"sort"
"github.com/Cheviiot/vintner/internal/wineenv"
)
// dirKind selects which install directory a tool's real .exe lives in.
type dirKind int
@@ -46,6 +50,32 @@ var Tools = map[string]spec{
// nativeTools are handled entirely without Wine.
var nativeTools = map[string]bool{"cmd": true, "findstr": true}
// IsTool reports whether name is a recognized wrapped tool - either a
// Wine-hosted one in Tools or a native shim in nativeTools. Shared between
// the ordinary multi-call dispatch (invoked *as* one of these names via a
// same-directory symlink) and `vintner <tool> ...` direct dispatch, so both
// recognize exactly the same set of names.
func IsTool(name string) bool {
_, ok := Tools[name]
return ok || nativeTools[name]
}
// ToolNames returns every recognized tool name, sorted - Tools and
// nativeTools combined. Used to generate shell completion without a
// separate hand-maintained list that could drift from the real set (as
// happened once already with a stale --with-dxsdk completion entry).
func ToolNames() []string {
names := make([]string, 0, len(Tools)+len(nativeTools))
for name := range Tools {
names = append(names, name)
}
for name := range nativeTools {
names = append(names, name)
}
sort.Strings(names)
return names
}
func (s spec) exeDir(p *wineenv.Paths) string {
switch s.dir {
case dirSDK:
+18
View File
@@ -43,3 +43,21 @@ func TestEveryToolHasAnExeName(t *testing.T) {
}
}
}
func TestIsTool(t *testing.T) {
for name := range Tools {
if !IsTool(name) {
t.Errorf("IsTool(%q) = false, want true (it's in Tools)", name)
}
}
for name := range nativeTools {
if !IsTool(name) {
t.Errorf("IsTool(%q) = false, want true (it's in nativeTools)", name)
}
}
for _, name := range []string{"download", "install", "env", "version", "help", "completion", "frobnicate", ""} {
if IsTool(name) {
t.Errorf("IsTool(%q) = true, want false", name)
}
}
}