Read Wine settings live at poll and spawn

Phase 2 of the Wine/Proton stage (decisions 3, 4, 16, 17, 19 in docs/testing/wine-proton.md): replace the Wine manager's startup snapshot with a live settings source read once per poll, so accepted global settings take effect with no reader restart. The source publishes only the active/global profile's Wine values; per-application profile switching never changes the governing list. A newly listed running program restarts its helper once in the same mode; removals and clearing the global switch never downgrade a running helper.
This commit is contained in:
hjozwiak committed 2026-11-02 01:15:33 -05:00
1 parent a60a251472
commit 9a10a15b68
13 files changed
+327 -51

No files matched your search

+5 -5
View File
@@ -92,7 +92,7 @@ func TestManagerCapturesActualHelperDiagnosticsAndBoundsInheritedStderr(t *testi
manager := &Manager{helper: helper, logger: slog.New(handler)}
child := &process{}
started := time.Now()
if err := manager.launch(child, prefix{path: root}, started); err != nil {
if err := manager.launch(child, prefix{path: root}, started, Settings{}); err != nil {
t.Fatal(err)
}
select {
@@ -124,7 +124,7 @@ func TestManagerExpectedStopIsNotAHelperFailure(t *testing.T) {
handler := &channelLogHandler{records: make(chan slog.Record, 32)}
manager := &Manager{helper: helper, logger: slog.New(handler)}
child := &process{}
if err := manager.launch(child, prefix{path: t.TempDir()}, time.Now()); err != nil {
if err := manager.launch(child, prefix{path: t.TempDir()}, time.Now(), Settings{}); err != nil {
t.Fatal(err)
}
stop(child)
@@ -208,11 +208,11 @@ func TestHelperStartRecordMirrorsProgramsAndExtra(t *testing.T) {
t.Fatal(err)
}
handler := &channelLogHandler{records: make(chan slog.Record, 32)}
manager := &Manager{helper: helper, logger: slog.New(handler),
ExcludedPrograms: []string{"Game.EXE", "game.exe", "Another.exe"}}
manager := &Manager{helper: helper, logger: slog.New(handler)}
child := &process{}
source := prefix{path: root, programs: map[string]bool{"launcher.exe": true, "game.exe": true}}
if err := manager.launch(child, source, time.Now()); err != nil {
settings := Settings{ExcludedPrograms: []string{"Game.EXE", "game.exe", "Another.exe"}}
if err := manager.launch(child, source, time.Now(), settings); err != nil {
t.Fatal(err)
}
defer stop(child)
+103
View File
@@ -0,0 +1,103 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package wine
import (
"os"
"path/filepath"
"strings"
"testing"
)
// The manager must read its settings source at each poll and helper spawn,
// never a snapshot taken when it was constructed. A live addition for a
// currently discovered program restarts the running helper once in the same
// mode, and removals never downgrade a running helper.
func TestManagerReadsSettingsSourceAtEachPoll(t *testing.T) {
t.Setenv("WINE_ACCESS_NO_DIALOG_READER", "")
root := t.TempDir()
procRoot := filepath.Join(root, "proc")
loader := filepath.Join(root, "wine")
write_helper_file(t, loader, "loader")
base := filepath.Join(procRoot, "101")
write_helper_file(t, filepath.Join(base, "cmdline"), "launcher.exe\x00")
write_helper_file(t, filepath.Join(base, "environ"), "WINEPREFIX="+root+"\x00")
if err := os.Symlink(loader, filepath.Join(base, "exe")); err != nil {
t.Fatal(err)
}
helper := filepath.Join(root, "helper")
write_helper_file(t, helper, "#!/bin/sh\nexec sleep 30\n")
current := Settings{}
reads := 0
manager := New(nil)
manager.helper, manager.procRoot = helper, procRoot
manager.SettingsSource = func() Settings {
reads++
return current
}
active := map[string]*process{}
defer func() {
for _, child := range active {
stop(child)
}
}()
manager.poll(active)
child := active[root]
if child == nil || child.cmd == nil || child.controllerOnly {
t.Fatal("helper did not start normally")
}
if len(child.exclusions) != 0 {
t.Fatalf("initial exclusions = %v", child.exclusions)
}
if reads == 0 {
t.Fatal("poll did not read the settings source")
}
pid := child.cmd.Process.Pid
// A live addition naming a currently discovered program reaches the
// running helper at the next poll: one restart, same mode, flag added.
readsBefore := reads
current = Settings{ExcludedPrograms: []string{"Launcher.exe"}}
manager.poll(active)
if reads <= readsBefore {
t.Fatal("later poll did not re-read the settings source")
}
if child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid == pid {
t.Fatal("live addition did not restart the helper in the same mode")
}
if !child.exclusions["launcher.exe"] {
t.Fatal("restarted helper lacks the live exclusion flag")
}
if got := strings.Join(child.cmd.Args[1:], " "); got != "--exclude-hook-program launcher.exe" {
t.Fatalf("restarted helper arguments = %q", got)
}
pid = child.cmd.Process.Pid
manager.poll(active)
if child.cmd.Process.Pid != pid {
t.Fatal("helper restarted more than once for one live addition")
}
// Removing the listing never restarts or downgrades the running helper.
current = Settings{}
manager.poll(active)
if child.cmd.Process.Pid != pid {
t.Fatal("helper restarted when a live listing was removed")
}
// Turning the global switch on live upgrades the helper wholesale;
// clearing it live never downgrades the running helper.
current = Settings{ControllerOnly: true}
manager.poll(active)
if !child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid == pid {
t.Fatal("live global switch did not upgrade the helper wholesale")
}
pid = child.cmd.Process.Pid
current = Settings{}
manager.poll(active)
if !child.controllerOnly || child.cmd.Process.Pid != pid {
t.Fatal("wholesale helper downgraded after the live switch was cleared")
}
}
+38 -15
View File
@@ -46,14 +46,26 @@ type process struct {
readyExclusions atomic.Int64
}
type Manager struct {
logger *slog.Logger
helper string
procRoot string
cancel context.CancelFunc
done chan struct{}
// Settings is one snapshot of the reader's Wine values. The manager never
// keeps its own copy across polls: each poll and helper spawn works from a
// fresh snapshot, so settings applied after startup take effect with no
// reader restart.
type Settings struct {
ControllerOnly bool
ExcludedPrograms []string
}
type Manager struct {
logger *slog.Logger
helper string
procRoot string
cancel context.CancelFunc
done chan struct{}
// SettingsSource supplies the live Wine settings. It is installed
// before Start and read only by the poll goroutine, once per poll;
// spawns made by that poll use that same snapshot. A nil source
// yields empty settings.
SettingsSource func() Settings
// unconfirmed remembers helper binaries whose ready record did not
// confirm the exclusions sent, keyed by launched executable path.
// Later spawns of such a binary go wholesale directly.
@@ -165,8 +177,19 @@ func (manager *Manager) run(ctx context.Context) {
}
}
func (manager *Manager) current_settings() Settings {
if manager.SettingsSource == nil {
return Settings{}
}
return manager.SettingsSource()
}
func (manager *Manager) poll(active map[string]*process) {
now := time.Now()
// Read the live settings once per poll. Restart decisions and every
// spawn in this poll use this snapshot, so a value applied between
// polls governs the next poll and spawn without a reader restart.
settings := manager.current_settings()
discovered := discover(manager.procRoot, uint32(os.Getuid()))
for path, source := range discovered {
child := active[path]
@@ -186,7 +209,7 @@ func (manager *Manager) poll(active map[string]*process) {
child.next = now.Add(backoff(child.failures))
child.cmd = nil
default:
if manager.restart_running_helper(child, source) {
if manager.restart_running_helper(child, source, settings) {
stop(child)
} else {
continue
@@ -196,7 +219,7 @@ func (manager *Manager) poll(active map[string]*process) {
if now.Before(child.next) {
continue
}
if err := manager.launch(child, source, now); err != nil {
if err := manager.launch(child, source, now, settings); err != nil {
child.failures++
child.next = now.Add(backoff(child.failures))
manager.logger.Warn("start Wine accessibility helper", "error", err)
@@ -214,11 +237,11 @@ func (manager *Manager) poll(active map[string]*process) {
// gain protection it lacks. Restarts only ever add protection: a wholesale
// helper never restarts here, and removing a listing or clearing the
// global switch never downgrades a running helper.
func (manager *Manager) restart_running_helper(child *process, source prefix) bool {
func (manager *Manager) restart_running_helper(child *process, source prefix, settings Settings) bool {
if child.controllerOnly {
return false
}
if manager.wholesale_controller_only() || manager.helper_unconfirmed(child.launchedHelper) {
if wholesale_controller_only(settings) || manager.helper_unconfirmed(child.launchedHelper) {
return true
}
if child.readySeen.Load() && int(child.readyExclusions.Load()) != len(child.exclusions) {
@@ -230,7 +253,7 @@ func (manager *Manager) restart_running_helper(child *process, source prefix) bo
// A newly listed program that is currently discovered but lacks its
// flag restarts the helper once, in the same mode, now carrying it.
// Listings for programs that are not running wait for the next spawn.
for _, name := range manager.exclusion_names() {
for _, name := range exclusion_names(settings) {
if source.programs[name] && !child.exclusions[name] {
return true
}
@@ -245,14 +268,14 @@ func backoff(failures int) time.Duration {
return time.Duration(1<<failures) * time.Second
}
func (manager *Manager) launch(child *process, source prefix, now time.Time) error {
func (manager *Manager) launch(child *process, source prefix, now time.Time, settings Settings) error {
helper, err := stage_helper(manager.helper, source.environment)
if err != nil {
return err
}
controllerOnly := child.controllerOnly || manager.wholesale_controller_only() ||
controllerOnly := child.controllerOnly || wholesale_controller_only(settings) ||
manager.helper_unconfirmed(helper)
arguments := manager.helper_arguments(controllerOnly)
arguments := helper_arguments(controllerOnly, settings)
cmd := exec.Command(helper, arguments...)
diagnostics := newHelperDiagnostics()
generation := child.generation.Add(1)
@@ -299,7 +322,7 @@ func (manager *Manager) launch(child *process, source prefix, now time.Time) err
child.controllerOnly = controllerOnly
child.launchedHelper = helper
child.exclusions = make(map[string]bool)
for _, name := range manager.exclusion_names() {
for _, name := range exclusion_names(settings) {
child.exclusions[name] = true
}
logger := manager.logger.With("helper_pid", cmd.Process.Pid)
+7 -7
View File
@@ -61,8 +61,8 @@ func wine_loader(exe, configured string) string {
// unconfirmed (see Manager.helper_unconfirmed) is the third trigger. A
// listed program by itself never selects wholesale mode: the helper gates
// it natively while other programs in the prefix keep dialog reading.
func (manager *Manager) wholesale_controller_only() bool {
if manager.ControllerOnly {
func wholesale_controller_only(settings Settings) bool {
if settings.ControllerOnly {
return true
}
override := os.Getenv("WINE_ACCESS_NO_DIALOG_READER")
@@ -74,10 +74,10 @@ func (manager *Manager) wholesale_controller_only() bool {
// deduplicated case-insensitively, and sorted. The validation mirrors the
// helper's own rules so the count sent here matches the count its ready
// record reports.
func (manager *Manager) exclusion_names() []string {
seen := make(map[string]bool, len(manager.ExcludedPrograms))
func exclusion_names(settings Settings) []string {
seen := make(map[string]bool, len(settings.ExcludedPrograms))
var names []string
for _, name := range manager.ExcludedPrograms {
for _, name := range settings.ExcludedPrograms {
if name == "" || len([]rune(name)) > 255 || strings.TrimSpace(name) != name ||
strings.ContainsAny(name, "/\\") ||
strings.ContainsFunc(name, func(char rune) bool { return char < ' ' }) {
@@ -94,9 +94,9 @@ func (manager *Manager) exclusion_names() []string {
return names
}
func (manager *Manager) helper_arguments(controllerOnly bool) []string {
func helper_arguments(controllerOnly bool, settings Settings) []string {
var arguments []string
for _, name := range manager.exclusion_names() {
for _, name := range exclusion_names(settings) {
arguments = append(arguments, "--exclude-hook-program", name)
}
if controllerOnly {
+18 -15
View File
@@ -88,24 +88,24 @@ func TestListedProgramDoesNotForceWholesaleMode(t *testing.T) {
// A listed program is gated natively by the helper. Its presence must
// never add the prefix-wide --no-dialog-reader, or ordinary programs in
// the same prefix lose dialog reading.
manager := &Manager{ExcludedPrograms: []string{"Game.EXE", "game.exe"}}
if manager.wholesale_controller_only() {
listed := Settings{ExcludedPrograms: []string{"Game.EXE", "game.exe"}}
if wholesale_controller_only(listed) {
t.Fatal("listed programs selected wholesale mode")
}
arguments := manager.helper_arguments(manager.wholesale_controller_only())
arguments := helper_arguments(wholesale_controller_only(listed), listed)
if len(arguments) != 2 || arguments[0] != "--exclude-hook-program" || arguments[1] != "game.exe" {
t.Fatalf("mixed-prefix arguments = %v", arguments)
}
global := &Manager{ControllerOnly: true, ExcludedPrograms: []string{"Game.exe"}}
if !global.wholesale_controller_only() {
global := Settings{ControllerOnly: true, ExcludedPrograms: []string{"Game.exe"}}
if !wholesale_controller_only(global) {
t.Fatal("global controller-only switch ignored")
}
arguments = global.helper_arguments(true)
arguments = helper_arguments(true, global)
if arguments[len(arguments)-1] != "--no-dialog-reader" {
t.Fatalf("global arguments = %v", arguments)
}
t.Setenv("WINE_ACCESS_NO_DIALOG_READER", "1")
if !(&Manager{}).wholesale_controller_only() {
if !wholesale_controller_only(Settings{}) {
t.Fatal("environment override ignored")
}
}
@@ -134,7 +134,7 @@ func TestLaunchForwardsProtonSyncAndKeepsReaderBus(t *testing.T) {
manager.helper = helper
child := &process{}
source := prefix{path: root, environment: map[string]string{"WINEESYNC": "1", "WINEFSYNC": "1", "XAUTHORITY": "/fixture/auth", "DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/pressure-vessel/bus"}}
if err := manager.launch(child, source, time.Now()); err != nil {
if err := manager.launch(child, source, time.Now(), Settings{}); err != nil {
t.Fatal(err)
}
select {
@@ -172,9 +172,10 @@ func TestDiscoverMixedPrefixKeepsOrdinaryProgramsReadable(t *testing.T) {
makeProcess("101", "launcher.exe")
helper := filepath.Join(root, "helper")
write_helper_file(t, helper, "#!/bin/sh\nexec sleep 30\n")
current := Settings{ExcludedPrograms: []string{"GAME.exe"}}
manager := New(nil)
manager.helper, manager.procRoot = helper, procRoot
manager.ExcludedPrograms = []string{"GAME.exe"}
manager.SettingsSource = func() Settings { return current }
active := map[string]*process{}
defer func() {
for _, child := range active {
@@ -215,14 +216,14 @@ func TestDiscoverMixedPrefixKeepsOrdinaryProgramsReadable(t *testing.T) {
}
// A listing for a program that is not running waits for the next
// natural spawn: no restart for prospective protection.
manager.ExcludedPrograms = []string{"GAME.exe", "Absent.exe"}
current.ExcludedPrograms = []string{"GAME.exe", "Absent.exe"}
manager.poll(active)
if child.cmd.Process.Pid != originalPID {
t.Fatal("helper restarted for a listed program that is not running")
}
// A newly listed program that is currently discovered but lacks its
// flag restarts the helper once, in the same mode, carrying the flag.
manager.ExcludedPrograms = []string{"GAME.exe", "Notepad.exe"}
current.ExcludedPrograms = []string{"GAME.exe", "Notepad.exe"}
manager.poll(active)
if child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid == originalPID {
t.Fatal("newly listed running program did not restart the helper in place")
@@ -236,20 +237,20 @@ func TestDiscoverMixedPrefixKeepsOrdinaryProgramsReadable(t *testing.T) {
t.Fatal("helper restarted more than once for one listing")
}
// Removing a listing never downgrades or restarts a running helper.
manager.ExcludedPrograms = []string{"GAME.exe"}
current.ExcludedPrograms = []string{"GAME.exe"}
manager.poll(active)
if child.cmd.Process.Pid != pid {
t.Fatal("helper restarted when a listing was removed")
}
// The global switch still upgrades a running helper to wholesale, and
// clearing it never downgrades the running helper.
manager.ControllerOnly = true
current.ControllerOnly = true
manager.poll(active)
if !child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid == pid {
t.Fatal("global switch did not upgrade the helper to wholesale")
}
pid = child.cmd.Process.Pid
manager.ControllerOnly = false
current.ControllerOnly = false
manager.poll(active)
if !child.controllerOnly || child.cmd.Process.Pid != pid {
t.Fatal("wholesale helper downgraded after the global switch cleared")
@@ -276,7 +277,9 @@ func TestUnconfirmedHelperRestartsWholesaleOnceAndIsRemembered(t *testing.T) {
write_helper_file(t, helper, script)
manager := New(nil)
manager.helper, manager.procRoot = helper, procRoot
manager.ExcludedPrograms = []string{"Game.exe"}
manager.SettingsSource = func() Settings {
return Settings{ExcludedPrograms: []string{"Game.exe"}}
}
active := map[string]*process{}
defer func() {
for _, child := range active {