mirror of
https://github.com/Cheviiot/Vintner.git
synced 2026-08-03 15:57:24 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de49e80cb7 | ||
|
|
98bac75767 | ||
|
|
5cf52c9f9f | ||
|
|
b366dfa9ac | ||
|
|
738234186d | ||
|
|
f6b9a0811a | ||
|
|
bdea270d71 |
@@ -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
|
||||
|
||||
@@ -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 "$@"
|
||||
`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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] выше.",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+65
-39
@@ -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
|
||||
}
|
||||
|
||||
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.
|
||||
// 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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user