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:
1 parent
a60a251472
commit
9a10a15b68
13 files changed
+327
-51
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user