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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -22,6 +23,38 @@ type helperDiagnostics struct {
|
|||||||
window time.Time
|
window time.Time
|
||||||
seen map[string]bool
|
seen map[string]bool
|
||||||
suppressed atomic.Uint64
|
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 {
|
func newHelperDiagnostics() *helperDiagnostics {
|
||||||
@@ -103,14 +136,18 @@ func (output *helperDiagnostics) logRecords(logger *slog.Logger) {
|
|||||||
reportSuppressed()
|
reportSuppressed()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logHelperRecord(logger, line)
|
if ready, valid := logHelperRecord(logger, line); valid && output.onReady != nil {
|
||||||
|
output.onReady(ready)
|
||||||
|
}
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
reportSuppressed()
|
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 {
|
if body, found := strings.CutPrefix(line, "identity "); found {
|
||||||
var identity struct {
|
var identity struct {
|
||||||
Version string `json:"version"`
|
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 == "" {
|
if err := json.Unmarshal([]byte(body), &identity); err != nil || identity.Version == "" || identity.Revision == "" {
|
||||||
logger.Warn("Invalid Wine helper identity record")
|
logger.Warn("Invalid Wine helper identity record")
|
||||||
return
|
return helperReady{}, false
|
||||||
}
|
}
|
||||||
logger.Info("Wine accessibility helper identity", "version", identity.Version,
|
logger.Info("Wine accessibility helper identity", "version", identity.Version,
|
||||||
"revision", identity.Revision, "modified", identity.Modified)
|
"revision", identity.Revision, "modified", identity.Modified)
|
||||||
return
|
return helperReady{}, false
|
||||||
}
|
}
|
||||||
if body, found := strings.CutPrefix(line, "ready "); found {
|
if strings.HasPrefix(line, "ready ") {
|
||||||
var status struct {
|
status, valid := parseHelperReady(line)
|
||||||
Reader string `json:"reader"`
|
if !valid {
|
||||||
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 == "" {
|
|
||||||
logger.Warn("Invalid Wine helper initialization record")
|
logger.Warn("Invalid Wine helper initialization record")
|
||||||
return
|
return helperReady{}, false
|
||||||
}
|
}
|
||||||
logger.Info("Wine accessibility helper initialized", "reader", status.Reader,
|
logger.Info("Wine accessibility helper initialized", "reader", status.Reader,
|
||||||
"controls_enabled", status.ControlsEnabled, "controller", status.Controller,
|
"controls_enabled", status.ControlsEnabled, "controller", status.Controller,
|
||||||
"review", status.Review, "msaa_worker", status.MSAAWorker,
|
"review", status.Review, "msaa_worker", status.MSAAWorker,
|
||||||
"win_events", status.WinEvents, "menu_hook", status.MenuHook,
|
"win_events", status.WinEvents, "menu_hook", status.MenuHook,
|
||||||
"input_hook", status.InputHook, "host32_started", status.Host32Started,
|
"input_hook", status.InputHook, "host32_started", status.Host32Started,
|
||||||
"control_poll", status.ControlPoll)
|
"control_poll", status.ControlPoll, "hook_exclusions", status.HookExclusions)
|
||||||
return
|
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 == "another helper already owns this Wine desktop" ||
|
||||||
line == "disabled by command-line options" {
|
line == "disabled by command-line options" {
|
||||||
logger.Info("Wine accessibility helper diagnostic", "diagnostic", line)
|
logger.Info("Wine accessibility helper diagnostic", "diagnostic", line)
|
||||||
} else {
|
} else {
|
||||||
logger.Warn("Wine accessibility helper diagnostic", "diagnostic", line)
|
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 {
|
type channelLogHandler struct {
|
||||||
records chan slog.Record
|
records chan slog.Record
|
||||||
attrs []slog.Attr
|
attrs []slog.Attr
|
||||||
|
|||||||
+114
-3
@@ -9,8 +9,10 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -33,6 +35,15 @@ type process struct {
|
|||||||
next time.Time
|
next time.Time
|
||||||
stopping atomic.Bool
|
stopping atomic.Bool
|
||||||
controllerOnly 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 {
|
type Manager struct {
|
||||||
@@ -43,6 +54,26 @@ type Manager struct {
|
|||||||
done chan struct{}
|
done chan struct{}
|
||||||
ControllerOnly bool
|
ControllerOnly bool
|
||||||
ExcludedPrograms []string
|
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 {
|
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.next = now.Add(backoff(child.failures))
|
||||||
child.cmd = nil
|
child.cmd = nil
|
||||||
default:
|
default:
|
||||||
if manager.controller_only(source) && !child.controllerOnly {
|
if manager.restart_running_helper(child, source) {
|
||||||
stop(child)
|
stop(child)
|
||||||
} else {
|
} else {
|
||||||
continue
|
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 {
|
func backoff(failures int) time.Duration {
|
||||||
if failures > 6 {
|
if failures > 6 {
|
||||||
failures = 6
|
failures = 6
|
||||||
@@ -191,10 +250,21 @@ func (manager *Manager) launch(child *process, source prefix, now time.Time) err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
arguments := manager.helper_arguments(controllerOnly)
|
||||||
cmd := exec.Command(helper, arguments...)
|
cmd := exec.Command(helper, arguments...)
|
||||||
diagnostics := newHelperDiagnostics()
|
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.Stdin = nil
|
||||||
cmd.Stdout = nil
|
cmd.Stdout = nil
|
||||||
cmd.Stderr = diagnostics
|
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.cmd, child.done, child.started = cmd, make(chan struct{}), now
|
||||||
child.stopping.Store(false)
|
child.stopping.Store(false)
|
||||||
child.controllerOnly = controllerOnly
|
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 := 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,
|
logger.Debug("Wine accessibility helper launch configuration", "executable", cmd.Path,
|
||||||
"wine_loader", source.loader, "wine_prefix", source.path)
|
"wine_loader", source.loader, "wine_prefix", source.path)
|
||||||
go diagnostics.logRecords(logger)
|
go diagnostics.logRecords(logger)
|
||||||
@@ -236,6 +319,34 @@ func (manager *Manager) launch(child *process, source prefix, now time.Time) err
|
|||||||
return nil
|
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) {
|
func (manager *Manager) wait(cmd *exec.Cmd, child *process, diagnostics *helperDiagnostics, logger *slog.Logger) {
|
||||||
err := cmd.Wait()
|
err := cmd.Wait()
|
||||||
diagnostics.finish()
|
diagnostics.finish()
|
||||||
|
|||||||
+34
-13
@@ -55,28 +55,49 @@ func wine_loader(exe, configured string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *Manager) controller_only(source prefix) bool {
|
// wholesale_controller_only reports whether the whole helper must run
|
||||||
if manager.ControllerOnly || os.Getenv("WINE_ACCESS_NO_DIALOG_READER") != "" && os.Getenv("WINE_ACCESS_NO_DIALOG_READER") != "0" {
|
// 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
|
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 {
|
for _, name := range manager.ExcludedPrograms {
|
||||||
if source.programs[strings.ToLower(name)] {
|
if name == "" || len([]rune(name)) > 255 || strings.TrimSpace(name) != name ||
|
||||||
return true
|
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 {
|
func (manager *Manager) helper_arguments(controllerOnly bool) []string {
|
||||||
var arguments []string
|
var arguments []string
|
||||||
names := append([]string(nil), manager.ExcludedPrograms...)
|
for _, name := range manager.exclusion_names() {
|
||||||
sort.Strings(names)
|
arguments = append(arguments, "--exclude-hook-program", name)
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if controllerOnly {
|
if controllerOnly {
|
||||||
arguments = append(arguments, "--no-dialog-reader")
|
arguments = append(arguments, "--no-dialog-reader")
|
||||||
|
|||||||
+147
-17
@@ -7,6 +7,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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", "")
|
t.Setenv("WINE_ACCESS_NO_DIALOG_READER", "")
|
||||||
manager := &Manager{ExcludedPrograms: []string{"Game.EXE"}}
|
// A listed program is gated natively by the helper. Its presence must
|
||||||
launcher := prefix{programs: map[string]bool{"launcher.exe": true}}
|
// never add the prefix-wide --no-dialog-reader, or ordinary programs in
|
||||||
arguments := manager.helper_arguments(manager.controller_only(launcher))
|
// 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" {
|
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
|
global := &Manager{ControllerOnly: true, ExcludedPrograms: []string{"Game.exe"}}
|
||||||
if !manager.controller_only(launcher) {
|
if !global.wholesale_controller_only() {
|
||||||
t.Fatal("game did not select 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" {
|
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")
|
t.Setenv("WINE_ACCESS_NO_DIALOG_READER", "1")
|
||||||
if !(&Manager{}).controller_only(prefix{}) {
|
if !(&Manager{}).wholesale_controller_only() {
|
||||||
t.Fatal("environment override ignored")
|
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()
|
root := t.TempDir()
|
||||||
procRoot := filepath.Join(root, "proc")
|
procRoot := filepath.Join(root, "proc")
|
||||||
loader := filepath.Join(root, "wine")
|
loader := filepath.Join(root, "wine")
|
||||||
@@ -174,27 +181,150 @@ func TestDiscoverAggregatesGamesAndEscalatesHelper(t *testing.T) {
|
|||||||
stop(child)
|
stop(child)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
// Launcher only: the exclusion flag is already armed, in normal mode.
|
||||||
manager.poll(active)
|
manager.poll(active)
|
||||||
child := active[root]
|
child := active[root]
|
||||||
if child == nil || child.cmd == nil || child.controllerOnly {
|
if child == nil || child.cmd == nil || child.controllerOnly {
|
||||||
t.Fatal("launcher helper did not start normally")
|
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
|
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("102", "Game.exe")
|
||||||
|
makeProcess("103", "notepad.exe")
|
||||||
discovered := discover(procRoot, uint32(os.Getuid()))
|
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)
|
t.Fatalf("programs = %v", discovered[root].programs)
|
||||||
}
|
}
|
||||||
manager.poll(active)
|
manager.poll(active)
|
||||||
if !child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid == originalPID {
|
if child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid != originalPID {
|
||||||
t.Fatal("helper did not switch to controller-only")
|
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 {
|
if err := os.RemoveAll(filepath.Join(procRoot, "102")); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
manager.poll(active)
|
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 {
|
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