diff --git a/internal/update/inuse_dll_windows_test.go b/internal/update/inuse_dll_windows_test.go new file mode 100644 index 0000000..984f01d --- /dev/null +++ b/internal/update/inuse_dll_windows_test.go @@ -0,0 +1,126 @@ +//go:build windows + +package update + +import ( + "context" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "modavd/internal/errs" +) + +// loadDLL puts a copy of a small system DLL at path and loads it into this process, the way MAA loads +// mumu-shim\shell\sdk\external_renderer_ipc.dll; freed at the end of the test (or by the returned func). +func loadDLL(t *testing.T, path string) (free func()) { + t.Helper() + b, err := os.ReadFile(filepath.Join(os.Getenv("SystemRoot"), "System32", "msimg32.dll")) + if err != nil { + t.Skipf("no system DLL to load: %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, b, 0o644); err != nil { + t.Fatal(err) + } + h, err := syscall.LoadLibrary(path) + if err != nil { + t.Fatalf("LoadLibrary %s: %v", path, err) + } + freed := false + free = func() { + if !freed { + freed = true + syscall.FreeLibrary(h) + } + } + t.Cleanup(free) + return free +} + +func TestFileInUseSeesLoadedDLL(t *testing.T) { + dir := t.TempDir() + dll := filepath.Join(dir, "external_renderer_ipc.dll") + free := loadDLL(t, dll) + if !fileInUse(dll) { + t.Error("a loaded DLL is not in use") + } + free() + if fileInUse(dll) { + t.Error("a freed DLL is still in use") + } + plain := filepath.Join(dir, "plain.txt") + os.WriteFile(plain, []byte("x"), 0o644) + readOnly := filepath.Join(dir, "readonly.txt") + os.WriteFile(readOnly, []byte("x"), 0o444) + for _, p := range []string{plain, readOnly, filepath.Join(dir, "missing.dll")} { + if fileInUse(p) { + t.Errorf("%s counts as in use", p) + } + } + if b, _ := os.ReadFile(plain); string(b) != "x" { + t.Errorf("the check changed the file: %q", b) + } +} + +// TestInstallOverThirdPartyShim: AUTO-MAS copies its own external_renderer_ipc.dll to the same path, so the root's +// file differs from the root manifest. A full package install takes the package's copy (the other one is kept in the +// backup until the install is done) and does not fail. +func TestInstallOverThirdPartyShim(t *testing.T) { + const shim = "mumu-shim/shell/sdk/external_renderer_ipc.dll" + withSettings(t, SourceGitHub, "") + r := writeRoot(t, 36, map[string]string{shim: "shim 36"}) + if err := os.WriteFile(filepath.Join(r.Dir, filepath.FromSlash(shim)), []byte("AUTO-MAS copy"), 0o644); err != nil { + t.Fatal(err) + } + f := newSources(t, 37, zipOf(t, 37, pkgFiles(37, map[string]string{shim: "shim 36"}), false)) + u := f.updater(r) + u.scan = func(bool) ([]processUse, error) { return nil, nil } + if st, err := u.Install(context.Background()); err != nil || !st.Updated { + t.Fatalf("install: %+v %v", st, err) + } + if got := read(t, r, shim); got != "shim 36" { + t.Errorf("shim = %q", got) + } +} + +// TestInstallBlockedByLoadedDLL: MAA holding the MuMu shell (or extras\modavd_extras.dll) loaded stops the install +// before anything is swapped, with a message that says which file and to close MAA; the stage stays installable and +// the next install, once MAA is closed, goes through. +func TestInstallBlockedByLoadedDLL(t *testing.T) { + const shim = "mumu-shim/shell/sdk/external_renderer_ipc.dll" + withSettings(t, SourceGitHub, "") + r := writeRoot(t, 36, map[string]string{shim: "shim 36"}) + f := newSources(t, 37, zipOf(t, 37, pkgFiles(37, map[string]string{shim: "shim 37"}), false)) + u := f.updater(r) + u.scan = func(bool) ([]processUse, error) { return nil, nil } + free := loadDLL(t, filepath.Join(r.Dir, filepath.FromSlash(shim))) + _, err := u.Install(context.Background()) + if errs.CodeOf(err) != errs.Busy || !strings.Contains(err.Error(), `mumu-shim\shell\sdk\external_renderer_ipc.dll`) || + !strings.Contains(err.Error(), "MAA") || !strings.Contains(err.Error(), "什么都没动") { + t.Fatalf("blocked: %v", err) + } + if st := u.Status(); st.Phase != phaseStaged || st.PreparedVersion != "v0.37.0" { + t.Fatalf("after: %+v", st) + } + for rel, want := range map[string]string{"sdk/emulator/emulator.exe": "emulator mas-36", "说明.txt": "ModAVD 0.36.0\n", + "tools/modavd-bridge.ps1": "# bridge 36\n"} { + if got := read(t, r, rel); got != want { + t.Errorf("%s = %q after the blocked install", rel, got) + } + } + if lexists(u.l.previous()) { + t.Error("previous\\ left behind") + } + free() + if st, err := u.Install(context.Background()); err != nil || !st.Updated { + t.Fatalf("install after MAA closed: %+v %v", st, err) + } + if got := read(t, r, shim); got != "shim 37" { + t.Errorf("shim = %q", got) + } +} diff --git a/internal/update/state_other.go b/internal/update/state_other.go index 0631eba..90f6af4 100644 --- a/internal/update/state_other.go +++ b/internal/update/state_other.go @@ -6,3 +6,6 @@ import "os" func isReparsePoint(st os.FileInfo) bool { return st.Mode()&os.ModeSymlink != 0 } func removeLink(p string) bool { return os.Remove(p) == nil } + +// fileInUse: a loaded library or an open file never stops a rename or an unlink here. +func fileInUse(string) bool { return false } diff --git a/internal/update/state_windows.go b/internal/update/state_windows.go index 07daeac..78ae678 100644 --- a/internal/update/state_windows.go +++ b/internal/update/state_windows.go @@ -3,11 +3,28 @@ package update import ( + "errors" "os" "syscall" ) -const fileAttributeReparsePoint = 0x400 +const ( + fileAttributeReparsePoint = 0x400 + errorSharingViolation = syscall.Errno(32) + errorLockViolation = syscall.Errno(33) +) + +// fileInUse: another process holds path so that it can be neither replaced nor deleted, e.g. a DLL MAA has loaded +// (a mapped image refuses write access) or a file opened without sharing. Opened for writing without truncating and +// closed at once; any other error (read-only, missing) is not "in use": the swap reports those itself. +func fileInUse(path string) bool { + f, err := os.OpenFile(path, os.O_WRONLY, 0) + if err == nil { + f.Close() + return false + } + return errors.Is(err, errorSharingViolation) || errors.Is(err, errorLockViolation) +} func isReparsePoint(st os.FileInfo) bool { sys, ok := st.Sys().(*syscall.Win32FileAttributeData) diff --git a/internal/update/swap.go b/internal/update/swap.go index cbd4005..4c21c01 100644 --- a/internal/update/swap.go +++ b/internal/update/swap.go @@ -243,6 +243,22 @@ func (l layout) applyRootFiles(p rootPlan) error { return nil } +// rootFileInUse is the first root file the plan replaces or deletes that another process holds (fileInUse), "" when +// none. The running program itself is left out: applyRootFiles renames it away first. +func (l layout) rootFileInUse(p rootPlan) string { + self := selfExe() + for _, rel := range append(append([]string{}, p.replace...), p.delete...) { + dst := l.at(rel) + if inFold(p.created, rel) || (self != "" && strings.EqualFold(dst, self)) || !isFile(dst) { + continue + } + if fileInUse(dst) { + return rel + } + } + return "" +} + // restoreRootFiles undoes applyRootFiles from previous\root. func (l layout) restoreRootFiles(p rootPlan) error { for _, rel := range p.created { @@ -312,6 +328,13 @@ func (u *Updater) swapIn() (string, error) { if err := l.backupRootFiles(plan); err != nil { return "", err } + // A root file another program holds (MAA loads extras\modavd_extras.dll and + // mumu-shim\shell\sdk\external_renderer_ipc.dll) cannot be replaced: found out after the renames, it would leave + // the root half swapped until that program exits. Checked while nothing has been changed yet. + if rel := l.rootFileInUse(plan); rel != "" { + l.backToStaged(st) + return "", swapBlocked("%s 正被别的程序占用(多半是 MAA 之类载入了它),什么都没动。请先关掉它再装", filepath.FromSlash(rel)) + } st.Set("phase", jsonord.NewString(phaseSwapping)) st.Set("plan", plan.value()) st.Set("swap", stepVal(stepBegin))