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:
hjozwiak committed 2026-11-01 23:27:55 -05:00
1 parent 6ed0e36960
commit 71093286c7
5 files changed
+459 -55

No files matched your search

+66 -22
View File
@@ -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
}
+98
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}