From a070a41aeb172e29e32ba05f90986b2f6cec7bb7 Mon Sep 17 00:00:00 2001 From: DeathofBrain <109609173+DeathofBrain@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:18:24 +0800 Subject: [PATCH 1/9] feat(macos): define ARM64 guest profile and emulator paths --- internal/avd/avd.go | 10 ++-- internal/avd/platform_test.go | 89 ++++++++++++++++++++++++++++++++ internal/root/components.go | 5 +- internal/root/components_test.go | 2 +- internal/root/platform.go | 46 +++++++++++------ internal/root/platform_test.go | 70 +++++++++++++++++++++++-- internal/root/root.go | 2 + 7 files changed, 200 insertions(+), 24 deletions(-) create mode 100644 internal/avd/platform_test.go diff --git a/internal/avd/avd.go b/internal/avd/avd.go index 5eee38b..1d14ee0 100644 --- a/internal/avd/avd.go +++ b/internal/avd/avd.go @@ -39,7 +39,8 @@ const ( // Display is fixed at 720p (user 10-04), written into config.ini before every boot, never `wm size`. ScreenWidth, ScreenHeight, ScreenDensity = 1280, 720, 240 TargetAPI = "android-34" - // image.sysdir.1, relative to the SDK root, with the trailing backslash AUTO-MAS writes. + // SystemImageSysdir is the Windows/AUTO-MAS value of image.sysdir.1. + // Create uses the host platform's system-image directory and path separator. SystemImageSysdir = `system-images\android-34\google_apis\x86_64\` ) @@ -605,6 +606,9 @@ func Create(r *root.Root, i int, opt CreateOptions) (*Instance, error) { for _, kv := range ConfigTemplate { cfg.Set(kv[0], kv[1]) } + p := root.CurrentPlatform() + cfg.Set("abi.type", p.GuestABI) + cfg.Set("hw.cpu.arch", p.GuestArch) for _, kv := range [][2]string{ {"avd.id", name}, {"avd.name", name}, @@ -616,14 +620,14 @@ func Create(r *root.Root, i int, opt CreateOptions) (*Instance, error) { {"hw.lcd.height", strconv.Itoa(ScreenHeight)}, {"hw.lcd.density", strconv.Itoa(ScreenDensity)}, {"hw.lcd.vsync", strconv.Itoa(fps)}, - {"image.sysdir.1", SystemImageSysdir}, + {"image.sysdir.1", p.SystemImageDir + string(filepath.Separator)}, } { cfg.Set(kv[0], kv[1]) } ini := NewIni() ini.Set("avd.ini.encoding", "UTF-8") ini.Set("path", dir) - ini.Set("path.rel", root.AvdDir+`\`+name+".avd") + ini.Set("path.rel", filepath.Join(root.AvdDir, name+".avd")) ini.Set("target", TargetAPI) if err := os.MkdirAll(r.AvdHome(), 0o755); err != nil { diff --git a/internal/avd/platform_test.go b/internal/avd/platform_test.go new file mode 100644 index 0000000..07a6e01 --- /dev/null +++ b/internal/avd/platform_test.go @@ -0,0 +1,89 @@ +package avd + +import ( + "os" + "path/filepath" + "runtime" + "slices" + "testing" + + "modavd/internal/root" +) + +func TestCreateHostGuestProfile(t *testing.T) { + var abi, arch, imageDir string + switch { + case runtime.GOOS == "windows" && runtime.GOARCH == "amd64": + abi, arch, imageDir = "x86_64", "x86_64", `system-images\android-34\google_apis\x86_64` + case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64": + abi, arch, imageDir = "arm64-v8a", "arm64", "system-images/android-34/google_apis/arm64-v8a" + case runtime.GOOS == "linux" && runtime.GOARCH == "amd64": + abi, arch, imageDir = "x86_64", "x86_64", "system-images/android-34/google_apis/x86_64" + default: + t.Skip("no guest profile for this host") + } + for _, localSDK := range []bool{false, true} { + t.Run(map[bool]string{false: "packaged", true: "sdkRoot"}[localSDK], func(t *testing.T) { + r, err := root.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if localSDK { + if err := os.WriteFile(r.MetadataPath(), []byte(`{"sdkRoot":"runtime sdk"}`), 0o644); err != nil { + t.Fatal(err) + } + } + imagePath := filepath.Join(r.RuntimeSDK(), imageDir, "system.img") + if err := os.MkdirAll(filepath.Dir(imagePath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(imagePath, []byte("host image fixture"), 0o644); err != nil { + t.Fatal(err) + } + in, err := Create(r, 1, CreateOptions{Title: "平台配置", MemoryMB: 4096, CPU: 4, DataPartitionGB: 16, FPS: 60}) + if err != nil { + t.Fatal(err) + } + cfg, err := ReadIni(ConfigPath(r, in.Index)) + if err != nil { + t.Fatal(err) + } + for key, want := range map[string]string{ + "abi.type": abi, "hw.cpu.arch": arch, + "image.sysdir.1": imageDir + string(filepath.Separator), + "hw.cpu.ncore": "4", "hw.ramSize": "4096M", "avd.ini.displayname": "平台配置", + } { + if got := cfg.Value(key); got != want { + t.Errorf("%s = %q; want %q", key, got, want) + } + } + // Resolve the actual written image.sysdir.1 against the selected SDK, including sdkRoot overrides. + image, err := os.ReadFile(filepath.Join(r.RuntimeSDK(), cfg.Value("image.sysdir.1"), "system.img")) + if err != nil || string(image) != "host image fixture" { + t.Fatalf("written system-image path does not resolve: %q, %v", image, err) + } + ini, err := ReadIni(IniPath(r, in.Index)) + if err != nil { + t.Fatal(err) + } + if ini.Value("path") != in.Dir || ini.Value("path.rel") != filepath.Join("avd", "mas_1.avd") { + t.Fatalf("AVD directory paths: path=%q path.rel=%q", ini.Value("path"), ini.Value("path.rel")) + } + if _, err := os.Stat(filepath.Join(r.Dir, ini.Value("path.rel"), "config.ini")); err != nil { + t.Fatalf("written path.rel does not resolve to the AVD: %v", err) + } + if !slices.Equal(ini.Keys(), []string{"avd.ini.encoding", "path", "path.rel", "target"}) { + t.Fatalf("AVD .ini key order changed: %v", ini.Keys()) + } + keys := cfg.Keys() + if len(keys) < len(ConfigTemplate) { + t.Fatalf("config has fewer keys than its template: %v", keys) + } + for n, kv := range ConfigTemplate { + if keys[n] != kv[0] { + t.Fatalf("template key %d moved: got %s; want %s", n, keys[n], kv[0]) + } + } + }) + } +} diff --git a/internal/root/components.go b/internal/root/components.go index c9679cc..af40e09 100644 --- a/internal/root/components.go +++ b/internal/root/components.go @@ -96,10 +96,11 @@ func SDKComponents() []SDKComponent { if runtime.GOOS == "windows" { return WindowsComponents() } + p := CurrentPlatform() return []SDKComponent{ {ID: "platform-tools", Name: PlatformToolsName, FetchName: PlatformToolsName, Target: "platform-tools", - StatusFiles: []string{CurrentPlatform().ADB}}, - {ID: "system-image", Name: SystemImageName, FetchName: SystemImageName, Target: SystemImageDir, + StatusFiles: []string{p.ADB}}, + {ID: "system-image", Name: p.SystemImageName, FetchName: p.SystemImageName, Target: p.SystemImageDir, StatusFiles: systemImageFiles()}, } } diff --git a/internal/root/components_test.go b/internal/root/components_test.go index c841e17..6f79923 100644 --- a/internal/root/components_test.go +++ b/internal/root/components_test.go @@ -211,7 +211,7 @@ func TestComponentsUseTable(t *testing.T) { } } comps := r.Components() - if !comps[0].Installed || !comps[2].Installed || comps[0].Name != PlatformToolsName || comps[2].Name != SystemImageName { + if !comps[0].Installed || !comps[2].Installed || comps[0].Name != PlatformToolsName || comps[2].Name != SDKComponents()[1].Name { t.Fatalf("%+v", comps) } os.Remove(filepath.Join(SDKComponents()[1].Dir(r.SDK()), SDKComponents()[1].StatusFiles[0])) diff --git a/internal/root/platform.go b/internal/root/platform.go index 4dda25b..59652a8 100644 --- a/internal/root/platform.go +++ b/internal/root/platform.go @@ -16,17 +16,21 @@ func platformKeyFiles() map[string][]string { } } -// Platform describes host-specific executable names and update identifiers. -// Callers use this table instead of embedding Windows names in shared logic. +// Platform describes host executable names, guest images and update identifiers. +// Callers use this table instead of embedding Windows or x86_64 values in shared logic. type Platform struct { - Emulator string - ADB string - QEMU string - QEMUHeadless string - QemuDir string - PackageSuffix string - MirrorOS string - MirrorArch string + Emulator string + ADB string + QEMU string + QEMUHeadless string + QemuDir string + PackageSuffix string + MirrorOS string + MirrorArch string + GuestABI string + GuestArch string + SystemImageDir string + SystemImageName string } // Supported reports whether the host matches one of the package's platform rows. @@ -41,13 +45,25 @@ func (p Platform) Windows() bool { return p.QemuDir == "windows-x86_64" && p.Mir func CurrentPlatform() Platform { switch { case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64": - // The qemu directory on macOS is expected to be qemu/darwin-aarch64; - // #8 will verify the package layout before changing this table. - return Platform{Emulator: "emulator", ADB: "adb", QEMU: "qemu-system-aarch64", QEMUHeadless: "qemu-system-aarch64", PackageSuffix: "macos-arm64", MirrorOS: "macos", MirrorArch: "arm64"} + return Platform{ + Emulator: "emulator", ADB: "adb", QEMU: "qemu-system-aarch64", QEMUHeadless: "qemu-system-aarch64-headless", QemuDir: "darwin-aarch64", + PackageSuffix: "macos-arm64", MirrorOS: "macos", MirrorArch: "arm64", + GuestABI: "arm64-v8a", GuestArch: "arm64", + SystemImageDir: "system-images/android-34/google_apis/arm64-v8a", SystemImageName: "系统镜像 Android 14 Google APIs arm64-v8a", + } case runtime.GOOS == "linux" && runtime.GOARCH == "amd64": - return Platform{Emulator: "emulator", ADB: "adb", QEMU: "qemu-system-x86_64", QEMUHeadless: "qemu-system-x86_64", PackageSuffix: "linux-x64", MirrorOS: "linux", MirrorArch: "x64"} + return Platform{ + Emulator: "emulator", ADB: "adb", QEMU: "qemu-system-x86_64", QEMUHeadless: "qemu-system-x86_64", + PackageSuffix: "linux-x64", MirrorOS: "linux", MirrorArch: "x64", + GuestABI: "x86_64", GuestArch: "x86_64", + SystemImageDir: "system-images/android-34/google_apis/x86_64", SystemImageName: SystemImageName, + } case runtime.GOOS == "windows" && runtime.GOARCH == "amd64": - return Platform{Emulator: "emulator.exe", ADB: "adb.exe", QEMU: "qemu-system-x86_64.exe", QEMUHeadless: "qemu-system-x86_64-headless.exe", QemuDir: "windows-x86_64", PackageSuffix: "win-x64", MirrorOS: "win", MirrorArch: "x64"} + return Platform{ + Emulator: "emulator.exe", ADB: "adb.exe", QEMU: "qemu-system-x86_64.exe", QEMUHeadless: "qemu-system-x86_64-headless.exe", QemuDir: "windows-x86_64", + PackageSuffix: "win-x64", MirrorOS: "win", MirrorArch: "x64", + GuestABI: "x86_64", GuestArch: "x86_64", SystemImageDir: SystemImageDir, SystemImageName: SystemImageName, + } default: // An unlisted host must not inherit Windows names or MirrorChyan ids. return Platform{} diff --git a/internal/root/platform_test.go b/internal/root/platform_test.go index 855a7b0..b643e98 100644 --- a/internal/root/platform_test.go +++ b/internal/root/platform_test.go @@ -1,6 +1,8 @@ package root import ( + "os" + "path/filepath" "runtime" "testing" ) @@ -9,17 +11,17 @@ func TestCurrentPlatformMatchesHost(t *testing.T) { p := CurrentPlatform() switch { case runtime.GOOS == "windows" && runtime.GOARCH == "amd64": - want := Platform{Emulator: "emulator.exe", ADB: "adb.exe", QEMU: "qemu-system-x86_64.exe", QEMUHeadless: "qemu-system-x86_64-headless.exe", QemuDir: "windows-x86_64", PackageSuffix: "win-x64", MirrorOS: "win", MirrorArch: "x64"} + want := Platform{Emulator: "emulator.exe", ADB: "adb.exe", QEMU: "qemu-system-x86_64.exe", QEMUHeadless: "qemu-system-x86_64-headless.exe", QemuDir: "windows-x86_64", PackageSuffix: "win-x64", MirrorOS: "win", MirrorArch: "x64", GuestABI: "x86_64", GuestArch: "x86_64", SystemImageDir: `system-images\android-34\google_apis\x86_64`, SystemImageName: "系统镜像 Android 14 Google APIs x86_64"} if p != want { t.Fatalf("Windows platform: %+v", p) } case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64": - want := Platform{Emulator: "emulator", ADB: "adb", QEMU: "qemu-system-aarch64", QEMUHeadless: "qemu-system-aarch64", PackageSuffix: "macos-arm64", MirrorOS: "macos", MirrorArch: "arm64"} + want := Platform{Emulator: "emulator", ADB: "adb", QEMU: "qemu-system-aarch64", QEMUHeadless: "qemu-system-aarch64-headless", QemuDir: "darwin-aarch64", PackageSuffix: "macos-arm64", MirrorOS: "macos", MirrorArch: "arm64", GuestABI: "arm64-v8a", GuestArch: "arm64", SystemImageDir: "system-images/android-34/google_apis/arm64-v8a", SystemImageName: "系统镜像 Android 14 Google APIs arm64-v8a"} if p != want { t.Fatalf("darwin platform: %+v", p) } case runtime.GOOS == "linux" && runtime.GOARCH == "amd64": - want := Platform{Emulator: "emulator", ADB: "adb", QEMU: "qemu-system-x86_64", QEMUHeadless: "qemu-system-x86_64", PackageSuffix: "linux-x64", MirrorOS: "linux", MirrorArch: "x64"} + want := Platform{Emulator: "emulator", ADB: "adb", QEMU: "qemu-system-x86_64", QEMUHeadless: "qemu-system-x86_64", PackageSuffix: "linux-x64", MirrorOS: "linux", MirrorArch: "x64", GuestABI: "x86_64", GuestArch: "x86_64", SystemImageDir: "system-images/android-34/google_apis/x86_64", SystemImageName: "系统镜像 Android 14 Google APIs x86_64"} if p != want { t.Fatalf("linux platform: %+v", p) } @@ -28,4 +30,66 @@ func TestCurrentPlatformMatchesHost(t *testing.T) { t.Fatalf("unsupported platform inherited values: %+v", p) } } + wantSupported := runtime.GOOS == "windows" && runtime.GOARCH == "amd64" || + runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" || + runtime.GOOS == "linux" && runtime.GOARCH == "amd64" + if p.Supported() != wantSupported || p.Windows() != (runtime.GOOS == "windows" && runtime.GOARCH == "amd64") { + t.Fatalf("host %s/%s has incorrect platform capabilities: %+v", runtime.GOOS, runtime.GOARCH, p) + } +} + +func TestWindowsImageConstantsStayCompatible(t *testing.T) { + // WindowsComponents is also inspected on Unix hosts; these constants must not follow the host profile. + if SystemImageDir != `system-images\android-34\google_apis\x86_64` || + SystemImageName != "系统镜像 Android 14 Google APIs x86_64" { + t.Fatalf("Windows image constants changed: %q, %q", SystemImageDir, SystemImageName) + } + if (Platform{}).Supported() || (Platform{}).Windows() { + t.Fatal("an unlisted platform must not inherit a supported host") + } +} + +func TestPlatformRuntimeSDKPaths(t *testing.T) { + p := CurrentPlatform() + if !p.Supported() { + t.Skip("no executable layout for this host") + } + for _, localSDK := range []bool{false, true} { + t.Run(map[bool]string{false: "packaged", true: "sdkRoot"}[localSDK], func(t *testing.T) { + r, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + sdk := filepath.Join(r.Dir, "sdk") + if localSDK { + if err := os.WriteFile(r.MetadataPath(), []byte(`{"sdkRoot":"runtime sdk"}`), 0o644); err != nil { + t.Fatal(err) + } + sdk = filepath.Join(r.Dir, "runtime sdk") + } + if r.RuntimeSDK() != sdk || r.EmulatorExe() != filepath.Join(sdk, "emulator", p.Emulator) || + r.AdbExe() != filepath.Join(sdk, "platform-tools", p.ADB) || + r.QemuHeadlessExe() != filepath.Join(sdk, "emulator", "qemu", p.QemuDir, p.QEMUHeadless) { + t.Fatalf("runtime SDK layout: sdk=%q emulator=%q adb=%q qemu=%q", r.RuntimeSDK(), r.EmulatorExe(), r.AdbExe(), r.QemuHeadlessExe()) + } + qemu := filepath.Join(sdk, "emulator", "qemu", p.QemuDir, p.QEMU) + for _, path := range []string{r.EmulatorExe(), qemu} { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("fixture"), 0o644); err != nil { + t.Fatal(err) + } + } + if !r.EmulatorPresent() { + t.Fatal("component detection did not find the host's emulator layout") + } + if err := os.Remove(qemu); err != nil { + t.Fatal(err) + } + if r.EmulatorPresent() { + t.Fatal("emulator reported present without its host QEMU binary") + } + }) + } } diff --git a/internal/root/root.go b/internal/root/root.go index 8a0e197..235e744 100644 --- a/internal/root/root.go +++ b/internal/root/root.go @@ -192,6 +192,7 @@ func jsonOneLine(v *jsonord.Value) string { // ---- layout ------------------------------------------------------------------------------------------------------ +// SystemImageDir retains the Windows/AUTO-MAS layout; other hosts use Platform.SystemImageDir. const ( SDKDir = "sdk" AvdDir = "avd" @@ -741,6 +742,7 @@ type ComponentStatus struct { } // Component names and fixed data (AUTO-MAS constants.py). +// SystemImageName retains the Windows name; other hosts use Platform.SystemImageName. const ( PlatformToolsName = "平台工具(adb)" EmulatorName = "Android 模拟器(emulator)" From 55f5530d5af4a7cd2dfd5cd326b06a2f07d250cf Mon Sep 17 00:00:00 2001 From: DeathofBrain <109609173+DeathofBrain@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:02:30 +0800 Subject: [PATCH 2/9] chore: remove test changes from macOS profile PR --- internal/avd/platform_test.go | 89 -------------------------------- internal/root/components_test.go | 2 +- internal/root/platform_test.go | 70 ++----------------------- 3 files changed, 4 insertions(+), 157 deletions(-) delete mode 100644 internal/avd/platform_test.go diff --git a/internal/avd/platform_test.go b/internal/avd/platform_test.go deleted file mode 100644 index 07a6e01..0000000 --- a/internal/avd/platform_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package avd - -import ( - "os" - "path/filepath" - "runtime" - "slices" - "testing" - - "modavd/internal/root" -) - -func TestCreateHostGuestProfile(t *testing.T) { - var abi, arch, imageDir string - switch { - case runtime.GOOS == "windows" && runtime.GOARCH == "amd64": - abi, arch, imageDir = "x86_64", "x86_64", `system-images\android-34\google_apis\x86_64` - case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64": - abi, arch, imageDir = "arm64-v8a", "arm64", "system-images/android-34/google_apis/arm64-v8a" - case runtime.GOOS == "linux" && runtime.GOARCH == "amd64": - abi, arch, imageDir = "x86_64", "x86_64", "system-images/android-34/google_apis/x86_64" - default: - t.Skip("no guest profile for this host") - } - for _, localSDK := range []bool{false, true} { - t.Run(map[bool]string{false: "packaged", true: "sdkRoot"}[localSDK], func(t *testing.T) { - r, err := root.New(t.TempDir()) - if err != nil { - t.Fatal(err) - } - if localSDK { - if err := os.WriteFile(r.MetadataPath(), []byte(`{"sdkRoot":"runtime sdk"}`), 0o644); err != nil { - t.Fatal(err) - } - } - imagePath := filepath.Join(r.RuntimeSDK(), imageDir, "system.img") - if err := os.MkdirAll(filepath.Dir(imagePath), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(imagePath, []byte("host image fixture"), 0o644); err != nil { - t.Fatal(err) - } - in, err := Create(r, 1, CreateOptions{Title: "平台配置", MemoryMB: 4096, CPU: 4, DataPartitionGB: 16, FPS: 60}) - if err != nil { - t.Fatal(err) - } - cfg, err := ReadIni(ConfigPath(r, in.Index)) - if err != nil { - t.Fatal(err) - } - for key, want := range map[string]string{ - "abi.type": abi, "hw.cpu.arch": arch, - "image.sysdir.1": imageDir + string(filepath.Separator), - "hw.cpu.ncore": "4", "hw.ramSize": "4096M", "avd.ini.displayname": "平台配置", - } { - if got := cfg.Value(key); got != want { - t.Errorf("%s = %q; want %q", key, got, want) - } - } - // Resolve the actual written image.sysdir.1 against the selected SDK, including sdkRoot overrides. - image, err := os.ReadFile(filepath.Join(r.RuntimeSDK(), cfg.Value("image.sysdir.1"), "system.img")) - if err != nil || string(image) != "host image fixture" { - t.Fatalf("written system-image path does not resolve: %q, %v", image, err) - } - ini, err := ReadIni(IniPath(r, in.Index)) - if err != nil { - t.Fatal(err) - } - if ini.Value("path") != in.Dir || ini.Value("path.rel") != filepath.Join("avd", "mas_1.avd") { - t.Fatalf("AVD directory paths: path=%q path.rel=%q", ini.Value("path"), ini.Value("path.rel")) - } - if _, err := os.Stat(filepath.Join(r.Dir, ini.Value("path.rel"), "config.ini")); err != nil { - t.Fatalf("written path.rel does not resolve to the AVD: %v", err) - } - if !slices.Equal(ini.Keys(), []string{"avd.ini.encoding", "path", "path.rel", "target"}) { - t.Fatalf("AVD .ini key order changed: %v", ini.Keys()) - } - keys := cfg.Keys() - if len(keys) < len(ConfigTemplate) { - t.Fatalf("config has fewer keys than its template: %v", keys) - } - for n, kv := range ConfigTemplate { - if keys[n] != kv[0] { - t.Fatalf("template key %d moved: got %s; want %s", n, keys[n], kv[0]) - } - } - }) - } -} diff --git a/internal/root/components_test.go b/internal/root/components_test.go index 6f79923..c841e17 100644 --- a/internal/root/components_test.go +++ b/internal/root/components_test.go @@ -211,7 +211,7 @@ func TestComponentsUseTable(t *testing.T) { } } comps := r.Components() - if !comps[0].Installed || !comps[2].Installed || comps[0].Name != PlatformToolsName || comps[2].Name != SDKComponents()[1].Name { + if !comps[0].Installed || !comps[2].Installed || comps[0].Name != PlatformToolsName || comps[2].Name != SystemImageName { t.Fatalf("%+v", comps) } os.Remove(filepath.Join(SDKComponents()[1].Dir(r.SDK()), SDKComponents()[1].StatusFiles[0])) diff --git a/internal/root/platform_test.go b/internal/root/platform_test.go index b643e98..855a7b0 100644 --- a/internal/root/platform_test.go +++ b/internal/root/platform_test.go @@ -1,8 +1,6 @@ package root import ( - "os" - "path/filepath" "runtime" "testing" ) @@ -11,17 +9,17 @@ func TestCurrentPlatformMatchesHost(t *testing.T) { p := CurrentPlatform() switch { case runtime.GOOS == "windows" && runtime.GOARCH == "amd64": - want := Platform{Emulator: "emulator.exe", ADB: "adb.exe", QEMU: "qemu-system-x86_64.exe", QEMUHeadless: "qemu-system-x86_64-headless.exe", QemuDir: "windows-x86_64", PackageSuffix: "win-x64", MirrorOS: "win", MirrorArch: "x64", GuestABI: "x86_64", GuestArch: "x86_64", SystemImageDir: `system-images\android-34\google_apis\x86_64`, SystemImageName: "系统镜像 Android 14 Google APIs x86_64"} + want := Platform{Emulator: "emulator.exe", ADB: "adb.exe", QEMU: "qemu-system-x86_64.exe", QEMUHeadless: "qemu-system-x86_64-headless.exe", QemuDir: "windows-x86_64", PackageSuffix: "win-x64", MirrorOS: "win", MirrorArch: "x64"} if p != want { t.Fatalf("Windows platform: %+v", p) } case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64": - want := Platform{Emulator: "emulator", ADB: "adb", QEMU: "qemu-system-aarch64", QEMUHeadless: "qemu-system-aarch64-headless", QemuDir: "darwin-aarch64", PackageSuffix: "macos-arm64", MirrorOS: "macos", MirrorArch: "arm64", GuestABI: "arm64-v8a", GuestArch: "arm64", SystemImageDir: "system-images/android-34/google_apis/arm64-v8a", SystemImageName: "系统镜像 Android 14 Google APIs arm64-v8a"} + want := Platform{Emulator: "emulator", ADB: "adb", QEMU: "qemu-system-aarch64", QEMUHeadless: "qemu-system-aarch64", PackageSuffix: "macos-arm64", MirrorOS: "macos", MirrorArch: "arm64"} if p != want { t.Fatalf("darwin platform: %+v", p) } case runtime.GOOS == "linux" && runtime.GOARCH == "amd64": - want := Platform{Emulator: "emulator", ADB: "adb", QEMU: "qemu-system-x86_64", QEMUHeadless: "qemu-system-x86_64", PackageSuffix: "linux-x64", MirrorOS: "linux", MirrorArch: "x64", GuestABI: "x86_64", GuestArch: "x86_64", SystemImageDir: "system-images/android-34/google_apis/x86_64", SystemImageName: "系统镜像 Android 14 Google APIs x86_64"} + want := Platform{Emulator: "emulator", ADB: "adb", QEMU: "qemu-system-x86_64", QEMUHeadless: "qemu-system-x86_64", PackageSuffix: "linux-x64", MirrorOS: "linux", MirrorArch: "x64"} if p != want { t.Fatalf("linux platform: %+v", p) } @@ -30,66 +28,4 @@ func TestCurrentPlatformMatchesHost(t *testing.T) { t.Fatalf("unsupported platform inherited values: %+v", p) } } - wantSupported := runtime.GOOS == "windows" && runtime.GOARCH == "amd64" || - runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" || - runtime.GOOS == "linux" && runtime.GOARCH == "amd64" - if p.Supported() != wantSupported || p.Windows() != (runtime.GOOS == "windows" && runtime.GOARCH == "amd64") { - t.Fatalf("host %s/%s has incorrect platform capabilities: %+v", runtime.GOOS, runtime.GOARCH, p) - } -} - -func TestWindowsImageConstantsStayCompatible(t *testing.T) { - // WindowsComponents is also inspected on Unix hosts; these constants must not follow the host profile. - if SystemImageDir != `system-images\android-34\google_apis\x86_64` || - SystemImageName != "系统镜像 Android 14 Google APIs x86_64" { - t.Fatalf("Windows image constants changed: %q, %q", SystemImageDir, SystemImageName) - } - if (Platform{}).Supported() || (Platform{}).Windows() { - t.Fatal("an unlisted platform must not inherit a supported host") - } -} - -func TestPlatformRuntimeSDKPaths(t *testing.T) { - p := CurrentPlatform() - if !p.Supported() { - t.Skip("no executable layout for this host") - } - for _, localSDK := range []bool{false, true} { - t.Run(map[bool]string{false: "packaged", true: "sdkRoot"}[localSDK], func(t *testing.T) { - r, err := New(t.TempDir()) - if err != nil { - t.Fatal(err) - } - sdk := filepath.Join(r.Dir, "sdk") - if localSDK { - if err := os.WriteFile(r.MetadataPath(), []byte(`{"sdkRoot":"runtime sdk"}`), 0o644); err != nil { - t.Fatal(err) - } - sdk = filepath.Join(r.Dir, "runtime sdk") - } - if r.RuntimeSDK() != sdk || r.EmulatorExe() != filepath.Join(sdk, "emulator", p.Emulator) || - r.AdbExe() != filepath.Join(sdk, "platform-tools", p.ADB) || - r.QemuHeadlessExe() != filepath.Join(sdk, "emulator", "qemu", p.QemuDir, p.QEMUHeadless) { - t.Fatalf("runtime SDK layout: sdk=%q emulator=%q adb=%q qemu=%q", r.RuntimeSDK(), r.EmulatorExe(), r.AdbExe(), r.QemuHeadlessExe()) - } - qemu := filepath.Join(sdk, "emulator", "qemu", p.QemuDir, p.QEMU) - for _, path := range []string{r.EmulatorExe(), qemu} { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte("fixture"), 0o644); err != nil { - t.Fatal(err) - } - } - if !r.EmulatorPresent() { - t.Fatal("component detection did not find the host's emulator layout") - } - if err := os.Remove(qemu); err != nil { - t.Fatal(err) - } - if r.EmulatorPresent() { - t.Fatal("emulator reported present without its host QEMU binary") - } - }) - } } From 5ad74e720770ad57fb3fc414e4335d953d592a21 Mon Sep 17 00:00:00 2001 From: DeathofBrain <109609173+DeathofBrain@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:33:22 +0800 Subject: [PATCH 3/9] feat: implement Darwin process runtime primitives --- internal/emu/manager.go | 5 + internal/win/lock_darwin.go | 59 +++++ internal/win/net_darwin.go | 324 +++++++++++++++++++++++++++ internal/win/other.go | 74 +----- internal/win/proc_darwin.go | 365 ++++++++++++++++++++++++++++++ internal/win/runtime_other.go | 76 +++++++ internal/win/start_darwin.go | 408 ++++++++++++++++++++++++++++++++++ 7 files changed, 1241 insertions(+), 70 deletions(-) create mode 100644 internal/win/lock_darwin.go create mode 100644 internal/win/net_darwin.go create mode 100644 internal/win/proc_darwin.go create mode 100644 internal/win/runtime_other.go create mode 100644 internal/win/start_darwin.go diff --git a/internal/emu/manager.go b/internal/emu/manager.go index 4f2a9ab..763f2de 100644 --- a/internal/emu/manager.go +++ b/internal/emu/manager.go @@ -183,6 +183,11 @@ func scanQemuOf(roots []*root.Root, indexes []int) (map[int]win.Process, error) } return found, nil } + // Process queries alone do not implement QemuBelongsTo's host ownership + // checks. Keep discovery unsupported until that identity chain is available. + if err := requireWindows(); err != nil { + return nil, err + } plans := make([]root.PortPlan, len(roots)) for k, r := range roots { plans[k] = r.Ports() diff --git a/internal/win/lock_darwin.go b/internal/win/lock_darwin.go new file mode 100644 index 0000000..6d75bbd --- /dev/null +++ b/internal/win/lock_darwin.go @@ -0,0 +1,59 @@ +//go:build darwin && cgo + +package win + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "syscall" + "time" + + "modavd/internal/errs" +) + +const lockPollInterval = 100 * time.Millisecond + +// TryLock takes the lock now or returns ok=false when someone else holds it. +func TryLock(path string) (*FileLock, bool, error) { return Lock(path, 0) } + +// Lock creates the lock file and its directory, then waits up to timeout for an +// exclusive flock. A busy lock is not an error; other failures are reported. +func Lock(path string, timeout time.Duration) (*FileLock, bool, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, false, errs.Wrap(errs.Internal, err, fmt.Sprintf("建不了锁文件目录 %s: %v", filepath.Dir(path), err)) + } + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o644) + if err != nil { + return nil, false, errs.Wrap(errs.Internal, err, fmt.Sprintf("打不开锁文件 %s: %v", path, err)) + } + deadline := time.Now().Add(timeout) + for { + err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err == nil { + return &FileLock{f: f}, true, nil + } + if !errors.Is(err, syscall.EWOULDBLOCK) && !errors.Is(err, syscall.EAGAIN) { + f.Close() + return nil, false, errs.Wrap(errs.Internal, err, fmt.Sprintf("锁文件 %s 加锁失败: %v", path, err)) + } + left := time.Until(deadline) + if left <= 0 { + f.Close() + return nil, false, nil + } + time.Sleep(min(lockPollInterval, left)) + } +} + +// Unlock releases and closes; nil-safe. Keep the file so later contenders lock +// the same inode instead of creating a second lock under an existing holder. +func (l *FileLock) Unlock() { + if l == nil || l.f == nil { + return + } + _ = syscall.Flock(int(l.f.Fd()), syscall.LOCK_UN) + _ = l.f.Close() // closing also releases the lock when explicit unlocking fails + l.f = nil +} diff --git a/internal/win/net_darwin.go b/internal/win/net_darwin.go new file mode 100644 index 0000000..8fb203f --- /dev/null +++ b/internal/win/net_darwin.go @@ -0,0 +1,324 @@ +//go:build darwin && cgo + +package win + +/* +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct modavd_tcp_listener { + uint64_t socket; + uint64_t generation; + int port; +}; + +// Read the global table before looking up owners: libproc can deny access to a +// process, but its sockets must still prevent us from reporting a port as free. +static int modavd_tcp_table_once(struct modavd_tcp_listener **out, size_t *count) { + size_t size = 0; + if (sysctlbyname("net.inet.tcp.pcblist64", NULL, &size, NULL, 0) != 0) + return errno; + if (size < 2 * sizeof(struct xinpgen) || size > 128 * 1024 * 1024) + return EPROTO; + void *buffer = malloc(size); + if (!buffer) return ENOMEM; + if (sysctlbyname("net.inet.tcp.pcblist64", buffer, &size, NULL, 0) != 0) { + int error = errno; + free(buffer); + return error; + } + if (size < 2 * sizeof(struct xinpgen)) { + free(buffer); + return EPROTO; + } + struct xinpgen first; + memcpy(&first, buffer, sizeof(first)); + if (first.xig_len != sizeof(first)) { + free(buffer); + return EPROTO; + } + size_t capacity = (size - 2 * sizeof(first)) / sizeof(struct xtcpcb64); + struct modavd_tcp_listener *rows = calloc(capacity ? capacity : 1, sizeof(*rows)); + if (!rows) { + free(buffer); + return ENOMEM; + } + size_t used = 0, offset = sizeof(first); + int error = EPROTO; + while (offset <= size - sizeof(uint32_t)) { + uint32_t length; + memcpy(&length, (char *)buffer + offset, sizeof(length)); + if (length == sizeof(struct xinpgen)) { + if (length != size - offset) break; + struct xinpgen last; + memcpy(&last, (char *)buffer + offset, sizeof(last)); + error = first.xig_gen == last.xig_gen && first.xig_count == last.xig_count ? 0 : EAGAIN; + break; + } + // These public SDK structures describe pcblist64, not the private _n ABI. + // Reject a different ABI or a short table instead of silently skipping it. + if (length != sizeof(struct xtcpcb64) || length > size - offset) + break; + struct xtcpcb64 tcp; + memcpy(&tcp, (char *)buffer + offset, sizeof(tcp)); + offset += length; + if (tcp.xt_inpcb.inp_gencnt > first.xig_gen) { + error = EAGAIN; + break; + } + if (tcp.t_state != TCPS_LISTEN) continue; + struct xsocket64 *socket = &tcp.xt_inpcb.xi_socket; + if (socket->xso_protocol != IPPROTO_TCP || socket->so_type != SOCK_STREAM || + (socket->xso_family != AF_INET && socket->xso_family != AF_INET6) || + tcp.xt_inpcb.inp_lport == 0 || used == capacity) + break; + rows[used++] = (struct modavd_tcp_listener){ + socket->xso_so, tcp.xt_inpcb.inp_gencnt, ntohs(tcp.xt_inpcb.inp_lport) + }; + } + free(buffer); + if (error) { + free(rows); + return error; + } + *out = rows; + *count = used; + return 0; +} + +static int modavd_tcp_table(struct modavd_tcp_listener **out, size_t *count) { + *out = NULL; + *count = 0; + int error = EAGAIN; + for (int attempt = 0; attempt < 4; attempt++) { + error = modavd_tcp_table_once(out, count); + if (error != EAGAIN && error != ENOMEM) return error; + } + return error; +} + +// This is only an owner lookup. Missing or unreadable descriptors never remove +// entries from the independently collected global occupancy table. +static int modavd_tcp_process(int pid, struct modavd_tcp_listener **out, size_t *count) { + *out = NULL; + *count = 0; + for (int attempt = 0; attempt < 4; attempt++) { + errno = 0; + int size = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, NULL, 0); + if (size <= 0) return size == 0 && errno == 0 ? 0 : (errno ? errno : EIO); + if (size % sizeof(struct proc_fdinfo) != 0 || size > 64 * 1024 * 1024) + return EPROTO; + size_t capacity = size / sizeof(struct proc_fdinfo) + 16; + size_t bytes = capacity * sizeof(struct proc_fdinfo); + struct proc_fdinfo *fds = malloc(bytes); + if (!fds) return ENOMEM; + errno = 0; + size = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, fds, (int)bytes); + int error = errno; + if (size <= 0 || size % sizeof(*fds) != 0) { + free(fds); + return error ? error : EIO; + } + if ((size_t)size >= bytes) { + free(fds); + continue; + } + size_t n = size / sizeof(*fds); + struct modavd_tcp_listener *rows = calloc(n ? n : 1, sizeof(*rows)); + if (!rows) { + free(fds); + return ENOMEM; + } + size_t used = 0; + for (size_t i = 0; i < n; i++) { + if (fds[i].proc_fdtype != PROX_FDTYPE_SOCKET) continue; + struct socket_fdinfo info; + if (proc_pidfdinfo(pid, fds[i].proc_fd, PROC_PIDFDSOCKETINFO, &info, sizeof(info)) != sizeof(info)) + continue; + if (info.psi.soi_kind != SOCKINFO_TCP || info.psi.soi_type != SOCK_STREAM || + info.psi.soi_protocol != IPPROTO_TCP || + (info.psi.soi_family != AF_INET && info.psi.soi_family != AF_INET6) || + info.psi.soi_proto.pri_tcp.tcpsi_state != TSI_S_LISTEN) + continue; + struct in_sockinfo *in = &info.psi.soi_proto.pri_tcp.tcpsi_ini; + rows[used++] = (struct modavd_tcp_listener){ + info.psi.soi_so, in->insi_gencnt, ntohs((uint16_t)in->insi_lport) + }; + } + free(fds); + *out = rows; + *count = used; + return 0; + } + return EAGAIN; +} +*/ +import "C" + +import ( + "fmt" + "sort" + "syscall" + "unsafe" + + "modavd/internal/errs" +) + +type darwinTCPListener struct { + socket, generation uint64 + port int +} + +func darwinTCPTable() ([]darwinTCPListener, error) { + var rows *C.struct_modavd_tcp_listener + var count C.size_t + if code := C.modavd_tcp_table(&rows, &count); code != 0 { + err := syscall.Errno(code) + return nil, errs.Wrap(errs.Internal, err, fmt.Sprintf("读不到完整的系统 TCP 监听表: %v", err)) + } + defer C.free(unsafe.Pointer(rows)) + result := make([]darwinTCPListener, 0, int(count)) + for _, row := range unsafe.Slice(rows, int(count)) { + result = append(result, darwinTCPListener{uint64(row.socket), uint64(row.generation), int(row.port)}) + } + return result, nil +} + +func darwinTCPOwners(rows []darwinTCPListener) (map[darwinTCPListener]map[uint32]bool, error) { + owners := make(map[darwinTCPListener]map[uint32]bool, len(rows)) + for _, row := range rows { + owners[row] = map[uint32]bool{} + } + if len(rows) == 0 { + return owners, nil + } + processes, err := ListProcesses() + if err != nil { + return nil, err + } + for _, process := range processes { + if _, err := process.checkedInfo(); err != nil { + continue + } + var found *C.struct_modavd_tcp_listener + var count C.size_t + if code := C.modavd_tcp_process(C.int(process.PID), &found, &count); code != 0 { + continue + } + // A PID may have exited or been reused while its descriptors were read. + if _, err := process.checkedInfo(); err == nil { + for _, row := range unsafe.Slice(found, int(count)) { + key := darwinTCPListener{uint64(row.socket), uint64(row.generation), int(row.port)} + if key.socket != 0 && key.generation != 0 && owners[key] != nil { + owners[key][process.PID] = true + } + } + } + C.free(unsafe.Pointer(found)) + } + return owners, nil +} + +// Listeners reads every IPv4/IPv6 TCP LISTEN socket without connecting. PID is +// zero when its owner cannot be read; that row still represents an occupied port. +// A shared socket may have more than one owner and therefore more than one row. +func Listeners() ([]Listener, error) { + rows, err := darwinTCPTable() + if err != nil { + return nil, err + } + owners, err := darwinTCPOwners(rows) + if err != nil { + return nil, err + } + var result []Listener + for _, row := range rows { + if len(owners[row]) == 0 { + result = append(result, Listener{Port: row.port}) + continue + } + for pid := range owners[row] { + result = append(result, Listener{Port: row.port, PID: pid}) + } + } + sort.Slice(result, func(i, j int) bool { + if result[i].Port != result[j].Port { + return result[i].Port < result[j].Port + } + return result[i].PID < result[j].PID + }) + return result, nil +} + +// ListenerPID requires one known PID across all sockets listening on port. +// Incomplete ownership is an error: the ADB caller must not adopt an unknown +// service merely because a TCP socket exists. ok=false with no error means free. +func ListenerPID(port int) (pid uint32, ok bool, err error) { + rows, err := darwinTCPTable() + if err != nil { + return 0, false, err + } + wanted := rows[:0] + for _, row := range rows { + if row.port == port { + wanted = append(wanted, row) + } + } + if len(wanted) == 0 { + return 0, false, nil + } + owners, err := darwinTCPOwners(wanted) + if err != nil { + return 0, true, err + } + for _, row := range wanted { + if len(owners[row]) != 1 { + return 0, true, errs.Newf(errs.Internal, "TCP 端口 %d 已被占用,但无法确认唯一的所属进程", port) + } + for owner := range owners[row] { + if owner == 0 || (pid != 0 && pid != owner) { + return 0, true, errs.Newf(errs.Internal, "TCP 端口 %d 由多个进程监听,无法确认唯一的所属进程", port) + } + pid = owner + } + } + return pid, true, nil +} + +// BusyPorts returns the sorted, unique occupied TCP ports. Owner permissions do +// not affect occupancy; an incomplete global table returns an error, never free. +func BusyPorts(ports []int) ([]int, error) { + rows, err := darwinTCPTable() + if err != nil { + return nil, err + } + wanted := make(map[int]bool, len(ports)) + for _, port := range ports { + wanted[port] = true + } + busy := map[int]bool{} + for _, row := range rows { + if wanted[row.port] { + busy[row.port] = true + } + } + result := make([]int, 0, len(busy)) + for port := range busy { + result = append(result, port) + } + sort.Ints(result) + return result, nil +} diff --git a/internal/win/other.go b/internal/win/other.go index d093008..5a8e9ec 100644 --- a/internal/win/other.go +++ b/internal/win/other.go @@ -1,21 +1,21 @@ //go:build !windows // Package win is the platform boundary used by the rest of ModAVD. The -// non-Windows implementation deliberately exposes the same API while -// returning errs.Unsupported for operations that need a host implementation. +// non-Windows implementation exposes the same API; unimplemented operations +// return errs.Unsupported. Darwin process support uses libproc through cgo. package win import ( "errors" "os" "os/exec" - "time" "modavd/internal/errs" ) var errUnsupported = errs.New(errs.Unsupported, "当前平台暂不支持此操作") +// Created is Unix microseconds on Darwin; identities are local to this host. type Process struct { PID uint32 PPID uint32 @@ -40,77 +40,11 @@ var OrphanHelper string const SpawnHelperArg = "__spawn" const HideConsole = 0 -type FileLock struct{} +type FileLock struct{ f *os.File } // HideConsoleCmd is a no-op because non-Windows has no CREATE_NO_WINDOW flag. func HideConsoleCmd(*exec.Cmd) {} -// ListProcesses uses CreateToolhelp32Snapshot and Process32First/Next on Windows. -func ListProcesses() ([]Process, error) { return nil, errUnsupported } - -// FindProcess resolves a PID from the Windows process snapshot and checks its identity. -func FindProcess(uint32) (Process, bool) { return Process{}, false } - -// ProcessName reads the executable name from the Windows process snapshot. -func ProcessName(uint32) string { return "" } - -// CommandLine opens the Windows process and reads its command line from the PEB. -func (p Process) CommandLine() ([]string, error) { return nil, errUnsupported } - -// ImagePath calls QueryFullProcessImageNameW on Windows. -func (p Process) ImagePath() (string, error) { return "", errUnsupported } - -// StartedAt is unavailable without the Windows process APIs. Returning zero -// prevents a fabricated Unix timestamp from being reported as a process start. -func (p Process) StartedAt() time.Time { - return time.Time{} -} - -// Alive compares the Windows process identity and checks its exit state. -func (p Process) Alive() bool { return false } - -// Wait uses WaitForSingleObject on the Windows process handle. -func (p Process) Wait(time.Duration) bool { return true } - -// ExitCode calls GetExitCodeProcess on Windows. -func (p Process) ExitCode() (uint32, bool) { return 0, false } - -// CPUTime reads the Windows process kernel and user FILETIMEs. -func (p Process) CPUTime() (time.Duration, bool) { return 0, false } - -// Kill calls TerminateProcess on Windows. -func (p Process) Kill() error { return errUnsupported } - -// KillTree enumerates child processes and terminates the Windows process tree. -func KillTree(Process) error { return errUnsupported } - -// StartDetached creates a Windows process with detached-console flags. -func StartDetached(string, []string, StartOptions) (Process, error) { return Process{}, errUnsupported } - -// StartOrphan starts the Windows helper that adopts the child after its parent exits. -func StartOrphan(string, []string, StartOptions) (Process, error) { return Process{}, errUnsupported } - -// SpawnHelperMain is the Windows orphan helper's entry point. -func SpawnHelperMain([]string, *os.File) int { return 1 } - -// TryLock takes a non-blocking Windows LockFileEx lock. -func TryLock(string) (*FileLock, bool, error) { return nil, false, errUnsupported } - -// Lock retries Windows LockFileEx until the timeout. -func Lock(string, time.Duration) (*FileLock, bool, error) { return nil, false, errUnsupported } - -// Unlock releases the Windows LockFileEx handle. -func (l *FileLock) Unlock() {} - -// Listeners reads the Windows TCP table with GetExtendedTcpTable. -func Listeners() ([]Listener, error) { return nil, errUnsupported } - -// ListenerPID finds the owning PID in the Windows TCP table. -func ListenerPID(int) (uint32, bool, error) { return 0, false, errUnsupported } - -// BusyPorts compares requested ports with the Windows TCP and UDP tables. -func BusyPorts([]int) ([]int, error) { return nil, errUnsupported } - // SetAboveNormalPriority calls SetPriorityClass with ABOVE_NORMAL_PRIORITY_CLASS. func SetAboveNormalPriority(Process) error { return errUnsupported } diff --git a/internal/win/proc_darwin.go b/internal/win/proc_darwin.go new file mode 100644 index 0000000..ca7a0cb --- /dev/null +++ b/internal/win/proc_darwin.go @@ -0,0 +1,365 @@ +//go:build darwin && cgo + +package win + +/* +#include +#include +#include +#include +#include + +static int bsd_info(int pid, struct proc_bsdinfo *info) { + errno = 0; + int n = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, info, sizeof(*info)); + return n == sizeof(*info) ? 0 : (errno ? errno : EIO); +} + +static int task_time(int pid, uint64_t *ns) { + struct proc_taskinfo info; + errno = 0; + int n = proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &info, sizeof(info)); + if (n != sizeof(info)) return errno ? errno : EIO; + *ns = info.pti_total_user + info.pti_total_system; + return 0; +} + +static int argument_limit(int *limit) { + size_t size = sizeof(*limit); + int mib[] = { CTL_KERN, KERN_ARGMAX }; + return sysctl(mib, 2, limit, &size, NULL, 0) == 0 ? 0 : errno; +} + +static int process_args(int pid, void *buf, size_t *size) { + int mib[] = { CTL_KERN, KERN_PROCARGS2, pid }; + return sysctl(mib, 3, buf, size, NULL, 0) == 0 ? 0 : errno; +} +*/ +import "C" + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "os" + "sort" + "syscall" + "time" + "unsafe" +) + +func bsdInfo(pid uint32) (C.struct_proc_bsdinfo, error) { + var info C.struct_proc_bsdinfo + if pid == 0 || pid > 1<<31-1 { + return info, os.ErrProcessDone + } + if code := C.bsd_info(C.int(pid), &info); code != 0 { + if code == C.ESRCH { + return info, os.ErrProcessDone + } + return info, fmt.Errorf("读取进程 %d: %w", pid, syscall.Errno(code)) + } + if info.pbi_pid != C.uint32_t(pid) || info.pbi_status == C.SZOMB { + return info, os.ErrProcessDone + } + return info, nil +} + +func processFromInfo(info C.struct_proc_bsdinfo) Process { + nameString := func(buf []byte) string { + if end := bytes.IndexByte(buf, 0); end >= 0 { + buf = buf[:end] + } + return string(buf) + } + name := nameString(C.GoBytes(unsafe.Pointer(&info.pbi_name[0]), C.int(len(info.pbi_name)))) + if name == "" { + name = nameString(C.GoBytes(unsafe.Pointer(&info.pbi_comm[0]), C.int(len(info.pbi_comm)))) + } + return Process{ + PID: uint32(info.pbi_pid), PPID: uint32(info.pbi_ppid), Name: name, + Created: uint64(info.pbi_start_tvsec)*1_000_000 + uint64(info.pbi_start_tvusec), + } +} + +// A child that has not been waited on still owns its PID even after exit. +// Capture its identity before starting the reaper, including short-lived zombies. +func startedProcess(pid uint32) (Process, bool) { + var info C.struct_proc_bsdinfo + if pid == 0 || pid > 1<<31-1 || C.bsd_info(C.int(pid), &info) != 0 || info.pbi_pid != C.uint32_t(pid) { + return Process{}, false + } + p := processFromInfo(info) + return p, p.Created != 0 +} + +// ListProcesses takes a libproc snapshot. Processes that exit or cannot be read +// during the snapshot are omitted; every returned process has a start identity. +func ListProcesses() ([]Process, error) { + n, err := C.proc_listallpids(nil, 0) + if n <= 0 { + return nil, fmt.Errorf("读不到进程列表: %v", err) + } + for range 8 { + ids := make([]C.int, int(n)+128) + count, err := C.proc_listallpids(unsafe.Pointer(&ids[0]), C.int(len(ids)*4)) + if count <= 0 { + return nil, fmt.Errorf("读不到进程列表: %v", err) + } + if int(count) >= len(ids) { + n = count + continue + } + out := make([]Process, 0, int(count)) + for _, pid := range ids[:int(count)] { + if p, ok := FindProcess(uint32(pid)); ok { + out = append(out, p) + } + } + return out, nil + } + return nil, errors.New("进程列表持续增长,无法读取完整快照") +} + +// FindProcess returns a live process with its creation time, never a zombie. +func FindProcess(pid uint32) (Process, bool) { + info, err := bsdInfo(pid) + if err != nil { + return Process{}, false + } + p := processFromInfo(info) + return p, p.Created != 0 +} + +func ProcessName(pid uint32) string { + p, _ := FindProcess(pid) + return p.Name +} + +// checkedInfo refuses incomplete and stale identities. Query methods check both +// sides of a PID-based read, so a reused PID cannot supply another process's data. +func (p Process) checkedInfo() (C.struct_proc_bsdinfo, error) { + if p.Created == 0 { + return C.struct_proc_bsdinfo{}, errors.New("进程缺少创建时间,无法验证身份") + } + info, err := bsdInfo(p.PID) + if err != nil { + return info, err + } + if processFromInfo(info).Created != p.Created { + return info, os.ErrProcessDone + } + return info, nil +} + +func (p Process) StartedAt() time.Time { + if p.Created == 0 { + return time.Time{} + } + return time.UnixMicro(int64(p.Created)) +} + +func (p Process) Alive() bool { + if p.Created == 0 { + return false + } + _, err := p.checkedInfo() + // A permission or transient query failure is not evidence of exit. + return !errors.Is(err, os.ErrProcessDone) +} + +func (p Process) CommandLine() ([]string, error) { + if _, err := p.checkedInfo(); err != nil { + return nil, err + } + var limit C.int + if code := C.argument_limit(&limit); code != 0 { + return nil, fmt.Errorf("读不到 kern.argmax: %w", syscall.Errno(code)) + } + if limit < 4 || limit > 64<<20 { + return nil, fmt.Errorf("无效的 kern.argmax: %d", limit) + } + buf := make([]byte, int(limit)) + size := C.size_t(len(buf)) + code := C.process_args(C.int(p.PID), unsafe.Pointer(&buf[0]), &size) + if _, err := p.checkedInfo(); err != nil { + return nil, err + } + if code != 0 { + return nil, fmt.Errorf("读不到进程 %d 的命令行: %w", p.PID, syscall.Errno(code)) + } + if size < 4 || uint64(size) > uint64(len(buf)) { + return nil, errors.New("进程命令行长度无效") + } + buf = buf[:int(size)] + argc := int(binary.NativeEndian.Uint32(buf[:4])) + buf = buf[4:] + if argc < 1 || argc > len(buf) { + return nil, errors.New("进程参数数量无效") + } + // KERN_PROCARGS2: argc, executable path, NUL padding, then argc C strings. + end := bytes.IndexByte(buf, 0) + if end < 0 { + return nil, errors.New("进程命令行缺少程序路径") + } + buf = bytes.TrimLeft(buf[end:], "\x00") + args := make([]string, 0, argc) + for range argc { + end = bytes.IndexByte(buf, 0) + if end < 0 { + return nil, errors.New("进程命令行不完整") + } + args = append(args, string(buf[:end])) + buf = buf[end+1:] + } + return args, nil +} + +func (p Process) ImagePath() (string, error) { + if _, err := p.checkedInfo(); err != nil { + return "", err + } + buf := make([]byte, C.PROC_PIDPATHINFO_MAXSIZE) + n, queryErr := C.proc_pidpath(C.int(p.PID), unsafe.Pointer(&buf[0]), C.uint32_t(len(buf))) + if _, err := p.checkedInfo(); err != nil { + return "", err + } + if n <= 0 || int(n) > len(buf) { + return "", fmt.Errorf("读不到进程 %d 的程序路径: %v", p.PID, queryErr) + } + return string(bytes.TrimRight(buf[:int(n)], "\x00")), nil +} + +// Wait uses the reaper for our children and polls identity for unrelated processes. +func (p Process) Wait(timeout time.Duration) bool { + if done, known := childWait(p, timeout); known { + return done + } + deadline := time.Now().Add(timeout) + for p.Alive() { + left := time.Until(deadline) + if left <= 0 { + return false + } + time.Sleep(min(20*time.Millisecond, left)) + } + return true +} + +// ExitCode is known only for direct children started in this process. Darwin +// cannot recover the historical exit status of an unrelated or orphan process. +func (p Process) ExitCode() (uint32, bool) { return childExit(p) } + +func (p Process) CPUTime() (time.Duration, bool) { + if _, err := p.checkedInfo(); err != nil { + return 0, false + } + var ns C.uint64_t + code := C.task_time(C.int(p.PID), &ns) + if _, err := p.checkedInfo(); err != nil || code != 0 || uint64(ns) > 1<<63-1 { + return 0, false + } + return time.Duration(ns), true +} + +// Kill checks the creation time immediately before SIGKILL. Darwin's signal API +// takes a PID, unlike a Windows process handle; it cannot pin an identity across +// the check and signal. Missing identities and PID 0/1 are never signalled. +func (p Process) Kill() error { + if p.PID <= 1 { + return errors.New("拒绝结束 PID 0 或 1") + } + if _, err := p.checkedInfo(); err != nil { + if errors.Is(err, os.ErrProcessDone) { + return nil + } + return err + } + if err := syscall.Kill(int(p.PID), syscall.SIGKILL); err != nil && err != syscall.ESRCH { + return fmt.Errorf("结束进程 %d: %w", p.PID, err) + } + return nil +} + +// KillTree uses one snapshot, verifies creation order and kills deepest first. +// Each signal checks identity again; an already gone root owns no new tree. +func KillTree(root Process) error { + if root.PID <= 1 { + return errors.New("拒绝结束 PID 0 或 1 的进程树") + } + if _, err := root.checkedInfo(); err != nil { + if errors.Is(err, os.ErrProcessDone) { + return nil + } + return err + } + all, err := ListProcesses() + if err != nil { + return err + } + children := make(map[uint32][]Process) + for _, p := range all { + children[p.PPID] = append(children[p.PPID], p) + } + type node struct { + p Process + ancestors []Process + } + queue := []node{{p: root}} + seen := map[uint32]bool{root.PID: true} + var found []node + for len(queue) > 0 { + n := queue[0] + queue = queue[1:] + for _, p := range children[n.p.PID] { + if seen[p.PID] || p.Created == 0 || p.Created < n.p.Created { + continue + } + seen[p.PID] = true + ancestors := append(append([]Process(nil), n.ancestors...), n.p) + child := node{p, ancestors} + found = append(found, child) + queue = append(queue, child) + } + } + if _, err := root.checkedInfo(); err != nil { + if errors.Is(err, os.ErrProcessDone) { + return nil + } + return err + } + sort.SliceStable(found, func(i, j int) bool { return len(found[i].ancestors) > len(found[j].ancestors) }) + var first error + for _, n := range found { + // Snapshot rows are read at different times. An intermediate parent's + // PID may already belong to an unrelated process; verify the entire + // ancestry before acting on an otherwise valid descendant identity. + chain := append(n.ancestors, n.p) + member := true + for i, p := range chain { + info, err := p.checkedInfo() + if err != nil { + if !errors.Is(err, os.ErrProcessDone) && first == nil { + first = err + } + member = false + break + } + if i > 0 && uint32(info.pbi_ppid) != chain[i-1].PID { + member = false + break + } + } + if !member { + continue + } + if err := n.p.Kill(); err != nil && first == nil { + first = err + } + } + if err := root.Kill(); err != nil { + return err + } + return first +} diff --git a/internal/win/runtime_other.go b/internal/win/runtime_other.go new file mode 100644 index 0000000..12ea0d1 --- /dev/null +++ b/internal/win/runtime_other.go @@ -0,0 +1,76 @@ +//go:build !windows && (!darwin || !cgo) + +package win + +import ( + "os" + "time" +) + +// Darwin process queries require libproc (cgo). Cross builds without cgo keep +// the unsupported boundary instead of substituting incomplete process identity. +// ListProcesses uses CreateToolhelp32Snapshot and Process32First/Next on Windows. +func ListProcesses() ([]Process, error) { return nil, errUnsupported } + +// FindProcess resolves a PID from the Windows process snapshot and checks its identity. +func FindProcess(uint32) (Process, bool) { return Process{}, false } + +// ProcessName reads the executable name from the Windows process snapshot. +func ProcessName(uint32) string { return "" } + +// CommandLine opens the Windows process and reads its command line from the PEB. +func (p Process) CommandLine() ([]string, error) { return nil, errUnsupported } + +// ImagePath calls QueryFullProcessImageNameW on Windows. +func (p Process) ImagePath() (string, error) { return "", errUnsupported } + +// StartedAt is unavailable without the Windows process APIs. Returning zero +// prevents a fabricated Unix timestamp from being reported as a process start. +func (p Process) StartedAt() time.Time { + return time.Time{} +} + +// Alive compares the Windows process identity and checks its exit state. +func (p Process) Alive() bool { return false } + +// Wait uses WaitForSingleObject on the Windows process handle. +func (p Process) Wait(time.Duration) bool { return true } + +// ExitCode calls GetExitCodeProcess on Windows. +func (p Process) ExitCode() (uint32, bool) { return 0, false } + +// CPUTime reads the Windows process kernel and user FILETIMEs. +func (p Process) CPUTime() (time.Duration, bool) { return 0, false } + +// Kill calls TerminateProcess on Windows. +func (p Process) Kill() error { return errUnsupported } + +// KillTree enumerates child processes and terminates the Windows process tree. +func KillTree(Process) error { return errUnsupported } + +// StartDetached creates a Windows process with detached-console flags. +func StartDetached(string, []string, StartOptions) (Process, error) { return Process{}, errUnsupported } + +// StartOrphan starts the Windows helper that adopts the child after its parent exits. +func StartOrphan(string, []string, StartOptions) (Process, error) { return Process{}, errUnsupported } + +// SpawnHelperMain is the Windows orphan helper's entry point. +func SpawnHelperMain([]string, *os.File) int { return 1 } + +// TryLock takes a non-blocking Windows LockFileEx lock. +func TryLock(string) (*FileLock, bool, error) { return nil, false, errUnsupported } + +// Lock retries Windows LockFileEx until the timeout. +func Lock(string, time.Duration) (*FileLock, bool, error) { return nil, false, errUnsupported } + +// Unlock releases the Windows LockFileEx handle. +func (l *FileLock) Unlock() {} + +// Listeners reads the Windows TCP table with GetExtendedTcpTable. +func Listeners() ([]Listener, error) { return nil, errUnsupported } + +// ListenerPID finds the owning PID in the Windows TCP table. +func ListenerPID(int) (uint32, bool, error) { return 0, false, errUnsupported } + +// BusyPorts compares requested ports with the Windows TCP and UDP tables. +func BusyPorts([]int) ([]int, error) { return nil, errUnsupported } diff --git a/internal/win/start_darwin.go b/internal/win/start_darwin.go new file mode 100644 index 0000000..b028d58 --- /dev/null +++ b/internal/win/start_darwin.go @@ -0,0 +1,408 @@ +//go:build darwin && cgo + +package win + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "sync" + "syscall" + "time" + + "modavd/internal/errs" +) + +const ( + darwinOrphanTimeout = 15 * time.Second + darwinKeepExited = 10 * time.Minute +) + +type darwinChildKey struct { + pid uint32 + created uint64 +} + +type darwinChild struct { + done chan struct{} + process *os.Process + code uint32 + known bool + exitedAt time.Time +} + +var darwinChildren = struct { + sync.Mutex + items map[darwinChildKey]*darwinChild +}{items: make(map[darwinChildKey]*darwinChild)} + +// Only a direct child's one cmd.Wait goroutine can recover its wait status. +// Orphans and processes discovered later deliberately have no cached exit code. +func darwinRememberChild(cmd *exec.Cmd, p Process) { + record := &darwinChild{done: make(chan struct{}), process: cmd.Process} + darwinChildren.Lock() + darwinPruneChildren() + darwinChildren.items[darwinChildKey{p.PID, p.Created}] = record + darwinChildren.Unlock() + go func() { + _ = cmd.Wait() // also reap unsuccessful and signaled children + var code uint32 + known := false + if state := cmd.ProcessState; state != nil { + if status, ok := state.Sys().(syscall.WaitStatus); ok { + switch { + case status.Exited(): + code, known = uint32(status.ExitStatus()), true + case status.Signaled(): + // The public API is unsigned: use the shell convention instead + // of converting Go's -1 for a signal to an enormous exit code. + code, known = uint32(128+status.Signal()), true + } + } + } + darwinChildren.Lock() + record.code, record.known, record.exitedAt = code, known, time.Now() + close(record.done) + darwinChildren.Unlock() + }() +} + +// darwinPruneChildren is called with darwinChildren locked. Like Windows' kept +// handles, completed records are retained for ten minutes and pruned on use. +func darwinPruneChildren() { + cutoff := time.Now().Add(-darwinKeepExited) + for key, record := range darwinChildren.items { + if !record.exitedAt.IsZero() && record.exitedAt.Before(cutoff) { + delete(darwinChildren.items, key) + } + } +} + +func darwinChildOf(p Process) *darwinChild { + if p.PID == 0 || p.Created == 0 { + return nil + } + darwinChildren.Lock() + defer darwinChildren.Unlock() + darwinPruneChildren() + return darwinChildren.items[darwinChildKey{p.PID, p.Created}] +} + +// childExit is for Process.ExitCode. It never supplies an exit code from another +// process that happened to reuse a PID. +func childExit(p Process) (uint32, bool) { + record := darwinChildOf(p) + if record == nil { + return 0, false + } + select { + case <-record.done: + return record.code, record.known + default: + return 0, false + } +} + +// childWait is for Process.Wait: known says this package owns the child and its +// wait goroutine; done says that goroutine has reaped it within timeout. +func childWait(p Process, timeout time.Duration) (done, known bool) { + record := darwinChildOf(p) + if record == nil { + return false, false + } + select { + case <-record.done: + return true, true + default: + } + if timeout <= 0 { + return false, true + } + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-record.done: + return true, true + case <-timer.C: + return false, true + } +} + +func darwinResolveExe(exe string) (string, error) { + if exe == "" { + return "", errors.New("没有给出要启动的程序") + } + if !strings.ContainsRune(exe, '/') { + path, err := exec.LookPath(exe) + if err != nil { + return "", err + } + exe = path + } + return filepath.Abs(exe) +} + +// Unix environment keys are case-sensitive; as with exec.Cmd, the last duplicate +// wins. A non-nil empty slice means an empty environment, not inheritance. +func darwinChildEnv(env []string) []string { + if env == nil { + env = os.Environ() + } + seen := make(map[string]bool, len(env)) + out := make([]string, 0, len(env)) + for i := len(env) - 1; i >= 0; i-- { + key, _, ok := strings.Cut(env[i], "=") + if !ok || key == "" || seen[key] { + continue + } + seen[key] = true + out = append(out, env[i]) + } + slices.Reverse(out) + return out +} + +func darwinLaunchError(exe string, err error) error { + return errs.Wrap(errs.LaunchFailed, err, fmt.Sprintf("启动 %s 失败: %v", filepath.Base(exe), err)) +} + +// StartDetached creates a new session and process group, with only stdin (null), +// stdout and stderr inherited. The child survives this process's normal exit. +// It remains our child until reaped; StartOrphan additionally removes ancestry. +func StartDetached(exe string, args []string, opt StartOptions) (Process, error) { + abs, err := darwinResolveExe(exe) + if err != nil { + return Process{}, darwinLaunchError(exe, err) + } + cmd := exec.Command(abs, args...) + cmd.Dir = opt.Dir + if cmd.Dir == "" { + cmd.Dir = filepath.Dir(abs) + } + cmd.Env = darwinChildEnv(opt.Env) + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + input, err := os.Open(os.DevNull) + if err != nil { + return Process{}, darwinLaunchError(abs, err) + } + defer input.Close() + output := opt.Output + if output == nil { + path := opt.OutputPath + if path == "" { + path = os.DevNull + } else if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return Process{}, darwinLaunchError(abs, err) + } + output, err = os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644) + if err != nil { + return Process{}, darwinLaunchError(abs, err) + } + defer output.Close() + } + cmd.Stdin, cmd.Stdout, cmd.Stderr = input, output, output + if err := cmd.Start(); err != nil { + return Process{}, darwinLaunchError(abs, err) + } + // Do not reap before reading creation time: even a very short lived child is + // still ours as a zombie, so its PID cannot already identify an unrelated one. + p, ok := startedProcess(uint32(cmd.Process.Pid)) + if !ok || p.Created == 0 { + _ = cmd.Process.Kill() + _ = cmd.Wait() + return Process{}, darwinLaunchError(abs, errors.New("读不到新进程的创建时间")) + } + p.Name = filepath.Base(abs) + darwinRememberChild(cmd, p) + return p, nil +} + +type darwinSpawnRequest struct { + Exe string `json:"exe"` + Args []string `json:"args"` + Dir string `json:"dir,omitempty"` + OutputPath string `json:"output_path,omitempty"` +} + +type darwinSpawnReply struct { + PID uint32 `json:"pid,omitempty"` + Created uint64 `json:"created,omitempty"` + Error string `json:"error,omitempty"` +} + +// StartOrphan runs the existing __spawn protocol in a short lived helper. It +// returns only after that helper has been reaped and a surviving target has +// moved to launchd (PID 1), in its own process group. Its eventual exit code is +// unknown to us: it is not our child and no success status is fabricated. +func StartOrphan(exe string, args []string, opt StartOptions) (Process, error) { + if opt.Output != nil { + return Process{}, errs.New(errs.Internal, "StartOrphan 只能用 OutputPath 指定输出文件") + } + if OrphanHelper == "" { + return StartDetached(exe, args, opt) + } + abs, err := darwinResolveExe(exe) + if err != nil { + return Process{}, darwinLaunchError(exe, err) + } + req := darwinSpawnRequest{Exe: abs, Args: args, Dir: opt.Dir, OutputPath: opt.OutputPath} + if req.Dir == "" { + req.Dir = filepath.Dir(abs) + } else if req.Dir, err = filepath.Abs(req.Dir); err != nil { + return Process{}, darwinLaunchError(abs, err) + } + if req.OutputPath != "" { + if req.OutputPath, err = filepath.Abs(req.OutputPath); err != nil { + return Process{}, darwinLaunchError(abs, err) + } + } + encoded, err := json.Marshal(req) + if err != nil { + return Process{}, darwinLaunchError(abs, err) + } + reader, writer, err := os.Pipe() + if err != nil { + return Process{}, darwinLaunchError(abs, err) + } + defer reader.Close() + cmd := exec.Command(OrphanHelper, SpawnHelperArg, string(encoded)) + cmd.Env = darwinChildEnv(opt.Env) + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + cmd.Stdout, cmd.Stderr = writer, writer + if err := cmd.Start(); err != nil { + writer.Close() + return Process{}, darwinLaunchError(abs, err) + } + writer.Close() + helper, helperKnown := startedProcess(uint32(cmd.Process.Pid)) + waited := make(chan error, 1) + helperDone := make(chan struct{}) + go func() { + waited <- cmd.Wait() + close(helperDone) + }() + deadline := time.Now().Add(darwinOrphanTimeout) + var target Process + failed := func(err error) (Process, error) { + // Capture any children before ending a helper that has not reported yet. + // Identity checks in KillTree/Kill avoid acting on a reused helper PID. + if helperKnown && helper.Created != 0 { + _ = KillTree(helper) + } + if target.PID != 0 && target.Created != 0 { + _ = target.Kill() + } + _ = cmd.Process.Kill() + select { + case <-helperDone: + case <-time.After(time.Second): + } + return Process{}, darwinLaunchError(abs, err) + } + type lineResult struct { + line string + err error + } + lines := make(chan lineResult, 1) + go func() { + line, err := bufio.NewReader(io.LimitReader(reader, 1<<16+1)).ReadString('\n') + lines <- lineResult{line, err} + }() + timer := time.NewTimer(time.Until(deadline)) + defer timer.Stop() + var line lineResult + select { + case line = <-lines: + case <-timer.C: + return failed(errors.New("中转进程 15 秒内没有回报")) + } + var reply darwinSpawnReply + if line.err != nil || len(line.line) > 1<<16 || json.Unmarshal([]byte(line.line), &reply) != nil || + (reply.Error == "" && (reply.PID == 0 || reply.Created == 0)) { + return failed(errors.New("中转进程没有正常回报")) + } + if reply.PID != 0 && reply.Created != 0 { + if reply.PID == uint32(os.Getpid()) || reply.PID == uint32(cmd.Process.Pid) { + return failed(errors.New("中转进程回报了无效的子进程身份")) + } + target = Process{PID: reply.PID, PPID: uint32(cmd.Process.Pid), Name: filepath.Base(abs), Created: reply.Created} + } + select { + case err := <-waited: + if reply.Error != "" { + return Process{}, errs.New(errs.LaunchFailed, reply.Error) + } + if err != nil { + return failed(fmt.Errorf("中转进程退出失败: %w", err)) + } + case <-timer.C: + return failed(errors.New("中转进程 15 秒内没有退出")) + } + for { + info, err := target.checkedInfo() + if errors.Is(err, os.ErrProcessDone) { + // The reported process has already exited. Returning its original + // identity lets callers observe that, without touching a reused PID. + return target, nil + } + if err != nil { + return failed(fmt.Errorf("无法验证新进程的身份: %w", err)) + } + current := processFromInfo(info) + group, err := syscall.Getpgid(int(target.PID)) + if current.PPID == 1 && err == nil && group == int(target.PID) { + afterInfo, err := target.checkedInfo() + if errors.Is(err, os.ErrProcessDone) { + return target, nil + } + if err != nil { + return failed(fmt.Errorf("无法再次验证新进程的身份: %w", err)) + } + if processFromInfo(afterInfo).PPID == 1 { + target.PPID = 1 + return target, nil + } + } + if !time.Now().Before(deadline) { + return failed(errors.New("新进程没有脱离中转进程和调用者的进程组")) + } + time.Sleep(min(10*time.Millisecond, time.Until(deadline))) + } +} + +// SpawnHelperMain takes the same one JSON argument as the Windows helper and +// writes exactly one JSON reply. Target stdout/stderr never use the reply pipe. +func SpawnHelperMain(args []string, stdout *os.File) int { + reply := func(rep darwinSpawnReply) bool { + return json.NewEncoder(stdout).Encode(rep) == nil + } + if len(args) != 1 { + reply(darwinSpawnReply{Error: fmt.Sprintf("中转进程要一个 JSON 参数,收到 %d 个", len(args))}) + return 1 + } + var req darwinSpawnRequest + if err := json.Unmarshal([]byte(args[0]), &req); err != nil || req.Exe == "" { + reply(darwinSpawnReply{Error: "中转进程的 JSON 参数无效或没有要启动的程序"}) + return 1 + } + p, err := StartDetached(req.Exe, req.Args, StartOptions{Dir: req.Dir, OutputPath: req.OutputPath}) + if err != nil { + reply(darwinSpawnReply{Error: errs.As(err).Message}) + return 1 + } + if !reply(darwinSpawnReply{PID: p.PID, Created: p.Created}) { + if child := darwinChildOf(p); child != nil { + _ = child.process.Kill() + <-child.done + } + return 1 + } + return 0 +} From fcefa74eaf1ab89128e005db0e90e3aa7920cc5f Mon Sep 17 00:00:00 2001 From: DeathofBrain <109609173+DeathofBrain@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:09:54 +0800 Subject: [PATCH 4/9] refactor: separate host routing from platform backends --- cmd/modavd-gui/main.go | 12 ++-- cmd/modavd-gui/msgbox.go | 4 +- cmd/modavd-gui/single.go | 6 +- internal/adb/adb.go | 36 +++++----- internal/adb/identity.go | 8 +-- internal/adb/identity_other.go | 4 +- internal/cli/cli.go | 12 ++-- internal/components/fetch.go | 10 +-- internal/emu/adbcmd.go | 6 +- internal/emu/affinity_test.go | 28 ++++---- internal/emu/afterboot.go | 30 ++++---- internal/emu/bootwatch.go | 26 +++---- internal/emu/bridge.go | 12 ++-- internal/emu/check.go | 8 +-- internal/emu/createinit.go | 4 +- internal/emu/crud.go | 10 +-- internal/emu/disk.go | 24 +++---- internal/emu/gates_other_test.go | 6 +- internal/emu/install.go | 6 +- internal/emu/launch.go | 34 ++++----- internal/emu/logs.go | 26 +++---- internal/emu/manager.go | 26 +++---- internal/emu/qemu_errors_test.go | 4 +- internal/emu/start.go | 42 +++++------ internal/emu/status.go | 20 +++--- internal/emu/stop.go | 28 ++++---- internal/emu/types.go | 6 +- internal/emu/vkprobe.go | 4 +- internal/emu/vulkanfix.go | 6 +- internal/emu/window.go | 8 +-- internal/host/darwin.go | 55 ++++++++++++++ internal/host/host.go | 4 ++ .../{win/runtime_other.go => host/other.go} | 30 +++++++- internal/{win => host}/terminate_other.go | 2 +- .../other.go => host/unsupported_other.go} | 43 ++--------- internal/host/windows.go | 72 +++++++++++++++++++ internal/{win => macos}/lock_darwin.go | 2 +- internal/{win => macos}/net_darwin.go | 2 +- internal/{win => macos}/proc_darwin.go | 2 +- internal/{win => macos}/start_darwin.go | 9 +-- internal/macos/types_darwin.go | 33 +++++++++ internal/report/host_windows.go | 8 +-- internal/update/settings.go | 6 +- internal/update/swap.go | 4 +- internal/update/updater.go | 10 +-- 45 files changed, 449 insertions(+), 289 deletions(-) create mode 100644 internal/host/darwin.go create mode 100644 internal/host/host.go rename internal/{win/runtime_other.go => host/other.go} (87%) rename internal/{win => host}/terminate_other.go (85%) rename internal/{win/other.go => host/unsupported_other.go} (74%) create mode 100644 internal/host/windows.go rename internal/{win => macos}/lock_darwin.go (99%) rename internal/{win => macos}/net_darwin.go (99%) rename internal/{win => macos}/proc_darwin.go (99%) rename internal/{win => macos}/start_darwin.go (97%) create mode 100644 internal/macos/types_darwin.go diff --git a/cmd/modavd-gui/main.go b/cmd/modavd-gui/main.go index dd7612d..39a33c9 100644 --- a/cmd/modavd-gui/main.go +++ b/cmd/modavd-gui/main.go @@ -32,20 +32,20 @@ import ( "modavd/internal/buildinfo" "modavd/internal/emu" "modavd/internal/errs" + "modavd/internal/host" "modavd/internal/logx" "modavd/internal/root" "modavd/internal/ui" - "modavd/internal/win" ) func main() { - // Hidden subcommands first, before any flag parsing or root lookup: emu and win re-run this executable as the - // spawn helper of win.StartOrphan, as the Vulkan probe child and as the boot watch (same routing as internal/cli + // Hidden subcommands first, before any flag parsing or root lookup: emu and host re-run this executable as the + // spawn helper of host.StartOrphan, as the Vulkan probe child and as the boot watch (same routing as internal/cli // Main). if len(os.Args) > 1 { switch os.Args[1] { - case win.SpawnHelperArg: - os.Exit(win.SpawnHelperMain(os.Args[2:], os.Stdout)) + case host.SpawnHelperArg: + os.Exit(host.SpawnHelperMain(os.Args[2:], os.Stdout)) case emu.VulkanProbeArg: os.Exit(emu.RunVulkanProbe(os.Stdout)) case emu.BootWatchArg: @@ -55,7 +55,7 @@ func main() { } } if exe, err := os.Executable(); err == nil { - win.OrphanHelper = exe + host.SetOrphanHelper(exe) } os.Exit(run(os.Args[1:])) } diff --git a/cmd/modavd-gui/msgbox.go b/cmd/modavd-gui/msgbox.go index ae73bdc..9d0b720 100644 --- a/cmd/modavd-gui/msgbox.go +++ b/cmd/modavd-gui/msgbox.go @@ -1,5 +1,5 @@ package main -import "modavd/internal/win" +import "modavd/internal/host" -func fatal(msg string) { _ = win.MessageBox(msg) } +func fatal(msg string) { _ = host.MessageBox(msg) } diff --git a/cmd/modavd-gui/single.go b/cmd/modavd-gui/single.go index b542027..0385e8b 100644 --- a/cmd/modavd-gui/single.go +++ b/cmd/modavd-gui/single.go @@ -11,9 +11,9 @@ import ( "strings" "time" + "modavd/internal/host" "modavd/internal/root" "modavd/internal/ui" - "modavd/internal/win" ) // One manager per root: a lock file plus a state file under %LOCALAPPDATA%\ModAVD, both named after a hash of the @@ -41,12 +41,12 @@ type state struct { type instance struct { r *root.Root - lock *win.FileLock + lock *host.FileLock } // lockInstance takes the root's manager lock; nil (and no error) when another manager holds it. func lockInstance(r *root.Root) (*instance, error) { - l, ok, err := win.TryLock(lockPath(r)) + l, ok, err := host.TryLock(lockPath(r)) if err != nil { return nil, err } diff --git a/internal/adb/adb.go b/internal/adb/adb.go index fb05562..3a3f6cd 100644 --- a/internal/adb/adb.go +++ b/internal/adb/adb.go @@ -24,9 +24,9 @@ import ( "time" "modavd/internal/errs" + "modavd/internal/host" "modavd/internal/logx" "modavd/internal/root" - "modavd/internal/win" ) // DefaultTimeout of one adb command. @@ -232,7 +232,7 @@ func (c *Client) portConflict(k serverKind, port int, pid uint32, name string) * // listener reports whether somebody listens on port, with its PID and process name ("" when unreadable). Reads the // system table only; connecting to an emulator's adb port would disturb it. func listener(port int) (up bool, pid uint32, name string, err error) { - pid, up, err = win.ListenerPID(port) + pid, up, err = host.ListenerPID(port) if err != nil { return false, 0, "", errs.Wrap(errs.Internal, err, "读不出系统的 TCP 监听表: "+err.Error()) } @@ -240,7 +240,7 @@ func listener(port int) (up bool, pid uint32, name string, err error) { return false, 0, "", nil } if pid != 0 { - name = win.ProcessName(pid) + name = host.ProcessName(pid) } return true, pid, name, nil } @@ -310,10 +310,10 @@ func (c *Client) ensure(ctx context.Context, k serverKind, port int, timeout tim c.Log.Infof("启动魔改 AVD %s adb server(端口 %d)", k.label, port) // StartOrphan rather than StartDetached: an MCP client ends modavd's whole process tree when it disconnects, and // while the start-server client (our child for ~2 s) is alive the server it forks is part of that tree. Losing - // the server is not harmless: the emulator registers with it only once, at boot. Without win.OrphanHelper (unit - // tests) StartOrphan is StartDetached. Output goes nowhere, like AUTO-MAS (stdout=DEVNULL). - client, err := win.StartOrphan(c.Root.AdbExe(), []string{"-P", strconv.Itoa(port), "start-server"}, - win.StartOptions{Env: c.Env()}) + // the server is not harmless: the emulator registers with it only once, at boot. Without a configured orphan + // helper (unit tests) StartOrphan is StartDetached. Output goes nowhere, like AUTO-MAS (stdout=DEVNULL). + client, err := host.StartOrphan(c.Root.AdbExe(), []string{"-P", strconv.Itoa(port), "start-server"}, + host.StartOptions{Env: c.Env()}) if err != nil { return false, errs.Wrap(errs.Internal, err, fmt.Sprintf("启动魔改 AVD %s adb server(端口 %d)失败: %s", k.label, port, errs.As(err).Message)) @@ -323,7 +323,7 @@ func (c *Client) ensure(ctx context.Context, k serverKind, port int, timeout tim // waitListening polls until port listens, at most timeout (AUTO-MAS _wait_listening). It does not wait for the // start-server client to exit, but gives up early when that client has failed. -func waitListening(ctx context.Context, port int, timeout time.Duration, client win.Process) (bool, error) { +func waitListening(ctx context.Context, port int, timeout time.Duration, client host.Process) (bool, error) { deadline := time.Now().Add(timeout) for { up, err := listening(port) @@ -344,7 +344,7 @@ func waitListening(ctx context.Context, port int, timeout time.Duration, client // clientFailed: the start-server client exited without success. Once it is gone its exit code may be unreadable // (nobody holds a handle to it); that counts as failed too, since a client that succeeded leaves the port listening. -func clientFailed(p win.Process) bool { +func clientFailed(p host.Process) bool { if p.PID == 0 || p.Alive() { return false } @@ -455,7 +455,7 @@ func (c *Client) execAdb(ctx context.Context, raw bool, out io.Writer, limit, ti defer cancel() cmd := exec.CommandContext(tctx, c.Root.AdbExe(), cmdArgs...) cmd.Env = c.Env() - win.HideConsoleCmd(cmd) + host.HideConsoleCmd(cmd) cmd.WaitDelay = pipeWaitDelay // Cancel runs only when the deadline (or ctx) ends adb while it still runs, not when merely the pipe wait after a // normal exit outlasts the deadline; that is what tells a timeout from a slow pipe. @@ -735,25 +735,25 @@ var qemuOnAdbPort = QemuOnAdbPort // Somebody listening is not enough: the port may belong to another program, and registering it would hand that // program's port to the private server. Nor are the arguments: the same-numbered instance of another root has the // same ones, and registering it would put one emulator on two private servers. -func QemuOnAdbPort(r *root.Root, console int) (win.Process, bool) { +func QemuOnAdbPort(r *root.Root, console int) (host.Process, bool) { if r == nil { - return win.Process{}, false + return host.Process{}, false } i, ok := r.Ports().IndexOfConsole(console) if !ok { - return win.Process{}, false + return host.Process{}, false } - pid, up, err := win.ListenerPID(console + 1) + pid, up, err := host.ListenerPID(console + 1) if err != nil || !up || pid == 0 { - return win.Process{}, false + return host.Process{}, false } - p, ok := win.FindProcess(pid) + p, ok := host.FindProcess(pid) if !ok || !isQemuProcessName(p.Name) { - return win.Process{}, false + return host.Process{}, false } cmdline, err := p.CommandLine() if err != nil || !qemuArgsMatch(cmdline, console, root.AvdName(i)) || !QemuBelongsTo(r, p, i) { - return win.Process{}, false + return host.Process{}, false } return p, true } diff --git a/internal/adb/identity.go b/internal/adb/identity.go index 0ae644d..38a17d4 100644 --- a/internal/adb/identity.go +++ b/internal/adb/identity.go @@ -13,8 +13,8 @@ import ( "time" "unsafe" + "modavd/internal/host" "modavd/internal/root" - "modavd/internal/win" ) // QemuBelongsTo reports whether qemu process p, already matched to instance i by its "-avd mas_" and "-ports @@ -39,7 +39,7 @@ import ( // leftover, p's start time unknown, unreadable) the qemu is not ours: taking another root's emulator for ours // is the harm to avoid, and ours is found a moment later, once its lock (taken early in qemu's main) or its // runtime file is there. -func QemuBelongsTo(r *root.Root, p win.Process, i int) bool { +func QemuBelongsTo(r *root.Root, p host.Process, i int) bool { if r == nil || p.PID == 0 { return false } @@ -102,7 +102,7 @@ const ( // retries fail if its handle stays open across them (the probing thread preempted between CreateFile and CloseHandle // on a busy host is enough), and qemu then panics with "Running multiple emulators with the same AVD" (main.cpp). // That is one reason the probe reads with every share mode now and never stands in the emulator's way. -func runLockHeldBy(avdDir string, p win.Process) bool { +func runLockHeldBy(avdDir string, p host.Process) bool { started := p.StartedAt() if p.PID == 0 || started.IsZero() { return false @@ -206,7 +206,7 @@ func isDir(path string) bool { // runningAvdDir is the avd.dir= value of qemu p's runtime file pid_.ini in dir; "" when there is no current one // (missing, unreadable, without that line, or written before p started). -func runningAvdDir(dir string, p win.Process) string { +func runningAvdDir(dir string, p host.Process) string { path := filepath.Join(dir, "pid_"+strconv.FormatUint(uint64(p.PID), 10)+".ini") st, err := os.Stat(path) if err != nil || !st.Mode().IsRegular() { diff --git a/internal/adb/identity_other.go b/internal/adb/identity_other.go index 643b0f2..81f37b7 100644 --- a/internal/adb/identity_other.go +++ b/internal/adb/identity_other.go @@ -6,13 +6,13 @@ import ( "os" "path/filepath" + "modavd/internal/host" "modavd/internal/root" - "modavd/internal/win" ) // QemuBelongsTo is unavailable until the non-Windows process and file-lock // implementation is provided; callers conservatively reject the process. -func QemuBelongsTo(*root.Root, win.Process, int) bool { return false } +func QemuBelongsTo(*root.Root, host.Process, int) bool { return false } func EmulatorDiscoveryDir() string { if d := os.Getenv("ANDROID_EMULATOR_HOME"); d != "" { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 9f8d542..5b855df 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -64,8 +64,8 @@ // Before Main returns, a command that used a manager waits up to backgroundWait for the manager's background work // (emu.Manager.Wait: the logcat restart after a re-registration), after its output is written. // -// Hidden subcommands, routed before any flag parsing or root lookup: __spawn (win.SpawnHelperMain, the helper of -// win.StartOrphan), __vkprobe (emu.RunVulkanProbe, the Vulkan probe child process) and __bootwatch +// Hidden subcommands, routed before any flag parsing or root lookup: __spawn (host.SpawnHelperMain, the helper of +// host.StartOrphan), __vkprobe (emu.RunVulkanProbe, the Vulkan probe child process) and __bootwatch // (emu.RunBootWatch). package cli @@ -81,10 +81,10 @@ import ( "modavd/internal/components" "modavd/internal/emu" "modavd/internal/errs" + "modavd/internal/host" "modavd/internal/logx" "modavd/internal/mcp" "modavd/internal/root" - "modavd/internal/win" ) // manager is the part of *emu.Manager the commands use; tests substitute a fake. @@ -150,12 +150,12 @@ func defaultDeps() deps { func Main(args []string, stdin io.Reader, stdout, stderr io.Writer) int { if len(args) > 0 { switch args[0] { - case win.SpawnHelperArg: + case host.SpawnHelperArg: f, ok := stdout.(*os.File) if !ok { f = os.Stdout } - return win.SpawnHelperMain(args[1:], f) + return host.SpawnHelperMain(args[1:], f) case emu.VulkanProbeArg: return emu.RunVulkanProbe(stdout) case emu.BootWatchArg: @@ -164,7 +164,7 @@ func Main(args []string, stdin io.Reader, stdout, stderr io.Writer) int { } // StartOrphan re-runs this executable as the spawn helper. if exe, err := os.Executable(); err == nil { - win.OrphanHelper = exe + host.SetOrphanHelper(exe) } // SIGTERM is what Go delivers for a console close / logoff on Windows. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) diff --git a/internal/components/fetch.go b/internal/components/fetch.go index 505afb6..c8294f6 100644 --- a/internal/components/fetch.go +++ b/internal/components/fetch.go @@ -25,9 +25,9 @@ import ( "modavd/internal/buildinfo" "modavd/internal/errs" + "modavd/internal/host" "modavd/internal/logx" "modavd/internal/root" - "modavd/internal/win" ) // Mirror is one download source: a base URL the component paths are relative to. @@ -46,7 +46,7 @@ var defaultMirrors = []Mirror{ var ( fetchGOOS = runtime.GOOS componentCatalogue = root.SDKComponents - diskFree = win.DiskFree + diskFree = host.DiskFree ) // Defaults and limits of the three timeouts (the script's -ConnectTimeoutSec, -StallTimeoutSec, -ManifestTimeoutSec). @@ -221,7 +221,7 @@ func (f *fetcher) run(ctx context.Context) (res *Result, err error) { f.openLog() defer f.closeLog() } - var lock *win.FileLock + var lock *host.FileLock defer func() { if err != nil { f.failed(err, lock != nil) @@ -377,9 +377,9 @@ func isASCII(s string) bool { // lock is logs\fetch-components.lock: one fetch per root at a time (update does the same with .update\modavd.lock). // Only its holder clears sdk\.staging, where another run could be unpacking. The system drops it when the process // ends. -func (f *fetcher) lock() (*win.FileLock, error) { +func (f *fetcher) lock() (*host.FileLock, error) { p := filepath.Join(f.r.LogsDir(), "fetch-components.lock") - l, ok, err := win.TryLock(p) + l, ok, err := host.TryLock(p) if err != nil { return nil, errs.Wrap(errs.Internal, err, "补全组件的锁文件打不开: "+p) } diff --git a/internal/emu/adbcmd.go b/internal/emu/adbcmd.go index 51db03f..f4f1650 100644 --- a/internal/emu/adbcmd.go +++ b/internal/emu/adbcmd.go @@ -13,7 +13,7 @@ import ( "time" "modavd/internal/errs" - "modavd/internal/win" + "modavd/internal/host" ) const passthroughServerWait = 20 * time.Second @@ -66,7 +66,7 @@ func (m *Manager) AdbPassthrough(ctx context.Context, i int, args []string, stdi cmd.Stdin, cmd.Stdout, cmd.Stderr = stdin, stdout, stderr if !hasConsole() { // Without a console of ours a console child would get a new, visible one. - win.HideConsoleCmd(cmd) + host.HideConsoleCmd(cmd) } cmd.WaitDelay = 2 * time.Second err = cmd.Run() @@ -88,5 +88,5 @@ func (m *Manager) AdbPassthrough(ctx context.Context, i int, args []string, stdi // hasConsole: this process is attached to a console (a child then shares it and opens no window of its own). func hasConsole() bool { - return win.GetConsoleWindow() != 0 + return host.GetConsoleWindow() != 0 } diff --git a/internal/emu/affinity_test.go b/internal/emu/affinity_test.go index 1dcf918..5500ec9 100644 --- a/internal/emu/affinity_test.go +++ b/internal/emu/affinity_test.go @@ -4,26 +4,26 @@ import ( "slices" "testing" + "modavd/internal/host" "modavd/internal/root" - "modavd/internal/win" ) var ( - x3dBig = win.L3Group{Size: 96 << 20, Mask: 0x0000FFFF} - x3dSmall = win.L3Group{Size: 32 << 20, Mask: 0xFFFF0000} - x3d = []win.L3Group{x3dBig, x3dSmall} // 7950X3D: two groups of 16 logical processors + x3dBig = host.L3Group{Size: 96 << 20, Mask: 0x0000FFFF} + x3dSmall = host.L3Group{Size: 32 << 20, Mask: 0xFFFF0000} + x3d = []host.L3Group{x3dBig, x3dSmall} // 7950X3D: two groups of 16 logical processors ) func pinned(mask uint64, cores int) otherQemu { return otherQemu{Mask: mask, Cores: cores} } func TestHasBigL3(t *testing.T) { cases := map[string]struct { - groups []win.L3Group + groups []host.L3Group want bool }{ "x3d": {x3d, true}, - "equal": {[]win.L3Group{{Size: 32 << 20, Mask: 0xFF}, {Size: 32 << 20, Mask: 0xFF00}}, false}, - "single": {[]win.L3Group{{Size: 36 << 20, Mask: 0xFFFFFF}}, false}, + "equal": {[]host.L3Group{{Size: 32 << 20, Mask: 0xFF}, {Size: 32 << 20, Mask: 0xFF00}}, false}, + "single": {[]host.L3Group{{Size: 36 << 20, Mask: 0xFFFFFF}}, false}, "none": {nil, false}, } for name, c := range cases { @@ -69,7 +69,7 @@ func TestPlanHostProductionPinned(t *testing.T) { t.Errorf("unreadable: %+v", p) } // Also when the big group comes second in the list. - rev := []win.L3Group{x3dSmall, x3dBig} + rev := []host.L3Group{x3dSmall, x3dBig} if p := planHost(rev, 32, unread, 6); p.Mask != x3dSmall.Mask || !slices.Equal(p.Free, []int{16, 4}) { t.Errorf("unreadable, reversed list: %+v", p) } @@ -81,7 +81,7 @@ func TestPlanHostSpreadAndTies(t *testing.T) { t.Errorf("unpinned other: %+v", p) } // Big L3 wins a tie whichever order the list has. - if p := planHost([]win.L3Group{x3dSmall, x3dBig}, 32, nil, 6); p.Mask != x3dBig.Mask || p.Group != 1 { + if p := planHost([]host.L3Group{x3dSmall, x3dBig}, 32, nil, 6); p.Mask != x3dBig.Mask || p.Group != 1 { t.Errorf("reversed list: %+v", p) } // A mask over parts of both groups loads them by overlap: 8 processors in the big one, 4 in the small one. @@ -97,9 +97,9 @@ func TestPlanHostSpreadAndTies(t *testing.T) { // Groups of different processor counts (Ryzen AI 300: 4 Zen5 cores / 8 threads with 16 MB L3, 8 Zen5c cores / 16 // threads with 8 MB): the first instance gets the big L3 as before the split by load, later ones go by free share. func TestPlanHostUnequalGroups(t *testing.T) { - zen5 := win.L3Group{Size: 16 << 20, Mask: 0x0000FF} - zen5c := win.L3Group{Size: 8 << 20, Mask: 0xFFFF00} - groups := []win.L3Group{zen5c, zen5} + zen5 := host.L3Group{Size: 16 << 20, Mask: 0x0000FF} + zen5c := host.L3Group{Size: 8 << 20, Mask: 0xFFFF00} + groups := []host.L3Group{zen5c, zen5} var others []otherQemu wantMask := []uint64{zen5.Mask, zen5c.Mask, zen5c.Mask} wantFree := [][]int{{16, 8}, {16, 2}, {10, 2}} @@ -180,7 +180,7 @@ func TestPlanHostFit(t *testing.T) { // Ordinary CPUs (one L3, or all L3 the same size) are never pinned, whatever runs; only Spare is computed, over the // whole host. func TestPlanHostOrdinary(t *testing.T) { - cases := map[string][]win.L3Group{ + cases := map[string][]host.L3Group{ "single": {{Size: 32 << 20, Mask: 0xFFFF}}, "equal": {{Size: 32 << 20, Mask: 0xFF}, {Size: 32 << 20, Mask: 0xFF00}}, "none": nil, @@ -231,7 +231,7 @@ func TestQemuCoresCommandLine(t *testing.T) { {[]string{"qemu.exe", "-cores", "0"}, 6}, {[]string{"qemu.exe", "-cores"}, 6}, } { - if got := m.qemuCores(win.Process{}, c.cmdline, nil, root.PortPlan{}, 6); got != c.want { + if got := m.qemuCores(host.Process{}, c.cmdline, nil, root.PortPlan{}, 6); got != c.want { t.Errorf("%v: %d, want %d", c.cmdline, got, c.want) } } diff --git a/internal/emu/afterboot.go b/internal/emu/afterboot.go index 4c57e89..9f055f8 100644 --- a/internal/emu/afterboot.go +++ b/internal/emu/afterboot.go @@ -15,9 +15,9 @@ import ( "modavd/internal/adb" "modavd/internal/avd" "modavd/internal/guest" + "modavd/internal/host" "modavd/internal/jsonord" "modavd/internal/root" - "modavd/internal/win" ) // allowInstallSourcesScript lets every app that asks for REQUEST_INSTALL_PACKAGES install APKs while that app op is @@ -106,7 +106,7 @@ func (m *Manager) afterBoot(ctx context.Context, inst *avd.Instance, mode bootSe prog.emit("tune", fmt.Sprintf("实例 %d 开机后调优", i), progressTune) // 1) host scheduling: priority, X3D pinning; what the host has left sizes the busy loops of 8b if !running { - spare = planHost(nil, hostCPUs(win.L3Groups()), m.otherQemus(p, cores), cores).Spare + spare = planHost(nil, hostCPUs(host.L3Groups()), m.otherQemus(p, cores), cores).Spare return } note, plan := m.tuneHostScheduling(p, cores) @@ -125,7 +125,7 @@ func (m *Manager) afterBoot(ctx context.Context, inst *avd.Instance, mode bootSe // 3) disk and screenshot channel state into the log (write_cache is root-only) wc := m.su(ctx, i, "cat /sys/block/vd*/queue/write_cache | sort -u", 0) shm := "缺失" - if win.SharedMemoryExists(shmName(ports, i)) { + if host.SharedMemoryExists(shmName(ports, i)) { shm = "在" } m.Log.Infof("实例 %d 磁盘缓存: %s;截图共享内存 %s: %s", i, orText(joinNonEmptyLines(wc.Out, " / "), "读不到"), @@ -244,16 +244,16 @@ func affinityLockPath() string { // (ModAVD#34: pinning every instance to the big group left the other CCD idle). Only p is changed; every other qemu // on the host, production's included, is only read. Returns the note for the log and the plan (its Spare sizes the // busy loops of setupIdlepoll). -func (m *Manager) tuneHostScheduling(p win.Process, cores int) (string, hostPlan) { +func (m *Manager) tuneHostScheduling(p host.Process, cores int) (string, hostPlan) { var notes []string - if err := win.SetAboveNormalPriority(p); err != nil { + if err := host.SetAboveNormalPriority(p); err != nil { notes = append(notes, fmt.Sprintf("设优先级失败(%v)", err)) } else { notes = append(notes, "优先级高于正常") } - groups := win.L3Groups() + groups := host.L3Groups() if hasBigL3(groups) { - lock, ok, err := win.Lock(affinityLockPath(), affinityLockWait) + lock, ok, err := host.Lock(affinityLockPath(), affinityLockWait) if err != nil || !ok { notes = append(notes, "拿不到绑核锁,不绑核") plan := planHost(nil, hostCPUs(groups), m.otherQemus(p, cores), cores) @@ -270,7 +270,7 @@ func (m *Manager) tuneHostScheduling(p win.Process, cores int) (string, hostPlan } if plan.Group < 0 { notes = append(notes, fmt.Sprintf("各组 L3 空余核数 %v 都不够 %d 个 vCPU,不绑核", plan.Free, cores)) - } else if err := win.SetAffinity(p, plan.Mask); err != nil { + } else if err := host.SetAffinity(p, plan.Mask); err != nil { notes = append(notes, fmt.Sprintf("绑核失败(%v)", err)) } else { notes = append(notes, fmt.Sprintf("绑定 L3 %d MB 的核心 %s(各组空余核数 %v,本实例 %d 个 vCPU)", @@ -280,7 +280,7 @@ func (m *Manager) tuneHostScheduling(p win.Process, cores int) (string, hostPlan } // hostCPUs is the logical processor count: those under an L3 group, at least runtime.NumCPU. -func hostCPUs(groups []win.L3Group) int { +func hostCPUs(groups []host.L3Group) int { var all uint64 for _, g := range groups { all |= g.Mask @@ -297,8 +297,8 @@ type otherQemu struct { // otherQemus reads (never changes) every qemu process on the host except self: this root's, another root's, // production's. vCPUs: hw.cpu.ncore of this root's instance it runs, else "-cores N" on its command line, else cores // (the vCPU count is in the AVD config, not on a modavd or AUTO-MAS command line). -func (m *Manager) otherQemus(self win.Process, cores int) []otherQemu { - procs, err := win.ListProcesses() +func (m *Manager) otherQemus(self host.Process, cores int) []otherQemu { + procs, err := host.ListProcesses() if err != nil { return nil } @@ -314,7 +314,7 @@ func (m *Manager) otherQemus(self win.Process, cores int) []otherQemu { continue } o := otherQemu{Cores: cores} - if mask, err := win.GetAffinity(q); err == nil { + if mask, err := host.GetAffinity(q); err == nil { o.Mask = mask } if cmdline, err := q.CommandLine(); err == nil { @@ -326,7 +326,7 @@ func (m *Manager) otherQemus(self win.Process, cores int) []otherQemu { } // qemuCores is the vCPU count of qemu q (see otherQemus); insts are this root's instances, plan its port plan. -func (m *Manager) qemuCores(q win.Process, cmdline []string, insts []*avd.Instance, plan root.PortPlan, def int) int { +func (m *Manager) qemuCores(q host.Process, cmdline []string, insts []*avd.Instance, plan root.PortPlan, def int) int { for _, in := range insts { if cmdlineMatches(cmdline, root.AvdName(in.Index), plan.Console(in.Index)) && adb.QemuBelongsTo(m.Root, q, in.Index) { @@ -347,7 +347,7 @@ func (m *Manager) qemuCores(q win.Process, cmdline []string, insts []*avd.Instan } // hasBigL3 is true when the L3 groups differ in size (AMD X3D). -func hasBigL3(groups []win.L3Group) bool { +func hasBigL3(groups []host.L3Group) bool { if len(groups) < 2 { return false } @@ -377,7 +377,7 @@ type hostPlan struct { // before, also where the groups differ in processor count (Ryzen AI 300: 8 Zen5 threads with 16 MB, 16 Zen5c threads // with 8 MB), and on equal groups (7950X3D) the share orders like the free count. A group with fewer free processors // than cores is no option, and when none is left the instance is not pinned and Windows spreads it. -func planHost(groups []win.L3Group, hostCPUs int, others []otherQemu, cores int) hostPlan { +func planHost(groups []host.L3Group, hostCPUs int, others []otherQemu, cores int) hostPlan { plan := hostPlan{Group: -1, Spare: hostCPUs - cores} for _, o := range others { plan.Spare -= o.Cores diff --git a/internal/emu/bootwatch.go b/internal/emu/bootwatch.go index 29a9755..fa4b000 100644 --- a/internal/emu/bootwatch.go +++ b/internal/emu/bootwatch.go @@ -33,9 +33,9 @@ import ( "modavd/internal/adb" "modavd/internal/errs" + "modavd/internal/host" "modavd/internal/logx" "modavd/internal/root" - "modavd/internal/win" ) // BootWatchArg is the hidden subcommand both executables route to RunBootWatch. @@ -123,12 +123,12 @@ type bootRecord struct { } // names: q is the qemu process this record was written for. -func (r bootRecord) names(q win.Process) bool { +func (r bootRecord) names(q host.Process) bool { return r.QemuPID != 0 && r.QemuCreated != 0 && r.QemuPID == q.PID && r.QemuCreated == q.Created } // guestRebooted: the record names q and the guest now runs another kernel boot (bootID; "" = unknown, no reboot). -func (r bootRecord) guestRebooted(q win.Process, bootID string) bool { +func (r bootRecord) guestRebooted(q host.Process, bootID string) bool { return r.names(q) && r.BootID != "" && bootID != "" && bootID != r.BootID } @@ -168,7 +168,7 @@ func shortBootID(id string) string { } // bootWatchArgv is the watch's command line: this executable with BootWatchArg (tests replace it). -var bootWatchArgv = func(rootDir string, i int, q win.Process) ([]string, error) { +var bootWatchArgv = func(rootDir string, i int, q host.Process) ([]string, error) { exe, err := os.Executable() if err != nil { return nil, err @@ -180,7 +180,7 @@ var bootWatchArgv = func(rootDir string, i int, q win.Process) ([]string, error) // startBootWatch starts the boot watch of qemu q (RunBootWatch), detached and out of our process tree, unless one runs // for this instance already (its lock is held: the watch of this same qemu, since a watch ends with its qemu). A // failure is a warning. -func (m *Manager) startBootWatch(i int, q win.Process) { +func (m *Manager) startBootWatch(i int, q host.Process) { if m.lockHeld(i, "watch") { return } @@ -188,8 +188,8 @@ func (m *Manager) startBootWatch(i int, q win.Process) { if err == nil { logPath := filepath.Join(m.Root.LogsDir(), bootWatchLogName) rotateLog(logPath, bootWatchLogMax) - var p win.Process - if p, err = win.StartOrphan(argv[0], argv[1:], win.StartOptions{OutputPath: logPath}); err == nil { + var p host.Process + if p, err = host.StartOrphan(argv[0], argv[1:], host.StartOptions{OutputPath: logPath}); err == nil { m.Log.Debugf("实例 %d 开机守护已起(pid %d,日志 %s)", i, p.PID, logPath) return } @@ -229,12 +229,12 @@ func RunBootWatch(args []string, w io.Writer) int { } // the emulator's re-registration and logcat capture start detached processes through this executable too if exe, err := os.Executable(); err == nil { - win.OrphanHelper = exe + host.SetOrphanHelper(exe) } m := New(r, log) - q := win.Process{PID: uint32(pid), Created: created} + q := host.Process{PID: uint32(pid), Created: created} - l, free, err := win.TryLock(m.lockPath(i, "watch")) + l, free, err := host.TryLock(m.lockPath(i, "watch")) if err != nil { log.Errorf("实例 %d 开机守护的锁文件打不开: %v", i, err) return 1 @@ -273,7 +273,7 @@ func RunBootWatch(args []string, w io.Writer) int { type bootWatch struct { m *Manager i int - q win.Process + q host.Process // missingSince: since when the private server has not known the instance (zero: it did at the last look), or // since the watch last registered it again (bootWatchReregisterAfter). missingSince time.Time @@ -324,9 +324,9 @@ func (m *Manager) removeBootRecord(i int) { // watchStartLock takes the start lock for the watch's start without waiting, and checks that no stop runs: errs.Busy // otherwise (the watch looks again later). -func (m *Manager) watchStartLock(i int) (*win.FileLock, error) { +func (m *Manager) watchStartLock(i int) (*host.FileLock, error) { path := m.lockPath(i, "start") - l, free, err := win.TryLock(path) + l, free, err := host.TryLock(path) if err != nil { return nil, errs.Wrap(errs.Internal, err, "实例锁文件打不开: "+path) } diff --git a/internal/emu/bridge.go b/internal/emu/bridge.go index 83781d8..67d98eb 100644 --- a/internal/emu/bridge.go +++ b/internal/emu/bridge.go @@ -34,10 +34,10 @@ import ( "modavd/internal/avd" "modavd/internal/errs" + "modavd/internal/host" "modavd/internal/jsonord" "modavd/internal/logx" "modavd/internal/root" - "modavd/internal/win" ) const ( @@ -296,7 +296,7 @@ func (m *Manager) runBridgeScript(ctx context.Context, i int, action string, reb cmd := bridgeCommandContext(cctx, ps, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command) // the script's adb calls: same environment as ours (an adb that starts a server must not scan local ports either) cmd.Env = m.ADB.Env() - win.HideConsoleCmd(cmd) + host.HideConsoleCmd(cmd) // a killed powershell may leave an adb child holding the output pipe: stop waiting for it (as adbcmd.go) cmd.WaitDelay = 2 * time.Second out, err := cmd.CombinedOutput() @@ -338,7 +338,7 @@ func (m *Manager) finishBridge(ctx context.Context, i int, opt StartOptions, res return res, err } // Two starts of one instance (CLI, MCP, the manager) must not install at the same time: the second one leaves it. - lock, ok, err := win.TryLock(m.lockPath(i, "bridge")) + lock, ok, err := host.TryLock(m.lockPath(i, "bridge")) if err != nil || !ok { res.Warnings = append(res.Warnings, fmt.Sprintf("实例 %d 的执行桥正由另一次 modavd 在装,这次不装", i)) return res, nil @@ -418,7 +418,7 @@ func (m *Manager) installBridge(ctx context.Context, i int, opt StartOptions, re // or an app is in the foreground once the setup is done (someone began to use the instance meanwhile; the update // restarts adbd and boots cold), and a script that fails is only a warning, as in finishBridge. func (m *Manager) updateBridge(ctx context.Context, i int, opt StartOptions, res *StartResult) (*StartResult, error) { - lock, ok, err := win.TryLock(m.lockPath(i, "bridge")) + lock, ok, err := host.TryLock(m.lockPath(i, "bridge")) if err != nil { res.Warnings = append(res.Warnings, fmt.Sprintf("实例 %d 的执行桥锁文件打不开,这次不看执行桥要不要更新: %v", i, err)) return res, nil @@ -505,7 +505,7 @@ func bridgeUpdateRecovery(i int, marker, logFile string) string { // bridgeUpdateBlocked: an install holds the guest lock or an app is in the foreground (the rules of owedMountGate, // checked once; nothing stays held), so the update, which restarts adbd and boots cold, waits. func (m *Manager) bridgeUpdateBlocked(ctx context.Context, i int) (mountBlocker, bool) { - l, free, err := win.TryLock(m.guestLockPath(i)) + l, free, err := host.TryLock(m.guestLockPath(i)) if err != nil || !free { return installBlocker(err), true } @@ -596,7 +596,7 @@ func (m *Manager) Bridge(ctx context.Context, i int, action string) (*BridgeResu } return &BridgeResult{Index: i, Action: action, ExitCode: 0, Message: "arm64 客体不需要执行桥", Warnings: []string{}}, nil } - bridgeLock, ok, err := win.TryLock(m.lockPath(i, "bridge")) + bridgeLock, ok, err := host.TryLock(m.lockPath(i, "bridge")) if err != nil { return nil, errs.Wrap(errs.Internal, err, "实例执行桥锁文件打不开") } diff --git a/internal/emu/check.go b/internal/emu/check.go index ea421e2..ce2361f 100644 --- a/internal/emu/check.go +++ b/internal/emu/check.go @@ -29,8 +29,8 @@ import ( "modavd/internal/adb" "modavd/internal/avd" "modavd/internal/errs" + "modavd/internal/host" "modavd/internal/root" - "modavd/internal/win" ) const ( @@ -352,7 +352,7 @@ func memoryItem(memoryMB int, availableMB uint64, err error) CheckItem { } func memoryItemNow(memoryMB int) CheckItem { - avail, err := win.AvailableMemoryMB() + avail, err := host.AvailableMemoryMB() return memoryItem(memoryMB, avail, err) } @@ -375,7 +375,7 @@ func diskItem(drive string, free uint64, err error) CheckItem { func (m *Manager) diskItemNow() CheckItem { target := diskExistingAncestor(m.Root.AvdHome()) drive := diskDrive(target) - free, err := win.DiskFree(target) + free, err := host.DiskFree(target) return diskItem(drive, free, err) } @@ -461,7 +461,7 @@ func (m *Manager) checkAcceleration(ctx context.Context, useCache bool) accelRes cmd.Env = m.ADB.Env() // Like the boot itself (launch.go): our working directory may be anywhere, the emulator's own is always there. cmd.Dir = filepath.Dir(exe) - win.HideConsoleCmd(cmd) + host.HideConsoleCmd(cmd) cmd.WaitDelay = 2 * time.Second var out bytes.Buffer cmd.Stdout, cmd.Stderr = &out, &out diff --git a/internal/emu/createinit.go b/internal/emu/createinit.go index 8ba784a..d2ffa88 100644 --- a/internal/emu/createinit.go +++ b/internal/emu/createinit.go @@ -32,9 +32,9 @@ import ( "modavd/internal/avd" "modavd/internal/errs" + "modavd/internal/host" "modavd/internal/jsonord" "modavd/internal/root" - "modavd/internal/win" ) const ( @@ -319,7 +319,7 @@ func (p *createProgress) send(step, msg string) { // setupNew is the setup of a new instance (or a retry of one that failed); the create lock is held by the caller and // released here. cfg is the instance's config.ini as the caller read it. -func (m *Manager) setupNew(ctx context.Context, i int, cfg *avd.Ini, req CreateRequest, lock *win.FileLock) (*InstanceInfo, error) { +func (m *Manager) setupNew(ctx context.Context, i int, cfg *avd.Ini, req CreateRequest, lock *host.FileLock) (*InstanceInfo, error) { defer lock.Unlock() if err := m.setCreation(i, creationCreating, "", ""); err != nil { return nil, err diff --git a/internal/emu/crud.go b/internal/emu/crud.go index 4eb7616..1413dc2 100644 --- a/internal/emu/crud.go +++ b/internal/emu/crud.go @@ -13,8 +13,8 @@ import ( "modavd/internal/avd" "modavd/internal/errs" + "modavd/internal/host" "modavd/internal/root" - "modavd/internal/win" ) // Create makes a new instance (validated options, free index and ports), sets it up with one headless boot @@ -139,7 +139,7 @@ func (m *Manager) freeIndex(existing map[int]bool) (int, error) { } var busyErr error i, err := avd.FreeIndex(m.Root.Ports(), existing, func(ports []int) []int { - busy, err := win.BusyPorts(ports) + busy, err := host.BusyPorts(ports) if err != nil { busyErr = err return ports @@ -226,7 +226,7 @@ func (m *Manager) Update(ctx context.Context, i int, req UpdateRequest) (*Update if err != nil { return nil, err } - var p *win.Process + var p *host.Process q, ok, err := m.qemuOf(i) if err != nil { return nil, err @@ -325,9 +325,9 @@ func (m *Manager) Delete(ctx context.Context, i int) (*DeleteResult, error) { } // tryInstanceLock takes lock kind of instance i now or fails with errs.Busy ("魔改 AVD 实例 0 正在启动,无法删除"). -func (m *Manager) tryInstanceLock(i int, kind, what string) (*win.FileLock, error) { +func (m *Manager) tryInstanceLock(i int, kind, what string) (*host.FileLock, error) { path := m.lockPath(i, kind) - l, ok, err := win.TryLock(path) + l, ok, err := host.TryLock(path) if err != nil { return nil, errs.Wrap(errs.Internal, err, "实例锁文件打不开: "+path) } diff --git a/internal/emu/disk.go b/internal/emu/disk.go index 4c101cd..3cfabc4 100644 --- a/internal/emu/disk.go +++ b/internal/emu/disk.go @@ -24,7 +24,7 @@ import ( "modavd/internal/adb" "modavd/internal/errs" - "modavd/internal/win" + "modavd/internal/host" ) // userdataOverlay / userdataBacking name the data-disk files inside an instance @@ -229,7 +229,7 @@ func (m *Manager) runQemuImgCheck(ctx context.Context, image string) (out []byte cmd := exec.CommandContext(cctx, exe, "check", "--output=json", image) var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr - win.HideConsoleCmd(cmd) + host.HideConsoleCmd(cmd) cmd.WaitDelay = 2 * time.Second err = cmd.Run() if ctx.Err() != nil { @@ -299,7 +299,7 @@ func (m *Manager) DiskInfo(ctx context.Context, i int) (*DiskInfoResult, error) return nil, err } - onDisk, err := win.GetCompressedFileSize(overlay) + onDisk, err := host.GetCompressedFileSize(overlay) if err != nil { return nil, err } @@ -324,7 +324,7 @@ func (m *Manager) DiskInfo(ctx context.Context, i int) (*DiskInfoResult, error) // Not seen running, but a start may be launching qemu right now (it holds the start lock for the whole start, // before qemuOf can see the process). Take that lock so qemu-img does not read the overlay while a launch opens // it; if a start holds it, report the header-only numbers rather than fail or stall a boot (ModAVD/ModAVD#2 P3-8). - startLock, ok, err := win.TryLock(m.lockPath(i, "start")) + startLock, ok, err := host.TryLock(m.lockPath(i, "start")) if err != nil { return nil, errs.Wrap(errs.Internal, err, "实例锁文件打不开: "+m.lockPath(i, "start")) } @@ -397,7 +397,7 @@ var compactFileSystems = map[string]bool{"NTFS": true, "REFS": true} // mid-rename could leave only the temp file, and the emulator would then build an empty overlay over the backing, // so a later run would delete the one good copy (ModAVD/ModAVD#2 P2-1). func requireCompactFS(overlay string) error { - fs, err := win.VolumeFileSystem(overlay) + fs, err := host.VolumeFileSystem(overlay) if err != nil { return err } @@ -434,7 +434,7 @@ func (m *Manager) lockInstanceForCompact(i int) (func(), error) { {"bridge", "正在装卸执行桥"}, {"create", "正在新建"}, } - var locks []*win.FileLock + var locks []*host.FileLock unlock := func() { for k := len(locks) - 1; k >= 0; k-- { locks[k].Unlock() @@ -442,7 +442,7 @@ func (m *Manager) lockInstanceForCompact(i int) (func(), error) { } for _, kw := range kinds { path := m.lockPath(i, kw.kind) - l, ok, err := win.TryLock(path) + l, ok, err := host.TryLock(path) if err != nil { unlock() return nil, errs.Wrap(errs.Internal, err, "实例锁文件打不开: "+path) @@ -470,7 +470,7 @@ func (m *Manager) runQemuImg(ctx context.Context, timeout time.Duration, args .. cmd := exec.CommandContext(cctx, exe, args...) var so, se bytes.Buffer cmd.Stdout, cmd.Stderr = &so, &se - win.HideConsoleCmd(cmd) + host.HideConsoleCmd(cmd) cmd.WaitDelay = 2 * time.Second runErr := cmd.Run() if ctx.Err() != nil { @@ -702,7 +702,7 @@ func (m *Manager) DiskCompact(ctx context.Context, i int) (*DiskCompactResult, e } backingName := filepath.Base(info.BackingFilename) - before, err := win.GetCompressedFileSize(overlay) + before, err := host.GetCompressedFileSize(overlay) if err != nil { return nil, err } @@ -726,7 +726,7 @@ func (m *Manager) DiskCompact(ctx context.Context, i int) (*DiskCompactResult, e if err != nil { return nil, err } - free, err := win.DiskFree(in.Dir) + free, err := host.DiskFree(in.Dir) if err != nil { return nil, err } @@ -762,12 +762,12 @@ func (m *Manager) DiskCompact(ctx context.Context, i int) (*DiskCompactResult, e _ = os.Remove(tmp) return nil, errs.Wrap(errs.Internal, err, "刷新整理后的数据盘失败") } - if err := win.MoveFileReplaceThrough(tmp, overlay); err != nil { + if err := host.MoveFileReplaceThrough(tmp, overlay); err != nil { _ = os.Remove(tmp) return nil, err } - after, err := win.GetCompressedFileSize(overlay) + after, err := host.GetCompressedFileSize(overlay) if err != nil { return nil, err } diff --git a/internal/emu/gates_other_test.go b/internal/emu/gates_other_test.go index 94a2a1a..1900ce3 100644 --- a/internal/emu/gates_other_test.go +++ b/internal/emu/gates_other_test.go @@ -12,8 +12,8 @@ import ( "modavd/internal/avd" "modavd/internal/errs" + "modavd/internal/host" "modavd/internal/root" - "modavd/internal/win" ) func gateTree(t *testing.T, dir string) []string { @@ -81,8 +81,8 @@ func TestQemuScanRejectsUnsupportedCommandLine(t *testing.T) { } saved := listQemuProcesses t.Cleanup(func() { listQemuProcesses = saved }) - listQemuProcesses = func() ([]win.Process, error) { - return []win.Process{{PID: 4242, Name: "qemu-system-x86_64"}}, nil + listQemuProcesses = func() ([]host.Process, error) { + return []host.Process{{PID: 4242, Name: "qemu-system-x86_64"}}, nil } if _, err := scanQemuOf([]*root.Root{r}, []int{1}); errs.CodeOf(err) != errs.Unsupported { t.Fatalf("lost command-line unsupported: %v", err) diff --git a/internal/emu/install.go b/internal/emu/install.go index e7ae255..1bd69af 100644 --- a/internal/emu/install.go +++ b/internal/emu/install.go @@ -11,7 +11,7 @@ import ( "modavd/internal/adb" "modavd/internal/apk" "modavd/internal/errs" - "modavd/internal/win" + "modavd/internal/host" ) // apkInstallTimeout: game packages are 1-3 GB; push + install on a slow disk takes minutes (AUTO-MAS @@ -91,12 +91,12 @@ func (m *Manager) InstallAPK(ctx context.Context, i int, apkPath string) (*Insta // lockGuestForInstall takes the guest lock of instance i (TryLock on guestLockPath), waiting up to // installGuestLockWait while a start holds it to mount the guest Vulkan fix. errs.Busy after that, errs.Canceled on a // cancel, errs.Internal when the lock file cannot be opened. -func (m *Manager) lockGuestForInstall(ctx context.Context, i int) (*win.FileLock, error) { +func (m *Manager) lockGuestForInstall(ctx context.Context, i int) (*host.FileLock, error) { path := m.guestLockPath(i) deadline := time.Now().Add(installGuestLockWait) logged := false for { - l, ok, err := win.TryLock(path) + l, ok, err := host.TryLock(path) if err != nil { return nil, errs.Wrap(errs.Internal, err, "实例锁文件打不开: "+path) } diff --git a/internal/emu/launch.go b/internal/emu/launch.go index fbdc7af..113ed19 100644 --- a/internal/emu/launch.go +++ b/internal/emu/launch.go @@ -14,8 +14,8 @@ import ( "modavd/internal/adb" "modavd/internal/avd" "modavd/internal/errs" + "modavd/internal/host" "modavd/internal/root" - "modavd/internal/win" ) // adbServerStartTimeout is how long a launch waits for the private server (AUTO-MAS ensure_adb_server default). @@ -110,7 +110,7 @@ func (m *Manager) prepareLaunch(ctx context.Context, inst *avd.Instance, window func (m *Manager) launchPlanFor(ctx context.Context, inst *avd.Instance, memoryMB int, window bool) (*launchPlan, error) { i := inst.Index port := m.Root.Ports().Console(i) - busy, err := win.BusyPorts([]int{port, port + 1, port + 2}) + busy, err := host.BusyPorts([]int{port, port + 1, port + 2}) if err != nil { return nil, errs.Wrap(errs.Internal, err, fmt.Sprintf("魔改 AVD 实例 %d 读不出系统的 TCP 监听表: %v", i, err)) } @@ -167,11 +167,11 @@ func (m *Manager) launchPlanFor(ctx context.Context, inst *avd.Instance, memoryM // the avd.dir of its emulator runtime file (in adb.EmulatorDiscoveryDir, where qemu writes it and adb.QemuBelongsTo // reads it), else its executable. found=false also when the listener table or a process cannot be read: the port // check after the host checks still reports busy ports. -func (m *Manager) otherRootInstance(plan root.PortPlan, i int) (p win.Process, where string, found bool) { +func (m *Manager) otherRootInstance(plan root.PortPlan, i int) (p host.Process, where string, found bool) { console := plan.Console(i) - ls, err := win.Listeners() + ls, err := host.Listeners() if err != nil { - return win.Process{}, "", false + return host.Process{}, "", false } seen := map[uint32]bool{} for _, l := range ls { @@ -179,7 +179,7 @@ func (m *Manager) otherRootInstance(plan root.PortPlan, i int) (p win.Process, w continue } seen[l.PID] = true - q, ok := win.FindProcess(l.PID) + q, ok := host.FindProcess(l.PID) if !ok { continue } @@ -187,21 +187,21 @@ func (m *Manager) otherRootInstance(plan root.PortPlan, i int) (p win.Process, w if err != nil { continue } - if otherRootQemu(q, cmdline, i, console, func(q win.Process) bool { return adb.QemuBelongsTo(m.Root, q, i) }) { + if otherRootQemu(q, cmdline, i, console, func(q host.Process) bool { return adb.QemuBelongsTo(m.Root, q, i) }) { return q, qemuWhere(runtimeAvdDir(adb.EmulatorDiscoveryDir(), q), cmdline), true } } - return win.Process{}, "", false + return host.Process{}, "", false } // otherRootQemu: q (with its command line) is instance i of some root by name and console port, and ours says it is // not m.Root's. -func otherRootQemu(q win.Process, cmdline []string, i, console int, ours func(win.Process) bool) bool { +func otherRootQemu(q host.Process, cmdline []string, i, console int, ours func(host.Process) bool) bool { return isQemuName(q.Name) && cmdlineMatches(cmdline, root.AvdName(i), console) && !ours(q) } // otherRootBusy is the error of a start whose ports the same-numbered instance of another root holds. -func otherRootBusy(i int, p win.Process, where string) *errs.Error { +func otherRootBusy(i int, p host.Process, where string) *errs.Error { return errs.Newf(errs.Busy, "魔改 AVD 实例 %d 的端口被另一个根目录的同编号实例占用(pid %d,%s)", i, p.PID, where). WithAdvice("先在那个根目录把它关掉再启动") } @@ -219,7 +219,7 @@ func qemuWhere(avdDir string, cmdline []string) string { // runtimeAvdDir is the avd.dir= of q's runtime file pid_.ini in dir; "" when there is no current one (missing, // unreadable, without that line, or older than q: a leftover of an earlier process with that PID). -func runtimeAvdDir(dir string, q win.Process) string { +func runtimeAvdDir(dir string, q host.Process) string { path := filepath.Join(dir, "pid_"+strconv.FormatUint(uint64(q.PID), 10)+".ini") st, err := os.Stat(path) if err != nil || !st.Mode().IsRegular() { @@ -245,23 +245,23 @@ func runtimeAvdDir(dir string, q win.Process) string { // startEmulator starts emulator.exe with StartOrphan (AUTO-MAS launch_emulator): never as our child, so whoever ends // modavd's process tree (an MCP client disconnecting, a job object) cannot cut the guest's power. Output goes to // plan.logPath, the working directory is the emulator's own. -func (m *Manager) startEmulator(i int, plan *launchPlan) (win.Process, error) { +func (m *Manager) startEmulator(i int, plan *launchPlan) (host.Process, error) { exe := m.Root.EmulatorExe() cache := m.Root.ShaderCacheDir() if err := os.MkdirAll(cache, 0o755); err != nil { - return win.Process{}, errs.Wrap(errs.Internal, err, fmt.Sprintf("建着色器缓存目录 %s 失败: %v", cache, err)) + return host.Process{}, errs.Wrap(errs.Internal, err, fmt.Sprintf("建着色器缓存目录 %s 失败: %v", cache, err)) } if err := os.MkdirAll(filepath.Dir(plan.logPath), 0o755); err != nil { - return win.Process{}, errs.Wrap(errs.Internal, err, fmt.Sprintf("建日志目录 %s 失败: %v", + return host.Process{}, errs.Wrap(errs.Internal, err, fmt.Sprintf("建日志目录 %s 失败: %v", filepath.Dir(plan.logPath), err)) } - p, err := win.StartOrphan(exe, plan.args, win.StartOptions{ + p, err := host.StartOrphan(exe, plan.args, host.StartOptions{ Dir: filepath.Dir(exe), Env: m.emulatorEnv(cache), OutputPath: plan.logPath, }) if err != nil { - return win.Process{}, errs.Wrap(errs.LaunchFailed, err, + return host.Process{}, errs.Wrap(errs.LaunchFailed, err, fmt.Sprintf("魔改 AVD 实例 %d 启动失败(emulator.exe 没能运行): %v", i, err)) } return p, nil @@ -274,7 +274,7 @@ func (m *Manager) startEmulator(i int, plan *launchPlan) (win.Process, error) { // the guest as device local only. Endfield waited for write-combine drains on every vertex write there (title // screen 14.6 -> 60 fps); games that never touch that memory are unaffected, emulators before mas-27 ignore it. // -// Appended after the private adb environment: win.StartOrphan / StartDetached keep the last of entries with the same +// Appended after the private adb environment: host.StartOrphan / StartDetached keep the last of entries with the same // name (compared case-insensitively), so these override a user's own values, as AUTO-MAS env.update does. func (m *Manager) emulatorEnv(shaderCache string) []string { return append(m.ADB.Env(), diff --git a/internal/emu/logs.go b/internal/emu/logs.go index 60f89b0..b097ff7 100644 --- a/internal/emu/logs.go +++ b/internal/emu/logs.go @@ -14,9 +14,9 @@ import ( "time" "modavd/internal/avd" + "modavd/internal/host" "modavd/internal/logx" "modavd/internal/root" - "modavd/internal/win" ) // What in the logs dir is ours to prune: the files emulator output mas_-