Add Wine ready-record and mixed-prefix spawn semantics
Phase 1 of the Wine/Proton stage (decisions 2, 5, 16, 17, 18, 24 in docs/testing/wine-proton.md): classify the helper exclusion-count line as info, parse the ready hook_exclusions count, mirror programs=[...] extra=[...] in the helper-start record, and stop passing prefix-wide --no-dialog-reader merely because a listed program is present, so ordinary programs in a mixed prefix keep dialog reading. Wholesale mode remains for the global controller-only switch, the environment override, and helpers whose ready record does not confirm the exclusions sent; unconfirmed binaries are remembered. A newly listed running program restarts its helper once in the same mode; removals never downgrade.
This commit is contained in:
1 parent
6ed0e36960
commit
71093286c7
5 files changed
+459
-55
No files matched your search
@@ -3,6 +3,7 @@ package wine
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -22,6 +23,38 @@ type helperDiagnostics struct {
|
||||
window time.Time
|
||||
seen map[string]bool
|
||||
suppressed atomic.Uint64
|
||||
// onReady, when set before logRecords starts, receives each valid
|
||||
// ready record so the manager can act on it. It runs on the logging
|
||||
// worker and must only record state, never supervise processes.
|
||||
onReady func(helperReady)
|
||||
}
|
||||
|
||||
// helperReady is the helper's startup status record. Older helpers omit
|
||||
// hook_exclusions and therefore report zero, which is accurate for them.
|
||||
type helperReady struct {
|
||||
Reader string `json:"reader"`
|
||||
ControlsEnabled bool `json:"controls_enabled"`
|
||||
Controller bool `json:"controller"`
|
||||
Review bool `json:"review"`
|
||||
MSAAWorker bool `json:"msaa_worker"`
|
||||
WinEvents bool `json:"win_events"`
|
||||
MenuHook bool `json:"menu_hook"`
|
||||
InputHook bool `json:"input_hook"`
|
||||
Host32Started bool `json:"host32_started"`
|
||||
ControlPoll bool `json:"control_poll"`
|
||||
HookExclusions int `json:"hook_exclusions"`
|
||||
}
|
||||
|
||||
func parseHelperReady(line string) (helperReady, bool) {
|
||||
body, found := strings.CutPrefix(line, "ready ")
|
||||
if !found {
|
||||
return helperReady{}, false
|
||||
}
|
||||
var status helperReady
|
||||
if err := json.Unmarshal([]byte(body), &status); err != nil || status.Reader == "" {
|
||||
return helperReady{}, false
|
||||
}
|
||||
return status, true
|
||||
}
|
||||
|
||||
func newHelperDiagnostics() *helperDiagnostics {
|
||||
@@ -103,14 +136,18 @@ func (output *helperDiagnostics) logRecords(logger *slog.Logger) {
|
||||
reportSuppressed()
|
||||
return
|
||||
}
|
||||
logHelperRecord(logger, line)
|
||||
if ready, valid := logHelperRecord(logger, line); valid && output.onReady != nil {
|
||||
output.onReady(ready)
|
||||
}
|
||||
case <-ticker.C:
|
||||
reportSuppressed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func logHelperRecord(logger *slog.Logger, line string) {
|
||||
// logHelperRecord logs one helper diagnostic line and returns the parsed
|
||||
// ready record when the line was a valid one.
|
||||
func logHelperRecord(logger *slog.Logger, line string) (helperReady, bool) {
|
||||
if body, found := strings.CutPrefix(line, "identity "); found {
|
||||
var identity struct {
|
||||
Version string `json:"version"`
|
||||
@@ -119,42 +156,49 @@ func logHelperRecord(logger *slog.Logger, line string) {
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &identity); err != nil || identity.Version == "" || identity.Revision == "" {
|
||||
logger.Warn("Invalid Wine helper identity record")
|
||||
return
|
||||
return helperReady{}, false
|
||||
}
|
||||
logger.Info("Wine accessibility helper identity", "version", identity.Version,
|
||||
"revision", identity.Revision, "modified", identity.Modified)
|
||||
return
|
||||
return helperReady{}, false
|
||||
}
|
||||
if body, found := strings.CutPrefix(line, "ready "); found {
|
||||
var status struct {
|
||||
Reader string `json:"reader"`
|
||||
ControlsEnabled bool `json:"controls_enabled"`
|
||||
Controller bool `json:"controller"`
|
||||
Review bool `json:"review"`
|
||||
MSAAWorker bool `json:"msaa_worker"`
|
||||
WinEvents bool `json:"win_events"`
|
||||
MenuHook bool `json:"menu_hook"`
|
||||
InputHook bool `json:"input_hook"`
|
||||
Host32Started bool `json:"host32_started"`
|
||||
ControlPoll bool `json:"control_poll"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &status); err != nil || status.Reader == "" {
|
||||
if strings.HasPrefix(line, "ready ") {
|
||||
status, valid := parseHelperReady(line)
|
||||
if !valid {
|
||||
logger.Warn("Invalid Wine helper initialization record")
|
||||
return
|
||||
return helperReady{}, false
|
||||
}
|
||||
logger.Info("Wine accessibility helper initialized", "reader", status.Reader,
|
||||
"controls_enabled", status.ControlsEnabled, "controller", status.Controller,
|
||||
"review", status.Review, "msaa_worker", status.MSAAWorker,
|
||||
"win_events", status.WinEvents, "menu_hook", status.MenuHook,
|
||||
"input_hook", status.InputHook, "host32_started", status.Host32Started,
|
||||
"control_poll", status.ControlPoll)
|
||||
return
|
||||
"control_poll", status.ControlPoll, "hook_exclusions", status.HookExclusions)
|
||||
return status, true
|
||||
}
|
||||
if strings.HasPrefix(line, "reader event replies resumed after ") ||
|
||||
// The plain exclusion-count line is expected startup evidence (it is
|
||||
// emitted even when the helper then exits early), while a rejected
|
||||
// exclusion value stays a warning through the default branch.
|
||||
if hookExclusionCountLine(line) ||
|
||||
strings.HasPrefix(line, "reader event replies resumed after ") ||
|
||||
line == "another helper already owns this Wine desktop" ||
|
||||
line == "disabled by command-line options" {
|
||||
logger.Info("Wine accessibility helper diagnostic", "diagnostic", line)
|
||||
} else {
|
||||
logger.Warn("Wine accessibility helper diagnostic", "diagnostic", line)
|
||||
}
|
||||
return helperReady{}, false
|
||||
}
|
||||
|
||||
func hookExclusionCountLine(line string) bool {
|
||||
rest, found := strings.CutPrefix(line, "hook exclusions for ")
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
number, found := strings.CutSuffix(rest, " program(s)")
|
||||
if !found || number == "" {
|
||||
return false
|
||||
}
|
||||
count, err := strconv.Atoi(number)
|
||||
return err == nil && count >= 0 && strconv.Itoa(count) == number
|
||||
}
|
||||
@@ -144,6 +144,104 @@ func TestManagerExpectedStopIsNotAHelperFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelperDiagnosticsHookExclusionEvidence(t *testing.T) {
|
||||
var log bytes.Buffer
|
||||
output := newHelperDiagnostics()
|
||||
readyRecords := make(chan helperReady, 4)
|
||||
output.onReady = func(ready helperReady) { readyRecords <- ready }
|
||||
go output.logRecords(slog.New(slog.NewJSONHandler(&log, nil)))
|
||||
for _, line := range []string{
|
||||
"wine-access: hook exclusions for 2 program(s)\n",
|
||||
"wine-access: ignoring invalid --exclude-hook-program value\n",
|
||||
"wine-access: ready {\"reader\":\"org.example.Reader\",\"controls_enabled\":true,\"hook_exclusions\":2}\n",
|
||||
// Older helpers have no count field; they report zero.
|
||||
"wine-access: ready {\"reader\":\"org.example.Reader\",\"controls_enabled\":true}\n",
|
||||
} {
|
||||
if _, err := output.Write([]byte(line)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
output.finish()
|
||||
<-output.done
|
||||
levels := map[string]string{}
|
||||
var exclusionCounts []float64
|
||||
for _, line := range strings.Split(strings.TrimSpace(log.String()), "\n") {
|
||||
var record map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &record); err != nil {
|
||||
t.Fatalf("invalid log record %q: %v", line, err)
|
||||
}
|
||||
if diagnostic, ok := record["diagnostic"].(string); ok {
|
||||
levels[diagnostic] = record["level"].(string)
|
||||
}
|
||||
if record["msg"] == "Wine accessibility helper initialized" {
|
||||
count, ok := record["hook_exclusions"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("initialized record lacks hook_exclusions: %s", line)
|
||||
}
|
||||
exclusionCounts = append(exclusionCounts, count)
|
||||
}
|
||||
}
|
||||
if levels["hook exclusions for 2 program(s)"] != "INFO" {
|
||||
t.Fatalf("plain exclusion-count line levels = %v", levels)
|
||||
}
|
||||
if levels["ignoring invalid --exclude-hook-program value"] != "WARN" {
|
||||
t.Fatalf("invalid exclusion line levels = %v", levels)
|
||||
}
|
||||
if len(exclusionCounts) != 2 || exclusionCounts[0] != 2 || exclusionCounts[1] != 0 {
|
||||
t.Fatalf("logged hook_exclusions = %v, want [2 0]", exclusionCounts)
|
||||
}
|
||||
close(readyRecords)
|
||||
var consumed []int
|
||||
for ready := range readyRecords {
|
||||
consumed = append(consumed, ready.HookExclusions)
|
||||
}
|
||||
if len(consumed) != 2 || consumed[0] != 2 || consumed[1] != 0 {
|
||||
t.Fatalf("consumed ready counts = %v, want [2 0]", consumed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelperStartRecordMirrorsProgramsAndExtra(t *testing.T) {
|
||||
t.Setenv("WINE_ACCESS_NO_DIALOG_READER", "")
|
||||
root := t.TempDir()
|
||||
helper := filepath.Join(root, "helper")
|
||||
if err := os.WriteFile(helper, []byte("#!/bin/sh\nexec sleep 30\n"), 0700); err != nil {
|
||||
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"}}
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stop(child)
|
||||
deadline := time.After(2 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case record := <-handler.records:
|
||||
if record.Message != "Wine accessibility helper started" {
|
||||
continue
|
||||
}
|
||||
attrs := map[string]string{}
|
||||
record.Attrs(func(attr slog.Attr) bool {
|
||||
attrs[attr.Key] = attr.Value.String()
|
||||
return true
|
||||
})
|
||||
if attrs["programs"] != "['game.exe', 'launcher.exe']" {
|
||||
t.Fatalf("programs = %q", attrs["programs"])
|
||||
}
|
||||
want := "['--exclude-hook-program', 'another.exe', '--exclude-hook-program', 'game.exe']"
|
||||
if attrs["extra"] != want {
|
||||
t.Fatalf("extra = %q, want %q", attrs["extra"], want)
|
||||
}
|
||||
return
|
||||
case <-deadline:
|
||||
t.Fatal("helper-start record missing")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type channelLogHandler struct {
|
||||
records chan slog.Record
|
||||
attrs []slog.Attr
|
||||
|
||||
+114
-3
@@ -9,8 +9,10 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -33,6 +35,15 @@ type process struct {
|
||||
next time.Time
|
||||
stopping atomic.Bool
|
||||
controllerOnly bool
|
||||
// launchedHelper and exclusions describe the most recent launch and
|
||||
// are written only by the poll goroutine. The ready fields are written
|
||||
// by the diagnostics worker for the launch generation that armed them
|
||||
// and read by the poll goroutine.
|
||||
launchedHelper string
|
||||
exclusions map[string]bool
|
||||
generation atomic.Uint64
|
||||
readySeen atomic.Bool
|
||||
readyExclusions atomic.Int64
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
@@ -43,6 +54,26 @@ type Manager struct {
|
||||
done chan struct{}
|
||||
ControllerOnly bool
|
||||
ExcludedPrograms []string
|
||||
// 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.
|
||||
unconfirmedMu sync.Mutex
|
||||
unconfirmed map[string]bool
|
||||
}
|
||||
|
||||
func (manager *Manager) helper_unconfirmed(helper string) bool {
|
||||
manager.unconfirmedMu.Lock()
|
||||
defer manager.unconfirmedMu.Unlock()
|
||||
return manager.unconfirmed[helper]
|
||||
}
|
||||
|
||||
func (manager *Manager) mark_unconfirmed(helper string) {
|
||||
manager.unconfirmedMu.Lock()
|
||||
defer manager.unconfirmedMu.Unlock()
|
||||
if manager.unconfirmed == nil {
|
||||
manager.unconfirmed = make(map[string]bool)
|
||||
}
|
||||
manager.unconfirmed[helper] = true
|
||||
}
|
||||
|
||||
func New(logger *slog.Logger) *Manager {
|
||||
@@ -155,7 +186,7 @@ func (manager *Manager) poll(active map[string]*process) {
|
||||
child.next = now.Add(backoff(child.failures))
|
||||
child.cmd = nil
|
||||
default:
|
||||
if manager.controller_only(source) && !child.controllerOnly {
|
||||
if manager.restart_running_helper(child, source) {
|
||||
stop(child)
|
||||
} else {
|
||||
continue
|
||||
@@ -179,6 +210,34 @@ func (manager *Manager) poll(active map[string]*process) {
|
||||
}
|
||||
}
|
||||
|
||||
// restart_running_helper decides whether a running helper must restart to
|
||||
// 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 {
|
||||
if child.controllerOnly {
|
||||
return false
|
||||
}
|
||||
if manager.wholesale_controller_only() || manager.helper_unconfirmed(child.launchedHelper) {
|
||||
return true
|
||||
}
|
||||
if child.readySeen.Load() && int(child.readyExclusions.Load()) != len(child.exclusions) {
|
||||
manager.mark_unconfirmed(child.launchedHelper)
|
||||
manager.logger.Warn("Wine accessibility helper did not confirm hook exclusions; restarting without dialog reading",
|
||||
"sent", len(child.exclusions), "reported", child.readyExclusions.Load())
|
||||
return true
|
||||
}
|
||||
// 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() {
|
||||
if source.programs[name] && !child.exclusions[name] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func backoff(failures int) time.Duration {
|
||||
if failures > 6 {
|
||||
failures = 6
|
||||
@@ -191,10 +250,21 @@ func (manager *Manager) launch(child *process, source prefix, now time.Time) err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
controllerOnly := child.controllerOnly || manager.controller_only(source)
|
||||
controllerOnly := child.controllerOnly || manager.wholesale_controller_only() ||
|
||||
manager.helper_unconfirmed(helper)
|
||||
arguments := manager.helper_arguments(controllerOnly)
|
||||
cmd := exec.Command(helper, arguments...)
|
||||
diagnostics := newHelperDiagnostics()
|
||||
generation := child.generation.Add(1)
|
||||
child.readySeen.Store(false)
|
||||
child.readyExclusions.Store(0)
|
||||
diagnostics.onReady = func(ready helperReady) {
|
||||
if child.generation.Load() != generation {
|
||||
return
|
||||
}
|
||||
child.readyExclusions.Store(int64(ready.HookExclusions))
|
||||
child.readySeen.Store(true)
|
||||
}
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = diagnostics
|
||||
@@ -227,8 +297,21 @@ func (manager *Manager) launch(child *process, source prefix, now time.Time) err
|
||||
child.cmd, child.done, child.started = cmd, make(chan struct{}), now
|
||||
child.stopping.Store(false)
|
||||
child.controllerOnly = controllerOnly
|
||||
child.launchedHelper = helper
|
||||
child.exclusions = make(map[string]bool)
|
||||
for _, name := range manager.exclusion_names() {
|
||||
child.exclusions[name] = true
|
||||
}
|
||||
logger := manager.logger.With("helper_pid", cmd.Process.Pid)
|
||||
logger.Info("Wine accessibility helper started")
|
||||
// Mirror the other reader's helper-start evidence: sorted discovered
|
||||
// program names and the exact extra helper arguments.
|
||||
programs := make([]string, 0, len(source.programs))
|
||||
for name := range source.programs {
|
||||
programs = append(programs, name)
|
||||
}
|
||||
sort.Strings(programs)
|
||||
logger.Info("Wine accessibility helper started",
|
||||
"programs", mirroredStringList(programs), "extra", mirroredStringList(arguments))
|
||||
logger.Debug("Wine accessibility helper launch configuration", "executable", cmd.Path,
|
||||
"wine_loader", source.loader, "wine_prefix", source.path)
|
||||
go diagnostics.logRecords(logger)
|
||||
@@ -236,6 +319,34 @@ func (manager *Manager) launch(child *process, source prefix, now time.Time) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// mirroredStringList formats values as the other reader's spawn log does
|
||||
// (a Python list of repr strings), so both readers' helper-start records
|
||||
// can be compared directly.
|
||||
func mirroredStringList(values []string) string {
|
||||
parts := make([]string, len(values))
|
||||
for index, value := range values {
|
||||
parts[index] = mirroredString(value)
|
||||
}
|
||||
return "[" + strings.Join(parts, ", ") + "]"
|
||||
}
|
||||
|
||||
func mirroredString(value string) string {
|
||||
quote := "'"
|
||||
if strings.Contains(value, "'") && !strings.Contains(value, `"`) {
|
||||
quote = `"`
|
||||
}
|
||||
var builder strings.Builder
|
||||
builder.WriteString(quote)
|
||||
for _, char := range value {
|
||||
if char == '\\' || string(char) == quote {
|
||||
builder.WriteByte('\\')
|
||||
}
|
||||
builder.WriteRune(char)
|
||||
}
|
||||
builder.WriteString(quote)
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func (manager *Manager) wait(cmd *exec.Cmd, child *process, diagnostics *helperDiagnostics, logger *slog.Logger) {
|
||||
err := cmd.Wait()
|
||||
diagnostics.finish()
|
||||
|
||||
+34
-13
@@ -55,28 +55,49 @@ func wine_loader(exe, configured string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (manager *Manager) controller_only(source prefix) bool {
|
||||
if manager.ControllerOnly || os.Getenv("WINE_ACCESS_NO_DIALOG_READER") != "" && os.Getenv("WINE_ACCESS_NO_DIALOG_READER") != "0" {
|
||||
// wholesale_controller_only reports whether the whole helper must run
|
||||
// without dialog reading. Only the global controller-only switch and the
|
||||
// environment override select it here; a helper binary remembered as
|
||||
// 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 {
|
||||
return true
|
||||
}
|
||||
override := os.Getenv("WINE_ACCESS_NO_DIALOG_READER")
|
||||
return override != "" && override != "0"
|
||||
}
|
||||
|
||||
// exclusion_names returns the hook-exclusion program names exactly as the
|
||||
// helper will store them: validated at the process boundary, lowercased,
|
||||
// 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))
|
||||
var names []string
|
||||
for _, name := range manager.ExcludedPrograms {
|
||||
if source.programs[strings.ToLower(name)] {
|
||||
return true
|
||||
if name == "" || len([]rune(name)) > 255 || strings.TrimSpace(name) != name ||
|
||||
strings.ContainsAny(name, "/\\") ||
|
||||
strings.ContainsFunc(name, func(char rune) bool { return char < ' ' }) {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(name)
|
||||
if seen[lower] {
|
||||
continue
|
||||
}
|
||||
seen[lower] = true
|
||||
names = append(names, lower)
|
||||
}
|
||||
return false
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func (manager *Manager) helper_arguments(controllerOnly bool) []string {
|
||||
var arguments []string
|
||||
names := append([]string(nil), manager.ExcludedPrograms...)
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
// Validate again at the process boundary; never pass paths or separators
|
||||
// into the native hook exclusion list.
|
||||
if name != "" && !strings.ContainsAny(name, "\x00\r\n/\\") {
|
||||
arguments = append(arguments, "--exclude-hook-program", strings.ToLower(name))
|
||||
}
|
||||
for _, name := range manager.exclusion_names() {
|
||||
arguments = append(arguments, "--exclude-hook-program", name)
|
||||
}
|
||||
if controllerOnly {
|
||||
arguments = append(arguments, "--no-dialog-reader")
|
||||
|
||||
+147
-17
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -82,24 +83,29 @@ func TestContainerHelperPublishesImmutableCompleteSets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerOnlyExclusionsReachLauncherBeforeGameStarts(t *testing.T) {
|
||||
func TestListedProgramDoesNotForceWholesaleMode(t *testing.T) {
|
||||
t.Setenv("WINE_ACCESS_NO_DIALOG_READER", "")
|
||||
manager := &Manager{ExcludedPrograms: []string{"Game.EXE"}}
|
||||
launcher := prefix{programs: map[string]bool{"launcher.exe": true}}
|
||||
arguments := manager.helper_arguments(manager.controller_only(launcher))
|
||||
// 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() {
|
||||
t.Fatal("listed programs selected wholesale mode")
|
||||
}
|
||||
arguments := manager.helper_arguments(manager.wholesale_controller_only())
|
||||
if len(arguments) != 2 || arguments[0] != "--exclude-hook-program" || arguments[1] != "game.exe" {
|
||||
t.Fatalf("launcher arguments = %v", arguments)
|
||||
t.Fatalf("mixed-prefix arguments = %v", arguments)
|
||||
}
|
||||
launcher.programs["game.exe"] = true
|
||||
if !manager.controller_only(launcher) {
|
||||
t.Fatal("game did not select controller-only")
|
||||
global := &Manager{ControllerOnly: true, ExcludedPrograms: []string{"Game.exe"}}
|
||||
if !global.wholesale_controller_only() {
|
||||
t.Fatal("global controller-only switch ignored")
|
||||
}
|
||||
arguments = manager.helper_arguments(true)
|
||||
arguments = global.helper_arguments(true)
|
||||
if arguments[len(arguments)-1] != "--no-dialog-reader" {
|
||||
t.Fatalf("game arguments = %v", arguments)
|
||||
t.Fatalf("global arguments = %v", arguments)
|
||||
}
|
||||
t.Setenv("WINE_ACCESS_NO_DIALOG_READER", "1")
|
||||
if !(&Manager{}).controller_only(prefix{}) {
|
||||
if !(&Manager{}).wholesale_controller_only() {
|
||||
t.Fatal("environment override ignored")
|
||||
}
|
||||
}
|
||||
@@ -148,7 +154,8 @@ func TestLaunchForwardsProtonSyncAndKeepsReaderBus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverAggregatesGamesAndEscalatesHelper(t *testing.T) {
|
||||
func TestDiscoverMixedPrefixKeepsOrdinaryProgramsReadable(t *testing.T) {
|
||||
t.Setenv("WINE_ACCESS_NO_DIALOG_READER", "")
|
||||
root := t.TempDir()
|
||||
procRoot := filepath.Join(root, "proc")
|
||||
loader := filepath.Join(root, "wine")
|
||||
@@ -174,27 +181,150 @@ func TestDiscoverAggregatesGamesAndEscalatesHelper(t *testing.T) {
|
||||
stop(child)
|
||||
}
|
||||
}()
|
||||
// Launcher only: the exclusion flag is already armed, in normal mode.
|
||||
manager.poll(active)
|
||||
child := active[root]
|
||||
if child == nil || child.cmd == nil || child.controllerOnly {
|
||||
t.Fatal("launcher helper did not start normally")
|
||||
}
|
||||
if !child.exclusions["game.exe"] {
|
||||
t.Fatal("launcher helper lacks the armed exclusion flag")
|
||||
}
|
||||
originalPID := child.cmd.Process.Pid
|
||||
// The listed game and an ordinary program appear. The helper already
|
||||
// carries the flag, so nothing restarts and nothing goes wholesale:
|
||||
// the ordinary program keeps dialog reading by construction, since
|
||||
// --no-dialog-reader was never passed.
|
||||
makeProcess("102", "Game.exe")
|
||||
makeProcess("103", "notepad.exe")
|
||||
discovered := discover(procRoot, uint32(os.Getuid()))
|
||||
if len(discovered[root].programs) != 2 {
|
||||
if len(discovered[root].programs) != 3 {
|
||||
t.Fatalf("programs = %v", discovered[root].programs)
|
||||
}
|
||||
manager.poll(active)
|
||||
if !child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid == originalPID {
|
||||
t.Fatal("helper did not switch to controller-only")
|
||||
if child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid != originalPID {
|
||||
t.Fatal("listed game restarted or flipped the helper wholesale")
|
||||
}
|
||||
pid := child.cmd.Process.Pid
|
||||
// The listed game disappears: still no restart and no mode change.
|
||||
if err := os.RemoveAll(filepath.Join(procRoot, "102")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager.poll(active)
|
||||
if child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid != originalPID {
|
||||
t.Fatal("helper restarted when the listed game disappeared")
|
||||
}
|
||||
// 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"}
|
||||
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"}
|
||||
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")
|
||||
}
|
||||
if !child.exclusions["notepad.exe"] {
|
||||
t.Fatal("restarted helper lacks the new exclusion flag")
|
||||
}
|
||||
pid := child.cmd.Process.Pid
|
||||
manager.poll(active)
|
||||
if child.cmd.Process.Pid != pid {
|
||||
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"}
|
||||
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
|
||||
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
|
||||
manager.poll(active)
|
||||
if !child.controllerOnly || child.cmd.Process.Pid != pid {
|
||||
t.Fatal("controller-only helper downgraded after game disappeared")
|
||||
t.Fatal("wholesale helper downgraded after the global switch cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnconfirmedHelperRestartsWholesaleOnceAndIsRemembered(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")
|
||||
// An old helper that ignores the exclusion flags reports zero.
|
||||
script := "#!/bin/sh\n" +
|
||||
"echo 'wine-access: ready {\"reader\":\"org.example.Reader\",\"controls_enabled\":true,\"hook_exclusions\":0}' >&2\n" +
|
||||
"exec sleep 30\n"
|
||||
write_helper_file(t, helper, script)
|
||||
manager := New(nil)
|
||||
manager.helper, manager.procRoot = helper, procRoot
|
||||
manager.ExcludedPrograms = []string{"Game.exe"}
|
||||
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("first helper did not start normally")
|
||||
}
|
||||
if got := strings.Join(child.cmd.Args[1:], " "); got != "--exclude-hook-program game.exe" {
|
||||
t.Fatalf("first launch arguments = %q", got)
|
||||
}
|
||||
firstPID := child.cmd.Process.Pid
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for !child.readySeen.Load() && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if !child.readySeen.Load() {
|
||||
t.Fatal("manager did not consume the ready record")
|
||||
}
|
||||
manager.poll(active)
|
||||
if !child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid == firstPID {
|
||||
t.Fatal("unconfirmed helper did not restart wholesale")
|
||||
}
|
||||
if got := strings.Join(child.cmd.Args[1:], " "); got != "--exclude-hook-program game.exe --no-dialog-reader" {
|
||||
t.Fatalf("wholesale launch arguments = %q", got)
|
||||
}
|
||||
wholesalePID := child.cmd.Process.Pid
|
||||
manager.poll(active)
|
||||
if child.cmd.Process.Pid != wholesalePID {
|
||||
t.Fatal("unconfirmed helper restarted wholesale more than once")
|
||||
}
|
||||
// The binary is remembered: a later spawn goes wholesale directly.
|
||||
stop(child)
|
||||
fresh := map[string]*process{}
|
||||
defer func() {
|
||||
for _, child := range fresh {
|
||||
stop(child)
|
||||
}
|
||||
}()
|
||||
manager.poll(fresh)
|
||||
replacement := fresh[root]
|
||||
if replacement == nil || replacement.cmd == nil || !replacement.controllerOnly {
|
||||
t.Fatal("remembered helper did not start wholesale directly")
|
||||
}
|
||||
if got := strings.Join(replacement.cmd.Args[1:], " "); got != "--exclude-hook-program game.exe --no-dialog-reader" {
|
||||
t.Fatalf("remembered launch arguments = %q", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user