From 71093286c78ee10d69ef468c4d256a87202e6fc6 Mon Sep 17 00:00:00 2001 From: Hunter Jozwiak Date: Sun, 1 Nov 2026 23:27:55 -0500 Subject: [PATCH] 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. --- internal/wine/diagnostics.go | 88 ++++++++++++---- internal/wine/diagnostics_test.go | 98 ++++++++++++++++++ internal/wine/manager.go | 117 ++++++++++++++++++++- internal/wine/proton.go | 47 ++++++--- internal/wine/proton_test.go | 164 ++++++++++++++++++++++++++---- 5 files changed, 459 insertions(+), 55 deletions(-) diff --git a/internal/wine/diagnostics.go b/internal/wine/diagnostics.go index cf29ed7..993151d 100644 --- a/internal/wine/diagnostics.go +++ b/internal/wine/diagnostics.go @@ -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 } diff --git a/internal/wine/diagnostics_test.go b/internal/wine/diagnostics_test.go index f9b4968..981a08f 100644 --- a/internal/wine/diagnostics_test.go +++ b/internal/wine/diagnostics_test.go @@ -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 diff --git a/internal/wine/manager.go b/internal/wine/manager.go index cf6d08c..5213264 100644 --- a/internal/wine/manager.go +++ b/internal/wine/manager.go @@ -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() diff --git a/internal/wine/proton.go b/internal/wine/proton.go index e236aad..44e4fc5 100644 --- a/internal/wine/proton.go +++ b/internal/wine/proton.go @@ -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") diff --git a/internal/wine/proton_test.go b/internal/wine/proton_test.go index c7e85af..60c2308 100644 --- a/internal/wine/proton_test.go +++ b/internal/wine/proton_test.go @@ -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) } }