mirror of
https://github.com/Cheviiot/Vintner.git
synced 2026-08-03 15:57:24 +00:00
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.
This commit is contained in:
@@ -23,6 +23,7 @@ approach: download the real MSVC/WinSDK, wrap the compiler under Wine.
|
|||||||
- [Commands](#commands)
|
- [Commands](#commands)
|
||||||
- [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)
|
||||||
@@ -164,6 +165,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
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-31
@@ -84,27 +84,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 +141,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 +178,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 +201,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 +225,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 +281,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 +305,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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user