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
+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