Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions internal/update/inuse_dll_windows_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions internal/update/state_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
19 changes: 18 additions & 1 deletion internal/update/state_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions internal/update/swap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
Expand Down
Loading