detached 70afb41

#7 feat(wine): keep Wayland driver warnings in a rotated log

state
merged · c542b0e · merge commit
branch
feat/wine-logs → main
opened
bnema ·
updated

Launch WINEDEBUG defaults to err+all,warn+waylanddrv; BNETCTL_WINEDEBUG overrides it. Wine launch stderr goes to $XDG_STATE_HOME/bnetctl/logs with latest.log; keeps 3 logs, max 100 MiB of old logs. Reviewed (go-reviewer); go test -race, vet, staticcheck, golangci-lint pass.

CommitSubjectAuthorDateSignatureStatus
70afb41feat(wine): keep Wayland driver warnings in a rotated logbrice GPG Verified

Checks 2

6 files · +390 -5
2/2
M README.md +16 -1
@@ -69,6 +69,12 @@ bnetctl clean -a # Also remove desktop entries and data
6969
7070 For example: `BNETCTL_WINE=/usr/bin/wine bnetctl launch`.
7171
72+`BNETCTL_WINEDEBUG`, when set, overrides the launch `WINEDEBUG` verbatim
73+(including an empty value). The default is `err+all,warn+waylanddrv`, retaining
74+errors and Wayland driver warnings at low cost. To silence Wine diagnostics,
75+use `BNETCTL_WINEDEBUG=-all bnetctl launch`. Helper commands such as wineboot,
76+registry edits, and taskkill keep diagnostics disabled.
77+
7278 ### HDR
7379
7480 Native Wayland automatically enables HDR for supported World of Warcraft
@@ -112,7 +118,15 @@ X11 mode leaves the user's HDR environment untouched.
112118 Stop/clean wait up to 10 seconds for the lock; retry after maintenance finishes.
113119 Launch releases its lock once Wine starts, not at the end of the game session.
114120 - **More detail:** run `bnetctl --verbose launch` to see runtime selection and
115- maintenance diagnostics. Logs are saved to `~/.cache/bnetctl/bnetctl.log`.
121+ maintenance diagnostics. bnetctl logs are saved to `~/.cache/bnetctl/bnetctl.log`.
122+- **Wine diagnostics:** launch stderr is saved to
123+ `$XDG_STATE_HOME/bnetctl/logs/wine-<timestamp>-<unique>.log` (falling back to
124+ `~/.local/state/bnetctl/logs`), with `latest.log` pointing to the newest log.
125+ Up to 3 launches are retained (~100 MiB of old logs); at launch, older logs
126+ are pruned and old logs over 50 MiB are deleted. The running log is uncapped.
127+ The path is reported once at info level; Wine stdout and bnetctl's own
128+ terminal logs are unchanged. If log setup fails, bnetctl warns and leaves
129+ Wine stderr on the terminal.
116130
117131 ## Directories
118132
@@ -126,6 +140,7 @@ are not currently honored. Full cleanup retains the data directory and its
126140 | Prefix | `~/.local/share/bnetctl/prefix` |
127141 | Cache | `~/.cache/bnetctl` |
128142 | Log | `~/.cache/bnetctl/bnetctl.log` |
143+| Wine logs | `$XDG_STATE_HOME/bnetctl/logs` (default `~/.local/state/bnetctl/logs`) |
129144
130145 ## License
131146
@@ -69,6 +69,12 @@ bnetctl clean -a # Also remove desktop entries and data
69 69
70 For example: `BNETCTL_WINE=/usr/bin/wine bnetctl launch`.70 For example: `BNETCTL_WINE=/usr/bin/wine bnetctl launch`.
71 71
72+`BNETCTL_WINEDEBUG`, when set, overrides the launch `WINEDEBUG` verbatim
73+(including an empty value). The default is `err+all,warn+waylanddrv`, retaining
74+errors and Wayland driver warnings at low cost. To silence Wine diagnostics,
75+use `BNETCTL_WINEDEBUG=-all bnetctl launch`. Helper commands such as wineboot,
76+registry edits, and taskkill keep diagnostics disabled.
77+
72 ### HDR78 ### HDR
73 79
74 Native Wayland automatically enables HDR for supported World of Warcraft80 Native Wayland automatically enables HDR for supported World of Warcraft
@@ -112,7 +118,15 @@ X11 mode leaves the user's HDR environment untouched.
112 Stop/clean wait up to 10 seconds for the lock; retry after maintenance finishes.118 Stop/clean wait up to 10 seconds for the lock; retry after maintenance finishes.
113 Launch releases its lock once Wine starts, not at the end of the game session.119 Launch releases its lock once Wine starts, not at the end of the game session.
114 - **More detail:** run `bnetctl --verbose launch` to see runtime selection and120 - **More detail:** run `bnetctl --verbose launch` to see runtime selection and
115- maintenance diagnostics. Logs are saved to `~/.cache/bnetctl/bnetctl.log`.121+ maintenance diagnostics. bnetctl logs are saved to `~/.cache/bnetctl/bnetctl.log`.
122+- **Wine diagnostics:** launch stderr is saved to
123+ `$XDG_STATE_HOME/bnetctl/logs/wine-<timestamp>-<unique>.log` (falling back to
124+ `~/.local/state/bnetctl/logs`), with `latest.log` pointing to the newest log.
125+ Up to 3 launches are retained (~100 MiB of old logs); at launch, older logs
126+ are pruned and old logs over 50 MiB are deleted. The running log is uncapped.
127+ The path is reported once at info level; Wine stdout and bnetctl's own
128+ terminal logs are unchanged. If log setup fails, bnetctl warns and leaves
129+ Wine stderr on the terminal.
116 130
117 ## Directories131 ## Directories
118 132
@@ -126,6 +140,7 @@ are not currently honored. Full cleanup retains the data directory and its
126 | Prefix | `~/.local/share/bnetctl/prefix` |140 | Prefix | `~/.local/share/bnetctl/prefix` |
127 | Cache | `~/.cache/bnetctl` |141 | Cache | `~/.cache/bnetctl` |
128 | Log | `~/.cache/bnetctl/bnetctl.log` |142 | Log | `~/.cache/bnetctl/bnetctl.log` |
143+| Wine logs | `$XDG_STATE_HOME/bnetctl/logs` (default `~/.local/state/bnetctl/logs`) |
129 144
130 ## License145 ## License
131 146
M internal/adapters/env/gpu.go +5 -2
@@ -146,8 +146,11 @@ func BuildGPUEnvForDisplayHDR(driver DisplayDriver, noHDR bool) *domain.WineEnv
146146 env.Vars["WINEESYNC"] = "1"
147147 env.Vars["WINEFSYNC"] = "1"
148148
149- // Suppress Wine debug output for performance
150- env.Vars["WINEDEBUG"] = "-all"
149+ // Keep low-cost problem diagnostics unless explicitly overridden.
150+ env.Vars["WINEDEBUG"] = "err+all,warn+waylanddrv"
151+ if debug, set := os.LookupEnv("BNETCTL_WINEDEBUG"); set {
152+ env.Vars["WINEDEBUG"] = debug
153+ }
151154
152155 // FreeType hinting for better font rendering in Wine
153156 env.Vars["FREETYPE_PROPERTIES"] = "truetype:interpreter-version=35"
@@ -146,8 +146,11 @@ func BuildGPUEnvForDisplayHDR(driver DisplayDriver, noHDR bool) *domain.WineEnv
146 env.Vars["WINEESYNC"] = "1"146 env.Vars["WINEESYNC"] = "1"
147 env.Vars["WINEFSYNC"] = "1"147 env.Vars["WINEFSYNC"] = "1"
148 148
149- // Suppress Wine debug output for performance149+ // Keep low-cost problem diagnostics unless explicitly overridden.
150- env.Vars["WINEDEBUG"] = "-all"150+ env.Vars["WINEDEBUG"] = "err+all,warn+waylanddrv"
151+ if debug, set := os.LookupEnv("BNETCTL_WINEDEBUG"); set {
152+ env.Vars["WINEDEBUG"] = debug
153+ }
151 154
152 // FreeType hinting for better font rendering in Wine155 // FreeType hinting for better font rendering in Wine
153 env.Vars["FREETYPE_PROPERTIES"] = "truetype:interpreter-version=35"156 env.Vars["FREETYPE_PROPERTIES"] = "truetype:interpreter-version=35"
M internal/adapters/env/gpu_test.go +29 -0
@@ -1,6 +1,7 @@
11 package env
22
33 import (
4+ "os"
45 "slices"
56 "testing"
67 )
@@ -55,3 +56,31 @@ func TestBuildGPUEnvForDisplayX11KeepsDisplay(t *testing.T) {
5556 t.Fatalf("X11 environment must inherit DISPLAY: %#v", got.Unset)
5657 }
5758 }
59+
60+func TestWineDebug(t *testing.T) {
61+ for _, tt := range []struct {
62+ name string
63+ value string
64+ set bool
65+ want string
66+ }{
67+ {name: "default", want: "err+all,warn+waylanddrv"},
68+ {name: "disabled", set: true, value: "-all", want: "-all"},
69+ {name: "custom", set: true, value: "+relay", want: "+relay"},
70+ {name: "empty", set: true},
71+ } {
72+ t.Run(tt.name, func(t *testing.T) {
73+ t.Setenv("BNETCTL_WINEDEBUG", tt.value)
74+ if !tt.set {
75+ if err := os.Unsetenv("BNETCTL_WINEDEBUG"); err != nil {
76+ t.Fatal(err)
77+ }
78+ }
79+ for _, driver := range []DisplayDriver{DisplayDriverWayland, DisplayDriverX11} {
80+ if got := BuildGPUEnvForDisplay(driver).Vars["WINEDEBUG"]; got != tt.want {
81+ t.Fatalf("%s: got %q, want %q", driver, got, tt.want)
82+ }
83+ }
84+ })
85+ }
86+}
@@ -1,6 +1,7 @@
1 package env1 package env
2 2
3 import (3 import (
4+ "os"
4 "slices"5 "slices"
5 "testing"6 "testing"
6 )7 )
@@ -55,3 +56,31 @@ func TestBuildGPUEnvForDisplayX11KeepsDisplay(t *testing.T) {
55 t.Fatalf("X11 environment must inherit DISPLAY: %#v", got.Unset)56 t.Fatalf("X11 environment must inherit DISPLAY: %#v", got.Unset)
56 }57 }
57 }58 }
59+
60+func TestWineDebug(t *testing.T) {
61+ for _, tt := range []struct {
62+ name string
63+ value string
64+ set bool
65+ want string
66+ }{
67+ {name: "default", want: "err+all,warn+waylanddrv"},
68+ {name: "disabled", set: true, value: "-all", want: "-all"},
69+ {name: "custom", set: true, value: "+relay", want: "+relay"},
70+ {name: "empty", set: true},
71+ } {
72+ t.Run(tt.name, func(t *testing.T) {
73+ t.Setenv("BNETCTL_WINEDEBUG", tt.value)
74+ if !tt.set {
75+ if err := os.Unsetenv("BNETCTL_WINEDEBUG"); err != nil {
76+ t.Fatal(err)
77+ }
78+ }
79+ for _, driver := range []DisplayDriver{DisplayDriverWayland, DisplayDriverX11} {
80+ if got := BuildGPUEnvForDisplay(driver).Vars["WINEDEBUG"]; got != tt.want {
81+ t.Fatalf("%s: got %q, want %q", driver, got, tt.want)
82+ }
83+ }
84+ })
85+ }
86+}
A internal/adapters/wine/logs.go +123 -0
@@ -0,0 +1,123 @@
1+package wine
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "sort"
8+ "strings"
9+ "time"
10+)
11+
12+const (
13+ wineLogCountLimit = 3
14+ wineLogTotalBudget int64 = 100 << 20
15+ wineLogFileLimit int64 = 50 << 20
16+)
17+
18+// launchStderr uses a file descriptor directly: Wine descendants can retain it
19+// without a copy goroutine delaying cmd.Wait until every descendant exits.
20+func (a *Adapter) launchStderr() *os.File {
21+ file, err := openWineLog()
22+ if err != nil {
23+ a.log.Warn("cannot create Wine log; using terminal stderr", "error", err)
24+ return os.Stderr
25+ }
26+ a.log.Info("Wine stderr log", "path", file.Name())
27+ if debug, set := os.LookupEnv("BNETCTL_WINEDEBUG"); set && debug != "err+all,warn+waylanddrv" {
28+ a.log.Warn("custom WINEDEBUG can produce large logs", "path", file.Name())
29+ }
30+ return file
31+}
32+
33+func openWineLog() (*os.File, error) {
34+ state := os.Getenv("XDG_STATE_HOME")
35+ if state == "" {
36+ home, err := os.UserHomeDir()
37+ if err != nil {
38+ return nil, err
39+ }
40+ state = filepath.Join(home, ".local", "state")
41+ }
42+ dir := filepath.Join(state, "bnetctl", "logs")
43+ if err := os.MkdirAll(dir, 0o700); err != nil {
44+ return nil, err
45+ }
46+ // Fixed-width UTC timestamps sort chronologically; the random suffix avoids
47+ // collisions when launches occur together.
48+ file, err := os.CreateTemp(dir, "wine-"+time.Now().UTC().Format("20060102T150405.000000000Z")+"-*.log")
49+ if err != nil {
50+ return nil, err
51+ }
52+ fail := func(err error) (*os.File, error) {
53+ _ = file.Close()
54+ _ = os.Remove(file.Name())
55+ return nil, err
56+ }
57+ if err := rotateWineLogs(dir, filepath.Base(file.Name())); err != nil {
58+ return fail(err)
59+ }
60+ // Build the replacement beside latest.log, then rename it atomically.
61+ link := file.Name() + ".latest"
62+ if err := os.Symlink(filepath.Base(file.Name()), link); err != nil {
63+ return fail(err)
64+ }
65+ if err := os.Rename(link, filepath.Join(dir, "latest.log")); err != nil {
66+ _ = os.Remove(link)
67+ return fail(err)
68+ }
69+ return file, nil
70+}
71+
72+// rotateWineLogs always reserves one slot for the new log, regardless of its
73+// name or size. Only old files are subject to size limits at launch time.
74+func rotateWineLogs(dir, newName string) error {
75+ entries, err := os.ReadDir(dir)
76+ if err != nil {
77+ return err
78+ }
79+ var names []string
80+ for _, entry := range entries {
81+ if entry.Name() != newName && entry.Type().IsRegular() && strings.HasPrefix(entry.Name(), "wine-") && strings.HasSuffix(entry.Name(), ".log") {
82+ names = append(names, entry.Name())
83+ }
84+ }
85+ sort.Sort(sort.Reverse(sort.StringSlice(names)))
86+ var kept []string
87+ for i, name := range names {
88+ path := filepath.Join(dir, name)
89+ info, err := os.Stat(path)
90+ if err != nil {
91+ return err
92+ }
93+ if i >= wineLogCountLimit-1 || info.Size() > wineLogFileLimit {
94+ if err := os.Remove(path); err != nil {
95+ return fmt.Errorf("rotate Wine logs: %w", err)
96+ }
97+ } else {
98+ kept = append(kept, name)
99+ }
100+ }
101+ return pruneWineLogBudget(dir, kept)
102+}
103+
104+// names are newest first. Remove oldest files until the old-log budget fits.
105+func pruneWineLogBudget(dir string, names []string) error {
106+ sizes := make([]int64, len(names))
107+ var total int64
108+ for i, name := range names {
109+ info, err := os.Stat(filepath.Join(dir, name))
110+ if err != nil {
111+ return err
112+ }
113+ sizes[i] = info.Size()
114+ total += sizes[i]
115+ }
116+ for i := len(names) - 1; i >= 0 && total > wineLogTotalBudget; i-- {
117+ if err := os.Remove(filepath.Join(dir, names[i])); err != nil {
118+ return fmt.Errorf("rotate Wine logs: %w", err)
119+ }
120+ total -= sizes[i]
121+ }
122+ return nil
123+}
@@ -0,0 +1,123 @@
1+package wine
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "sort"
8+ "strings"
9+ "time"
10+)
11+
12+const (
13+ wineLogCountLimit = 3
14+ wineLogTotalBudget int64 = 100 << 20
15+ wineLogFileLimit int64 = 50 << 20
16+)
17+
18+// launchStderr uses a file descriptor directly: Wine descendants can retain it
19+// without a copy goroutine delaying cmd.Wait until every descendant exits.
20+func (a *Adapter) launchStderr() *os.File {
21+ file, err := openWineLog()
22+ if err != nil {
23+ a.log.Warn("cannot create Wine log; using terminal stderr", "error", err)
24+ return os.Stderr
25+ }
26+ a.log.Info("Wine stderr log", "path", file.Name())
27+ if debug, set := os.LookupEnv("BNETCTL_WINEDEBUG"); set && debug != "err+all,warn+waylanddrv" {
28+ a.log.Warn("custom WINEDEBUG can produce large logs", "path", file.Name())
29+ }
30+ return file
31+}
32+
33+func openWineLog() (*os.File, error) {
34+ state := os.Getenv("XDG_STATE_HOME")
35+ if state == "" {
36+ home, err := os.UserHomeDir()
37+ if err != nil {
38+ return nil, err
39+ }
40+ state = filepath.Join(home, ".local", "state")
41+ }
42+ dir := filepath.Join(state, "bnetctl", "logs")
43+ if err := os.MkdirAll(dir, 0o700); err != nil {
44+ return nil, err
45+ }
46+ // Fixed-width UTC timestamps sort chronologically; the random suffix avoids
47+ // collisions when launches occur together.
48+ file, err := os.CreateTemp(dir, "wine-"+time.Now().UTC().Format("20060102T150405.000000000Z")+"-*.log")
49+ if err != nil {
50+ return nil, err
51+ }
52+ fail := func(err error) (*os.File, error) {
53+ _ = file.Close()
54+ _ = os.Remove(file.Name())
55+ return nil, err
56+ }
57+ if err := rotateWineLogs(dir, filepath.Base(file.Name())); err != nil {
58+ return fail(err)
59+ }
60+ // Build the replacement beside latest.log, then rename it atomically.
61+ link := file.Name() + ".latest"
62+ if err := os.Symlink(filepath.Base(file.Name()), link); err != nil {
63+ return fail(err)
64+ }
65+ if err := os.Rename(link, filepath.Join(dir, "latest.log")); err != nil {
66+ _ = os.Remove(link)
67+ return fail(err)
68+ }
69+ return file, nil
70+}
71+
72+// rotateWineLogs always reserves one slot for the new log, regardless of its
73+// name or size. Only old files are subject to size limits at launch time.
74+func rotateWineLogs(dir, newName string) error {
75+ entries, err := os.ReadDir(dir)
76+ if err != nil {
77+ return err
78+ }
79+ var names []string
80+ for _, entry := range entries {
81+ if entry.Name() != newName && entry.Type().IsRegular() && strings.HasPrefix(entry.Name(), "wine-") && strings.HasSuffix(entry.Name(), ".log") {
82+ names = append(names, entry.Name())
83+ }
84+ }
85+ sort.Sort(sort.Reverse(sort.StringSlice(names)))
86+ var kept []string
87+ for i, name := range names {
88+ path := filepath.Join(dir, name)
89+ info, err := os.Stat(path)
90+ if err != nil {
91+ return err
92+ }
93+ if i >= wineLogCountLimit-1 || info.Size() > wineLogFileLimit {
94+ if err := os.Remove(path); err != nil {
95+ return fmt.Errorf("rotate Wine logs: %w", err)
96+ }
97+ } else {
98+ kept = append(kept, name)
99+ }
100+ }
101+ return pruneWineLogBudget(dir, kept)
102+}
103+
104+// names are newest first. Remove oldest files until the old-log budget fits.
105+func pruneWineLogBudget(dir string, names []string) error {
106+ sizes := make([]int64, len(names))
107+ var total int64
108+ for i, name := range names {
109+ info, err := os.Stat(filepath.Join(dir, name))
110+ if err != nil {
111+ return err
112+ }
113+ sizes[i] = info.Size()
114+ total += sizes[i]
115+ }
116+ for i := len(names) - 1; i >= 0 && total > wineLogTotalBudget; i-- {
117+ if err := os.Remove(filepath.Join(dir, names[i])); err != nil {
118+ return fmt.Errorf("rotate Wine logs: %w", err)
119+ }
120+ total -= sizes[i]
121+ }
122+ return nil
123+}
A internal/adapters/wine/logs_test.go +206 -0
@@ -0,0 +1,206 @@
1+package wine
2+
3+import (
4+ "bytes"
5+ "io"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "github.com/bnema/bnetctl/internal/domain"
12+ "github.com/charmbracelet/log"
13+)
14+
15+func TestWineLogRotationAndLatest(t *testing.T) {
16+ state := t.TempDir()
17+ t.Setenv("XDG_STATE_HOME", state)
18+ dir := filepath.Join(state, "bnetctl", "logs")
19+ var paths []string
20+ for i := 0; i < 8; i++ {
21+ file, err := openWineLog()
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ paths = append(paths, file.Name())
26+ if err := file.Close(); err != nil {
27+ t.Fatal(err)
28+ }
29+ target, err := os.Readlink(filepath.Join(dir, "latest.log"))
30+ if err != nil || target != filepath.Base(file.Name()) {
31+ t.Fatalf("latest = %q, %v", target, err)
32+ }
33+ }
34+ logs, err := filepath.Glob(filepath.Join(dir, "wine-*.log"))
35+ if err != nil || len(logs) != 3 {
36+ t.Fatalf("logs = %v, %v", logs, err)
37+ }
38+ for i, path := range paths {
39+ _, err := os.Stat(path)
40+ if i < 5 && !os.IsNotExist(err) {
41+ t.Fatalf("old log retained: %s (%v)", path, err)
42+ }
43+ if i >= 5 && err != nil {
44+ t.Fatalf("recent log missing: %s (%v)", path, err)
45+ }
46+ }
47+}
48+
49+func TestWineLogHomeFallback(t *testing.T) {
50+ home := t.TempDir()
51+ t.Setenv("HOME", home)
52+ t.Setenv("XDG_STATE_HOME", "")
53+ file, err := openWineLog()
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ t.Cleanup(func() {
58+ if err := file.Close(); err != nil {
59+ t.Error(err)
60+ }
61+ })
62+ if filepath.Dir(file.Name()) != filepath.Join(home, ".local", "state", "bnetctl", "logs") {
63+ t.Fatal(file.Name())
64+ }
65+}
66+
67+func TestLaunchStderr(t *testing.T) {
68+ for _, async := range []bool{false, true} {
69+ t.Run(map[bool]string{false: "blocking", true: "async"}[async], func(t *testing.T) {
70+ state := t.TempDir()
71+ t.Setenv("XDG_STATE_HOME", state)
72+ script := filepath.Join(t.TempDir(), "wine")
73+ if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf 'driver warning\\n' >&2\n"), 0o755); err != nil {
74+ t.Fatal(err)
75+ }
76+ adapter := NewAdapter(log.New(io.Discard))
77+ adapter.detected = &domain.WineRuntime{WineBin: script}
78+ if async {
79+ done, err := adapter.RunExeAsync(t.TempDir(), "Battle.net.exe", nil)
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ if err := <-done; err != nil {
84+ t.Fatal(err)
85+ }
86+ } else if err := adapter.RunExe(t.TempDir(), "Battle.net.exe", nil); err != nil {
87+ t.Fatal(err)
88+ }
89+ data, err := os.ReadFile(filepath.Join(state, "bnetctl", "logs", "latest.log"))
90+ if err != nil || string(data) != "driver warning\n" {
91+ t.Fatalf("stderr = %q, %v", data, err)
92+ }
93+ })
94+ }
95+}
96+
97+func TestWineLogFailureFallsBack(t *testing.T) {
98+ state := filepath.Join(t.TempDir(), "not-a-directory")
99+ if err := os.WriteFile(state, nil, 0o600); err != nil {
100+ t.Fatal(err)
101+ }
102+ t.Setenv("XDG_STATE_HOME", state)
103+ adapter := NewAdapter(log.New(io.Discard))
104+ if got := adapter.launchStderr(); got != os.Stderr {
105+ if err := got.Close(); err != nil {
106+ t.Error(err)
107+ }
108+ t.Fatal("expected terminal fallback")
109+ }
110+}
111+
112+func sparseWineLog(t *testing.T, dir, name string, size int64) {
113+ t.Helper()
114+ f, err := os.Create(filepath.Join(dir, name))
115+ if err != nil {
116+ t.Fatal(err)
117+ }
118+ t.Cleanup(func() {
119+ if err := f.Close(); err != nil {
120+ t.Error(err)
121+ }
122+ })
123+ if err := f.Truncate(size); err != nil {
124+ t.Fatal(err)
125+ }
126+}
127+
128+func TestWineLogBudgetDeletesOldest(t *testing.T) {
129+ dir := t.TempDir()
130+ names := []string{"wine-3.log", "wine-2.log", "wine-1.log"}
131+ for _, name := range names {
132+ sparseWineLog(t, dir, name, 50<<20)
133+ }
134+ if err := pruneWineLogBudget(dir, names); err != nil {
135+ t.Fatal(err)
136+ }
137+ for i, name := range names {
138+ _, err := os.Stat(filepath.Join(dir, name))
139+ if i == 2 {
140+ if !os.IsNotExist(err) {
141+ t.Fatalf("oldest retained: %v", err)
142+ }
143+ } else if err != nil {
144+ t.Fatal(err)
145+ }
146+ }
147+}
148+
149+func TestWineLogOversizedAndNewExemption(t *testing.T) {
150+ dir := t.TempDir()
151+ // The new file sorts oldest and exceeds both limits, but must survive.
152+ sparseWineLog(t, dir, "wine-0.log", 200<<20)
153+ sparseWineLog(t, dir, "wine-3.log", (50<<20)+1)
154+ sparseWineLog(t, dir, "wine-2.log", 50<<20)
155+ sparseWineLog(t, dir, "wine-1.log", 1)
156+ if err := rotateWineLogs(dir, "wine-0.log"); err != nil {
157+ t.Fatal(err)
158+ }
159+ for _, name := range []string{"wine-0.log", "wine-2.log"} {
160+ if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
161+ t.Fatal(err)
162+ }
163+ }
164+ for _, name := range []string{"wine-3.log", "wine-1.log"} {
165+ if _, err := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(err) {
166+ t.Fatalf("log not removed: %s: %v", name, err)
167+ }
168+ }
169+}
170+
171+func TestCustomWineDebugWarning(t *testing.T) {
172+ for _, tt := range []struct {
173+ name, value string
174+ set, warn bool
175+ }{
176+ {name: "unset"},
177+ {name: "default", set: true, value: "err+all,warn+waylanddrv"},
178+ {name: "disabled", set: true, value: "-all", warn: true},
179+ {name: "empty", set: true, warn: true},
180+ } {
181+ t.Run(tt.name, func(t *testing.T) {
182+ t.Setenv("XDG_STATE_HOME", t.TempDir())
183+ t.Setenv("BNETCTL_WINEDEBUG", tt.value)
184+ if !tt.set {
185+ if err := os.Unsetenv("BNETCTL_WINEDEBUG"); err != nil {
186+ t.Fatal(err)
187+ }
188+ }
189+ var output bytes.Buffer
190+ adapter := NewAdapter(log.New(&output))
191+ f := adapter.launchStderr()
192+ t.Cleanup(func() {
193+ if err := f.Close(); err != nil {
194+ t.Error(err)
195+ }
196+ })
197+ count := strings.Count(output.String(), "custom WINEDEBUG can produce large logs")
198+ if (tt.warn && count != 1) || (!tt.warn && count != 0) {
199+ t.Fatal(output.String())
200+ }
201+ if tt.warn && !strings.Contains(output.String(), f.Name()) {
202+ t.Fatal("warning missing path")
203+ }
204+ })
205+ }
206+}
@@ -0,0 +1,206 @@
1+package wine
2+
3+import (
4+ "bytes"
5+ "io"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "github.com/bnema/bnetctl/internal/domain"
12+ "github.com/charmbracelet/log"
13+)
14+
15+func TestWineLogRotationAndLatest(t *testing.T) {
16+ state := t.TempDir()
17+ t.Setenv("XDG_STATE_HOME", state)
18+ dir := filepath.Join(state, "bnetctl", "logs")
19+ var paths []string
20+ for i := 0; i < 8; i++ {
21+ file, err := openWineLog()
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ paths = append(paths, file.Name())
26+ if err := file.Close(); err != nil {
27+ t.Fatal(err)
28+ }
29+ target, err := os.Readlink(filepath.Join(dir, "latest.log"))
30+ if err != nil || target != filepath.Base(file.Name()) {
31+ t.Fatalf("latest = %q, %v", target, err)
32+ }
33+ }
34+ logs, err := filepath.Glob(filepath.Join(dir, "wine-*.log"))
35+ if err != nil || len(logs) != 3 {
36+ t.Fatalf("logs = %v, %v", logs, err)
37+ }
38+ for i, path := range paths {
39+ _, err := os.Stat(path)
40+ if i < 5 && !os.IsNotExist(err) {
41+ t.Fatalf("old log retained: %s (%v)", path, err)
42+ }
43+ if i >= 5 && err != nil {
44+ t.Fatalf("recent log missing: %s (%v)", path, err)
45+ }
46+ }
47+}
48+
49+func TestWineLogHomeFallback(t *testing.T) {
50+ home := t.TempDir()
51+ t.Setenv("HOME", home)
52+ t.Setenv("XDG_STATE_HOME", "")
53+ file, err := openWineLog()
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ t.Cleanup(func() {
58+ if err := file.Close(); err != nil {
59+ t.Error(err)
60+ }
61+ })
62+ if filepath.Dir(file.Name()) != filepath.Join(home, ".local", "state", "bnetctl", "logs") {
63+ t.Fatal(file.Name())
64+ }
65+}
66+
67+func TestLaunchStderr(t *testing.T) {
68+ for _, async := range []bool{false, true} {
69+ t.Run(map[bool]string{false: "blocking", true: "async"}[async], func(t *testing.T) {
70+ state := t.TempDir()
71+ t.Setenv("XDG_STATE_HOME", state)
72+ script := filepath.Join(t.TempDir(), "wine")
73+ if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf 'driver warning\\n' >&2\n"), 0o755); err != nil {
74+ t.Fatal(err)
75+ }
76+ adapter := NewAdapter(log.New(io.Discard))
77+ adapter.detected = &domain.WineRuntime{WineBin: script}
78+ if async {
79+ done, err := adapter.RunExeAsync(t.TempDir(), "Battle.net.exe", nil)
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ if err := <-done; err != nil {
84+ t.Fatal(err)
85+ }
86+ } else if err := adapter.RunExe(t.TempDir(), "Battle.net.exe", nil); err != nil {
87+ t.Fatal(err)
88+ }
89+ data, err := os.ReadFile(filepath.Join(state, "bnetctl", "logs", "latest.log"))
90+ if err != nil || string(data) != "driver warning\n" {
91+ t.Fatalf("stderr = %q, %v", data, err)
92+ }
93+ })
94+ }
95+}
96+
97+func TestWineLogFailureFallsBack(t *testing.T) {
98+ state := filepath.Join(t.TempDir(), "not-a-directory")
99+ if err := os.WriteFile(state, nil, 0o600); err != nil {
100+ t.Fatal(err)
101+ }
102+ t.Setenv("XDG_STATE_HOME", state)
103+ adapter := NewAdapter(log.New(io.Discard))
104+ if got := adapter.launchStderr(); got != os.Stderr {
105+ if err := got.Close(); err != nil {
106+ t.Error(err)
107+ }
108+ t.Fatal("expected terminal fallback")
109+ }
110+}
111+
112+func sparseWineLog(t *testing.T, dir, name string, size int64) {
113+ t.Helper()
114+ f, err := os.Create(filepath.Join(dir, name))
115+ if err != nil {
116+ t.Fatal(err)
117+ }
118+ t.Cleanup(func() {
119+ if err := f.Close(); err != nil {
120+ t.Error(err)
121+ }
122+ })
123+ if err := f.Truncate(size); err != nil {
124+ t.Fatal(err)
125+ }
126+}
127+
128+func TestWineLogBudgetDeletesOldest(t *testing.T) {
129+ dir := t.TempDir()
130+ names := []string{"wine-3.log", "wine-2.log", "wine-1.log"}
131+ for _, name := range names {
132+ sparseWineLog(t, dir, name, 50<<20)
133+ }
134+ if err := pruneWineLogBudget(dir, names); err != nil {
135+ t.Fatal(err)
136+ }
137+ for i, name := range names {
138+ _, err := os.Stat(filepath.Join(dir, name))
139+ if i == 2 {
140+ if !os.IsNotExist(err) {
141+ t.Fatalf("oldest retained: %v", err)
142+ }
143+ } else if err != nil {
144+ t.Fatal(err)
145+ }
146+ }
147+}
148+
149+func TestWineLogOversizedAndNewExemption(t *testing.T) {
150+ dir := t.TempDir()
151+ // The new file sorts oldest and exceeds both limits, but must survive.
152+ sparseWineLog(t, dir, "wine-0.log", 200<<20)
153+ sparseWineLog(t, dir, "wine-3.log", (50<<20)+1)
154+ sparseWineLog(t, dir, "wine-2.log", 50<<20)
155+ sparseWineLog(t, dir, "wine-1.log", 1)
156+ if err := rotateWineLogs(dir, "wine-0.log"); err != nil {
157+ t.Fatal(err)
158+ }
159+ for _, name := range []string{"wine-0.log", "wine-2.log"} {
160+ if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
161+ t.Fatal(err)
162+ }
163+ }
164+ for _, name := range []string{"wine-3.log", "wine-1.log"} {
165+ if _, err := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(err) {
166+ t.Fatalf("log not removed: %s: %v", name, err)
167+ }
168+ }
169+}
170+
171+func TestCustomWineDebugWarning(t *testing.T) {
172+ for _, tt := range []struct {
173+ name, value string
174+ set, warn bool
175+ }{
176+ {name: "unset"},
177+ {name: "default", set: true, value: "err+all,warn+waylanddrv"},
178+ {name: "disabled", set: true, value: "-all", warn: true},
179+ {name: "empty", set: true, warn: true},
180+ } {
181+ t.Run(tt.name, func(t *testing.T) {
182+ t.Setenv("XDG_STATE_HOME", t.TempDir())
183+ t.Setenv("BNETCTL_WINEDEBUG", tt.value)
184+ if !tt.set {
185+ if err := os.Unsetenv("BNETCTL_WINEDEBUG"); err != nil {
186+ t.Fatal(err)
187+ }
188+ }
189+ var output bytes.Buffer
190+ adapter := NewAdapter(log.New(&output))
191+ f := adapter.launchStderr()
192+ t.Cleanup(func() {
193+ if err := f.Close(); err != nil {
194+ t.Error(err)
195+ }
196+ })
197+ count := strings.Count(output.String(), "custom WINEDEBUG can produce large logs")
198+ if (tt.warn && count != 1) || (!tt.warn && count != 0) {
199+ t.Fatal(output.String())
200+ }
201+ if tt.warn && !strings.Contains(output.String(), f.Name()) {
202+ t.Fatal("warning missing path")
203+ }
204+ })
205+ }
206+}
M internal/adapters/wine/runtime.go +11 -2
@@ -213,7 +213,11 @@ func (a *Adapter) RunExe(prefixPath string, exePath string, wineEnv *domain.Wine
213213 cmd := exec.Command(rt.WineBin, exePath)
214214 cmd.Env = envMapToSlice(env)
215215 cmd.Stdout = os.Stdout
216- cmd.Stderr = os.Stderr
216+ stderr := a.launchStderr()
217+ if stderr != os.Stderr {
218+ defer func() { _ = stderr.Close() }()
219+ }
220+ cmd.Stderr = stderr
217221 return cmd.Run()
218222 }
219223
@@ -234,7 +238,12 @@ func (a *Adapter) RunExeAsync(prefixPath string, exePath string, wineEnv *domain
234238 cmd := exec.Command(rt.WineBin, cmdArgs...)
235239 cmd.Env = envMapToSlice(env)
236240 cmd.Stdout = os.Stdout
237- cmd.Stderr = os.Stderr
241+ stderr := a.launchStderr()
242+ if stderr != os.Stderr {
243+ // Start duplicates the descriptor into the child before we close it.
244+ defer func() { _ = stderr.Close() }()
245+ }
246+ cmd.Stderr = stderr
238247
239248 if err := cmd.Start(); err != nil {
240249 return nil, fmt.Errorf("start process: %w", err)
@@ -213,7 +213,11 @@ func (a *Adapter) RunExe(prefixPath string, exePath string, wineEnv *domain.Wine
213 cmd := exec.Command(rt.WineBin, exePath)213 cmd := exec.Command(rt.WineBin, exePath)
214 cmd.Env = envMapToSlice(env)214 cmd.Env = envMapToSlice(env)
215 cmd.Stdout = os.Stdout215 cmd.Stdout = os.Stdout
216- cmd.Stderr = os.Stderr216+ stderr := a.launchStderr()
217+ if stderr != os.Stderr {
218+ defer func() { _ = stderr.Close() }()
219+ }
220+ cmd.Stderr = stderr
217 return cmd.Run()221 return cmd.Run()
218 }222 }
219 223
@@ -234,7 +238,12 @@ func (a *Adapter) RunExeAsync(prefixPath string, exePath string, wineEnv *domain
234 cmd := exec.Command(rt.WineBin, cmdArgs...)238 cmd := exec.Command(rt.WineBin, cmdArgs...)
235 cmd.Env = envMapToSlice(env)239 cmd.Env = envMapToSlice(env)
236 cmd.Stdout = os.Stdout240 cmd.Stdout = os.Stdout
237- cmd.Stderr = os.Stderr241+ stderr := a.launchStderr()
242+ if stderr != os.Stderr {
243+ // Start duplicates the descriptor into the child before we close it.
244+ defer func() { _ = stderr.Close() }()
245+ }
246+ cmd.Stderr = stderr
238 247
239 if err := cmd.Start(); err != nil {248 if err := cmd.Start(); err != nil {
240 return nil, fmt.Errorf("start process: %w", err)249 return nil, fmt.Errorf("start process: %w", err)

Comments00 unresolved

    no comments yet.

    Keyboard shortcuts

    Single-key shortcuts are on. ctrl k and esc work whatever this says.

    ctrl k :
    go to a section, branch, tag, file or repository
    ?
    this list
    j k
    move the row cursor down / up
    enter
    open the row under the cursor; on Files, a directory opens or closes in place
    ↑ ↓ ← → home end
    in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
    l h
    on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. On pages with a side panel: into the panel (on a merge request, its files) / back to the content; there j k move between its controls
    g g G
    first / last row
    /
    filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
    b
    switch branch or tag
    g s g f g c g b g m g i g r g a
    tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
    g h
    home
    w
    wide mode: use the whole width of the screen, or not (remembered)
    y
    copy a permalink pinned to the commit; in a file, click a line number to select it, shift-click for a range
    d u x
    diff: split / unified / collapse or expand all
    e E
    diff: the whole file in view / every file, with the lines the hunks hide
    n
    merge request: next note in the diff
    l
    merge request: the list of files (j k move, enter opens, esc back)
    M
    merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
    R
    merge request: mark the head read
    esc
    close a dialog, leave a field

    Branches 2

    Tags 2

    ↑↓ moveenter openesc close