8 Commits
Author SHA1 Message Date
АлександрandGitHub 6220d26072 Update README.md 2026-07-31 19:38:18 +10:00
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 1336 additions and 87 deletions
+66 -5
View File
@@ -1,4 +1,4 @@
# vintner # Vintner
[![CI](https://github.com/Cheviiot/vintner/actions/workflows/ci.yml/badge.svg)](https://github.com/Cheviiot/vintner/actions/workflows/ci.yml) [![CI](https://github.com/Cheviiot/vintner/actions/workflows/ci.yml/badge.svg)](https://github.com/Cheviiot/vintner/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/Cheviiot/vintner)](https://github.com/Cheviiot/vintner/releases/latest) [![Release](https://img.shields.io/github/v/release/Cheviiot/vintner)](https://github.com/Cheviiot/vintner/releases/latest)
@@ -21,8 +21,11 @@ approach: download the real MSVC/WinSDK, wrap the compiler under Wine.
- [Installation](#installation) - [Installation](#installation)
- [Quick start](#quick-start) - [Quick start](#quick-start)
- [Commands](#commands) - [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 drivers (WDK)](#building-drivers-wdk)
- [Building against D3DX9 (DirectX SDK)](#building-against-d3dx9-directx-sdk) - [Building against D3DX9 (DirectX SDK)](#building-against-d3dx9-directx-sdk)
- [Automated/scripted builds](#automatedscripted-builds)
- [Language](#language) - [Language](#language)
- [Shell completion](#shell-completion) - [Shell completion](#shell-completion)
- [Using clang-cl/lld-link instead of Wine](#using-clang-cllld-link-instead-of-wine) - [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:\...` 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 paths back to Unix paths in the output, so your build system's error
parsing keeps working. parsing keeps working.
- As `vintner`: it exposes the `download`, `install`, `env`, `version` - As `vintner`: it exposes the `download`, `install`, `env`, `version`,
and `completion` subcommands below (short aliases: `dl`, `i`, `e`, `v`; `doctor` and `completion` subcommands below (short aliases: `dl`, `i`,
`help`/`h` prints usage). `e`, `v`; `help`/`h` prints usage).
## Installation ## Installation
@@ -76,11 +79,13 @@ as package dependencies.
- `msitools` (`msiextract`) — unpacks the `.msi` payloads MSVC/WinSDK ship as. - `msitools` (`msiextract`) — unpacks the `.msi` payloads MSVC/WinSDK ship as.
- `git` — applies the compatibility patches bundled with `download` (see - `git` — applies the compatibility patches bundled with `download` (see
[Compatibility patches](#compatibility-patches)). [Compatibility patches](#compatibility-patches)).
- `cabextract` — only needed for `download --with-dxsdk` (see
[Building against D3DX9](#building-against-d3dx9-directx-sdk)).
On ALT Linux: On ALT Linux:
```bash ```bash
pkcon install wine msitools git pkcon install wine msitools git cabextract
``` ```
## Quick start ## Quick start
@@ -99,6 +104,9 @@ export PATH=~/.vintner/bin/x64:$PATH
cl /nologo /EHsc hello.cpp 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 ## 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 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 env (e) --bin <dir>/bin/<arch> print INCLUDE/LIB for native clang-cl/lld-link use
vintner version (v) print the version vintner version (v) print the version
vintner doctor check wine/toolchain setup
vintner help (h) print usage vintner help (h) print usage
vintner completion bash|zsh print a shell completion script 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 dependency tree of whatever would actually be selected — honoring every
other flag — without downloading anything. 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) ## Building drivers (WDK)
`--with-wdk` also fetches the Windows Driver Kit: headers, import libs, `--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 Requires `cabextract` on `PATH` (the installer is a self-extracting CAB
archive). 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 ## Language
CLI text (usage, progress lines, prompts) defaults to English. Set CLI text (usage, progress lines, prompts) defaults to English. Set
+35 -8
View File
@@ -1,6 +1,11 @@
package main package main
import "fmt" import (
"fmt"
"strings"
"github.com/Cheviiot/vintner/internal/wrapper"
)
// runCompletion prints a shell completion script for shell ("bash" or // runCompletion prints a shell completion script for shell ("bash" or
// "zsh") to stdout, meant to be sourced directly: // "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 // The flag lists below are hand-maintained alongside download.go/env.go's
// flag.FlagSet definitions rather than generated from them - there's no // flag.FlagSet definitions rather than generated from them - there's no
// reflection-friendly registry to walk, and the flag set rarely changes. // 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 { func runCompletion(args []string) int {
if len(args) != 1 { if len(args) != 1 {
fmt.Println("usage: vintner completion bash|zsh") fmt.Println("usage: vintner completion bash|zsh")
@@ -18,10 +28,10 @@ func runCompletion(args []string) int {
} }
switch args[0] { switch args[0] {
case "bash": case "bash":
fmt.Print(bashCompletionScript) fmt.Print(bashCompletionScript())
return 0 return 0
case "zsh": case "zsh":
fmt.Print(zshCompletionScript) fmt.Print(zshCompletionScript())
return 0 return 0
default: default:
fmt.Printf("vintner completion: unsupported shell %q (want bash or zsh)\n", args[0]) 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 " + "--msvc-version --sdk-version --host-arch --only-host --language " +
"--include-optional --skip-recommended --only-download --only-unpack " + "--include-optional --skip-recommended --only-download --only-unpack " +
"--keep-unpack --skip-patch --list-workloads --list-components " + "--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() { _vintner_complete() {
local cur cmd local cur cmd
COMPREPLY=() COMPREPLY=()
@@ -43,7 +60,7 @@ _vintner_complete() {
cmd="${COMP_WORDS[1]}" cmd="${COMP_WORDS[1]}"
if [ "$COMP_CWORD" -eq 1 ]; then 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 return 0
fi fi
@@ -65,8 +82,15 @@ _vintner_complete() {
} }
complete -F _vintner_complete vintner 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 zsh completion - source <(vintner completion zsh)
_vintner() { _vintner() {
@@ -80,10 +104,11 @@ _vintner() {
'e:alias for env' 'e:alias for env'
'version:print the version' 'version:print the version'
'v:alias for version' 'v:alias for version'
'doctor:check wine/toolchain setup'
'help:print usage' 'help:print usage'
'h:alias for help' 'h:alias for help'
'completion:print a shell completion script' 'completion:print a shell completion script'
) ` + toolEntries.String() + ` )
if (( CURRENT == 2 )); then if (( CURRENT == 2 )); then
_describe 'command' subcommands _describe 'command' subcommands
@@ -115,6 +140,7 @@ _vintner() {
'--list-components[list available components and exit]' '--list-components[list available components and exit]'
'--print-deps-tree[print the dependency tree and exit]' '--print-deps-tree[print the dependency tree and exit]'
'--with-wdk[also fetch the Windows Driver Kit]' '--with-wdk[also fetch the Windows Driver Kit]'
'--with-dxsdk[also fetch the DirectX SDK]'
'--architecture[target architecture]:arch:(x86 x64 arm arm64 host)' '--architecture[target architecture]:arch:(x86 x64 arm arm64 host)'
'--ignore[package id to skip]:package id:' '--ignore[package id to skip]:package id:'
'-h[show help]' '-h[show help]'
@@ -138,3 +164,4 @@ _vintner() {
_vintner "$@" _vintner "$@"
` `
}
+22 -2
View File
@@ -4,6 +4,8 @@ import (
"os/exec" "os/exec"
"strings" "strings"
"testing" "testing"
"github.com/Cheviiot/vintner/internal/wrapper"
) )
// TestCompletionScriptsAreSyntacticallyValid catches the easy way to break // TestCompletionScriptsAreSyntacticallyValid catches the easy way to break
@@ -15,8 +17,8 @@ func TestCompletionScriptsAreSyntacticallyValid(t *testing.T) {
shell string shell string
script string script string
}{ }{
{"bash", bashCompletionScript}, {"bash", bashCompletionScript()},
{"zsh", zshCompletionScript}, {"zsh", zshCompletionScript()},
} { } {
t.Run(tc.shell, func(t *testing.T) { t.Run(tc.shell, func(t *testing.T) {
if _, err := exec.LookPath(tc.shell); err != nil { 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) { func TestRunCompletionUnknownShell(t *testing.T) {
if code := runCompletion([]string{"fish"}); code != 1 { if code := runCompletion([]string{"fish"}); code != 1 {
t.Errorf("runCompletion([\"fish\"]) = %d, want 1", code) 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/download"
"github.com/Cheviiot/vintner/internal/i18n" "github.com/Cheviiot/vintner/internal/i18n"
"github.com/Cheviiot/vintner/internal/lock"
) )
func runDownload(args []string) int { func runDownload(args []string) int {
@@ -177,6 +178,12 @@ func runDownload(args []string) int {
fmt.Fprintln(os.Stderr, "vintner download:", err) fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1 return 1
} }
unlock, err := lock.Acquire(destAbs)
if err != nil {
fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1
}
defer unlock()
unpack := destAbs unpack := destAbs
if !*onlyUnpack { if !*onlyUnpack {
+11 -8
View File
@@ -1,9 +1,9 @@
// Command vintner cross compiles with the real MSVC toolchain on Linux // 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`, // 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 // `rc`, `midl`, `mt`, `dumpbin`, `msbuild`, etc. when invoked under one of
// those names (via symlinks set up by `vintner install`), and // those names (via symlinks set up by `vintner install`) or as
// otherwise exposes the `download`/`install`/`env`/`version` management // `vintner <tool> ...` directly (see runTool), and otherwise exposes the
// subcommands. // `download`/`install`/`env`/`version` management subcommands.
package main package main
import ( import (
@@ -24,11 +24,8 @@ func main() {
base := filepath.Base(os.Args[0]) base := filepath.Base(os.Args[0])
name := strings.TrimSuffix(strings.ToLower(base), ".exe") name := strings.TrimSuffix(strings.ToLower(base), ".exe")
if _, ok := wrapper.Tools[name]; ok { if wrapper.IsTool(name) {
os.Exit(wrapper.Run(name, os.Args[1:])) os.Exit(wrapper.Run(name, os.Args[1:], ""))
}
if name == "cmd" || name == "findstr" {
os.Exit(wrapper.Run(name, os.Args[1:]))
} }
os.Exit(runCLI(os.Args[1:])) os.Exit(runCLI(os.Args[1:]))
@@ -40,6 +37,10 @@ func runCLI(args []string) int {
return 1 return 1
} }
if wrapper.IsTool(args[0]) {
return runTool(args[0], args[1:])
}
switch args[0] { switch args[0] {
case "download", "dl": case "download", "dl":
return runDownload(args[1:]) return runDownload(args[1:])
@@ -49,6 +50,8 @@ func runCLI(args []string) int {
return runEnv(args[1:]) return runEnv(args[1:])
case "completion": case "completion":
return runCompletion(args[1:]) return runCompletion(args[1:])
case "doctor":
return runDoctor(args[1:])
case "version", "v", "--version": case "version", "v", "--version":
fmt.Println(versionString()) fmt.Println(versionString())
return 0 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} 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 { 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 { if err != nil {
return err return err
} }
defer resp.Body.Close() 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) return fmt.Errorf("GET %s: %s", url, resp.Status)
} }
tmp := dest + ".part"
out, err := os.Create(tmp)
if err != nil { if err != nil {
return err return err
} }
if _, err := io.Copy(out, resp.Body); err != nil { if _, err := io.Copy(out, resp.Body); err != nil {
out.Close() 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 return err
} }
if err := out.Close(); err != nil { if err := out.Close(); err != nil {
os.Remove(tmp)
return err return err
} }
return os.Rename(tmp, dest) 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 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 env (e) --bin <dir/bin/arch> print INCLUDE/LIB for native clang-cl/lld-link use
vintner version (v) print the version vintner version (v) print the version
vintner doctor check wine/toolchain setup
vintner help (h) show this message vintner help (h) show this message
vintner completion bash|zsh print a shell completion script 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. Language: set VINTNER_LANG=ru (or LANG=ru_RU...) for Russian output.
Completion: source <(vintner completion bash) # or zsh 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 cl, link, lib, ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd, findstr
`, `,
RU: `vintner — кросс-компиляция настоящим MSVC на Linux через Wine 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 install (i) [каталог] настроить обёртки для скачанного MSVC
vintner env (e) --bin <dir/bin/arch> вывести INCLUDE/LIB для clang-cl/lld-link напрямую vintner env (e) --bin <dir/bin/arch> вывести INCLUDE/LIB для clang-cl/lld-link напрямую
vintner version (v) показать версию vintner version (v) показать версию
vintner doctor проверить настройку wine/toolchain
vintner help (h) показать эту справку vintner help (h) показать эту справку
vintner completion bash|zsh вывести скрипт автодополнения для оболочки vintner completion bash|zsh вывести скрипт автодополнения для оболочки
@@ -104,9 +109,13 @@ Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly:
--dest/[каталог] по умолчанию — ~/.vintner. --dest/[каталог] по умолчанию — ~/.vintner.
Язык: установите VINTNER_LANG=en (или LANG=en_US...) для вывода на английском. Язык: установите VINTNER_LANG=en (или LANG=en_US...) для вывода на английском.
Можно не трогать PATH: "vintner cl ...", "vintner msbuild ..." и т.д. работают
напрямую (VINTNER_BIN — если каталог не стандартный ~/.vintner/bin/<hostarch>).
Автодополнение: source <(vintner completion bash) # или zsh Автодополнение: 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 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", RU: "Каталог не указан, используется значение по умолчанию: %s",
}, },
"install.done": { "install.done": {
EN: "Done. Add %s to PATH to use 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 и т.д.", RU: "Готово. Добавьте %s в PATH, чтобы использовать cl, link, lib и т.д. напрямую, либо запускайте их как «vintner cl», «vintner link» и т.д., не трогая PATH.",
}, },
"env.usage": { "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\": ", EN: "Do you accept the license? Answer \"yes\" or \"no\": ",
RU: "Вы принимаете лицензию? Ответьте «yes» или «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" "strings"
"github.com/Cheviiot/vintner/assets" "github.com/Cheviiot/vintner/assets"
"github.com/Cheviiot/vintner/internal/lock"
"github.com/Cheviiot/vintner/internal/wineenv" "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) 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 - // Targets are relative so the whole installed tree stays relocatable -
// moving or renaming dest doesn't break these symlinks the way an // moving or renaming dest doesn't break these symlinks the way an
// absolute target baked in at install time would. // 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 { if p, err := exec.LookPath("wine"); err == nil {
return p, 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 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 { func msbuildPlatform(arch string) string {
switch arch { switch arch {
case "x86": 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"]) 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 package wrapper
import ( import (
"fmt"
"os" "os"
"os/exec" "os/exec"
) )
@@ -25,17 +26,21 @@ func execInherit(args []string) int {
if len(args) == 0 { if len(args) == 0 {
return 0 return 0
} }
cmd := exec.Command(args[0], args[1:]...) tc, cleanup := newToolCommand(args[0], args[1:]...)
cmd.Stdin = os.Stdin defer cleanup()
cmd.Stdout = os.Stdout tc.Stdin = os.Stdin
cmd.Stderr = os.Stderr tc.Stdout = os.Stdout
setNewProcessGroup(cmd) tc.Stderr = os.Stderr
if err := cmd.Start(); err != nil { if err := tc.Start(); err != nil {
return 127 return 127
} }
stopSignals := forwardSignals(cmd.Process) stopSignals := forwardSignals(tc.Process)
defer stopSignals() 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 { if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode() return exitErr.ExitCode()
} }
+65 -39
View File
@@ -30,7 +30,15 @@ const toolRelayName = "toolrelay.exe"
// Run executes the named multi-call tool with args, exactly as the original // Run executes the named multi-call tool with args, exactly as the original
// bash wrappers would, and returns the process exit code. // 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] { if nativeTools[tool] {
return runNative(tool, args) return runNative(tool, args)
} }
@@ -41,19 +49,23 @@ func Run(tool string, args []string) int {
return 127 return 127
} }
scriptDir := binDir
if scriptDir == "" {
// os.Executable() (backed by /proc/self/exe on Linux) fully resolves // os.Executable() (backed by /proc/self/exe on Linux) fully resolves
// symlinks, unlike os.Args[0]: not every shell passes a PATH-resolved // symlinks, unlike os.Args[0]: not every shell passes a
// absolute path as argv[0] (some just pass the bare command name), which // PATH-resolved absolute path as argv[0] (some just pass the bare
// would make an argv[0]-based lookup resolve against the caller's cwd // command name), which would make an argv[0]-based lookup resolve
// instead of the actual install dir. `install` sets each arch dir up // against the caller's cwd instead of the actual install dir.
// with its own local copy of the binary precisely so this resolves to // `install` sets each arch dir up with its own local copy of the
// <dest>/bin/<arch>, not <dest>/bin. // binary precisely so this resolves to <dest>/bin/<arch>, not
// <dest>/bin.
exePath, err := os.Executable() exePath, err := os.Executable()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
scriptDir := filepath.Dir(exePath) scriptDir = filepath.Dir(exePath)
}
cfg, err := wineenv.Load(scriptDir) cfg, err := wineenv.Load(scriptDir)
if err != nil { 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 // read as-is), and add the extra environment MSBuild's own
// toolset/SDK-detection props need on top of the generic // toolset/SDK-detection props need on top of the generic
// INCLUDE/LIB/WINEPATH, plus any global properties a project file // 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...) 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) env := buildEnv(paths)
for k, v := range msbuildEnv(cfg, paths) { for k, v := range msbuildEnv(cfg, paths) {
env = append(env, k+"="+v) env = append(env, k+"="+v)
} }
cmd.Env = env tc.Env = env
cmd.Stdin = os.Stdin tc.Stdin = os.Stdin
setNewProcessGroup(cmd) exitCode = runRawStdout(tc)
exitCode = runRawStdout(cmd)
default: default:
relay := filepath.Join(paths.BaseUnix, "bin", toolRelayName) relay := filepath.Join(paths.BaseUnix, "bin", toolRelayName)
if fi, err := os.Stat(relay); err == nil && !fi.IsDir() { if fi, err := os.Stat(relay); err == nil && !fi.IsDir() {
exitCode = runViaToolRelay(wineBin, relay, toolExePath, rewritten, paths, s.stdoutFilter, s.stderrFilter) exitCode = runViaToolRelay(wineBin, relay, toolExePath, rewritten, paths, s.stdoutFilter, s.stderrFilter)
} else { } else {
cmd := exec.Command(wineBin, append([]string{toolExePath}, rewritten...)...) tc, cleanup := newToolCommand(wineBin, append([]string{toolExePath}, rewritten...)...)
cmd.Env = buildEnv(paths) defer cleanup()
cmd.Stdin = os.Stdin tc.Env = buildEnv(paths)
setNewProcessGroup(cmd) tc.Stdin = os.Stdin
exitCode = runFiltered(cmd, s.stdoutFilter, s.stderrFilter) 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) defer os.Remove(stderrFifo)
cmdArgs := append([]string{relayExe, exePath}, args...) cmdArgs := append([]string{relayExe, exePath}, args...)
cmd := exec.Command(wineBin, cmdArgs...) tc, cleanup := newToolCommand(wineBin, cmdArgs...)
cmd.Env = append(buildEnv(paths), "MSVCGOWINE_STDOUT="+stdoutFifo, "MSVCGOWINE_STDERR="+stderrFifo) defer cleanup()
setNewProcessGroup(cmd) tc.Env = append(buildEnv(paths), "MSVCGOWINE_STDOUT="+stdoutFifo, "MSVCGOWINE_STDERR="+stderrFifo)
if devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0); err == nil { if devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0); err == nil {
defer devNull.Close() defer devNull.Close()
cmd.Stdout = devNull tc.Stdout = devNull
cmd.Stderr = devNull tc.Stderr = devNull
} }
if err := cmd.Start(); err != nil { if err := tc.Start(); err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
stopSignals := forwardSignals(cmd.Process) stopSignals := forwardSignals(tc.Process)
defer stopSignals() defer stopSignals()
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -176,10 +190,14 @@ func runViaToolRelay(wineBin, relayExe, exePath string, args []string, paths *wi
pumpLines(f, os.Stderr, stderrF) pumpLines(f, os.Stderr, stderrF)
}() }()
err := cmd.Wait() err := tc.Wait()
wg.Wait() wg.Wait()
if err != nil { if err != nil {
if tc.timedOut() {
fmt.Fprintln(os.Stderr, tc.timeoutMessage())
return 124
}
if exitErr, ok := err.(*exec.ExitError); ok { if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode() 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 // own copy goroutines - not the caller's terminal or pipe - are exposed to
// Wine's background processes holding those descriptors open; see // Wine's background processes holding those descriptors open; see
// pipeDrainGrace. // pipeDrainGrace.
func runRawStdout(cmd *exec.Cmd) int { func runRawStdout(tc *toolCommand) int {
stdout, err := cmd.StdoutPipe() stdout, err := tc.StdoutPipe()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
stderr, err := cmd.StderrPipe() stderr, err := tc.StderrPipe()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
if err := cmd.Start(); err != nil { if err := tc.Start(); err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
stopSignals := forwardSignals(cmd.Process) stopSignals := forwardSignals(tc.Process)
defer stopSignals() defer stopSignals()
doneOut := make(chan struct{}) 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.Stdout, stdout); close(doneOut) }()
go func() { io.Copy(os.Stderr, stderr); close(doneErr) }() go func() { io.Copy(os.Stderr, stderr); close(doneErr) }()
err = cmd.Wait() err = tc.Wait()
drain(doneOut) drain(doneOut)
drain(doneErr) drain(doneErr)
if err != nil { if err != nil {
if tc.timedOut() {
fmt.Fprintln(os.Stderr, tc.timeoutMessage())
return 124
}
if exitErr, ok := err.(*exec.ExitError); ok { if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode() return exitErr.ExitCode()
} }
@@ -271,23 +293,23 @@ func buildEnv(p *wineenv.Paths) []string {
// runFiltered streams stdout/stderr line by line through the tool's // runFiltered streams stdout/stderr line by line through the tool's
// filters (CR-stripping always applied first), then waits for completion. // filters (CR-stripping always applied first), then waits for completion.
func runFiltered(cmd *exec.Cmd, stdoutF, stderrF lineFilter) int { func runFiltered(tc *toolCommand, stdoutF, stderrF lineFilter) int {
stdout, err := cmd.StdoutPipe() stdout, err := tc.StdoutPipe()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
stderr, err := cmd.StderrPipe() stderr, err := tc.StderrPipe()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
if err := cmd.Start(); err != nil { if err := tc.Start(); err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
stopSignals := forwardSignals(cmd.Process) stopSignals := forwardSignals(tc.Process)
defer stopSignals() defer stopSignals()
doneOut := make(chan struct{}) 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(stdout, os.Stdout, stdoutF); close(doneOut) }()
go func() { pumpLines(stderr, os.Stderr, stderrF); close(doneErr) }() go func() { pumpLines(stderr, os.Stderr, stderrF); close(doneErr) }()
err = cmd.Wait() err = tc.Wait()
drain(doneOut) drain(doneOut)
drain(doneErr) drain(doneErr)
if err != nil { if err != nil {
if tc.timedOut() {
fmt.Fprintln(os.Stderr, tc.timeoutMessage())
return 124
}
if exitErr, ok := err.(*exec.ExitError); ok { if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode() 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. // filtering its output.
package wrapper 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. // dirKind selects which install directory a tool's real .exe lives in.
type dirKind int type dirKind int
@@ -46,6 +50,32 @@ var Tools = map[string]spec{
// nativeTools are handled entirely without Wine. // nativeTools are handled entirely without Wine.
var nativeTools = map[string]bool{"cmd": true, "findstr": true} 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 { func (s spec) exeDir(p *wineenv.Paths) string {
switch s.dir { switch s.dir {
case dirSDK: 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)
}
}
}