detached d2c37b0

#4 fix(wine): post-v1.0.0 review fixes and simpler Wine selection

state
merged · d2c37b0 · fast-forward
branch
fix/post-v1-review → main
opened
bnema ·
updated

Fixes from the post-v1.0.0 review:

  • kill/stop uses a Wine at least as new as the prefix's recorded version
  • one sibling prefix.lock serializes maintenance, stop and clean (incl. clean -a)
  • stop report lists clients closed gracefully
  • interrupted first prefix creation can be retried
  • Wine selection: BNETCTL_WINE > PATH wine > /opt/wine-cachyos, all modes
  • README rewritten

Checks: golangci-lint, go vet, go test -race.

CommitSubjectAuthorDateSignatureStatus
d2c37b0test(wine): synchronize shutdown wait on observed contentionbrice GPG Verified
6438725refactor(wine): use a single binary resolver for every modebrice GPG Verified
5f89969docs(readme): document home-based paths and retained cleanup lockbrice GPG Verified
48889d6fix(clean): preserve exclusion and lock inode throughout full cleanupbrice GPG Verified
6ef0214fix(wine): retry interrupted initial prefix creation safelybrice GPG Verified
3bbe02cfix(wine): report clients stopped by graceful closebrice GPG Verified
83151d4docs(readme): simplify setup usage and Wine selection guidancebrice GPG Verified
866e925refactor(wine): prefer PATH Wine consistently in all modesbrice GPG Verified
3265b6dfix(prefix): serialize maintenance shutdown and deletionbrice GPG Verified
bc05f59fix(wine): respect prefix runtime minimum during shutdownbrice GPG Verified

Checks 0

no checks

15 files · +662 -214
no checks
M README.md +65 -98
@@ -1,126 +1,93 @@
11 # bnetctl
22
3-A Go CLI tool to install, manage, and run [Battle.net](https://battle.net) on Linux via [wine-cachyos](https://github.com/CachyOS/wine-cachyos).
4-
5-Built for **Wayland** tiling WMs such as niri and Hyprland. Native Wayland is the default, with an explicit X11/XWayland mode for compatibility. Automatic GPU detection supports AMD, NVIDIA, and Intel.
6-
7-## Dependencies
8-
9-**CachyOS** (all packages available in the CachyOS repos):
10-
11-```bash
12-# Required — optimized Wine installed under /opt/wine-cachyos
13-sudo pacman -S wine-cachyos-opt
14-
15-# Required — DXVK/VKD3D translate Direct3D to Vulkan (critical for frame pacing)
16-sudo pacman -S dxvk-mingw-git vkd3d-proton-mingw-git
17-
18-# Optional — extracts Battle.net icon for the .desktop entry
19-sudo pacman -S icoutils
20-```
21-
22-**Arch Linux** (non-CachyOS): `wine-cachyos` requires the [CachyOS repositories](https://wiki.cachyos.net/adding_repo/). The closest alternative is `wine-tkg-git` (AUR) — untested.
3+Install, manage, and run [Battle.net](https://battle.net) on Linux via Wine.
4+Native Wayland is the default; X11/XWayland is available for compatibility.
5+GPU-specific settings are applied automatically for AMD, NVIDIA, and Intel.
6+
7+## Requirements
8+
9+- Wine, **>= 11.19 for Wayland**. On Arch Linux, install either:
10+ ```bash
11+ sudo pacman -S wine-staging
12+ # Or: sudo pacman -S wine
13+ ```
14+- Vulkan drivers for your GPU, plus DXVK and VKD3D-proton package data.
15+ On CachyOS these are available as `dxvk-mingw-git` and
16+ `vkd3d-proton-mingw-git`; other distributions may package them differently.
17+- Optional: `icoutils` to extract the Battle.net icon for desktop entries.
18+- Optional: CachyOS Wine installed at `/opt/wine-cachyos` as a fallback.
2319
2420 ## Install
2521
22+With Go installed:
23+
2624 ```bash
2725 go install github.com/bnema/bnetctl@latest
2826 ```
2927
28+Make sure your Go binary directory (usually `~/go/bin`) is on `PATH`.
29+
3030 ## Usage
3131
3232 ```bash
33-bnetctl install # Download, create prefix, install Battle.net
34-bnetctl install --display-driver x11 # Install through X11/XWayland
35-bnetctl launch # Start Battle.net using native Wayland
36-bnetctl launch --display-driver x11 # Start through X11/XWayland
37-bnetctl desktop add # Create Wayland and X11 menu entries
38-bnetctl desktop remove # Remove .desktop menu entry
39-bnetctl kill # Stop Battle.net, including leftover Wine processes
40-bnetctl clean # Remove cache and prefix
41-bnetctl clean -a # Full purge (cache, prefix, desktop entry, data)
33+bnetctl install # Download and install Battle.net
34+bnetctl launch # Launch with native Wayland
35+bnetctl install --display-driver x11 # Install through X11/XWayland
36+bnetctl launch --display-driver x11 # Launch through X11/XWayland
37+bnetctl launch --no-hdr # Disable automatic HDR settings
38+bnetctl desktop add # Add Wayland and X11 menu entries
39+bnetctl desktop remove # Remove menu entries
40+bnetctl kill # Stop Battle.net and leftover Wine processes
41+bnetctl stop # Alias for kill
42+bnetctl clean # Remove cache and prefix
43+bnetctl clean -a # Also remove desktop entries and data
4244 ```
4345
44-## What it does
46+### Wine selection
4547
46-- Prefers `/opt/wine-cachyos/bin/wine`; in Wayland mode, prefers Wine >= 11.19, falling back to `wine` on PATH when necessary. `BNETCTL_WINE` always wins
47-- Creates a Wine prefix with DXVK + VKD3D-proton auto-installed
48-- Downloads and runs the official Battle.net installer
49-- Uses native Wayland by default for both `install` and `launch`, with `--display-driver x11` available for XWayland
50-- Sets up NTSync (auto-detected), esync/fsync, GPU-specific env vars
51-- Disables Wine systray (orphan floating window on Wayland tiling WMs)
52-- Extracts the Battle.net icon and creates separate Wayland and X11 desktop entries
53-- Graceful process management with orphan cleanup
48+1. `BNETCTL_WINE`, when set, selects the Wine executable for every display mode.
49+2. Otherwise, use `wine` on `PATH`, falling back to `/opt/wine-cachyos/bin/wine`.
50+3. A tracked prefix requires Wine at least as new as its recorded version, including for stop; an older explicit override is rejected.
51+4. `wineboot` and `wineserver` must be beside the resolved Wine binary; Wayland warns below 11.19.
5452
55-## HDR on Wayland
53+For example: `BNETCTL_WINE=/usr/bin/wine bnetctl launch`.
5654
57-HDR is enabled automatically for native Wayland only for `WowClassic.exe`,
58-`Wow.exe`, `WoW.exe` (retail), `WowClassicT.exe`, `WowT.exe`, and `WoWT.exe`
59-(DXVK matches executable names case-sensitively), using per-executable
60-`dxgi.enableHDR = True` sections in `DXVK_CONFIG`. This avoids exposing HDR to
61-Battle.net's Chromium/CEF launcher. It requires **Wine >= 11.19**, an HDR-capable
62-compositor with HDR enabled, and an HDR-capable monitor/game.
55+### HDR
6356
64-An existing `DXVK_HDR` environment variable is respected, including an empty
65-value; when it is set, no automatic config is added. Otherwise, the WoW sections
66-are appended after any existing `DXVK_CONFIG` lines, preserving their scope.
67-To skip automatic HDR entries and unset inherited `DXVK_HDR` (user-provided
68-`DXVK_CONFIG` is left untouched):
57+Native Wayland automatically enables HDR for supported World of Warcraft
58+executables through per-executable DXVK settings, not for the Battle.net
59+launcher. Use Wine >= 11.19, an HDR-capable compositor with HDR enabled, and
60+an HDR-capable monitor; also enable HDR in the game.
6961
70-```bash
71-bnetctl launch --no-hdr
72-```
62+An existing `DXVK_HDR` takes precedence. `--no-hdr` disables automatic settings
63+and unsets inherited `DXVK_HDR`, but leaves user-provided `DXVK_CONFIG` intact.
64+X11 mode leaves the user's HDR environment untouched.
7365
74-X11 mode leaves the user's HDR environment untouched. Games started by
75-Battle.net inherit its per-executable config; enable HDR in the game's own
76-settings too.
77-
78-For Wayland `install` and `launch`, bnetctl prefers CachyOS Wine when it meets the
79-minimum, otherwise qualifying `wine` on PATH. If neither qualifies, it retains
80-the usual preference and warns. X11 selection is unchanged. `BNETCTL_WINE`
81-always wins, e.g. `BNETCTL_WINE=/usr/bin/wine`; companion `wineboot` and
82-`wineserver` must be beside its resolved binary.
83-
84-On the first launch of an existing prefix without a version marker, or after a
85-Wine version change, bnetctl runs `wineboot -u -r`, waits for Wine to exit, and
86-directly reinstalls and verifies DXVK/vkd3d-proton DLL symlinks and `native`
87-overrides. No setup scripts are executed. Package data is found beside the
88-resolved setup scripts, falling back to `/usr/share/dxvk` and
89-`/usr/share/vkd3d-proton` (including Arch's vkd3d wrapper).
90-
91-**Maintenance limitation:** `-r` skips Run keys and Startup, but Wine still
92-processes RunServicesOnce and HKLM RunOnce (`programs/wineboot/wineboot.c`,
93-lines 1931 and 1945; Run/Startup are gated at 1947–1952). Before maintenance,
94-bnetctl reads `pfx/system.reg` and `pfx/user.reg` and refuses if RunOnce or
95-RunServicesOnce, including Wow6432Node variants, contains any values. The error
96-lists the entries. bnetctl never deletes or disables these registry values.
97-Resolve pending one-time startup tasks yourself before retrying maintenance.
98-
99-The `prefix/wine-version` marker is committed only after verification. A
100-`prefix/wine-version.migrating` record is written before updating; failures
101-retain it and force a retry, even if the committed version already matches.
102-Both records establish a minimum Wine version: X11 and Wayland will use an
103-available runtime at least that new, or refuse. An older explicit `BNETCTL_WINE`
104-is rejected rather than silently overridden. Switching display modes never
105-downgrades a tracked prefix. Version probes (`wine --version`) may run during
106-selection; downgrade refusals run no prefix Wine commands.
107-
108-An exclusive `prefix/.bnetctl.lock` protects selection and maintenance until the
109-application process starts; concurrent invocations fail immediately. Close
110-Battle.net and all its games before switching Wine versions. The first update
111-may take longer. The default prefix is `~/.local/share/bnetctl/prefix`.
112-
113-Use `bnetctl --verbose launch --display-driver wayland` to see the selected Wine,
114-HDR policy, and any one-time prefix update on stderr. Logs are always saved to
115-`~/.cache/bnetctl/bnetctl.log` (or the corresponding XDG cache directory).
66+## Troubleshooting
67+
68+- **Wayland rendering or login problems:** try `--display-driver x11` and check
69+ your Wine version and Vulkan drivers.
70+- **Wine version changes:** close Battle.net and its games before switching.
71+ bnetctl updates the prefix and verifies graphics DLLs when needed. Pending
72+ RunOnce/RunServicesOnce tasks can block maintenance; resolve the reported
73+ tasks before retrying. Stop and clean do not run migration preflight.
74+- **Prefix busy:** maintenance, shutdown, and deletion share an exclusion lock.
75+ Stop/clean wait up to 10 seconds for the lock; retry after maintenance finishes.
76+ Launch releases its lock once Wine starts, not at the end of the game session.
77+- **More detail:** run `bnetctl --verbose launch` to see runtime selection and
78+ maintenance diagnostics. Logs are saved to `~/.cache/bnetctl/bnetctl.log`.
11679
11780 ## Directories
11881
119-| Type | Path |
120-|------|------|
82+Paths are based on your home directory; `XDG_DATA_HOME` and `XDG_CACHE_HOME`
83+are not currently honored. Full cleanup retains the data directory and its
84+`prefix.lock` file so concurrent invocations keep using the same lock inode.
85+
86+| Type | Default path |
87+|------|--------------|
12188 | Data | `~/.local/share/bnetctl` |
122-| Cache | `~/.cache/bnetctl` |
12389 | Prefix | `~/.local/share/bnetctl/prefix` |
90+| Cache | `~/.cache/bnetctl` |
12491 | Log | `~/.cache/bnetctl/bnetctl.log` |
12592
12693 ## License
@@ -1,126 +1,93 @@
1 # bnetctl1 # bnetctl
2 2
3-A Go CLI tool to install, manage, and run [Battle.net](https://battle.net) on Linux via [wine-cachyos](https://github.com/CachyOS/wine-cachyos).3+Install, manage, and run [Battle.net](https://battle.net) on Linux via Wine.
4-4+Native Wayland is the default; X11/XWayland is available for compatibility.
5-Built for **Wayland** tiling WMs such as niri and Hyprland. Native Wayland is the default, with an explicit X11/XWayland mode for compatibility. Automatic GPU detection supports AMD, NVIDIA, and Intel.5+GPU-specific settings are applied automatically for AMD, NVIDIA, and Intel.
6-6+
7-## Dependencies7+## Requirements
8-8+
9-**CachyOS** (all packages available in the CachyOS repos):9+- Wine, **>= 11.19 for Wayland**. On Arch Linux, install either:
10-10+ ```bash
11-```bash11+ sudo pacman -S wine-staging
12-# Required — optimized Wine installed under /opt/wine-cachyos12+ # Or: sudo pacman -S wine
13-sudo pacman -S wine-cachyos-opt13+ ```
14-14+- Vulkan drivers for your GPU, plus DXVK and VKD3D-proton package data.
15-# Required — DXVK/VKD3D translate Direct3D to Vulkan (critical for frame pacing)15+ On CachyOS these are available as `dxvk-mingw-git` and
16-sudo pacman -S dxvk-mingw-git vkd3d-proton-mingw-git16+ `vkd3d-proton-mingw-git`; other distributions may package them differently.
17-17+- Optional: `icoutils` to extract the Battle.net icon for desktop entries.
18-# Optional — extracts Battle.net icon for the .desktop entry18+- Optional: CachyOS Wine installed at `/opt/wine-cachyos` as a fallback.
19-sudo pacman -S icoutils
20-```
21-
22-**Arch Linux** (non-CachyOS): `wine-cachyos` requires the [CachyOS repositories](https://wiki.cachyos.net/adding_repo/). The closest alternative is `wine-tkg-git` (AUR) — untested.
23 19
24 ## Install20 ## Install
25 21
22+With Go installed:
23+
26 ```bash24 ```bash
27 go install github.com/bnema/bnetctl@latest25 go install github.com/bnema/bnetctl@latest
28 ```26 ```
29 27
28+Make sure your Go binary directory (usually `~/go/bin`) is on `PATH`.
29+
30 ## Usage30 ## Usage
31 31
32 ```bash32 ```bash
33-bnetctl install # Download, create prefix, install Battle.net33+bnetctl install # Download and install Battle.net
34-bnetctl install --display-driver x11 # Install through X11/XWayland34+bnetctl launch # Launch with native Wayland
35-bnetctl launch # Start Battle.net using native Wayland35+bnetctl install --display-driver x11 # Install through X11/XWayland
36-bnetctl launch --display-driver x11 # Start through X11/XWayland36+bnetctl launch --display-driver x11 # Launch through X11/XWayland
37-bnetctl desktop add # Create Wayland and X11 menu entries37+bnetctl launch --no-hdr # Disable automatic HDR settings
38-bnetctl desktop remove # Remove .desktop menu entry38+bnetctl desktop add # Add Wayland and X11 menu entries
39-bnetctl kill # Stop Battle.net, including leftover Wine processes39+bnetctl desktop remove # Remove menu entries
40-bnetctl clean # Remove cache and prefix40+bnetctl kill # Stop Battle.net and leftover Wine processes
41-bnetctl clean -a # Full purge (cache, prefix, desktop entry, data)41+bnetctl stop # Alias for kill
42+bnetctl clean # Remove cache and prefix
43+bnetctl clean -a # Also remove desktop entries and data
42 ```44 ```
43 45
44-## What it does46+### Wine selection
45 47
46-- Prefers `/opt/wine-cachyos/bin/wine`; in Wayland mode, prefers Wine >= 11.19, falling back to `wine` on PATH when necessary. `BNETCTL_WINE` always wins48+1. `BNETCTL_WINE`, when set, selects the Wine executable for every display mode.
47-- Creates a Wine prefix with DXVK + VKD3D-proton auto-installed49+2. Otherwise, use `wine` on `PATH`, falling back to `/opt/wine-cachyos/bin/wine`.
48-- Downloads and runs the official Battle.net installer50+3. A tracked prefix requires Wine at least as new as its recorded version, including for stop; an older explicit override is rejected.
49-- Uses native Wayland by default for both `install` and `launch`, with `--display-driver x11` available for XWayland51+4. `wineboot` and `wineserver` must be beside the resolved Wine binary; Wayland warns below 11.19.
50-- Sets up NTSync (auto-detected), esync/fsync, GPU-specific env vars
51-- Disables Wine systray (orphan floating window on Wayland tiling WMs)
52-- Extracts the Battle.net icon and creates separate Wayland and X11 desktop entries
53-- Graceful process management with orphan cleanup
54 52
55-## HDR on Wayland53+For example: `BNETCTL_WINE=/usr/bin/wine bnetctl launch`.
56 54
57-HDR is enabled automatically for native Wayland only for `WowClassic.exe`,55+### HDR
58-`Wow.exe`, `WoW.exe` (retail), `WowClassicT.exe`, `WowT.exe`, and `WoWT.exe`
59-(DXVK matches executable names case-sensitively), using per-executable
60-`dxgi.enableHDR = True` sections in `DXVK_CONFIG`. This avoids exposing HDR to
61-Battle.net's Chromium/CEF launcher. It requires **Wine >= 11.19**, an HDR-capable
62-compositor with HDR enabled, and an HDR-capable monitor/game.
63 56
64-An existing `DXVK_HDR` environment variable is respected, including an empty57+Native Wayland automatically enables HDR for supported World of Warcraft
65-value; when it is set, no automatic config is added. Otherwise, the WoW sections58+executables through per-executable DXVK settings, not for the Battle.net
66-are appended after any existing `DXVK_CONFIG` lines, preserving their scope.59+launcher. Use Wine >= 11.19, an HDR-capable compositor with HDR enabled, and
67-To skip automatic HDR entries and unset inherited `DXVK_HDR` (user-provided60+an HDR-capable monitor; also enable HDR in the game.
68-`DXVK_CONFIG` is left untouched):
69 61
70-```bash62+An existing `DXVK_HDR` takes precedence. `--no-hdr` disables automatic settings
71-bnetctl launch --no-hdr63+and unsets inherited `DXVK_HDR`, but leaves user-provided `DXVK_CONFIG` intact.
72-```64+X11 mode leaves the user's HDR environment untouched.
73 65
74-X11 mode leaves the user's HDR environment untouched. Games started by66+## Troubleshooting
75-Battle.net inherit its per-executable config; enable HDR in the game's own67+
76-settings too.68+- **Wayland rendering or login problems:** try `--display-driver x11` and check
77-69+ your Wine version and Vulkan drivers.
78-For Wayland `install` and `launch`, bnetctl prefers CachyOS Wine when it meets the70+- **Wine version changes:** close Battle.net and its games before switching.
79-minimum, otherwise qualifying `wine` on PATH. If neither qualifies, it retains71+ bnetctl updates the prefix and verifies graphics DLLs when needed. Pending
80-the usual preference and warns. X11 selection is unchanged. `BNETCTL_WINE`72+ RunOnce/RunServicesOnce tasks can block maintenance; resolve the reported
81-always wins, e.g. `BNETCTL_WINE=/usr/bin/wine`; companion `wineboot` and73+ tasks before retrying. Stop and clean do not run migration preflight.
82-`wineserver` must be beside its resolved binary.74+- **Prefix busy:** maintenance, shutdown, and deletion share an exclusion lock.
83-75+ Stop/clean wait up to 10 seconds for the lock; retry after maintenance finishes.
84-On the first launch of an existing prefix without a version marker, or after a76+ Launch releases its lock once Wine starts, not at the end of the game session.
85-Wine version change, bnetctl runs `wineboot -u -r`, waits for Wine to exit, and77+- **More detail:** run `bnetctl --verbose launch` to see runtime selection and
86-directly reinstalls and verifies DXVK/vkd3d-proton DLL symlinks and `native`78+ maintenance diagnostics. Logs are saved to `~/.cache/bnetctl/bnetctl.log`.
87-overrides. No setup scripts are executed. Package data is found beside the
88-resolved setup scripts, falling back to `/usr/share/dxvk` and
89-`/usr/share/vkd3d-proton` (including Arch's vkd3d wrapper).
90-
91-**Maintenance limitation:** `-r` skips Run keys and Startup, but Wine still
92-processes RunServicesOnce and HKLM RunOnce (`programs/wineboot/wineboot.c`,
93-lines 1931 and 1945; Run/Startup are gated at 1947–1952). Before maintenance,
94-bnetctl reads `pfx/system.reg` and `pfx/user.reg` and refuses if RunOnce or
95-RunServicesOnce, including Wow6432Node variants, contains any values. The error
96-lists the entries. bnetctl never deletes or disables these registry values.
97-Resolve pending one-time startup tasks yourself before retrying maintenance.
98-
99-The `prefix/wine-version` marker is committed only after verification. A
100-`prefix/wine-version.migrating` record is written before updating; failures
101-retain it and force a retry, even if the committed version already matches.
102-Both records establish a minimum Wine version: X11 and Wayland will use an
103-available runtime at least that new, or refuse. An older explicit `BNETCTL_WINE`
104-is rejected rather than silently overridden. Switching display modes never
105-downgrades a tracked prefix. Version probes (`wine --version`) may run during
106-selection; downgrade refusals run no prefix Wine commands.
107-
108-An exclusive `prefix/.bnetctl.lock` protects selection and maintenance until the
109-application process starts; concurrent invocations fail immediately. Close
110-Battle.net and all its games before switching Wine versions. The first update
111-may take longer. The default prefix is `~/.local/share/bnetctl/prefix`.
112-
113-Use `bnetctl --verbose launch --display-driver wayland` to see the selected Wine,
114-HDR policy, and any one-time prefix update on stderr. Logs are always saved to
115-`~/.cache/bnetctl/bnetctl.log` (or the corresponding XDG cache directory).
116 79
117 ## Directories80 ## Directories
118 81
119-| Type | Path |82+Paths are based on your home directory; `XDG_DATA_HOME` and `XDG_CACHE_HOME`
120-|------|------|83+are not currently honored. Full cleanup retains the data directory and its
84+`prefix.lock` file so concurrent invocations keep using the same lock inode.
85+
86+| Type | Default path |
87+|------|--------------|
121 | Data | `~/.local/share/bnetctl` |88 | Data | `~/.local/share/bnetctl` |
122-| Cache | `~/.cache/bnetctl` |
123 | Prefix | `~/.local/share/bnetctl/prefix` |89 | Prefix | `~/.local/share/bnetctl/prefix` |
90+| Cache | `~/.cache/bnetctl` |
124 | Log | `~/.cache/bnetctl/bnetctl.log` |91 | Log | `~/.cache/bnetctl/bnetctl.log` |
125 92
126 ## License93 ## License
M cmd/root.go +2 -2
@@ -20,10 +20,10 @@ var verbose bool
2020
2121 var rootCmd = &cobra.Command{
2222 Use: "bnetctl",
23- Short: "Battle.net launcher for Linux via wine-cachyos",
23+ Short: "Battle.net launcher for Linux via Wine",
2424 Version: version + " (" + commit + ")",
2525 Long: `A CLI tool to install, manage, and run Battle.net on Linux
26-using wine-cachyos as the compatibility layer.
26+using Wine as the compatibility layer.
2727
2828 Quick start:
2929 bnetctl install Download and install Battle.net
@@ -20,10 +20,10 @@ var verbose bool
20 20
21 var rootCmd = &cobra.Command{21 var rootCmd = &cobra.Command{
22 Use: "bnetctl",22 Use: "bnetctl",
23- Short: "Battle.net launcher for Linux via wine-cachyos",23+ Short: "Battle.net launcher for Linux via Wine",
24 Version: version + " (" + commit + ")",24 Version: version + " (" + commit + ")",
25 Long: `A CLI tool to install, manage, and run Battle.net on Linux25 Long: `A CLI tool to install, manage, and run Battle.net on Linux
26-using wine-cachyos as the compatibility layer.26+using Wine as the compatibility layer.
27 27
28 Quick start:28 Quick start:
29 bnetctl install Download and install Battle.net29 bnetctl install Download and install Battle.net
M internal/adapters/env/gpu.go +2 -2
@@ -138,10 +138,10 @@ func BuildGPUEnvForDisplayHDR(driver DisplayDriver, noHDR bool) *domain.WineEnv
138138 // environment, so force-disabling would also kill frame generation
139139 // for games. Keep user Vulkan layers untouched.
140140
141- // NTSync: wine-cachyos detects /dev/ntsync automatically. No env var needed.
141+ // NTSync: Wine detects /dev/ntsync automatically. No env var needed.
142142
143143 // Wine sync primitives — enable esync/fsync for frame pacing.
144- // NTSync (preferred) is auto-detected by wine-cachyos from /dev/ntsync.
144+ // NTSync (preferred) is auto-detected by Wine from /dev/ntsync.
145145 // esync/fsync are fallbacks if ntsync is unavailable.
146146 env.Vars["WINEESYNC"] = "1"
147147 env.Vars["WINEFSYNC"] = "1"
@@ -138,10 +138,10 @@ func BuildGPUEnvForDisplayHDR(driver DisplayDriver, noHDR bool) *domain.WineEnv
138 // environment, so force-disabling would also kill frame generation138 // environment, so force-disabling would also kill frame generation
139 // for games. Keep user Vulkan layers untouched.139 // for games. Keep user Vulkan layers untouched.
140 140
141- // NTSync: wine-cachyos detects /dev/ntsync automatically. No env var needed.141+ // NTSync: Wine detects /dev/ntsync automatically. No env var needed.
142 142
143 // Wine sync primitives — enable esync/fsync for frame pacing.143 // Wine sync primitives — enable esync/fsync for frame pacing.
144- // NTSync (preferred) is auto-detected by wine-cachyos from /dev/ntsync.144+ // NTSync (preferred) is auto-detected by Wine from /dev/ntsync.
145 // esync/fsync are fallbacks if ntsync is unavailable.145 // esync/fsync are fallbacks if ntsync is unavailable.
146 env.Vars["WINEESYNC"] = "1"146 env.Vars["WINEESYNC"] = "1"
147 env.Vars["WINEFSYNC"] = "1"147 env.Vars["WINEFSYNC"] = "1"
M internal/adapters/wine/runtime.go +43 -30
@@ -23,7 +23,7 @@ const (
2323 cachyOSWineBinary = "/opt/wine-cachyos/bin/wine"
2424 )
2525
26-// Adapter implements ports.RuntimePort using wine-cachyos directly
26+// Adapter implements ports.RuntimePort using Wine directly
2727 type Adapter struct {
2828 log *log.Logger
2929 wayland bool
@@ -43,14 +43,14 @@ func NewWaylandAdapter(log *log.Logger) *Adapter {
4343 return &Adapter{log: log, wayland: true}
4444 }
4545
46-// Detect checks if wine-cachyos is available and returns runtime info
46+// Detect checks if Wine is available and returns runtime info
4747 func (a *Adapter) Detect() (*domain.WineRuntime, error) {
4848 log := a.log
4949 if a.detected != nil {
5050 return a.detected, nil
5151 }
5252
53- wineBin, wineBootBin, wineServerBin, err := resolveBinariesForWayland(a.wayland)
53+ wineBin, wineBootBin, wineServerBin, err := resolveBinaries()
5454 if err != nil {
5555 return nil, err
5656 }
@@ -367,20 +367,39 @@ func (a *Adapter) waitForBattleNetExit(prefixPath string, max time.Duration) {
367367 // reaped here - `wineserver -k` cannot reach them, because there is no server
368368 // left to talk to.
369369 func (a *Adapter) GracefulKillPrefix(prefixPath string, timeout time.Duration) (domain.StopResult, error) {
370+ release, err := a.lockPrefixExclusion(prefixPath, timeout)
371+ if err != nil {
372+ return domain.StopResult{}, err
373+ }
374+ defer release()
375+ return a.gracefulKillPrefix(prefixPath, timeout)
376+}
377+
378+// WithStoppedPrefix keeps shutdown and the caller's deletion in one exclusion
379+// interval. The callback is never invoked unless shutdown has been verified.
380+func (a *Adapter) WithStoppedPrefix(prefixPath string, timeout time.Duration, remove func() error) error {
381+ release, err := a.lockPrefixExclusion(prefixPath, timeout)
382+ if err != nil {
383+ return err
384+ }
385+ defer release()
386+ if _, err := a.gracefulKillPrefix(prefixPath, timeout); err != nil {
387+ return err
388+ }
389+ return remove()
390+}
391+
392+func (a *Adapter) gracefulKillPrefix(prefixPath string, timeout time.Duration) (domain.StopResult, error) {
393+ if _, err := a.selectPrefixRuntime(prefixPath); err != nil {
394+ return domain.StopResult{}, err
395+ }
370396 log := a.log
371397 pfxDir := filepath.Join(prefixPath, "pfx")
372398 log.Debug("graceful kill initiated", "prefix", pfxDir, "timeout", timeout)
373399
374400 var result domain.StopResult
375401
376- // Let Battle.net save its session before the prefix is taken down.
377- if a.IsProcessRunning(prefixPath) {
378- a.closeBattleNetWindows(prefixPath)
379- a.waitForBattleNetExit(prefixPath, battleNetCloseWait)
380- }
381-
382- // Snapshot what is still running: the wineserver terminates its own clients, so
383- // they cannot be listed after the stop.
402+ // Capture the initial session before WM_CLOSE can make clients disappear.
384403 session, snapshotErr := prefixProcesses(pfxDir, "")
385404 if snapshotErr != nil {
386405 log.Warn("cannot list the wine session before stopping it", "prefix", pfxDir, "error", snapshotErr)
@@ -388,6 +407,12 @@ func (a *Adapter) GracefulKillPrefix(prefixPath string, timeout time.Duration) (
388407 result.Session, result.Wineserver = splitWineserver(processNames(session))
389408 }
390409
410+ // Let Battle.net save its session before the prefix is taken down.
411+ if a.IsProcessRunning(prefixPath) {
412+ a.closeBattleNetWindows(prefixPath)
413+ a.waitForBattleNetExit(prefixPath, battleNetCloseWait)
414+ }
415+
391416 // Stop the wineserver. When none is running, the prefix only holds leftovers
392417 // from an earlier run, and the sweep below is what removes them.
393418 var forceErr error
@@ -705,32 +730,20 @@ func (a *Adapter) buildEnv(pfxDir string, wineEnv *domain.WineEnv) map[string]st
705730 }
706731
707732 func resolveBinaries() (wine, wineboot, wineserver string, err error) {
708- return resolveBinariesForWayland(false)
709-}
710-
711-func resolveBinariesForWayland(wayland bool) (wine, wineboot, wineserver string, err error) {
712733 wine = os.Getenv(wineOverrideEnv)
713734 if wine == "" {
714- if isExecutable(cachyOSWineBinary) {
715- wine = cachyOSWineBinary
716- } else if wine, err = exec.LookPath("wine"); err != nil {
717- return "", "", "", fmt.Errorf("wine not found: %w (install wine-cachyos-opt or set %s)", err, wineOverrideEnv)
735+ if wine, err = exec.LookPath("wine"); err != nil {
736+ if isExecutable(cachyOSWineBinary) {
737+ wine = cachyOSWineBinary
738+ } else {
739+ return "", "", "", fmt.Errorf("wine not found: %w (install wine-staging or wine, or set %s)", err, wineOverrideEnv)
740+ }
718741 }
719742 } else if !filepath.IsAbs(wine) {
720743 if wine, err = exec.LookPath(wine); err != nil {
721744 return "", "", "", fmt.Errorf("resolve %s: %w", wineOverrideEnv, err)
722745 }
723746 }
724- if wayland && os.Getenv(wineOverrideEnv) == "" {
725- pathWine, _ := exec.LookPath("wine")
726- wine = selectHDRWine(wine, pathWine, func(path string) string {
727- out, err := exec.Command(path, "--version").Output()
728- if err != nil {
729- return ""
730- }
731- return strings.TrimSpace(string(out))
732- })
733- }
734747 if !isExecutable(wine) {
735748 return "", "", "", fmt.Errorf("wine executable is not usable: %s", wine)
736749 }
@@ -767,5 +780,5 @@ func (a *Adapter) binaries() (string, string, string, error) {
767780 if a.detected != nil {
768781 return a.detected.WineBin, a.detected.WineBootBin, a.detected.WineServerBin, nil
769782 }
770- return resolveBinariesForWayland(a.wayland)
783+ return resolveBinaries()
771784 }
@@ -23,7 +23,7 @@ const (
23 cachyOSWineBinary = "/opt/wine-cachyos/bin/wine"23 cachyOSWineBinary = "/opt/wine-cachyos/bin/wine"
24 )24 )
25 25
26-// Adapter implements ports.RuntimePort using wine-cachyos directly26+// Adapter implements ports.RuntimePort using Wine directly
27 type Adapter struct {27 type Adapter struct {
28 log *log.Logger28 log *log.Logger
29 wayland bool29 wayland bool
@@ -43,14 +43,14 @@ func NewWaylandAdapter(log *log.Logger) *Adapter {
43 return &Adapter{log: log, wayland: true}43 return &Adapter{log: log, wayland: true}
44 }44 }
45 45
46-// Detect checks if wine-cachyos is available and returns runtime info46+// Detect checks if Wine is available and returns runtime info
47 func (a *Adapter) Detect() (*domain.WineRuntime, error) {47 func (a *Adapter) Detect() (*domain.WineRuntime, error) {
48 log := a.log48 log := a.log
49 if a.detected != nil {49 if a.detected != nil {
50 return a.detected, nil50 return a.detected, nil
51 }51 }
52 52
53- wineBin, wineBootBin, wineServerBin, err := resolveBinariesForWayland(a.wayland)53+ wineBin, wineBootBin, wineServerBin, err := resolveBinaries()
54 if err != nil {54 if err != nil {
55 return nil, err55 return nil, err
56 }56 }
@@ -367,20 +367,39 @@ func (a *Adapter) waitForBattleNetExit(prefixPath string, max time.Duration) {
367 // reaped here - `wineserver -k` cannot reach them, because there is no server367 // reaped here - `wineserver -k` cannot reach them, because there is no server
368 // left to talk to.368 // left to talk to.
369 func (a *Adapter) GracefulKillPrefix(prefixPath string, timeout time.Duration) (domain.StopResult, error) {369 func (a *Adapter) GracefulKillPrefix(prefixPath string, timeout time.Duration) (domain.StopResult, error) {
370+ release, err := a.lockPrefixExclusion(prefixPath, timeout)
371+ if err != nil {
372+ return domain.StopResult{}, err
373+ }
374+ defer release()
375+ return a.gracefulKillPrefix(prefixPath, timeout)
376+}
377+
378+// WithStoppedPrefix keeps shutdown and the caller's deletion in one exclusion
379+// interval. The callback is never invoked unless shutdown has been verified.
380+func (a *Adapter) WithStoppedPrefix(prefixPath string, timeout time.Duration, remove func() error) error {
381+ release, err := a.lockPrefixExclusion(prefixPath, timeout)
382+ if err != nil {
383+ return err
384+ }
385+ defer release()
386+ if _, err := a.gracefulKillPrefix(prefixPath, timeout); err != nil {
387+ return err
388+ }
389+ return remove()
390+}
391+
392+func (a *Adapter) gracefulKillPrefix(prefixPath string, timeout time.Duration) (domain.StopResult, error) {
393+ if _, err := a.selectPrefixRuntime(prefixPath); err != nil {
394+ return domain.StopResult{}, err
395+ }
370 log := a.log396 log := a.log
371 pfxDir := filepath.Join(prefixPath, "pfx")397 pfxDir := filepath.Join(prefixPath, "pfx")
372 log.Debug("graceful kill initiated", "prefix", pfxDir, "timeout", timeout)398 log.Debug("graceful kill initiated", "prefix", pfxDir, "timeout", timeout)
373 399
374 var result domain.StopResult400 var result domain.StopResult
375 401
376- // Let Battle.net save its session before the prefix is taken down.402+ // Capture the initial session before WM_CLOSE can make clients disappear.
377- if a.IsProcessRunning(prefixPath) {
378- a.closeBattleNetWindows(prefixPath)
379- a.waitForBattleNetExit(prefixPath, battleNetCloseWait)
380- }
381-
382- // Snapshot what is still running: the wineserver terminates its own clients, so
383- // they cannot be listed after the stop.
384 session, snapshotErr := prefixProcesses(pfxDir, "")403 session, snapshotErr := prefixProcesses(pfxDir, "")
385 if snapshotErr != nil {404 if snapshotErr != nil {
386 log.Warn("cannot list the wine session before stopping it", "prefix", pfxDir, "error", snapshotErr)405 log.Warn("cannot list the wine session before stopping it", "prefix", pfxDir, "error", snapshotErr)
@@ -388,6 +407,12 @@ func (a *Adapter) GracefulKillPrefix(prefixPath string, timeout time.Duration) (
388 result.Session, result.Wineserver = splitWineserver(processNames(session))407 result.Session, result.Wineserver = splitWineserver(processNames(session))
389 }408 }
390 409
410+ // Let Battle.net save its session before the prefix is taken down.
411+ if a.IsProcessRunning(prefixPath) {
412+ a.closeBattleNetWindows(prefixPath)
413+ a.waitForBattleNetExit(prefixPath, battleNetCloseWait)
414+ }
415+
391 // Stop the wineserver. When none is running, the prefix only holds leftovers416 // Stop the wineserver. When none is running, the prefix only holds leftovers
392 // from an earlier run, and the sweep below is what removes them.417 // from an earlier run, and the sweep below is what removes them.
393 var forceErr error418 var forceErr error
@@ -705,32 +730,20 @@ func (a *Adapter) buildEnv(pfxDir string, wineEnv *domain.WineEnv) map[string]st
705 }730 }
706 731
707 func resolveBinaries() (wine, wineboot, wineserver string, err error) {732 func resolveBinaries() (wine, wineboot, wineserver string, err error) {
708- return resolveBinariesForWayland(false)
709-}
710-
711-func resolveBinariesForWayland(wayland bool) (wine, wineboot, wineserver string, err error) {
712 wine = os.Getenv(wineOverrideEnv)733 wine = os.Getenv(wineOverrideEnv)
713 if wine == "" {734 if wine == "" {
714- if isExecutable(cachyOSWineBinary) {735+ if wine, err = exec.LookPath("wine"); err != nil {
715- wine = cachyOSWineBinary736+ if isExecutable(cachyOSWineBinary) {
716- } else if wine, err = exec.LookPath("wine"); err != nil {737+ wine = cachyOSWineBinary
717- return "", "", "", fmt.Errorf("wine not found: %w (install wine-cachyos-opt or set %s)", err, wineOverrideEnv)738+ } else {
739+ return "", "", "", fmt.Errorf("wine not found: %w (install wine-staging or wine, or set %s)", err, wineOverrideEnv)
740+ }
718 }741 }
719 } else if !filepath.IsAbs(wine) {742 } else if !filepath.IsAbs(wine) {
720 if wine, err = exec.LookPath(wine); err != nil {743 if wine, err = exec.LookPath(wine); err != nil {
721 return "", "", "", fmt.Errorf("resolve %s: %w", wineOverrideEnv, err)744 return "", "", "", fmt.Errorf("resolve %s: %w", wineOverrideEnv, err)
722 }745 }
723 }746 }
724- if wayland && os.Getenv(wineOverrideEnv) == "" {
725- pathWine, _ := exec.LookPath("wine")
726- wine = selectHDRWine(wine, pathWine, func(path string) string {
727- out, err := exec.Command(path, "--version").Output()
728- if err != nil {
729- return ""
730- }
731- return strings.TrimSpace(string(out))
732- })
733- }
734 if !isExecutable(wine) {747 if !isExecutable(wine) {
735 return "", "", "", fmt.Errorf("wine executable is not usable: %s", wine)748 return "", "", "", fmt.Errorf("wine executable is not usable: %s", wine)
736 }749 }
@@ -767,5 +780,5 @@ func (a *Adapter) binaries() (string, string, string, error) {
767 if a.detected != nil {780 if a.detected != nil {
768 return a.detected.WineBin, a.detected.WineBootBin, a.detected.WineServerBin, nil781 return a.detected.WineBin, a.detected.WineBootBin, a.detected.WineServerBin, nil
769 }782 }
770- return resolveBinariesForWayland(a.wayland)783+ return resolveBinaries()
771 }784 }
M internal/adapters/wine/runtime_test.go +33 -0
@@ -423,3 +423,36 @@ func requireAlive(t *testing.T, cmd *exec.Cmd) {
423423 }
424424 }
425425 }
426+
427+func TestGracefulKillReportsClientClosedBeforeServerStop(t *testing.T) {
428+ prefix := t.TempDir()
429+ client := startFakeWineProcess(t, "Battle.net.exe", "WINEPREFIX="+winePrefixPath(prefix), "WINESERVERSOCKET=/tmp/server.sock")
430+ logPath := fakeWineBinaries(t, 0, fmt.Sprintf("if [ \"$1\" = '-k' ] && kill -0 %d 2>/dev/null; then exit 1; fi\n", client.Process.Pid))
431+ wine := os.Getenv(wineOverrideEnv)
432+ data, err := os.ReadFile(wine)
433+ if err != nil {
434+ t.Fatal(err)
435+ }
436+ data = append(data, []byte(fmt.Sprintf("if [ \"$1\" = taskkill ]; then kill -9 %d; fi\n", client.Process.Pid))...)
437+ if err := os.WriteFile(wine, data, 0755); err != nil {
438+ t.Fatal(err)
439+ }
440+ a := NewAdapter(log.New(io.Discard))
441+ result, err := a.GracefulKillPrefix(prefix, time.Second)
442+ if err != nil {
443+ t.Fatal(err)
444+ }
445+ if len(result.Session) != 1 || result.Session[0].PID != client.Process.Pid {
446+ t.Fatalf("closed client missing: %+v", result)
447+ }
448+ if len(result.Leftovers) != 0 {
449+ t.Fatalf("closed client counted as leftover: %+v", result)
450+ }
451+ calls, err := os.ReadFile(logPath)
452+ if err != nil {
453+ t.Fatal(err)
454+ }
455+ if !strings.Contains(string(calls), "wine taskkill") || !strings.Contains(string(calls), "wineserver -k\n") {
456+ t.Fatalf("missing close/stop: %s", calls)
457+ }
458+}
@@ -423,3 +423,36 @@ func requireAlive(t *testing.T, cmd *exec.Cmd) {
423 }423 }
424 }424 }
425 }425 }
426+
427+func TestGracefulKillReportsClientClosedBeforeServerStop(t *testing.T) {
428+ prefix := t.TempDir()
429+ client := startFakeWineProcess(t, "Battle.net.exe", "WINEPREFIX="+winePrefixPath(prefix), "WINESERVERSOCKET=/tmp/server.sock")
430+ logPath := fakeWineBinaries(t, 0, fmt.Sprintf("if [ \"$1\" = '-k' ] && kill -0 %d 2>/dev/null; then exit 1; fi\n", client.Process.Pid))
431+ wine := os.Getenv(wineOverrideEnv)
432+ data, err := os.ReadFile(wine)
433+ if err != nil {
434+ t.Fatal(err)
435+ }
436+ data = append(data, []byte(fmt.Sprintf("if [ \"$1\" = taskkill ]; then kill -9 %d; fi\n", client.Process.Pid))...)
437+ if err := os.WriteFile(wine, data, 0755); err != nil {
438+ t.Fatal(err)
439+ }
440+ a := NewAdapter(log.New(io.Discard))
441+ result, err := a.GracefulKillPrefix(prefix, time.Second)
442+ if err != nil {
443+ t.Fatal(err)
444+ }
445+ if len(result.Session) != 1 || result.Session[0].PID != client.Process.Pid {
446+ t.Fatalf("closed client missing: %+v", result)
447+ }
448+ if len(result.Leftovers) != 0 {
449+ t.Fatalf("closed client counted as leftover: %+v", result)
450+ }
451+ calls, err := os.ReadFile(logPath)
452+ if err != nil {
453+ t.Fatal(err)
454+ }
455+ if !strings.Contains(string(calls), "wine taskkill") || !strings.Contains(string(calls), "wineserver -k\n") {
456+ t.Fatalf("missing close/stop: %s", calls)
457+ }
458+}
M internal/adapters/wine/safety.go +77 -26
@@ -1,6 +1,7 @@
11 package wine
22
33 import (
4+ "errors"
45 "fmt"
56 "os"
67 "os/exec"
@@ -9,6 +10,7 @@ import (
910 "strings"
1011 "sync"
1112 "syscall"
13+ "time"
1214 )
1315
1416 func versionNumbers(v string) ([]int, error) {
@@ -82,7 +84,7 @@ func chooseMinimumWine(selected, version, minimum string) (string, string, error
8284 return "", "", fmt.Errorf("refusing Wine downgrade: BNETCTL_WINE is %s; prefix requires >= %s", version, minimum)
8385 }
8486 path, _ := exec.LookPath("wine")
85- for _, candidate := range []string{cachyOSWineBinary, path} {
87+ for _, candidate := range []string{path, cachyOSWineBinary} {
8688 if candidate == "" || !isExecutable(candidate) {
8789 continue
8890 }
@@ -119,25 +121,59 @@ func readVersion(path string) (string, error) {
119121 return v, nil
120122 }
121123 func (a *Adapter) LockPrefix(prefix string) (func(), error) {
122- if err := os.MkdirAll(prefix, 0755); err != nil {
124+ release, err := a.lockPrefixExclusion(prefix, 0)
125+ if err != nil {
123126 return nil, err
124127 }
125- f, err := os.OpenFile(filepath.Join(prefix, ".bnetctl.lock"), os.O_CREATE|os.O_RDWR, 0600)
128+ versions, err := a.selectPrefixRuntime(prefix)
126129 if err != nil {
130+ release()
127131 return nil, err
128132 }
129- if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
130- _ = f.Close()
131- return nil, fmt.Errorf("prefix is locked by another bnetctl invocation: %w", err)
133+ rt := a.detected
134+ if versions[prefixVersionFile+".migrating"] != "" || versions[prefixVersionFile] != rt.Version {
135+ if err := checkRunOnce(filepath.Join(prefix, "pfx")); err != nil {
136+ release()
137+ return nil, err
138+ }
139+ // Only an uncommitted initial creation can safely retry --init without
140+ // an architecture header. Established prefixes must still validate it.
141+ if versions[prefixVersionFile] == "" && versions[prefixVersionFile+".migrating"] != "" {
142+ if _, err := os.Stat(filepath.Join(prefix, "pfx", "system.reg")); os.IsNotExist(err) {
143+ if a.IsProcessRunning(prefix) {
144+ release()
145+ return nil, fmt.Errorf("cannot retry Wine prefix initialization while it is running")
146+ }
147+ if err := a.CreatePrefix(prefix); err != nil {
148+ release()
149+ return nil, fmt.Errorf("retry Wine prefix initialization: %w", err)
150+ }
151+ } else if err != nil {
152+ release()
153+ return nil, err
154+ }
155+ }
156+ // New prefixes have no system.reg until wineboot initializes them.
157+ if _, err := os.Stat(filepath.Join(prefix, "pfx")); err == nil {
158+ if _, err := readPrefixArch(filepath.Join(prefix, "pfx")); err != nil {
159+ release()
160+ return nil, err
161+ }
162+ } else if !os.IsNotExist(err) {
163+ release()
164+ return nil, err
165+ }
132166 }
133- var once sync.Once
134- release := func() { once.Do(func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN); _ = f.Close() }) }
167+ return release, nil
168+}
169+
170+// selectPrefixRuntime applies the recorded version floor without migration preflight.
171+func (a *Adapter) selectPrefixRuntime(prefix string) (map[string]string, error) {
135172 a.minimum = ""
136173 versions := map[string]string{}
137174 for _, name := range []string{prefixVersionFile, prefixVersionFile + ".migrating"} {
138175 v, err := readVersion(filepath.Join(prefix, name))
139176 if err != nil {
140- release()
141177 return nil, err
142178 }
143179 versions[name] = v
@@ -147,7 +183,6 @@ func (a *Adapter) LockPrefix(prefix string) (func(), error) {
147183 cmp, err = compareVersions(v, a.minimum)
148184 }
149185 if err != nil {
150- release()
151186 return nil, err
152187 }
153188 if cmp > 0 {
@@ -156,26 +191,42 @@ func (a *Adapter) LockPrefix(prefix string) (func(), error) {
156191 }
157192 }
158193 a.detected = nil
159- rt, err := a.Detect()
194+ _, err := a.Detect()
160195 if err != nil {
161- release()
162196 return nil, err
163197 }
164- if versions[prefixVersionFile+".migrating"] != "" || versions[prefixVersionFile] != rt.Version {
165- if err := checkRunOnce(filepath.Join(prefix, "pfx")); err != nil {
166- release()
167- return nil, err
198+ return versions, nil
199+}
200+
201+// lockPrefixExclusion never runs Wine or migration preflight. The persistent
202+// sibling inode must not be removed: waiters must all lock the same file even
203+// while the prefix is deleted and recreated.
204+func (a *Adapter) lockPrefixExclusion(prefix string, wait time.Duration) (func(), error) {
205+ prefix = filepath.Clean(prefix)
206+ if err := os.MkdirAll(filepath.Dir(prefix), 0755); err != nil {
207+ return nil, err
208+ }
209+ f, err := os.OpenFile(prefix+".lock", os.O_CREATE|os.O_RDWR, 0600)
210+ if err != nil {
211+ return nil, err
212+ }
213+ deadline := time.Now().Add(wait)
214+ announced := false
215+ for {
216+ err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
217+ if err == nil {
218+ break
168219 }
169- // New prefixes have no system.reg until wineboot initializes them.
170- if _, err := os.Stat(filepath.Join(prefix, "pfx")); err == nil {
171- if _, err := readPrefixArch(filepath.Join(prefix, "pfx")); err != nil {
172- release()
173- return nil, err
174- }
175- } else if !os.IsNotExist(err) {
176- release()
177- return nil, err
220+ if !errors.Is(err, syscall.EWOULDBLOCK) || !time.Now().Before(deadline) {
221+ _ = f.Close()
222+ return nil, fmt.Errorf("prefix is locked by another bnetctl invocation (retry after maintenance completes): %w", err)
178223 }
224+ if !announced {
225+ a.log.Info("waiting for prefix maintenance lock", "prefix", prefix, "timeout", wait)
226+ announced = true
227+ }
228+ time.Sleep(25 * time.Millisecond)
179229 }
180- return release, nil
230+ var once sync.Once
231+ return func() { once.Do(func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN); _ = f.Close() }) }, nil
181232 }
@@ -1,6 +1,7 @@
1 package wine1 package wine
2 2
3 import (3 import (
4+ "errors"
4 "fmt"5 "fmt"
5 "os"6 "os"
6 "os/exec"7 "os/exec"
@@ -9,6 +10,7 @@ import (
9 "strings"10 "strings"
10 "sync"11 "sync"
11 "syscall"12 "syscall"
13+ "time"
12 )14 )
13 15
14 func versionNumbers(v string) ([]int, error) {16 func versionNumbers(v string) ([]int, error) {
@@ -82,7 +84,7 @@ func chooseMinimumWine(selected, version, minimum string) (string, string, error
82 return "", "", fmt.Errorf("refusing Wine downgrade: BNETCTL_WINE is %s; prefix requires >= %s", version, minimum)84 return "", "", fmt.Errorf("refusing Wine downgrade: BNETCTL_WINE is %s; prefix requires >= %s", version, minimum)
83 }85 }
84 path, _ := exec.LookPath("wine")86 path, _ := exec.LookPath("wine")
85- for _, candidate := range []string{cachyOSWineBinary, path} {87+ for _, candidate := range []string{path, cachyOSWineBinary} {
86 if candidate == "" || !isExecutable(candidate) {88 if candidate == "" || !isExecutable(candidate) {
87 continue89 continue
88 }90 }
@@ -119,25 +121,59 @@ func readVersion(path string) (string, error) {
119 return v, nil121 return v, nil
120 }122 }
121 func (a *Adapter) LockPrefix(prefix string) (func(), error) {123 func (a *Adapter) LockPrefix(prefix string) (func(), error) {
122- if err := os.MkdirAll(prefix, 0755); err != nil {124+ release, err := a.lockPrefixExclusion(prefix, 0)
125+ if err != nil {
123 return nil, err126 return nil, err
124 }127 }
125- f, err := os.OpenFile(filepath.Join(prefix, ".bnetctl.lock"), os.O_CREATE|os.O_RDWR, 0600)128+ versions, err := a.selectPrefixRuntime(prefix)
126 if err != nil {129 if err != nil {
130+ release()
127 return nil, err131 return nil, err
128 }132 }
129- if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {133+ rt := a.detected
130- _ = f.Close()134+ if versions[prefixVersionFile+".migrating"] != "" || versions[prefixVersionFile] != rt.Version {
131- return nil, fmt.Errorf("prefix is locked by another bnetctl invocation: %w", err)135+ if err := checkRunOnce(filepath.Join(prefix, "pfx")); err != nil {
136+ release()
137+ return nil, err
138+ }
139+ // Only an uncommitted initial creation can safely retry --init without
140+ // an architecture header. Established prefixes must still validate it.
141+ if versions[prefixVersionFile] == "" && versions[prefixVersionFile+".migrating"] != "" {
142+ if _, err := os.Stat(filepath.Join(prefix, "pfx", "system.reg")); os.IsNotExist(err) {
143+ if a.IsProcessRunning(prefix) {
144+ release()
145+ return nil, fmt.Errorf("cannot retry Wine prefix initialization while it is running")
146+ }
147+ if err := a.CreatePrefix(prefix); err != nil {
148+ release()
149+ return nil, fmt.Errorf("retry Wine prefix initialization: %w", err)
150+ }
151+ } else if err != nil {
152+ release()
153+ return nil, err
154+ }
155+ }
156+ // New prefixes have no system.reg until wineboot initializes them.
157+ if _, err := os.Stat(filepath.Join(prefix, "pfx")); err == nil {
158+ if _, err := readPrefixArch(filepath.Join(prefix, "pfx")); err != nil {
159+ release()
160+ return nil, err
161+ }
162+ } else if !os.IsNotExist(err) {
163+ release()
164+ return nil, err
165+ }
132 }166 }
133- var once sync.Once167+ return release, nil
134- release := func() { once.Do(func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN); _ = f.Close() }) }168+}
169+
170+// selectPrefixRuntime applies the recorded version floor without migration preflight.
171+func (a *Adapter) selectPrefixRuntime(prefix string) (map[string]string, error) {
135 a.minimum = ""172 a.minimum = ""
136 versions := map[string]string{}173 versions := map[string]string{}
137 for _, name := range []string{prefixVersionFile, prefixVersionFile + ".migrating"} {174 for _, name := range []string{prefixVersionFile, prefixVersionFile + ".migrating"} {
138 v, err := readVersion(filepath.Join(prefix, name))175 v, err := readVersion(filepath.Join(prefix, name))
139 if err != nil {176 if err != nil {
140- release()
141 return nil, err177 return nil, err
142 }178 }
143 versions[name] = v179 versions[name] = v
@@ -147,7 +183,6 @@ func (a *Adapter) LockPrefix(prefix string) (func(), error) {
147 cmp, err = compareVersions(v, a.minimum)183 cmp, err = compareVersions(v, a.minimum)
148 }184 }
149 if err != nil {185 if err != nil {
150- release()
151 return nil, err186 return nil, err
152 }187 }
153 if cmp > 0 {188 if cmp > 0 {
@@ -156,26 +191,42 @@ func (a *Adapter) LockPrefix(prefix string) (func(), error) {
156 }191 }
157 }192 }
158 a.detected = nil193 a.detected = nil
159- rt, err := a.Detect()194+ _, err := a.Detect()
160 if err != nil {195 if err != nil {
161- release()
162 return nil, err196 return nil, err
163 }197 }
164- if versions[prefixVersionFile+".migrating"] != "" || versions[prefixVersionFile] != rt.Version {198+ return versions, nil
165- if err := checkRunOnce(filepath.Join(prefix, "pfx")); err != nil {199+}
166- release()200+
167- return nil, err201+// lockPrefixExclusion never runs Wine or migration preflight. The persistent
202+// sibling inode must not be removed: waiters must all lock the same file even
203+// while the prefix is deleted and recreated.
204+func (a *Adapter) lockPrefixExclusion(prefix string, wait time.Duration) (func(), error) {
205+ prefix = filepath.Clean(prefix)
206+ if err := os.MkdirAll(filepath.Dir(prefix), 0755); err != nil {
207+ return nil, err
208+ }
209+ f, err := os.OpenFile(prefix+".lock", os.O_CREATE|os.O_RDWR, 0600)
210+ if err != nil {
211+ return nil, err
212+ }
213+ deadline := time.Now().Add(wait)
214+ announced := false
215+ for {
216+ err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
217+ if err == nil {
218+ break
168 }219 }
169- // New prefixes have no system.reg until wineboot initializes them.220+ if !errors.Is(err, syscall.EWOULDBLOCK) || !time.Now().Before(deadline) {
170- if _, err := os.Stat(filepath.Join(prefix, "pfx")); err == nil {221+ _ = f.Close()
171- if _, err := readPrefixArch(filepath.Join(prefix, "pfx")); err != nil {222+ return nil, fmt.Errorf("prefix is locked by another bnetctl invocation (retry after maintenance completes): %w", err)
172- release()
173- return nil, err
174- }
175- } else if !os.IsNotExist(err) {
176- release()
177- return nil, err
178 }223 }
224+ if !announced {
225+ a.log.Info("waiting for prefix maintenance lock", "prefix", prefix, "timeout", wait)
226+ announced = true
227+ }
228+ time.Sleep(25 * time.Millisecond)
179 }229 }
180- return release, nil230+ var once sync.Once
231+ return func() { once.Do(func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN); _ = f.Close() }) }, nil
181 }232 }
M internal/adapters/wine/safety_test.go +221 -0
@@ -5,7 +5,9 @@ import (
55 "os"
66 "path/filepath"
77 "strings"
8+ "sync"
89 "testing"
10+ "time"
911
1012 "github.com/charmbracelet/log"
1113 )
@@ -149,3 +151,222 @@ func TestMatchingVersionAllowsRunOnce(t *testing.T) {
149151 t.Fatalf("recovery skipped preflight: %v", err)
150152 }
151153 }
154+
155+func TestShutdownSelectsRecordedMinimumWithoutPreflight(t *testing.T) {
156+ dir := fakeVersionInstall(t, "wine-11.21")
157+ t.Setenv(wineOverrideEnv, filepath.Join(dir, "wine"))
158+ prefix := t.TempDir()
159+ mustWrite(t, filepath.Join(prefix, prefixVersionFile), "wine-11.19\n")
160+ mustWrite(t, filepath.Join(prefix, "pfx/user.reg"), "[Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\RunOnce]\n\"pending\"=\"run\"\n")
161+ a := NewAdapter(log.New(io.Discard))
162+ if _, err := a.GracefulKillPrefix(prefix, 0); err != nil {
163+ t.Fatal(err)
164+ }
165+ if a.detected.Version != "wine-11.21" {
166+ t.Fatalf("runtime: %+v", a.detected)
167+ }
168+ mustWrite(t, filepath.Join(prefix, prefixVersionFile+".migrating"), "wine-12.0\n")
169+ if _, err := a.GracefulKillPrefix(prefix, 0); err == nil || !strings.Contains(err.Error(), "downgrade") {
170+ t.Fatalf("downgrade: %v", err)
171+ }
172+}
173+
174+func TestShutdownAndDeletionShareMaintenanceLock(t *testing.T) {
175+ dir := fakeVersionInstall(t, "wine-11.21")
176+ t.Setenv(wineOverrideEnv, filepath.Join(dir, "wine"))
177+ prefix := filepath.Join(t.TempDir(), "prefix")
178+ a := NewAdapter(log.New(io.Discard))
179+ b := NewAdapter(log.New(io.Discard))
180+ release, err := a.LockPrefix(prefix)
181+ if err != nil {
182+ t.Fatal(err)
183+ }
184+ if _, err := b.GracefulKillPrefix(prefix, 0); err == nil || !strings.Contains(err.Error(), "locked") {
185+ t.Fatalf("stop during maintenance: %v", err)
186+ }
187+ called := false
188+ if err := b.WithStoppedPrefix(prefix, 0, func() error { called = true; return nil }); err == nil || called {
189+ t.Fatalf("delete during maintenance: called=%v err=%v", called, err)
190+ }
191+ release()
192+ // Pending migration actions must not prevent shutdown/deletion.
193+ if err := os.MkdirAll(filepath.Join(prefix, "pfx"), 0755); err != nil {
194+ t.Fatal(err)
195+ }
196+ mustWrite(t, filepath.Join(prefix, "pfx/user.reg"), "[Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\RunOnce]\n\"pending\"=\"run\"\n")
197+ err = b.WithStoppedPrefix(prefix, 0, func() error {
198+ if err := os.RemoveAll(prefix); err != nil {
199+ return err
200+ }
201+ // Removal and recreation must not create a second lock inode.
202+ if err := os.MkdirAll(prefix, 0755); err != nil {
203+ return err
204+ }
205+ if unlock, err := a.LockPrefix(prefix); err == nil {
206+ unlock()
207+ t.Error("maintenance acquired lock during deletion")
208+ }
209+ if _, err := a.GracefulKillPrefix(prefix, 0); err == nil {
210+ t.Error("shutdown acquired lock during deletion")
211+ }
212+ return nil
213+ })
214+ if err != nil {
215+ t.Fatal(err)
216+ }
217+ unlock, err := a.LockPrefix(prefix)
218+ if err != nil {
219+ t.Fatal(err)
220+ }
221+ unlock()
222+}
223+
224+func TestShutdownWaitsForMaintenance(t *testing.T) {
225+ dir := fakeVersionInstall(t, "wine-11.21")
226+ t.Setenv(wineOverrideEnv, filepath.Join(dir, "wine"))
227+ prefix := filepath.Join(t.TempDir(), "prefix")
228+ a := NewAdapter(log.New(io.Discard))
229+ release, err := a.LockPrefix(prefix)
230+ if err != nil {
231+ t.Fatal(err)
232+ }
233+ defer release()
234+ writer := &lockContentionWriter{waiting: make(chan struct{})}
235+ done := make(chan error, 1)
236+ go func() {
237+ b := NewAdapter(log.New(writer))
238+ _, err := b.GracefulKillPrefix(prefix, time.Second)
239+ done <- err
240+ }()
241+ select {
242+ case err := <-done:
243+ t.Fatalf("shutdown did not wait: %v", err)
244+ case <-writer.waiting:
245+ case <-time.After(2 * time.Second):
246+ t.Fatal("shutdown never observed lock contention")
247+ }
248+ release()
249+ select {
250+ case err := <-done:
251+ if err != nil {
252+ t.Fatal(err)
253+ }
254+ case <-time.After(2 * time.Second):
255+ t.Fatal("shutdown did not resume")
256+ }
257+}
258+
259+func TestLockRetriesInterruptedInitialCreation(t *testing.T) {
260+ a, prefix, calls := migrationFixture(t)
261+ rt := a.detected
262+ // Provide deterministic detection while preserving the graphics fixture.
263+ mustWrite(t, rt.WineBin, "#!/bin/sh\nif [ \"$1\" = --version ]; then echo wine-11.21; exit 0; fi\nif [ \"$1\" = reg ] && [ \"$2\" = query ]; then echo \"$5 REG_SZ native\"; fi\n")
264+ mustWrite(t, rt.WineServerBin, "#!/bin/sh\nif [ \"$1\" = -k0 ]; then exit 1; fi\n")
265+ mustWrite(t, rt.WineBootBin, "#!/bin/sh\necho init >> '"+calls+"'\nprintf 'WINE REGISTRY Version 2\\n#arch=win64\\n' > \"$WINEPREFIX/system.reg\"\n")
266+ t.Setenv(wineOverrideEnv, rt.WineBin)
267+ // Detect discovers these setup paths; graphicsPlan uses their package data.
268+ tools := t.TempDir()
269+ for name, target := range map[string]string{"setup_dxvk": rt.DXVKSetupBin, "setup_vkd3d_proton": rt.VKD3DSetupBin} {
270+ if err := os.Symlink(target, filepath.Join(tools, name)); err != nil {
271+ t.Fatal(err)
272+ }
273+ }
274+ t.Setenv("PATH", tools)
275+ if err := os.Remove(filepath.Join(prefix, "pfx/system.reg")); err != nil {
276+ t.Fatal(err)
277+ }
278+ mustWrite(t, filepath.Join(prefix, prefixVersionFile+".migrating"), "wine-11.21\n")
279+ release, err := a.LockPrefix(prefix)
280+ if err != nil {
281+ t.Fatal(err)
282+ }
283+ release()
284+ version, err := readVersion(filepath.Join(prefix, prefixVersionFile))
285+ if err != nil || version != "wine-11.21" {
286+ t.Fatalf("version: %s %v", version, err)
287+ }
288+ if _, err := os.Stat(filepath.Join(prefix, prefixVersionFile+".migrating")); !os.IsNotExist(err) {
289+ t.Fatalf("pending record retained: %v", err)
290+ }
291+ data, err := os.ReadFile(calls)
292+ if err != nil || !strings.Contains(string(data), "init") {
293+ t.Fatalf("init not retried: %s %v", data, err)
294+ }
295+ // Missing or malformed architecture on an established prefix is not initial creation.
296+ for _, contents := range []string{"", "WINE REGISTRY Version 2\n#arch=garbage\n"} {
297+ if contents == "" {
298+ if err := os.Remove(filepath.Join(prefix, "pfx/system.reg")); err != nil {
299+ t.Fatal(err)
300+ }
301+ } else {
302+ mustWrite(t, filepath.Join(prefix, "pfx/system.reg"), contents)
303+ }
304+ mustWrite(t, filepath.Join(prefix, prefixVersionFile+".migrating"), "wine-11.21\n")
305+ if unlock, err := a.LockPrefix(prefix); err == nil {
306+ unlock()
307+ t.Fatal("established prefix with invalid architecture accepted")
308+ }
309+ }
310+}
311+
312+func TestInterruptedInitialCreationGuards(t *testing.T) {
313+ for _, guard := range []string{"running", "RunOnce", "boot failure", "malformed architecture"} {
314+ t.Run(guard, func(t *testing.T) {
315+ dir := fakeVersionInstall(t, "wine-11.21")
316+ t.Setenv(wineOverrideEnv, filepath.Join(dir, "wine"))
317+ prefix := t.TempDir()
318+ if err := os.MkdirAll(filepath.Join(prefix, "pfx"), 0755); err != nil {
319+ t.Fatal(err)
320+ }
321+ mustWrite(t, filepath.Join(prefix, prefixVersionFile+".migrating"), "wine-11.21\n")
322+ calls := filepath.Join(t.TempDir(), "boot-calls")
323+ mustWrite(t, filepath.Join(dir, "wineboot"), "#!/bin/sh\necho init >> '"+calls+"'\nexit 1\n")
324+ switch guard {
325+ case "running":
326+ mustWrite(t, filepath.Join(dir, "wineserver"), "#!/bin/sh\nexit 0\n")
327+ case "RunOnce":
328+ mustWrite(t, filepath.Join(prefix, "pfx/user.reg"), "[Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\RunOnce]\n\"pending\"=\"run\"\n")
329+ case "malformed architecture":
330+ mustWrite(t, filepath.Join(prefix, "pfx/system.reg"), "#arch=garbage\n")
331+ }
332+ a := NewAdapter(log.New(io.Discard))
333+ if unlock, err := a.LockPrefix(prefix); err == nil {
334+ unlock()
335+ t.Fatal("unsafe initialization accepted")
336+ }
337+ if _, err := os.Stat(filepath.Join(prefix, prefixVersionFile)); !os.IsNotExist(err) {
338+ t.Fatalf("committed version: %v", err)
339+ }
340+ if _, err := os.Stat(filepath.Join(prefix, prefixVersionFile+".migrating")); err != nil {
341+ t.Fatalf("lost retry marker: %v", err)
342+ }
343+ if _, err := os.Stat(calls); guard == "boot failure" {
344+ if err != nil {
345+ t.Fatal("boot retry did not run")
346+ }
347+ } else if !os.IsNotExist(err) {
348+ t.Fatal("guard ran wineboot")
349+ }
350+ // Every failure releases exclusion for a later retry or shutdown.
351+ unlock, err := a.lockPrefixExclusion(prefix, 0)
352+ if err != nil {
353+ t.Fatal(err)
354+ }
355+ unlock()
356+ })
357+ }
358+}
359+
360+// Observe the production contention message rather than guessing when the
361+// shutdown goroutine has attempted its first flock.
362+type lockContentionWriter struct {
363+ once sync.Once
364+ waiting chan struct{}
365+}
366+
367+func (w *lockContentionWriter) Write(p []byte) (int, error) {
368+ if strings.Contains(string(p), "waiting for prefix maintenance lock") {
369+ w.once.Do(func() { close(w.waiting) })
370+ }
371+ return len(p), nil
372+}
@@ -5,7 +5,9 @@ import (
5 "os"5 "os"
6 "path/filepath"6 "path/filepath"
7 "strings"7 "strings"
8+ "sync"
8 "testing"9 "testing"
10+ "time"
9 11
10 "github.com/charmbracelet/log"12 "github.com/charmbracelet/log"
11 )13 )
@@ -149,3 +151,222 @@ func TestMatchingVersionAllowsRunOnce(t *testing.T) {
149 t.Fatalf("recovery skipped preflight: %v", err)151 t.Fatalf("recovery skipped preflight: %v", err)
150 }152 }
151 }153 }
154+
155+func TestShutdownSelectsRecordedMinimumWithoutPreflight(t *testing.T) {
156+ dir := fakeVersionInstall(t, "wine-11.21")
157+ t.Setenv(wineOverrideEnv, filepath.Join(dir, "wine"))
158+ prefix := t.TempDir()
159+ mustWrite(t, filepath.Join(prefix, prefixVersionFile), "wine-11.19\n")
160+ mustWrite(t, filepath.Join(prefix, "pfx/user.reg"), "[Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\RunOnce]\n\"pending\"=\"run\"\n")
161+ a := NewAdapter(log.New(io.Discard))
162+ if _, err := a.GracefulKillPrefix(prefix, 0); err != nil {
163+ t.Fatal(err)
164+ }
165+ if a.detected.Version != "wine-11.21" {
166+ t.Fatalf("runtime: %+v", a.detected)
167+ }
168+ mustWrite(t, filepath.Join(prefix, prefixVersionFile+".migrating"), "wine-12.0\n")
169+ if _, err := a.GracefulKillPrefix(prefix, 0); err == nil || !strings.Contains(err.Error(), "downgrade") {
170+ t.Fatalf("downgrade: %v", err)
171+ }
172+}
173+
174+func TestShutdownAndDeletionShareMaintenanceLock(t *testing.T) {
175+ dir := fakeVersionInstall(t, "wine-11.21")
176+ t.Setenv(wineOverrideEnv, filepath.Join(dir, "wine"))
177+ prefix := filepath.Join(t.TempDir(), "prefix")
178+ a := NewAdapter(log.New(io.Discard))
179+ b := NewAdapter(log.New(io.Discard))
180+ release, err := a.LockPrefix(prefix)
181+ if err != nil {
182+ t.Fatal(err)
183+ }
184+ if _, err := b.GracefulKillPrefix(prefix, 0); err == nil || !strings.Contains(err.Error(), "locked") {
185+ t.Fatalf("stop during maintenance: %v", err)
186+ }
187+ called := false
188+ if err := b.WithStoppedPrefix(prefix, 0, func() error { called = true; return nil }); err == nil || called {
189+ t.Fatalf("delete during maintenance: called=%v err=%v", called, err)
190+ }
191+ release()
192+ // Pending migration actions must not prevent shutdown/deletion.
193+ if err := os.MkdirAll(filepath.Join(prefix, "pfx"), 0755); err != nil {
194+ t.Fatal(err)
195+ }
196+ mustWrite(t, filepath.Join(prefix, "pfx/user.reg"), "[Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\RunOnce]\n\"pending\"=\"run\"\n")
197+ err = b.WithStoppedPrefix(prefix, 0, func() error {
198+ if err := os.RemoveAll(prefix); err != nil {
199+ return err
200+ }
201+ // Removal and recreation must not create a second lock inode.
202+ if err := os.MkdirAll(prefix, 0755); err != nil {
203+ return err
204+ }
205+ if unlock, err := a.LockPrefix(prefix); err == nil {
206+ unlock()
207+ t.Error("maintenance acquired lock during deletion")
208+ }
209+ if _, err := a.GracefulKillPrefix(prefix, 0); err == nil {
210+ t.Error("shutdown acquired lock during deletion")
211+ }
212+ return nil
213+ })
214+ if err != nil {
215+ t.Fatal(err)
216+ }
217+ unlock, err := a.LockPrefix(prefix)
218+ if err != nil {
219+ t.Fatal(err)
220+ }
221+ unlock()
222+}
223+
224+func TestShutdownWaitsForMaintenance(t *testing.T) {
225+ dir := fakeVersionInstall(t, "wine-11.21")
226+ t.Setenv(wineOverrideEnv, filepath.Join(dir, "wine"))
227+ prefix := filepath.Join(t.TempDir(), "prefix")
228+ a := NewAdapter(log.New(io.Discard))
229+ release, err := a.LockPrefix(prefix)
230+ if err != nil {
231+ t.Fatal(err)
232+ }
233+ defer release()
234+ writer := &lockContentionWriter{waiting: make(chan struct{})}
235+ done := make(chan error, 1)
236+ go func() {
237+ b := NewAdapter(log.New(writer))
238+ _, err := b.GracefulKillPrefix(prefix, time.Second)
239+ done <- err
240+ }()
241+ select {
242+ case err := <-done:
243+ t.Fatalf("shutdown did not wait: %v", err)
244+ case <-writer.waiting:
245+ case <-time.After(2 * time.Second):
246+ t.Fatal("shutdown never observed lock contention")
247+ }
248+ release()
249+ select {
250+ case err := <-done:
251+ if err != nil {
252+ t.Fatal(err)
253+ }
254+ case <-time.After(2 * time.Second):
255+ t.Fatal("shutdown did not resume")
256+ }
257+}
258+
259+func TestLockRetriesInterruptedInitialCreation(t *testing.T) {
260+ a, prefix, calls := migrationFixture(t)
261+ rt := a.detected
262+ // Provide deterministic detection while preserving the graphics fixture.
263+ mustWrite(t, rt.WineBin, "#!/bin/sh\nif [ \"$1\" = --version ]; then echo wine-11.21; exit 0; fi\nif [ \"$1\" = reg ] && [ \"$2\" = query ]; then echo \"$5 REG_SZ native\"; fi\n")
264+ mustWrite(t, rt.WineServerBin, "#!/bin/sh\nif [ \"$1\" = -k0 ]; then exit 1; fi\n")
265+ mustWrite(t, rt.WineBootBin, "#!/bin/sh\necho init >> '"+calls+"'\nprintf 'WINE REGISTRY Version 2\\n#arch=win64\\n' > \"$WINEPREFIX/system.reg\"\n")
266+ t.Setenv(wineOverrideEnv, rt.WineBin)
267+ // Detect discovers these setup paths; graphicsPlan uses their package data.
268+ tools := t.TempDir()
269+ for name, target := range map[string]string{"setup_dxvk": rt.DXVKSetupBin, "setup_vkd3d_proton": rt.VKD3DSetupBin} {
270+ if err := os.Symlink(target, filepath.Join(tools, name)); err != nil {
271+ t.Fatal(err)
272+ }
273+ }
274+ t.Setenv("PATH", tools)
275+ if err := os.Remove(filepath.Join(prefix, "pfx/system.reg")); err != nil {
276+ t.Fatal(err)
277+ }
278+ mustWrite(t, filepath.Join(prefix, prefixVersionFile+".migrating"), "wine-11.21\n")
279+ release, err := a.LockPrefix(prefix)
280+ if err != nil {
281+ t.Fatal(err)
282+ }
283+ release()
284+ version, err := readVersion(filepath.Join(prefix, prefixVersionFile))
285+ if err != nil || version != "wine-11.21" {
286+ t.Fatalf("version: %s %v", version, err)
287+ }
288+ if _, err := os.Stat(filepath.Join(prefix, prefixVersionFile+".migrating")); !os.IsNotExist(err) {
289+ t.Fatalf("pending record retained: %v", err)
290+ }
291+ data, err := os.ReadFile(calls)
292+ if err != nil || !strings.Contains(string(data), "init") {
293+ t.Fatalf("init not retried: %s %v", data, err)
294+ }
295+ // Missing or malformed architecture on an established prefix is not initial creation.
296+ for _, contents := range []string{"", "WINE REGISTRY Version 2\n#arch=garbage\n"} {
297+ if contents == "" {
298+ if err := os.Remove(filepath.Join(prefix, "pfx/system.reg")); err != nil {
299+ t.Fatal(err)
300+ }
301+ } else {
302+ mustWrite(t, filepath.Join(prefix, "pfx/system.reg"), contents)
303+ }
304+ mustWrite(t, filepath.Join(prefix, prefixVersionFile+".migrating"), "wine-11.21\n")
305+ if unlock, err := a.LockPrefix(prefix); err == nil {
306+ unlock()
307+ t.Fatal("established prefix with invalid architecture accepted")
308+ }
309+ }
310+}
311+
312+func TestInterruptedInitialCreationGuards(t *testing.T) {
313+ for _, guard := range []string{"running", "RunOnce", "boot failure", "malformed architecture"} {
314+ t.Run(guard, func(t *testing.T) {
315+ dir := fakeVersionInstall(t, "wine-11.21")
316+ t.Setenv(wineOverrideEnv, filepath.Join(dir, "wine"))
317+ prefix := t.TempDir()
318+ if err := os.MkdirAll(filepath.Join(prefix, "pfx"), 0755); err != nil {
319+ t.Fatal(err)
320+ }
321+ mustWrite(t, filepath.Join(prefix, prefixVersionFile+".migrating"), "wine-11.21\n")
322+ calls := filepath.Join(t.TempDir(), "boot-calls")
323+ mustWrite(t, filepath.Join(dir, "wineboot"), "#!/bin/sh\necho init >> '"+calls+"'\nexit 1\n")
324+ switch guard {
325+ case "running":
326+ mustWrite(t, filepath.Join(dir, "wineserver"), "#!/bin/sh\nexit 0\n")
327+ case "RunOnce":
328+ mustWrite(t, filepath.Join(prefix, "pfx/user.reg"), "[Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\RunOnce]\n\"pending\"=\"run\"\n")
329+ case "malformed architecture":
330+ mustWrite(t, filepath.Join(prefix, "pfx/system.reg"), "#arch=garbage\n")
331+ }
332+ a := NewAdapter(log.New(io.Discard))
333+ if unlock, err := a.LockPrefix(prefix); err == nil {
334+ unlock()
335+ t.Fatal("unsafe initialization accepted")
336+ }
337+ if _, err := os.Stat(filepath.Join(prefix, prefixVersionFile)); !os.IsNotExist(err) {
338+ t.Fatalf("committed version: %v", err)
339+ }
340+ if _, err := os.Stat(filepath.Join(prefix, prefixVersionFile+".migrating")); err != nil {
341+ t.Fatalf("lost retry marker: %v", err)
342+ }
343+ if _, err := os.Stat(calls); guard == "boot failure" {
344+ if err != nil {
345+ t.Fatal("boot retry did not run")
346+ }
347+ } else if !os.IsNotExist(err) {
348+ t.Fatal("guard ran wineboot")
349+ }
350+ // Every failure releases exclusion for a later retry or shutdown.
351+ unlock, err := a.lockPrefixExclusion(prefix, 0)
352+ if err != nil {
353+ t.Fatal(err)
354+ }
355+ unlock()
356+ })
357+ }
358+}
359+
360+// Observe the production contention message rather than guessing when the
361+// shutdown goroutine has attempted its first flock.
362+type lockContentionWriter struct {
363+ once sync.Once
364+ waiting chan struct{}
365+}
366+
367+func (w *lockContentionWriter) Write(p []byte) (int, error) {
368+ if strings.Contains(string(p), "waiting for prefix maintenance lock") {
369+ w.once.Do(func() { close(w.waiting) })
370+ }
371+ return len(p), nil
372+}
M internal/adapters/wine/version.go +0 -11
@@ -22,14 +22,3 @@ func supportsWaylandHDR(version string) bool {
2222 }
2323 return major > 11 || major == 11 && minor >= 19
2424 }
25-
26-// Keep the legacy preference unless a candidate meets the HDR minimum.
27-func selectHDRWine(preferred, pathWine string, version func(string) string) string {
28- if supportsWaylandHDR(version(preferred)) {
29- return preferred
30- }
31- if pathWine != "" && pathWine != preferred && supportsWaylandHDR(version(pathWine)) {
32- return pathWine
33- }
34- return preferred
35-}
@@ -22,14 +22,3 @@ func supportsWaylandHDR(version string) bool {
22 }22 }
23 return major > 11 || major == 11 && minor >= 1923 return major > 11 || major == 11 && minor >= 19
24 }24 }
25-
26-// Keep the legacy preference unless a candidate meets the HDR minimum.
27-func selectHDRWine(preferred, pathWine string, version func(string) string) string {
28- if supportsWaylandHDR(version(preferred)) {
29- return preferred
30- }
31- if pathWine != "" && pathWine != preferred && supportsWaylandHDR(version(pathWine)) {
32- return pathWine
33- }
34- return preferred
35-}
M internal/adapters/wine/version_test.go +26 -20
@@ -3,6 +3,7 @@ package wine
33 import (
44 "bytes"
55 "github.com/charmbracelet/log"
6+ "io"
67 "os"
78 "path/filepath"
89 "strings"
@@ -28,26 +29,31 @@ func TestSupportsWaylandHDR(t *testing.T) {
2829 }
2930 }
3031
31-func TestSelectHDRWine(t *testing.T) {
32- for _, tt := range []struct{ name, preferredVersion, pathVersion, path, want string }{
33- {"both capable", "wine-11.19", "wine-11.21", "path", "preferred"},
34- {"path capable", "wine-10.0", "wine-11.21", "path", "path"},
35- {"neither capable", "wine-10.0", "wine-11.18", "path", "preferred"},
36- {"preferred malformed", "bad", "wine-11.21", "path", "path"},
37- {"path malformed", "wine-10.0", "bad", "path", "preferred"},
38- {"path absent", "wine-10.0", "", "", "preferred"},
39- } {
40- t.Run(tt.name, func(t *testing.T) {
41- got := selectHDRWine("preferred", tt.path, func(path string) string {
42- if path == "preferred" {
43- return tt.preferredVersion
44- }
45- return tt.pathVersion
46- })
47- if got != tt.want {
48- t.Fatalf("got %s, want %s", got, tt.want)
49- }
50- })
32+func TestPathWinePreferredInAllModes(t *testing.T) {
33+ dir := fakeVersionInstall(t, "wine-11.21")
34+ t.Setenv("PATH", dir)
35+ t.Setenv(wineOverrideEnv, "")
36+ for _, wayland := range []bool{false, true} {
37+ a := &Adapter{log: log.New(io.Discard), wayland: wayland}
38+ rt, err := a.Detect()
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ if rt.WineBin != filepath.Join(dir, "wine") || rt.WineBootBin != filepath.Join(dir, "wineboot") || rt.WineServerBin != filepath.Join(dir, "wineserver") {
43+ t.Fatalf("selection: %+v", rt)
44+ }
45+ }
46+ override := fakeVersionInstall(t, "wine-11.19")
47+ t.Setenv(wineOverrideEnv, filepath.Join(override, "wine"))
48+ for _, wayland := range []bool{false, true} {
49+ a := &Adapter{log: log.New(io.Discard), wayland: wayland}
50+ rt, err := a.Detect()
51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ if rt.WineBin != filepath.Join(override, "wine") {
55+ t.Fatalf("override: %+v", rt)
56+ }
5157 }
5258 }
5359
@@ -3,6 +3,7 @@ package wine
3 import (3 import (
4 "bytes"4 "bytes"
5 "github.com/charmbracelet/log"5 "github.com/charmbracelet/log"
6+ "io"
6 "os"7 "os"
7 "path/filepath"8 "path/filepath"
8 "strings"9 "strings"
@@ -28,26 +29,31 @@ func TestSupportsWaylandHDR(t *testing.T) {
28 }29 }
29 }30 }
30 31
31-func TestSelectHDRWine(t *testing.T) {32+func TestPathWinePreferredInAllModes(t *testing.T) {
32- for _, tt := range []struct{ name, preferredVersion, pathVersion, path, want string }{33+ dir := fakeVersionInstall(t, "wine-11.21")
33- {"both capable", "wine-11.19", "wine-11.21", "path", "preferred"},34+ t.Setenv("PATH", dir)
34- {"path capable", "wine-10.0", "wine-11.21", "path", "path"},35+ t.Setenv(wineOverrideEnv, "")
35- {"neither capable", "wine-10.0", "wine-11.18", "path", "preferred"},36+ for _, wayland := range []bool{false, true} {
36- {"preferred malformed", "bad", "wine-11.21", "path", "path"},37+ a := &Adapter{log: log.New(io.Discard), wayland: wayland}
37- {"path malformed", "wine-10.0", "bad", "path", "preferred"},38+ rt, err := a.Detect()
38- {"path absent", "wine-10.0", "", "", "preferred"},39+ if err != nil {
39- } {40+ t.Fatal(err)
40- t.Run(tt.name, func(t *testing.T) {41+ }
41- got := selectHDRWine("preferred", tt.path, func(path string) string {42+ if rt.WineBin != filepath.Join(dir, "wine") || rt.WineBootBin != filepath.Join(dir, "wineboot") || rt.WineServerBin != filepath.Join(dir, "wineserver") {
42- if path == "preferred" {43+ t.Fatalf("selection: %+v", rt)
43- return tt.preferredVersion44+ }
44- }45+ }
45- return tt.pathVersion46+ override := fakeVersionInstall(t, "wine-11.19")
46- })47+ t.Setenv(wineOverrideEnv, filepath.Join(override, "wine"))
47- if got != tt.want {48+ for _, wayland := range []bool{false, true} {
48- t.Fatalf("got %s, want %s", got, tt.want)49+ a := &Adapter{log: log.New(io.Discard), wayland: wayland}
49- }50+ rt, err := a.Detect()
50- })51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ if rt.WineBin != filepath.Join(override, "wine") {
55+ t.Fatalf("override: %+v", rt)
56+ }
51 }57 }
52 }58 }
53 59
M internal/adapters/xdg/filesystem.go +2 -0
@@ -92,3 +92,5 @@ func DefaultConfig() (*domain.Config, error) {
9292 LogFile: filepath.Join(cacheDir, "bnetctl.log"),
9393 }, nil
9494 }
95+
96+func (f *Filesystem) ReadDir(path string) ([]os.DirEntry, error) { return os.ReadDir(path) }
@@ -92,3 +92,5 @@ func DefaultConfig() (*domain.Config, error) {
92 LogFile: filepath.Join(cacheDir, "bnetctl.log"),92 LogFile: filepath.Join(cacheDir, "bnetctl.log"),
93 }, nil93 }, nil
94 }94 }
95+
96+func (f *Filesystem) ReadDir(path string) ([]os.DirEntry, error) { return os.ReadDir(path) }
M internal/ports/filesystem.go +3 -0
@@ -14,6 +14,9 @@ type FilesystemPort interface {
1414 // Exists checks if a path exists
1515 Exists(path string) bool
1616
17+ // ReadDir lists directory entries.
18+ ReadDir(path string) ([]os.DirEntry, error)
19+
1720 // Remove removes a file or directory recursively
1821 Remove(path string) error
1922
@@ -14,6 +14,9 @@ type FilesystemPort interface {
14 // Exists checks if a path exists14 // Exists checks if a path exists
15 Exists(path string) bool15 Exists(path string) bool
16 16
17+ // ReadDir lists directory entries.
18+ ReadDir(path string) ([]os.DirEntry, error)
19+
17 // Remove removes a file or directory recursively20 // Remove removes a file or directory recursively
18 Remove(path string) error21 Remove(path string) error
19 22
M internal/ports/runtime.go +5 -1
@@ -8,10 +8,14 @@ import (
88
99 // RuntimePort defines operations for running Windows executables via a compatibility layer
1010 type RuntimePort interface {
11+ // WithStoppedPrefix verifies shutdown and holds exclusion through remove.
12+ // It does not perform migration preflight; remove is skipped on stop failure.
13+ WithStoppedPrefix(prefixPath string, timeout time.Duration, remove func() error) error
14+
1115 // LockPrefix locks maintenance through process start and selects a non-downgrading runtime.
1216 LockPrefix(prefixPath string) (release func(), err error)
1317
14- // Detect checks if wine-cachyos is available on the system
18+ // Detect checks if Wine is available on the system
1519 Detect() (*domain.WineRuntime, error)
1620
1721 // UpdatePrefix updates the prefix and graphics when the selected Wine version changes.
@@ -8,10 +8,14 @@ import (
8 8
9 // RuntimePort defines operations for running Windows executables via a compatibility layer9 // RuntimePort defines operations for running Windows executables via a compatibility layer
10 type RuntimePort interface {10 type RuntimePort interface {
11+ // WithStoppedPrefix verifies shutdown and holds exclusion through remove.
12+ // It does not perform migration preflight; remove is skipped on stop failure.
13+ WithStoppedPrefix(prefixPath string, timeout time.Duration, remove func() error) error
14+
11 // LockPrefix locks maintenance through process start and selects a non-downgrading runtime.15 // LockPrefix locks maintenance through process start and selects a non-downgrading runtime.
12 LockPrefix(prefixPath string) (release func(), err error)16 LockPrefix(prefixPath string) (release func(), err error)
13 17
14- // Detect checks if wine-cachyos is available on the system18+ // Detect checks if Wine is available on the system
15 Detect() (*domain.WineRuntime, error)19 Detect() (*domain.WineRuntime, error)
16 20
17 // UpdatePrefix updates the prefix and graphics when the selected Wine version changes.21 // UpdatePrefix updates the prefix and graphics when the selected Wine version changes.
M internal/services/cleaner.go +38 -24
@@ -2,6 +2,9 @@ package services
22
33 import (
44 "fmt"
5+ "os"
6+ "path/filepath"
7+ "strings"
58 "time"
69
710 "github.com/charmbracelet/log"
@@ -56,16 +59,14 @@ func (s *CleanerService) CleanPrefix() error {
5659
5760 // A prefix whose wineserver is gone still holds wine processes, and removing
5861 // the directory under them leaves them running against deleted files.
59- if _, err := s.runtime.GracefulKillPrefix(s.cfg.PrefixDir, 10*time.Second); err != nil {
60- return fmt.Errorf("stop wine processes before removal: %w", err)
61- }
62-
63- if s.fs.Exists(s.cfg.PrefixDir) {
64- if err := s.fs.Remove(s.cfg.PrefixDir); err != nil {
65- return fmt.Errorf("remove prefix: %w", err)
62+ return s.runtime.WithStoppedPrefix(s.cfg.PrefixDir, 10*time.Second, func() error {
63+ if s.fs.Exists(s.cfg.PrefixDir) {
64+ if err := s.fs.Remove(s.cfg.PrefixDir); err != nil {
65+ return fmt.Errorf("remove prefix: %w", err)
66+ }
6667 }
67- }
68- return nil
68+ return nil
69+ })
6970 }
7071
7172 // CleanAll removes everything: cache, prefix, desktop entry, data dir
@@ -73,25 +74,38 @@ func (s *CleanerService) CleanAll() error {
7374 log := s.log
7475 log.Info("cleaning all bnetctl data")
7576
76- if err := s.CleanPrefix(); err != nil {
77- return err
78- }
77+ return s.runtime.WithStoppedPrefix(s.cfg.PrefixDir, 10*time.Second, func() error {
78+ if err := s.CleanCache(); err != nil {
79+ return err
80+ }
81+ for _, name := range []string{ports.EntryWayland, ports.EntryX11, ports.EntryLegacy} {
82+ _ = s.desktop.RemoveEntry(name)
83+ }
84+ // Preserve the persistent lock inode and every ancestor containing it.
85+ return s.removeContentsExcept(s.cfg.DataDir, filepath.Clean(s.cfg.PrefixDir)+".lock")
86+ })
87+}
7988
80- if err := s.CleanCache(); err != nil {
81- return err
89+func (s *CleanerService) removeContentsExcept(dir, keep string) error {
90+ entries, err := s.fs.ReadDir(dir)
91+ if os.IsNotExist(err) {
92+ return nil
8293 }
83-
84- // Remove desktop entries (Wayland + X11 plus legacy single entry)
85- for _, name := range []string{ports.EntryWayland, ports.EntryX11, ports.EntryLegacy} {
86- _ = s.desktop.RemoveEntry(name)
94+ if err != nil {
95+ return fmt.Errorf("read data directory: %w", err)
8796 }
88-
89- // Remove data dir
90- if s.fs.Exists(s.cfg.DataDir) {
91- if err := s.fs.Remove(s.cfg.DataDir); err != nil {
92- return fmt.Errorf("remove data dir: %w", err)
97+ for _, entry := range entries {
98+ path := filepath.Join(dir, entry.Name())
99+ if path == keep {
100+ continue
101+ }
102+ if strings.HasPrefix(keep, path+string(filepath.Separator)) {
103+ if err := s.removeContentsExcept(path, keep); err != nil {
104+ return err
105+ }
106+ } else if err := s.fs.Remove(path); err != nil {
107+ return fmt.Errorf("remove data: %w", err)
93108 }
94109 }
95-
96110 return nil
97111 }
@@ -2,6 +2,9 @@ package services
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "os"
6+ "path/filepath"
7+ "strings"
5 "time"8 "time"
6 9
7 "github.com/charmbracelet/log"10 "github.com/charmbracelet/log"
@@ -56,16 +59,14 @@ func (s *CleanerService) CleanPrefix() error {
56 59
57 // A prefix whose wineserver is gone still holds wine processes, and removing60 // A prefix whose wineserver is gone still holds wine processes, and removing
58 // the directory under them leaves them running against deleted files.61 // the directory under them leaves them running against deleted files.
59- if _, err := s.runtime.GracefulKillPrefix(s.cfg.PrefixDir, 10*time.Second); err != nil {62+ return s.runtime.WithStoppedPrefix(s.cfg.PrefixDir, 10*time.Second, func() error {
60- return fmt.Errorf("stop wine processes before removal: %w", err)63+ if s.fs.Exists(s.cfg.PrefixDir) {
61- }64+ if err := s.fs.Remove(s.cfg.PrefixDir); err != nil {
62-65+ return fmt.Errorf("remove prefix: %w", err)
63- if s.fs.Exists(s.cfg.PrefixDir) {66+ }
64- if err := s.fs.Remove(s.cfg.PrefixDir); err != nil {
65- return fmt.Errorf("remove prefix: %w", err)
66 }67 }
67- }68+ return nil
68- return nil69+ })
69 }70 }
70 71
71 // CleanAll removes everything: cache, prefix, desktop entry, data dir72 // CleanAll removes everything: cache, prefix, desktop entry, data dir
@@ -73,25 +74,38 @@ func (s *CleanerService) CleanAll() error {
73 log := s.log74 log := s.log
74 log.Info("cleaning all bnetctl data")75 log.Info("cleaning all bnetctl data")
75 76
76- if err := s.CleanPrefix(); err != nil {77+ return s.runtime.WithStoppedPrefix(s.cfg.PrefixDir, 10*time.Second, func() error {
77- return err78+ if err := s.CleanCache(); err != nil {
78- }79+ return err
80+ }
81+ for _, name := range []string{ports.EntryWayland, ports.EntryX11, ports.EntryLegacy} {
82+ _ = s.desktop.RemoveEntry(name)
83+ }
84+ // Preserve the persistent lock inode and every ancestor containing it.
85+ return s.removeContentsExcept(s.cfg.DataDir, filepath.Clean(s.cfg.PrefixDir)+".lock")
86+ })
87+}
79 88
80- if err := s.CleanCache(); err != nil {89+func (s *CleanerService) removeContentsExcept(dir, keep string) error {
81- return err90+ entries, err := s.fs.ReadDir(dir)
91+ if os.IsNotExist(err) {
92+ return nil
82 }93 }
83-94+ if err != nil {
84- // Remove desktop entries (Wayland + X11 plus legacy single entry)95+ return fmt.Errorf("read data directory: %w", err)
85- for _, name := range []string{ports.EntryWayland, ports.EntryX11, ports.EntryLegacy} {
86- _ = s.desktop.RemoveEntry(name)
87 }96 }
88-97+ for _, entry := range entries {
89- // Remove data dir98+ path := filepath.Join(dir, entry.Name())
90- if s.fs.Exists(s.cfg.DataDir) {99+ if path == keep {
91- if err := s.fs.Remove(s.cfg.DataDir); err != nil {100+ continue
92- return fmt.Errorf("remove data dir: %w", err)101+ }
102+ if strings.HasPrefix(keep, path+string(filepath.Separator)) {
103+ if err := s.removeContentsExcept(path, keep); err != nil {
104+ return err
105+ }
106+ } else if err := s.fs.Remove(path); err != nil {
107+ return fmt.Errorf("remove data: %w", err)
93 }108 }
94 }109 }
95-
96 return nil110 return nil
97 }111 }
A internal/services/cleaner_test.go +138 -0
@@ -0,0 +1,138 @@
1+package services
2+
3+import (
4+ "io"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/bnetctl/internal/adapters/wine"
13+ "github.com/bnema/bnetctl/internal/adapters/xdg"
14+ "github.com/bnema/bnetctl/internal/domain"
15+ "github.com/bnema/bnetctl/internal/ports"
16+ "github.com/charmbracelet/log"
17+)
18+
19+type cleanupFilesystem struct {
20+ *xdg.Filesystem
21+ cache string
22+ cleaning chan struct{}
23+ resume chan struct{}
24+}
25+
26+func (f *cleanupFilesystem) Remove(path string) error {
27+ if path == f.cache {
28+ close(f.cleaning)
29+ <-f.resume
30+ }
31+ return f.Filesystem.Remove(path)
32+}
33+
34+type cleanupDesktop struct{ ports.DesktopPort }
35+
36+func (cleanupDesktop) RemoveEntry(string) error { return nil }
37+
38+type contentionWriter struct {
39+ once sync.Once
40+ waiting chan struct{}
41+}
42+
43+func (w *contentionWriter) Write(p []byte) (int, error) {
44+ if strings.Contains(string(p), "waiting for prefix maintenance lock") {
45+ w.once.Do(func() { close(w.waiting) })
46+ }
47+ return len(p), nil
48+}
49+
50+func TestCleanAllPreservesLockForWaitingInvocation(t *testing.T) {
51+ root := t.TempDir()
52+ cfg := &domain.Config{DataDir: filepath.Join(root, "data"), CacheDir: filepath.Join(root, "cache")}
53+ cfg.PrefixDir = filepath.Join(cfg.DataDir, "prefix")
54+ for _, dir := range []string{cfg.PrefixDir, cfg.CacheDir} {
55+ if err := os.MkdirAll(dir, 0755); err != nil {
56+ t.Fatal(err)
57+ }
58+ }
59+ if err := os.WriteFile(filepath.Join(cfg.DataDir, "icon"), []byte("icon"), 0600); err != nil {
60+ t.Fatal(err)
61+ }
62+ bin := t.TempDir()
63+ for _, name := range []string{"wine", "wineboot", "wineserver"} {
64+ if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nif [ \"$1\" = --version ]; then echo wine-11.21; fi\nexit 0\n"), 0755); err != nil {
65+ t.Fatal(err)
66+ }
67+ }
68+ t.Setenv("BNETCTL_WINE", filepath.Join(bin, "wine"))
69+ fs := &cleanupFilesystem{Filesystem: xdg.NewFilesystem(), cache: cfg.CacheDir, cleaning: make(chan struct{}), resume: make(chan struct{})}
70+ var resumeOnce sync.Once
71+ resume := func() { resumeOnce.Do(func() { close(fs.resume) }) }
72+ defer resume()
73+ logger := log.New(io.Discard)
74+ svc := NewCleanerService(wine.NewAdapter(logger), cleanupDesktop{}, fs, cfg, logger)
75+ cleaned := make(chan error, 1)
76+ go func() { cleaned <- svc.CleanAll() }()
77+ select {
78+ case <-fs.cleaning:
79+ case <-time.After(3 * time.Second):
80+ t.Fatal("cleanup did not start")
81+ }
82+ lock := cfg.PrefixDir + ".lock"
83+ before, err := os.Stat(lock)
84+ if err != nil {
85+ t.Fatal(err)
86+ }
87+ parentBefore, err := os.Stat(cfg.DataDir)
88+ if err != nil {
89+ t.Fatal(err)
90+ }
91+ writer := &contentionWriter{waiting: make(chan struct{})}
92+ waiter := wine.NewAdapter(log.New(writer))
93+ waited := make(chan error, 1)
94+ go func() {
95+ waited <- waiter.WithStoppedPrefix(cfg.PrefixDir, 3*time.Second, func() error {
96+ after, err := os.Stat(lock)
97+ if err != nil {
98+ return err
99+ }
100+ if !os.SameFile(before, after) {
101+ t.Error("lock inode replaced")
102+ }
103+ return nil
104+ })
105+ }()
106+ select {
107+ case <-writer.waiting:
108+ case err := <-waited:
109+ t.Fatalf("waiter did not contend: %v", err)
110+ case <-time.After(3 * time.Second):
111+ t.Fatal("no observed contention")
112+ }
113+ resume()
114+ if err := <-cleaned; err != nil {
115+ t.Fatal(err)
116+ }
117+ if err := <-waited; err != nil {
118+ t.Fatal(err)
119+ }
120+ after, err := os.Stat(lock)
121+ if err != nil || !os.SameFile(before, after) {
122+ t.Fatalf("lock identity lost: %v", err)
123+ }
124+ parentAfter, err := os.Stat(cfg.DataDir)
125+ if err != nil || !os.SameFile(parentBefore, parentAfter) {
126+ t.Fatalf("lock parent replaced: %v", err)
127+ }
128+ entries, err := os.ReadDir(cfg.DataDir)
129+ if err != nil {
130+ t.Fatal(err)
131+ }
132+ if len(entries) != 1 || entries[0].Name() != "prefix.lock" {
133+ t.Fatalf("unexpected remaining data: %v", entries)
134+ }
135+ if _, err := os.Stat(cfg.CacheDir); !os.IsNotExist(err) {
136+ t.Fatalf("cache remains: %v", err)
137+ }
138+}
@@ -0,0 +1,138 @@
1+package services
2+
3+import (
4+ "io"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/bnetctl/internal/adapters/wine"
13+ "github.com/bnema/bnetctl/internal/adapters/xdg"
14+ "github.com/bnema/bnetctl/internal/domain"
15+ "github.com/bnema/bnetctl/internal/ports"
16+ "github.com/charmbracelet/log"
17+)
18+
19+type cleanupFilesystem struct {
20+ *xdg.Filesystem
21+ cache string
22+ cleaning chan struct{}
23+ resume chan struct{}
24+}
25+
26+func (f *cleanupFilesystem) Remove(path string) error {
27+ if path == f.cache {
28+ close(f.cleaning)
29+ <-f.resume
30+ }
31+ return f.Filesystem.Remove(path)
32+}
33+
34+type cleanupDesktop struct{ ports.DesktopPort }
35+
36+func (cleanupDesktop) RemoveEntry(string) error { return nil }
37+
38+type contentionWriter struct {
39+ once sync.Once
40+ waiting chan struct{}
41+}
42+
43+func (w *contentionWriter) Write(p []byte) (int, error) {
44+ if strings.Contains(string(p), "waiting for prefix maintenance lock") {
45+ w.once.Do(func() { close(w.waiting) })
46+ }
47+ return len(p), nil
48+}
49+
50+func TestCleanAllPreservesLockForWaitingInvocation(t *testing.T) {
51+ root := t.TempDir()
52+ cfg := &domain.Config{DataDir: filepath.Join(root, "data"), CacheDir: filepath.Join(root, "cache")}
53+ cfg.PrefixDir = filepath.Join(cfg.DataDir, "prefix")
54+ for _, dir := range []string{cfg.PrefixDir, cfg.CacheDir} {
55+ if err := os.MkdirAll(dir, 0755); err != nil {
56+ t.Fatal(err)
57+ }
58+ }
59+ if err := os.WriteFile(filepath.Join(cfg.DataDir, "icon"), []byte("icon"), 0600); err != nil {
60+ t.Fatal(err)
61+ }
62+ bin := t.TempDir()
63+ for _, name := range []string{"wine", "wineboot", "wineserver"} {
64+ if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nif [ \"$1\" = --version ]; then echo wine-11.21; fi\nexit 0\n"), 0755); err != nil {
65+ t.Fatal(err)
66+ }
67+ }
68+ t.Setenv("BNETCTL_WINE", filepath.Join(bin, "wine"))
69+ fs := &cleanupFilesystem{Filesystem: xdg.NewFilesystem(), cache: cfg.CacheDir, cleaning: make(chan struct{}), resume: make(chan struct{})}
70+ var resumeOnce sync.Once
71+ resume := func() { resumeOnce.Do(func() { close(fs.resume) }) }
72+ defer resume()
73+ logger := log.New(io.Discard)
74+ svc := NewCleanerService(wine.NewAdapter(logger), cleanupDesktop{}, fs, cfg, logger)
75+ cleaned := make(chan error, 1)
76+ go func() { cleaned <- svc.CleanAll() }()
77+ select {
78+ case <-fs.cleaning:
79+ case <-time.After(3 * time.Second):
80+ t.Fatal("cleanup did not start")
81+ }
82+ lock := cfg.PrefixDir + ".lock"
83+ before, err := os.Stat(lock)
84+ if err != nil {
85+ t.Fatal(err)
86+ }
87+ parentBefore, err := os.Stat(cfg.DataDir)
88+ if err != nil {
89+ t.Fatal(err)
90+ }
91+ writer := &contentionWriter{waiting: make(chan struct{})}
92+ waiter := wine.NewAdapter(log.New(writer))
93+ waited := make(chan error, 1)
94+ go func() {
95+ waited <- waiter.WithStoppedPrefix(cfg.PrefixDir, 3*time.Second, func() error {
96+ after, err := os.Stat(lock)
97+ if err != nil {
98+ return err
99+ }
100+ if !os.SameFile(before, after) {
101+ t.Error("lock inode replaced")
102+ }
103+ return nil
104+ })
105+ }()
106+ select {
107+ case <-writer.waiting:
108+ case err := <-waited:
109+ t.Fatalf("waiter did not contend: %v", err)
110+ case <-time.After(3 * time.Second):
111+ t.Fatal("no observed contention")
112+ }
113+ resume()
114+ if err := <-cleaned; err != nil {
115+ t.Fatal(err)
116+ }
117+ if err := <-waited; err != nil {
118+ t.Fatal(err)
119+ }
120+ after, err := os.Stat(lock)
121+ if err != nil || !os.SameFile(before, after) {
122+ t.Fatalf("lock identity lost: %v", err)
123+ }
124+ parentAfter, err := os.Stat(cfg.DataDir)
125+ if err != nil || !os.SameFile(parentBefore, parentAfter) {
126+ t.Fatalf("lock parent replaced: %v", err)
127+ }
128+ entries, err := os.ReadDir(cfg.DataDir)
129+ if err != nil {
130+ t.Fatal(err)
131+ }
132+ if len(entries) != 1 || entries[0].Name() != "prefix.lock" {
133+ t.Fatalf("unexpected remaining data: %v", entries)
134+ }
135+ if _, err := os.Stat(cfg.CacheDir); !os.IsNotExist(err) {
136+ t.Fatalf("cache remains: %v", err)
137+ }
138+}
M internal/services/launcher_test.go +7 -0
@@ -89,3 +89,10 @@ func TestLaunchDisablesSystrayBeforeStartingBattleNet(t *testing.T) {
8989 func (f *fakeRuntime) UpdatePrefix(string, *domain.WineEnv) error { return nil }
9090
9191 func (f *fakeRuntime) LockPrefix(string) (func(), error) { return func() {}, nil }
92+
93+func (f *fakeRuntime) WithStoppedPrefix(prefix string, timeout time.Duration, remove func() error) error {
94+ if _, err := f.GracefulKillPrefix(prefix, timeout); err != nil {
95+ return err
96+ }
97+ return remove()
98+}
@@ -89,3 +89,10 @@ func TestLaunchDisablesSystrayBeforeStartingBattleNet(t *testing.T) {
89 func (f *fakeRuntime) UpdatePrefix(string, *domain.WineEnv) error { return nil }89 func (f *fakeRuntime) UpdatePrefix(string, *domain.WineEnv) error { return nil }
90 90
91 func (f *fakeRuntime) LockPrefix(string) (func(), error) { return func() {}, nil }91 func (f *fakeRuntime) LockPrefix(string) (func(), error) { return func() {}, nil }
92+
93+func (f *fakeRuntime) WithStoppedPrefix(prefix string, timeout time.Duration, remove func() error) error {
94+ if _, err := f.GracefulKillPrefix(prefix, timeout); err != nil {
95+ return err
96+ }
97+ return remove()
98+}

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