From d69e2baa1ed4b1beac47a40ec4c97dfa67e7bc64 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Mon, 9 Nov 2026 17:50:43 +0100 Subject: [PATCH 1/3] =?UTF-8?q?adb=20server=20=E8=B5=B7=E4=B8=8D=E6=9D=A5?= =?UTF-8?q?=E6=97=B6=E7=95=99=E4=B8=8B=E5=8E=9F=E5=9B=A0=EF=BC=9B=E5=AE=89?= =?UTF-8?q?=E5=8D=93=E5=BC=80=E5=A5=BD=E8=80=8C=20adb=20=E6=B2=A1=E7=99=BB?= =?UTF-8?q?=E8=AE=B0=E4=B8=8A=E6=97=B6=E8=A1=A5=E7=99=BB=E8=AE=B0=EF=BC=9B?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=E7=BB=84=E4=BB=B6=E6=8C=AA=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E8=A2=AB=E5=8D=A0=E6=97=B6=E9=87=8D=E8=AF=95=E5=86=8D=E5=A4=8D?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 有用户新建实例时,私有 adb server 开机前后一直监听不上,安卓 72 秒就开好了,管理器却等满 300 秒才报「安卓没有启动完成」,原因无从查起;补全组件那边,Google Play 商店的目录刚写完 就挪不动(拒绝访问),连着三次都一样。 - 私有 adb server 的日志写到 logs\adb-server.log(Env 里设 ANDROID_ADB_LOG_PATH,模拟器 自己调 adb 时起的 server 也写这里),超过 2 MB 起新 server 前换成 .1。 - server 起了却没监听上:报出这份日志的最后几行;同样的内容只报一次。 - 本进程起来过的 server 不见了:报它是已经退出,还是还在但不监听了,附上日志最后几行。 - 等开机时,模拟器报告安卓开机完成(avd\mas_.avd\bootcompleted.ini 不比 qemu 旧), adb 却还连不上: - 每 10 秒替它向私有 adb server 登记一次(RegisterBooted)。模拟器只在开机结束时登记那 一次,server 是那以后才起来或重启过的,就再也看不到这台实例。 - 60 秒后仍连不上就不再等,报「安卓已经开好了,但私有 adb 一直用不了」,带上 adb 日志 的最后一行;等满总时限时也这样报。 - 补全组件把解好的目录挪进 sdk 时,在 Windows 上遇到拒绝访问、共享冲突,重试 30 秒;还 不行就复制过去再删 staging。只有挪进 sdk 这一步会复制,挪开已有目录、放回原处都只重试。 --- internal/adb/adb.go | 41 ++++- internal/adb/serverlog.go | 173 ++++++++++++++++++++++ internal/adb/serverlog_windows_test.go | 135 +++++++++++++++++ internal/components/fetch_windows_test.go | 42 +++++- internal/components/install.go | 2 +- internal/components/move_unix.go | 8 + internal/components/move_windows.go | 107 +++++++++++++ internal/components/move_windows_test.go | 133 +++++++++++++++++ internal/components/platform_darwin.go | 3 + internal/components/platform_other.go | 10 +- internal/emu/bootadb_windows_test.go | 112 ++++++++++++++ internal/emu/start.go | 65 +++++++- 12 files changed, 820 insertions(+), 11 deletions(-) create mode 100644 internal/adb/serverlog.go create mode 100644 internal/adb/serverlog_windows_test.go create mode 100644 internal/components/move_unix.go create mode 100644 internal/components/move_windows.go create mode 100644 internal/components/move_windows_test.go create mode 100644 internal/emu/bootadb_windows_test.go diff --git a/internal/adb/adb.go b/internal/adb/adb.go index c08c31c..26bc645 100644 --- a/internal/adb/adb.go +++ b/internal/adb/adb.go @@ -147,8 +147,9 @@ func (g *hookGroup) wait(timeout time.Duration) bool { } // Env is the environment for emulator.exe, adb servers and adb clients: ours plus ANDROID_SDK_ROOT and ANDROID_HOME = -// RuntimeSDK, ANDROID_AVD_HOME = AvdHome, ANDROID_ADB_SERVER_PORT = AdbServerPort and root.NoLocalScanEnv. Keys are -// replaced using the host's environment-key rules. +// RuntimeSDK, ANDROID_AVD_HOME = AvdHome, ANDROID_ADB_SERVER_PORT = AdbServerPort, ANDROID_ADB_LOG_PATH = +// ServerLogPath (also for a server an adb client starts by itself, such as the emulator's own adb commands at the end +// of boot) and root.NoLocalScanEnv. Keys are replaced using the host's environment-key rules. func (c *Client) Env() []string { sdk := c.Root.RuntimeSDK() set := map[string]string{ @@ -156,6 +157,7 @@ func (c *Client) Env() []string { "ANDROID_HOME": sdk, "ANDROID_AVD_HOME": c.Root.AvdHome(), "ANDROID_ADB_SERVER_PORT": strconv.Itoa(c.Root.AdbServerPort()), + "ANDROID_ADB_LOG_PATH": ServerLogPath(c.Root), } for k, v := range root.NoLocalScanEnv { set[k] = v @@ -315,12 +317,14 @@ func (c *Client) ensure(ctx context.Context, k serverKind, port int, timeout tim if up { return true, nil } + c.reportGoneServer(k, port) if err := ctx.Err(); err != nil { return false, canceled(ctx) } if err := prepareServerRoot(c.Root); err != nil { return false, err } + prepareServerLog(c.Root) 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 @@ -332,7 +336,15 @@ func (c *Client) ensure(ctx context.Context, k serverKind, port int, timeout tim return false, errs.Wrap(errs.Internal, err, fmt.Sprintf("启动魔改 AVD %s adb server(端口 %d)失败: %s", k.label, port, errs.As(err).Message)) } - return c.waitListening(ctx, k, port, timeout, client) + up, err = c.waitListening(ctx, k, port, timeout, client) + switch { + case err != nil: + case up: + c.rememberServer(port) + case ctx.Err() == nil: + c.reportServerLog(k, port, timeout) + } + return up, err } // waitListening polls until port listens, at most timeout (AUTO-MAS _wait_listening). It does not wait for the @@ -591,11 +603,30 @@ func consolePortOf(plan root.PortPlan, serial string) (int, bool) { // this root) and it is at least 60 s old; then get-state, Register, and up to 5 s of get-state == "device". // Serialized per serial within this process. Starts OnReregistered on success. func (c *Client) Reregister(ctx context.Context, serial string) bool { + return c.reregister(ctx, serial, false) +} + +// RegisterBooted is Reregister for the boot wait once the emulator has reported Android booted (its +// bootcompleted.ini is newer than its qemu): its one registration, at the end of boot, has happened, so a private +// server that does not know serial now came up only after it or was restarted since, and nobody else will register +// it. The caller holds the boot lock and knows the boot is over, so neither Booting(serial) nor the qemu age apply; +// the private server must be listening already (nothing is started here). +func (c *Client) RegisterBooted(ctx context.Context, serial string) bool { + if c.Root == nil { + return false + } + if up, err := c.serverListening(privateServer, c.Root.AdbServerPort()); err != nil || !up { + return false + } + return c.reregister(ctx, serial, true) +} + +func (c *Client) reregister(ctx context.Context, serial string, booted bool) bool { if c.Root == nil { return false // no root, no instance of its own (QemuOnAdbPort says so too) } console, ok := consolePortOf(c.Root.Ports(), serial) - if !ok || (c.Booting != nil && c.Booting(serial)) { + if !ok || (!booted && c.Booting != nil && c.Booting(serial)) { return false } qemu, ok := qemuOnAdbPort(c.Root, console) @@ -603,7 +634,7 @@ func (c *Client) Reregister(ctx context.Context, serial string) bool { return false } startedAt := qemu.StartedAt() - if startedAt.IsZero() || time.Since(startedAt) < reregisterMinQemuAge { + if !booted && (startedAt.IsZero() || time.Since(startedAt) < reregisterMinQemuAge) { return false } lock := serialLock(serial) diff --git a/internal/adb/serverlog.go b/internal/adb/serverlog.go new file mode 100644 index 0000000..7f4da0f --- /dev/null +++ b/internal/adb/serverlog.go @@ -0,0 +1,173 @@ +package adb + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "modavd/internal/host" + "modavd/internal/root" +) + +// The adb servers a root starts write their own log to \logs\adb-server.log (ANDROID_ADB_LOG_PATH in Env), not +// to %TEMP%\adb.log, which every adb server of the Windows user shares. When a server does not come up, or one that +// came up is gone again, its last lines say why: cannot bind, killed by remote request, a fatal check. A log that +// just stops says that something ended the process from outside. Without it, a server that never listened was +// a 20 s wait with no reason, again and again (a user's create waited 300 s for an Android that had booted after 72 s). + +// ServerLogName is the adb server log in the root's logs\. +const ServerLogName = "adb-server.log" + +const ( + // serverLogMax: past this the log moves to .1 before the next server start (a running server keeps it open, + // and then it stays). Every start writes a few lines; this is thousands of starts. + serverLogMax = 2 << 20 + // serverLogTailRead and serverLogTailLines bound what ServerLogTail reads and returns. + serverLogTailRead = 64 << 10 + serverLogTailLines = 8 + serverStartMarker = "--- adb starting" +) + +// ServerLogPath is \logs\adb-server.log. +func ServerLogPath(r *root.Root) string { return filepath.Join(r.LogsDir(), ServerLogName) } + +// prepareServerLog makes the logs directory (adb cannot open its log without it, and a server that cannot open its +// log exits) and moves a log past serverLogMax to .1. +func prepareServerLog(r *root.Root) { + path := ServerLogPath(r) + _ = os.MkdirAll(filepath.Dir(path), 0o755) + if fi, err := os.Stat(path); err == nil && fi.Size() > serverLogMax { + _ = os.Remove(path + ".1") + _ = os.Rename(path, path+".1") + } +} + +// ServerLogTail is the newest server start in the log: its "--- adb starting (pid N) ---" line and the last lines +// after it, at most serverLogTailLines; nil when the log is missing or empty. +func ServerLogTail(r *root.Root) []string { + f, err := root.OpenShared(ServerLogPath(r)) + if err != nil { + return nil + } + defer f.Close() + if fi, err := f.Stat(); err == nil && fi.Size() > serverLogTailRead { + if _, err := f.Seek(-serverLogTailRead, io.SeekEnd); err != nil { + return nil + } + } + b, err := io.ReadAll(f) + if err != nil { + return nil + } + text := DecodeOutput(b) + if i := strings.LastIndex(text, serverStartMarker); i >= 0 { + text = text[i:] + } + var lines []string + for _, l := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") { + if l = strings.TrimRight(l, " \t\r"); strings.TrimSpace(l) != "" { + lines = append(lines, l) + } + } + if len(lines) > serverLogTailLines && strings.HasPrefix(lines[0], serverStartMarker) { + lines = append(lines[:1:1], lines[len(lines)-(serverLogTailLines-1):]...) + } else if len(lines) > serverLogTailLines { + lines = lines[len(lines)-serverLogTailLines:] + } + return lines +} + +// ServerLogLast is the last line of ServerLogTail, "" when there is none: the one-line reason for a message. +func ServerLogLast(r *root.Root) string { + lines := ServerLogTail(r) + if len(lines) == 0 { + return "" + } + return strings.TrimSpace(lines[len(lines)-1]) +} + +// startedServer is a server this process started and saw listening. +type startedServer struct { + pid uint32 + created uint64 + at time.Time +} + +var serverState = struct { + sync.Mutex + started map[string]startedServer // by serverKey + reported map[string]string // the log lines last reported, by serverKey +}{started: map[string]startedServer{}, reported: map[string]string{}} + +func serverKey(r *root.Root, port int) string { + return strings.ToLower(filepath.Clean(r.Dir)) + "|" + strconv.Itoa(port) +} + +// rememberServer records who listens on port now, after this process started a server there. +func (c *Client) rememberServer(port int) { + pid, ok, err := host.ListenerPID(port) + if err != nil || !ok || pid == 0 { + return + } + s := startedServer{pid: pid, at: time.Now()} + if p, found := host.FindProcess(pid); found { + s.created = p.Created + } + serverState.Lock() + serverState.started[serverKey(c.Root, port)] = s + serverState.Unlock() +} + +// reportGoneServer: nothing listens on port, but this process saw a server it started listen there. Says whether +// that server has exited or is still there without listening, with the end of the log; forgets it. +func (c *Client) reportGoneServer(k serverKind, port int) { + key := serverKey(c.Root, port) + serverState.Lock() + s, ok := serverState.started[key] + delete(serverState.started, key) + serverState.Unlock() + if !ok { + return + } + p, found := host.FindProcess(s.pid) + what := "已经退出了" + if found && (s.created == 0 || p.Created == s.created) { + what = "还在,但不再监听" + } + c.Log.Warnf("魔改 AVD %s adb server(端口 %d,pid %d,%s 起来的)%s%s", k.label, port, s.pid, + s.at.Format("15:04:05"), what, logTailText(ServerLogTail(c.Root))) +} + +// reportServerLog says why a server started for port did not listen within timeout: the end of its log, once per +// distinct content (a server that fails the same way every 20 s is reported once). +func (c *Client) reportServerLog(k serverKind, port int, timeout time.Duration) { + lines := ServerLogTail(c.Root) + sig := "" + if len(lines) > 1 { + sig = strings.Join(lines[1:], "\n") // without the line that names the pid + } + key := serverKey(c.Root, port) + serverState.Lock() + same := len(lines) > 0 && serverState.reported[key] == sig + serverState.reported[key] = sig + serverState.Unlock() + if same { + return + } + c.Log.Warnf("魔改 AVD %s adb server(端口 %d)%.0f 秒内没监听上%s", k.label, port, timeout.Seconds(), + logTailText(lines)) +} + +// logTailText is "; adb 自己的日志(logs\adb-server.log)最后几行:" and the lines, indented, or a note that the log +// has nothing. +func logTailText(lines []string) string { + if len(lines) == 0 { + return `;adb 自己的日志(logs\` + ServerLogName + `)里没有记录` + } + return fmt.Sprintf(";adb 自己的日志(logs\\%s)最后几行:\n %s", ServerLogName, strings.Join(lines, "\n ")) +} diff --git a/internal/adb/serverlog_windows_test.go b/internal/adb/serverlog_windows_test.go new file mode 100644 index 0000000..9104cea --- /dev/null +++ b/internal/adb/serverlog_windows_test.go @@ -0,0 +1,135 @@ +package adb + +import ( + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "modavd/internal/root" + "modavd/internal/win" +) + +func writeServerLog(t *testing.T, r *root.Root, text string) { + t.Helper() + if err := os.MkdirAll(r.LogsDir(), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(ServerLogPath(r), []byte(text), 0o644); err != nil { + t.Fatal(err) + } +} + +// Env points every adb server of the root (and one an adb client starts by itself) at logs\adb-server.log. +func TestEnvServerLogPath(t *testing.T) { + r := &root.Root{Dir: tempDir(t)} + want := "ANDROID_ADB_LOG_PATH=" + filepath.Join(r.Dir, "logs", "adb-server.log") + if env := New(r, nil).Env(); !slices.Contains(env, want) { + t.Errorf("no %s in the environment", want) + } +} + +// The tail is the newest start (its first line kept when there are more lines than fit), nothing for no log. +func TestServerLogTail(t *testing.T) { + r := &root.Root{Dir: tempDir(t)} + if got := ServerLogTail(r); got != nil || ServerLogLast(r) != "" { + t.Fatalf("no log: %q", got) + } + var b strings.Builder + b.WriteString("--- adb starting (pid 1) ---\r\nold line\r\n") + b.WriteString("--- adb starting (pid 2) ---\r\n") + for i := range 10 { + b.WriteString("line " + string(rune('a'+i)) + "\r\n\r\n") + } + b.WriteString("could not install *smartsocket* listener: cannot bind to 127.0.0.1:20050 \r\n") + writeServerLog(t, r, b.String()) + got := ServerLogTail(r) + want := []string{"--- adb starting (pid 2) ---", "line e", "line f", "line g", "line h", "line i", "line j", + "could not install *smartsocket* listener: cannot bind to 127.0.0.1:20050"} + if !slices.Equal(got, want) { + t.Errorf("tail\n got %q\n want %q", got, want) + } + if last := ServerLogLast(r); last != want[len(want)-1] { + t.Errorf("last %q", last) + } + + // no start marker in what is read: the last lines + writeServerLog(t, r, strings.Repeat("x\n", 20)) + if got := ServerLogTail(r); len(got) != serverLogTailLines { + t.Errorf("without a marker: %q", got) + } +} + +// prepareServerLog makes logs\ and moves a log past serverLogMax to .1 (an older .1 goes). +func TestPrepareServerLog(t *testing.T) { + r := &root.Root{Dir: tempDir(t)} + prepareServerLog(r) + if fi, err := os.Stat(r.LogsDir()); err != nil || !fi.IsDir() { + t.Fatalf("logs dir: %v", err) + } + writeServerLog(t, r, "small\n") + prepareServerLog(r) + if _, err := os.Stat(ServerLogPath(r) + ".1"); err == nil { + t.Fatal("a small log was rotated") + } + if err := os.WriteFile(ServerLogPath(r)+".1", []byte("older"), 0o644); err != nil { + t.Fatal(err) + } + writeServerLog(t, r, strings.Repeat("y", serverLogMax+1)) + prepareServerLog(r) + b, err := os.ReadFile(ServerLogPath(r) + ".1") + if _, serr := os.Stat(ServerLogPath(r)); err != nil || len(b) != serverLogMax+1 || serr == nil { + t.Fatalf("rotation: .1 %d bytes (%v), log still there: %v", len(b), err, serr == nil) + } +} + +// A server that did not listen is reported with the end of its log, once while the log says the same. +func TestReportServerLogOncePerContent(t *testing.T) { + c, logs := hookClient(t) + writeServerLog(t, c.Root, "--- adb starting (pid 7) ---\nfailed to bind\n") + c.reportServerLog(privateServer, 20391, 20*time.Second) + writeServerLog(t, c.Root, "--- adb starting (pid 8) ---\nfailed to bind\n") + c.reportServerLog(privateServer, 20391, 20*time.Second) + if n := logs.count("20 秒内没监听上;adb 自己的日志(logs\\adb-server.log)最后几行:\n --- adb starting (pid 7) ---\n failed to bind"); n != 1 { + t.Fatalf("%d reports: %q", n, logs.msgs) + } + writeServerLog(t, c.Root, "--- adb starting (pid 9) ---\nsomething else\n") + c.reportServerLog(privateServer, 20391, 20*time.Second) + if logs.count("something else") != 1 { + t.Fatalf("a new reason not reported: %q", logs.msgs) + } +} + +// A server this process saw listen that is gone: exited, or still there without listening. Reported once. +func TestReportGoneServer(t *testing.T) { + c, logs := hookClient(t) + writeServerLog(t, c.Root, "--- adb starting (pid 5) ---\nadb server killed by remote request\n") + cmd := exec.Command("cmd.exe", "/c", "exit 0") + if err := cmd.Run(); err != nil { + t.Fatal(err) + } + key := serverKey(c.Root, 20391) + serverState.Lock() + serverState.started[key] = startedServer{pid: uint32(cmd.Process.Pid), created: 1, at: time.Now()} + serverState.Unlock() + c.reportGoneServer(privateServer, 20391) + c.reportGoneServer(privateServer, 20391) + if n := logs.count("已经退出了;adb 自己的日志(logs\\adb-server.log)最后几行:\n --- adb starting (pid 5) ---\n adb server killed by remote request"); n != 1 { + t.Fatalf("exited: %d reports: %q", n, logs.msgs) + } + + self, ok := win.FindProcess(uint32(os.Getpid())) + if !ok { + t.Fatal("this process not found") + } + serverState.Lock() + serverState.started[key] = startedServer{pid: self.PID, created: self.Created, at: time.Now()} + serverState.Unlock() + c.reportGoneServer(privateServer, 20391) + if logs.count("还在,但不再监听") != 1 { + t.Fatalf("alive: %q", logs.msgs) + } +} diff --git a/internal/components/fetch_windows_test.go b/internal/components/fetch_windows_test.go index 08058da..464166a 100644 --- a/internal/components/fetch_windows_test.go +++ b/internal/components/fetch_windows_test.go @@ -11,6 +11,7 @@ import ( "crypto/sha1" "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" "log" @@ -1010,8 +1011,13 @@ func TestFetchSetsAsideIncompleteDir(t *testing.T) { } // TestFetchPutsOldDirBack: when the new directory cannot be moved in (a file in it is held open here, without delete -// sharing), the old one that was set aside goes back to its place with everything in it. +// sharing, past the retries, and copying it fails too), the old one that was set aside goes back to its place with +// everything in it. func TestFetchPutsOldDirBack(t *testing.T) { + savedFor, savedCopy := moveRetryFor, copyFileFn + t.Cleanup(func() { moveRetryFor, copyFileFn = savedFor, savedCopy }) + moveRetryFor = 300 * time.Millisecond + copyFileFn = func(string, string) error { return errors.New("no copy here") } pt := ptZip(t, adbBody) setup(t, ptSpec(pt)) m := newMirror(t, map[string][]byte{"pt.zip": pt}) @@ -1049,6 +1055,40 @@ func TestFetchPutsOldDirBack(t *testing.T) { } } +// TestFetchCopiesHeldDirIn: a staged file somebody keeps open (security software scanning what was just written) makes +// the rename fail past its retries; the new directory is then copied into place, and the fetch succeeds. +func TestFetchCopiesHeldDirIn(t *testing.T) { + savedFor := moveRetryFor + t.Cleanup(func() { moveRetryFor = savedFor }) + moveRetryFor = 300 * time.Millisecond + pt := ptZip(t, adbBody) + setup(t, ptSpec(pt)) + m := newMirror(t, map[string][]byte{"pt.zip": pt}) + r := tempRoot(t) + var held *os.File + t.Cleanup(func() { + if held != nil { + held.Close() + } + }) + o := opts(m) + o.Progress = func(p Progress) { + if p.Stage == "extracting" && p.Final && held == nil { + held, _ = os.Open(filepath.Join(r.SDK(), ".staging", "platform-tools", "platform-tools", "adb.exe")) + } + } + out := fetch(t, nil, r, o) + if held == nil { + t.Fatal("the staged adb.exe was not there to hold") + } + if out.err != nil { + t.Fatalf("fetch: %v", out.err) + } + if got := readText(t, filepath.Join(r.SDK(), "platform-tools", "adb.exe")); got != adbBody { + t.Fatalf("adb.exe %q", got) + } +} + // TestFetchChecksStagedFiles: a zip without a key file, or with an entry outside the archive root, is refused while // still in sdk\.staging: the existing directory is left as it was, nothing half-installed, no staging left. func TestFetchChecksStagedFiles(t *testing.T) { diff --git a/internal/components/install.go b/internal/components/install.go index 285c0a3..8d1cc6a 100644 --- a/internal/components/install.go +++ b/internal/components/install.go @@ -250,7 +250,7 @@ func (f *fetcher) place(c root.SDKComponent, extracted string) (setAside string, } moveErr := os.MkdirAll(filepath.Dir(target), 0o755) if moveErr == nil { - moveErr = moveComponent(extracted, target) + moveErr = installComponent(extracted, target) } if moveErr != nil { if setAside != "" && moveComponent(setAside, target) == nil { diff --git a/internal/components/move_unix.go b/internal/components/move_unix.go new file mode 100644 index 0000000..447788a --- /dev/null +++ b/internal/components/move_unix.go @@ -0,0 +1,8 @@ +//go:build !windows + +package components + +import "os" + +// moveDir is os.Rename (see move_windows.go for why Windows needs more). +func moveDir(src, dst string, _ bool) error { return os.Rename(src, dst) } diff --git a/internal/components/move_windows.go b/internal/components/move_windows.go new file mode 100644 index 0000000..53d9daf --- /dev/null +++ b/internal/components/move_windows.go @@ -0,0 +1,107 @@ +//go:build windows + +package components + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "syscall" + "time" + + "modavd/internal/root" +) + +// moveDir renames a component directory, made to survive what a scanner does on Windows. On a user's computer with +// security software, renaming the Play Store directory failed with ERROR_ACCESS_DENIED a second after its five files +// were written, three fetches in a row: something kept them open, and a directory with an open file cannot be renamed. +// So the rename is retried for moveRetryFor while it fails that way. After that, with copyFallback (the source is a +// staging directory that is ours to throw away), the tree is copied into dst instead (new files, nothing to rename) +// and src removed as far as possible (what is left in the staging directory goes with the next fetch). +func moveDir(src, dst string, copyFallback bool) error { + err := moveRename(src, dst) + wait := moveRetryFirst + deadline := time.Now().Add(moveRetryFor) + for err != nil && transientMoveError(err) && time.Now().Before(deadline) { + moveSleep(wait) + wait = min(wait*2, moveRetryMaxWait) + err = moveRename(src, dst) + } + if err == nil || !copyFallback || !transientMoveError(err) { + return err + } + if cerr := copyTree(src, dst); cerr != nil { + _ = os.RemoveAll(dst) + return fmt.Errorf("%w(改用复制也没成:%v)", err, cerr) + } + _ = os.RemoveAll(src) + return nil +} + +var ( + moveRetryFor = 30 * time.Second + moveRetryFirst = 250 * time.Millisecond + moveRetryMaxWait = 4 * time.Second + moveRename = os.Rename + moveSleep = time.Sleep +) + +const ( + errorSharingViolation syscall.Errno = 32 + errorLockViolation syscall.Errno = 33 +) + +// transientMoveError: what a file somebody else has open makes a rename fail with. +func transientMoveError(err error) bool { + var errno syscall.Errno + return errors.As(err, &errno) && + (errno == syscall.ERROR_ACCESS_DENIED || errno == errorSharingViolation || errno == errorLockViolation) +} + +// copyTree copies the directory src to dst, which must not exist; files are read with full sharing. +func copyTree(src, dst string) error { + if _, err := os.Lstat(dst); err == nil { + return fmt.Errorf("%s 已经存在", dst) + } + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + to := filepath.Join(dst, rel) + switch { + case d.IsDir(): + return os.MkdirAll(to, 0o755) + case d.Type().IsRegular(): + return copyFileFn(path, to) + default: + return fmt.Errorf("%s 不是普通文件,不复制", path) + } + }) +} + +// copyFileFn is copyFile (a variable for the tests). +var copyFileFn = copyFile + +func copyFile(src, dst string) error { + in, err := root.OpenShared(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + return out.Close() +} diff --git a/internal/components/move_windows_test.go b/internal/components/move_windows_test.go new file mode 100644 index 0000000..de40068 --- /dev/null +++ b/internal/components/move_windows_test.go @@ -0,0 +1,133 @@ +package components + +import ( + "errors" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "modavd/internal/testutil" +) + +// moveFakes makes the rename fail with err for the first fails calls (all of them for fails < 0), and sleeping free. +func moveFakes(t *testing.T, fails int, err error) *int { + t.Helper() + savedRename, savedSleep, savedFor := moveRename, moveSleep, moveRetryFor + t.Cleanup(func() { moveRename, moveSleep, moveRetryFor = savedRename, savedSleep, savedFor }) + calls := 0 + moveRename = func(src, dst string) error { + calls++ + if fails < 0 || calls <= fails { + return &os.LinkError{Op: "rename", Old: src, New: dst, Err: err} + } + return os.Rename(src, dst) + } + moveSleep = func(time.Duration) {} + moveRetryFor = 300 * time.Millisecond + return &calls +} + +func moveTree(t *testing.T) (src, dst string) { + t.Helper() + base := testutil.TempDir(t) + src = filepath.Join(base, ".staging", "playstore") + for name, text := range map[string]string{"Phonesky.apk": "store", "sub/GmsCore.apk": "gms"} { + p := filepath.Join(src, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + } + return src, filepath.Join(base, "sdk", "playstore") +} + +func checkTree(t *testing.T, dst string) { + t.Helper() + for name, want := range map[string]string{"Phonesky.apk": "store", "sub/GmsCore.apk": "gms"} { + b, err := os.ReadFile(filepath.Join(dst, filepath.FromSlash(name))) + if err != nil || string(b) != want { + t.Errorf("%s: %q, %v", name, b, err) + } + } +} + +// Access denied for a while (a scanner holds a file just written): retried until the rename goes through. +func TestMoveDirRetriesAccessDenied(t *testing.T) { + src, dst := moveTree(t) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + t.Fatal(err) + } + calls := moveFakes(t, 3, syscall.ERROR_ACCESS_DENIED) + if err := moveDir(src, dst, false); err != nil { + t.Fatal(err) + } + if *calls != 4 { + t.Errorf("%d renames", *calls) + } + checkTree(t, dst) +} + +// Still denied after the retries: an install copies the tree and removes the source; any other move gives up. +func TestMoveDirCopiesWhenRenameKeepsFailing(t *testing.T) { + src, dst := moveTree(t) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + t.Fatal(err) + } + moveFakes(t, -1, errorSharingViolation) + if err := moveDir(src, dst, false); !errors.Is(err, errorSharingViolation) { + t.Fatalf("without the copy: %v", err) + } + if _, err := os.Stat(dst); err == nil { + t.Fatal("dst made without the copy") + } + if err := installComponent(src, dst); err != nil { + t.Fatal(err) + } + checkTree(t, dst) + if _, err := os.Stat(src); err == nil { + t.Error("source left behind") + } +} + +// Another error is not retried; a copy that cannot finish leaves no half dst. +func TestMoveDirOtherErrorAndFailedCopy(t *testing.T) { + src, dst := moveTree(t) + calls := moveFakes(t, -1, syscall.ERROR_FILE_NOT_FOUND) + if err := moveDir(src, dst, true); !errors.Is(err, syscall.ERROR_FILE_NOT_FOUND) || *calls != 1 { + t.Fatalf("%v after %d renames", err, *calls) + } + + moveFakes(t, -1, syscall.ERROR_ACCESS_DENIED) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + t.Fatal(err) + } + // a directory where a file should go: the copy fails half way + if err := os.MkdirAll(filepath.Join(src, "zz.apk", "x"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "zz.apk", "x", "f"), []byte("f"), 0o644); err != nil { + t.Fatal(err) + } + savedCopy := copyFileFn + t.Cleanup(func() { copyFileFn = savedCopy }) + copyFileFn = func(from, to string) error { + if filepath.Base(from) == "f" { + return errors.New("disk full") + } + return savedCopy(from, to) + } + err := moveDir(src, dst, true) + if !errors.Is(err, syscall.ERROR_ACCESS_DENIED) { + t.Fatalf("failed copy: %v", err) + } + if _, serr := os.Stat(dst); serr == nil { + t.Error("half copy left in dst") + } + if _, serr := os.Stat(filepath.Join(src, "Phonesky.apk")); serr != nil { + t.Error("source removed after a failed copy") + } +} diff --git a/internal/components/platform_darwin.go b/internal/components/platform_darwin.go index 55b58b1..3558478 100644 --- a/internal/components/platform_darwin.go +++ b/internal/components/platform_darwin.go @@ -200,3 +200,6 @@ func (f *fetcher) platformSpace(todo []root.SDKComponent) error { func moveComponent(src, dst string) error { return host.RenameNoReplace(src, dst) } + +// installComponent moves an unpacked component from the staging directory into place. +func installComponent(src, dst string) error { return moveComponent(src, dst) } diff --git a/internal/components/platform_other.go b/internal/components/platform_other.go index 9d60f92..b77f3f6 100644 --- a/internal/components/platform_other.go +++ b/internal/components/platform_other.go @@ -3,8 +3,6 @@ package components import ( - "os" - "modavd/internal/root" ) @@ -13,4 +11,10 @@ const nativeDarwinComponents = false func (f *fetcher) platformPaths() error { return nil } func (f *fetcher) platformIdle() error { return nil } func (f *fetcher) platformSpace([]root.SDKComponent) error { return nil } -func moveComponent(src, dst string) error { return os.Rename(src, dst) } + +// moveComponent renames a component directory (setting an incomplete one aside, putting it back). +func moveComponent(src, dst string) error { return moveDir(src, dst, false) } + +// installComponent moves an unpacked component from the staging directory into place; on Windows it may copy instead +// (moveDir), which is safe only because the source is ours to throw away. +func installComponent(src, dst string) error { return moveDir(src, dst, true) } diff --git a/internal/emu/bootadb_windows_test.go b/internal/emu/bootadb_windows_test.go new file mode 100644 index 0000000..70746ae --- /dev/null +++ b/internal/emu/bootadb_windows_test.go @@ -0,0 +1,112 @@ +package emu + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "modavd/internal/avd" + "modavd/internal/errs" + "modavd/internal/host" + "modavd/internal/root" + "modavd/internal/win" +) + +// bootAdbFakes makes instance 3's qemu q, the emulator report Android booted when booted says so, and counts the +// RegisterBooted tries (none succeeds); adb itself fails (the test root has no adb.exe). +func bootAdbFakes(t *testing.T, q win.Process, booted bool) *atomic.Int32 { + t.Helper() + savedFind, savedReported, savedRegister := waitBootFindQemu, emulatorReportedBoot, registerBooted + savedWait, savedEvery := adbAfterBootWait, bootRegisterEvery + t.Cleanup(func() { + waitBootFindQemu, emulatorReportedBoot, registerBooted = savedFind, savedReported, savedRegister + adbAfterBootWait, bootRegisterEvery = savedWait, savedEvery + }) + waitBootFindQemu = func(*Manager, int) (host.Process, bool, error) { return q, true, nil } + emulatorReportedBoot = func(*root.Root, int, host.Process) bool { return booted } + var tries atomic.Int32 + registerBooted = func(context.Context, *Manager, int) bool { tries.Add(1); return false } + return &tries +} + +// Android booted but adb never answers: the wait tries to register the instance, stops adbAfterBootWait after the +// emulator reported the boot (not at the far timeout), and says Android is up; at the timeout, it says the same. +func TestWaitBootAdbUnusableAfterBoot(t *testing.T) { + m, _ := opsTestManager(t) + q, _ := startSleeper(t) + tries := bootAdbFakes(t, q, true) + adbAfterBootWait, bootRegisterEvery = 1500*time.Millisecond, 500*time.Millisecond + t0 := time.Now() + _, err := m.waitBoot(context.Background(), 3, time.Hour, &q, nil, "", &progressor{}, nil, nil) + if errs.CodeOf(err) != errs.Timeout || !strings.Contains(err.Error(), "魔改 AVD 实例 3 的安卓已经开好了(模拟器报告开机完成),但之后") || + !strings.Contains(err.Error(), "私有 adb(端口 20391)一直用不了") { + t.Fatalf("err %v", err) + } + if d := time.Since(t0); d > time.Minute { + t.Errorf("waited %v", d) + } + if n := tries.Load(); n < 2 { + t.Errorf("%d register tries", n) + } + + // the last line of the adb server's log goes into the message + if err := os.MkdirAll(m.Root.LogsDir(), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(m.Root.LogsDir(), "adb-server.log"), []byte("--- adb starting (pid 9) ---\nfatal: boom\n"), 0o644); err != nil { + t.Fatal(err) + } + adbAfterBootWait = time.Hour + _, err = m.waitBoot(context.Background(), 3, 1500*time.Millisecond, &q, nil, "", &progressor{}, nil, nil) + if errs.CodeOf(err) != errs.Timeout || !strings.Contains(err.Error(), "安卓已经开好了") || + !strings.Contains(err.Error(), ";adb 自己的日志最后一行:fatal: boom。日志见 ") { + t.Fatalf("at the timeout: %v", err) + } +} + +// Without a boot report from the emulator nothing changes: no register tries, the plain timeout. +func TestWaitBootNoBootReport(t *testing.T) { + m, _ := opsTestManager(t) + q, _ := startSleeper(t) + tries := bootAdbFakes(t, q, false) + _, err := m.waitBoot(context.Background(), 3, 1500*time.Millisecond, &q, nil, "", &progressor{}, nil, nil) + if errs.CodeOf(err) != errs.Timeout || !strings.Contains(err.Error(), "启动超时(2 秒内安卓没有启动完成)") { + t.Fatalf("err %v", err) + } + if n := tries.Load(); n != 0 { + t.Errorf("%d register tries", n) + } +} + +// bootcompleted.ini counts only when it is no older than qemu (the emulator deletes it when it launches). +func TestEmulatorReportedBoot(t *testing.T) { + m, _ := opsTestManager(t) + q, _ := startSleeper(t) + ini := filepath.Join(avd.Dir(m.Root, 3), "bootcompleted.ini") + if emulatorReportedBoot(m.Root, 3, q) { + t.Fatal("no file: booted") + } + if err := os.MkdirAll(filepath.Dir(ini), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(ini, nil, 0o644); err != nil { + t.Fatal(err) + } + if !emulatorReportedBoot(m.Root, 3, q) { + t.Error("a file written after qemu started: not booted") + } + old := q.StartedAt().Add(-time.Minute) + if err := os.Chtimes(ini, old, old); err != nil { + t.Fatal(err) + } + if emulatorReportedBoot(m.Root, 3, q) { + t.Error("a file older than qemu: booted") + } + if emulatorReportedBoot(m.Root, 3, win.Process{PID: q.PID}) { + t.Error("qemu without a start time: booted") + } +} diff --git a/internal/emu/start.go b/internal/emu/start.go index 04a5585..957f544 100644 --- a/internal/emu/start.go +++ b/internal/emu/start.go @@ -7,14 +7,17 @@ import ( "fmt" "io/fs" "os" + "path/filepath" "strconv" "strings" "time" + "modavd/internal/adb" "modavd/internal/avd" "modavd/internal/errs" "modavd/internal/host" "modavd/internal/logx" + "modavd/internal/root" ) // Progress values: one scale for the whole call so they only ever grow (MCP requires strictly increasing progress). @@ -449,11 +452,13 @@ func (m *Manager) waitBoot(ctx context.Context, i int, timeout time.Duration, em started := time.Now() deadline := started.Add(timeout) nextReport := started.Add(bootReportInterval) + // booted: when the emulator was first seen to report Android booted; lastRegister: the last RegisterBooted try + var booted, lastRegister time.Time for { if err := ctxErr(ctx); err != nil { return host.Process{}, err } - p, found, err := m.findQemu(i) + p, found, err := waitBootFindQemu(m, i) if err != nil { return host.Process{}, err } @@ -467,7 +472,29 @@ func (m *Manager) waitBoot(ctx context.Context, i int, timeout time.Duration, em if err := ctxErr(ctx); err != nil { return host.Process{}, err } + // Android is up but adb does not answer: register the instance with a private server that came up (or was + // restarted) after the emulator's own registration, and stop waiting once that has not helped for a while. + if found { + if booted.IsZero() && emulatorReportedBoot(m.Root, i, p) { + booted = time.Now() + m.Log.Infof("实例 %d 的模拟器已报告安卓开机完成,等私有 adb 连上", i) + } + if !booted.IsZero() { + if time.Since(lastRegister) >= bootRegisterEvery { + lastRegister = time.Now() + if registerBooted(ctx, m, i) { + continue // ask getprop again right away + } + } + if time.Since(booted) >= adbAfterBootWait { + return host.Process{}, m.adbUnusableAfterBoot(i, booted) + } + } + } if !time.Now().Before(deadline) { + if !booted.IsZero() { + return host.Process{}, m.adbUnusableAfterBoot(i, booted) + } return host.Process{}, errs.Newf(errs.Timeout, "魔改 AVD 实例 %d 启动超时(%.0f 秒内安卓没有启动完成),日志见 %s", i, timeout.Seconds(), m.Root.LogsDir()) } @@ -502,6 +529,42 @@ func (m *Manager) waitBoot(ctx context.Context, i int, timeout time.Duration, em } } +var ( + // adbAfterBootWait: how long the boot wait goes on once the emulator has reported Android booted while adb still + // does not answer. adb normally answers before the boot completes; the rest of the timeout would only be waited + // out (a user's create waited 300 s for an Android that had booted after 72 s). + adbAfterBootWait = 60 * time.Second + // bootRegisterEvery: how often the boot wait tries RegisterBooted meanwhile. + bootRegisterEvery = 10 * time.Second +) + +// emulatorReportedBoot: the emulator of qemu has reported Android booted. It deletes avd\mas_.avd\bootcompleted.ini +// when it launches and writes it when the guest reports boot completion, so a file no older than qemu is this boot's. +var emulatorReportedBoot = func(r *root.Root, i int, qemu host.Process) bool { + started := qemu.StartedAt() + fi, err := os.Stat(filepath.Join(avd.Dir(r, i), "bootcompleted.ini")) + return err == nil && !started.IsZero() && !fi.ModTime().Before(started) +} + +// waitBootFindQemu is the boot wait's process scan (a variable for the tests). +var waitBootFindQemu = func(m *Manager, i int) (host.Process, bool, error) { return m.findQemu(i) } + +// registerBooted is adb's RegisterBooted for instance i (a variable for the tests). +var registerBooted = func(ctx context.Context, m *Manager, i int) bool { + return m.ADB.RegisterBooted(ctx, m.Root.Ports().Serial(i)) +} + +// adbUnusableAfterBoot is the boot wait's error when Android has booted but adb never answered: says so, with the +// last line of the adb server's own log. +func (m *Manager) adbUnusableAfterBoot(i int, booted time.Time) error { + msg := fmt.Sprintf("魔改 AVD 实例 %d 的安卓已经开好了(模拟器报告开机完成),但之后 %.0f 秒里私有 adb(端口 %d)一直用不了", + i, time.Since(booted).Seconds(), m.Root.AdbServerPort()) + if last := adb.ServerLogLast(m.Root); last != "" { + msg += ";adb 自己的日志最后一行:" + last + } + return errs.New(errs.Timeout, msg+"。日志见 "+m.Root.LogsDir()) +} + // qemuAfterSetup is the qemu process to report once the after-boot setup is done: the one the scan finds now, else // the one from the boot wait while it still runs (both the snapshot and the PID + creation time check must agree, so // a failed snapshot is no exit, as in waitBoot). gone: it has exited (old is returned for the message then). From 18ff94645b27ced1c989efd2b963796c3075332e Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Mon, 9 Nov 2026 17:53:40 +0100 Subject: [PATCH 2/3] =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=9A=E5=90=8C?= =?UTF-8?q?=E6=A0=B7=E7=9A=84=20adb=20=E6=97=A5=E5=BF=97=E5=8F=AA=E6=8A=A5?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E8=A6=81=E6=95=B0=E6=80=BB=E6=9D=A1=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/adb/serverlog_windows_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/adb/serverlog_windows_test.go b/internal/adb/serverlog_windows_test.go index 9104cea..a743cfb 100644 --- a/internal/adb/serverlog_windows_test.go +++ b/internal/adb/serverlog_windows_test.go @@ -93,12 +93,12 @@ func TestReportServerLogOncePerContent(t *testing.T) { c.reportServerLog(privateServer, 20391, 20*time.Second) writeServerLog(t, c.Root, "--- adb starting (pid 8) ---\nfailed to bind\n") c.reportServerLog(privateServer, 20391, 20*time.Second) - if n := logs.count("20 秒内没监听上;adb 自己的日志(logs\\adb-server.log)最后几行:\n --- adb starting (pid 7) ---\n failed to bind"); n != 1 { + if n := logs.count("20 秒内没监听上;adb 自己的日志(logs\\adb-server.log)最后几行:\n --- adb starting (pid 7) ---\n failed to bind"); n != 1 || logs.count("没监听上") != 1 { t.Fatalf("%d reports: %q", n, logs.msgs) } writeServerLog(t, c.Root, "--- adb starting (pid 9) ---\nsomething else\n") c.reportServerLog(privateServer, 20391, 20*time.Second) - if logs.count("something else") != 1 { + if logs.count("something else") != 1 || logs.count("没监听上") != 2 { t.Fatalf("a new reason not reported: %q", logs.msgs) } } From d3dab3dbc912ccb20c0d72732a8286aef6a1c396 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Mon, 9 Nov 2026 17:59:51 +0100 Subject: [PATCH 3/3] =?UTF-8?q?=E9=97=AE=E9=A2=98=E5=8C=85=E4=B9=9F?= =?UTF-8?q?=E6=94=B6=E7=A7=81=E6=9C=89=20adb=20server=20=E7=9A=84=E6=97=A5?= =?UTF-8?q?=E5=BF=97=20logs\adb-server.log=EF=BC=88=E4=B8=8D=E5=9C=A8?= =?UTF-8?q?=E5=B0=B1=E4=B8=8D=E6=8F=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.en.md | 2 +- README.md | 2 +- internal/report/extra.go | 24 +++++++++++++++--------- internal/report/extra_windows_test.go | 11 +++++++---- 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/README.en.md b/README.en.md index dd115b9..2f80c86 100644 --- a/README.en.md +++ b/README.en.md @@ -74,7 +74,7 @@ modavd [--root ] [--json] [--quiet] [--verbose] [args] | `disk compact` | Compact a stopped instance's data disk, or skip rewriting when estimated savings are too small. Verify before atomically replacing the original, and report host usage before and after, including growth. Large disks have budgets of many hours and effectively do not time out in normal use; cancel to stop an operation. Requires NTFS/ReFS, no snapshots and room for the temporary image. See [budgets and result fields](internal/emu/COMPACT.md) | | `check [--memory M]` | Computer check before starting | | `report-config [--no-clipboard]` | Copy the computer configuration and the game info from the running instances (read-only) | -| `collect-info [--out-dir D] [--stage-dir D] [--keep-stage] [--no-explorer]` | Collect a problem report and zip it (read-only). Besides the emulator logs it takes the manager's and the boot watch's logs (`modavd-ui.log`, `modavd-bootwatch.log` and their `.1`, the last 5 MB of each), every adb.exe process and the adb server log (the last 2 MB of `%TEMP%\adb.log`), and the TCP ports Windows reserves (netsh), saying whether a port this root uses is among them. Ctrl+C before ZIP completion returns `canceled`, deletes the unfinished ZIP and removes staging; `--keep-stage` preserves staging and includes its path in the error | +| `collect-info [--out-dir D] [--stage-dir D] [--keep-stage] [--no-explorer]` | Collect a problem report and zip it (read-only). Besides the emulator logs it takes the logs of the manager, the boot watch and the private adb servers (`modavd-ui.log`, `modavd-bootwatch.log`, `adb-server.log` and their `.1`, the last 5 MB of each), every adb.exe process and the adb server log (the last 2 MB of `%TEMP%\adb.log`), and the TCP ports Windows reserves (netsh), saying whether a port this root uses is among them. Ctrl+C before ZIP completion returns `canceled`, deletes the unfinished ZIP and removes staging; `--keep-stage` preserves staging and includes its path in the error | | `components [status]`, `components source [auto\|googledownloads_cn\|tencent\|google]`, `components fetch [--accept-license] [--mirror ID] [--mirror-url URL] [--keep-downloads] [--play-store]`, `components import-playstore ...` | View, choose, or fetch the Google components (adb and the system image; see "Fetching the Google components"); `--play-store` and `import-playstore` add the optional Google Play Store (see below) | | `window show\|hide`, `install `, `shell `, `adb -- ` | Window, install an APK, guest command, adb passthrough | | `update [status\|check\|prepare\|install\|recover] [--automas-version V]` | Update the whole ModAVD package in the root folder, using the update source from "Settings" (`设置`) (see "Updating ModAVD") | diff --git a/README.md b/README.md index 169ebdb..cfc18f2 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数] | `disk compact` | 整理已关机实例的数据盘;预计收益不足时跳过重写;转换后先校验再原子替换,原盘在替换成功前不动;报告前后占用和增减;大盘预算可达数十小时,正常整理基本不会超时,停止请取消;仅支持 NTFS/ReFS,拒绝快照或重写空间不足,详见[预算与结果契约](internal/emu/COMPACT.md) | | `check [--memory M]` | 开机前的电脑检查 | | `report-config [--no-clipboard]` | 复制电脑配置和开着实例里的游戏信息(只读) | -| `collect-info [--out-dir D] [--stage-dir D] [--keep-stage] [--no-explorer]` | 收集问题信息并打包(只读)。除模拟器日志外,还收管理器和开机守护的日志(`modavd-ui.log`、`modavd-bootwatch.log` 及其 `.1`,各取最后 5 MB)、所有 adb.exe 进程和 adb server 日志(`%TEMP%\adb.log` 最后 2 MB)、Windows 保留的 TCP 端口(netsh),并指出本目录要用的端口有没有落在里面;ZIP 完成前的 Ctrl+C 返回 `canceled`,删除未完成 ZIP 并清理暂存目录;`--keep-stage` 则保留暂存目录并在错误中给出路径 | +| `collect-info [--out-dir D] [--stage-dir D] [--keep-stage] [--no-explorer]` | 收集问题信息并打包(只读)。除模拟器日志外,还收管理器、开机守护和私有 adb server 的日志(`modavd-ui.log`、`modavd-bootwatch.log`、`adb-server.log` 及其 `.1`,各取最后 5 MB)、所有 adb.exe 进程和 adb server 日志(`%TEMP%\adb.log` 最后 2 MB)、Windows 保留的 TCP 端口(netsh),并指出本目录要用的端口有没有落在里面;ZIP 完成前的 Ctrl+C 返回 `canceled`,删除未完成 ZIP 并清理暂存目录;`--keep-stage` 则保留暂存目录并在错误中给出路径 | | `components [status]`、`components source [auto\|googledownloads_cn\|tencent\|google]`、`components fetch [--accept-license] [--mirror ID] [--mirror-url URL] [--keep-downloads] [--play-store]`、`components import-playstore <文件或文件夹>…` | 查看、选择或补全 Google 组件(adb 和系统镜像;见「补全 Google 组件」);`--play-store`、`import-playstore` 补全可选的 Google Play 商店(见下) | | `window show\|hide`、`install `、`shell <命令>`、`adb -- <参数>` | 窗口、装 APK、客体命令、adb 透传 | | `update [status\|check\|prepare\|install\|recover] [--automas-version V]` | 更新根目录里的整个 ModAVD 包,按「设置」里的更新源(见「更新 ModAVD」) | diff --git a/internal/report/extra.go b/internal/report/extra.go index 241df67..cedb78d 100644 --- a/internal/report/extra.go +++ b/internal/report/extra.go @@ -31,12 +31,18 @@ var ( const netshTimeout = 15 * time.Second -// managerLogs are the manager's own logs in logs\; a missing .1 backup is not mentioned. -var managerLogs = []struct{ name, what string }{ - {"modavd-ui.log", "管理器日志"}, - {"modavd-ui.log.1", "管理器日志(上一份)"}, - {"modavd-bootwatch.log", "开机守护日志"}, - {"modavd-bootwatch.log.1", "开机守护日志(上一份)"}, +// managerLogs are the manager's own logs in logs\, with the private adb servers' log (modavd before 0.7 had none); a +// missing quiet one is not mentioned. +var managerLogs = []struct { + name, what string + quiet bool +}{ + {"modavd-ui.log", "管理器日志", false}, + {"modavd-ui.log.1", "管理器日志(上一份)", true}, + {"modavd-bootwatch.log", "开机守护日志", false}, + {"modavd-bootwatch.log.1", "开机守护日志(上一份)", true}, + {"adb-server.log", "私有 adb server 日志", true}, + {"adb-server.log.1", "私有 adb server 日志(上一份)", true}, } // extra runs one of these sections like step (a failure or a panic is a line and a problem), without a timing line. @@ -60,15 +66,15 @@ func (c *collector) extra(name string, fn func() error) { } } -// managerLogFiles copies modavd-ui.log, modavd-bootwatch.log and their .1 backups (the last managerLogTailBytes of -// each) into logs\ of the package. +// managerLogFiles copies modavd-ui.log, modavd-bootwatch.log, adb-server.log and their .1 backups (the last +// managerLogTailBytes of each) into logs\ of the package. func (c *collector) managerLogFiles() error { c.section(sectionManagerLogs) logDir := filepath.Join(c.root, root.LogsDir) for _, f := range managerLogs { src := filepath.Join(logDir, f.name) if !isFile(src) { - if !strings.HasSuffix(f.name, ".1") { + if !f.quiet { c.L(f.what + "(" + f.name + "):没有") } continue diff --git a/internal/report/extra_windows_test.go b/internal/report/extra_windows_test.go index 3c9d249..aef4c8a 100644 --- a/internal/report/extra_windows_test.go +++ b/internal/report/extra_windows_test.go @@ -30,20 +30,23 @@ func TestCollectManagerLogsAdbAndPorts(t *testing.T) { f := newFixture(t, "full", testTempDir(t), "") guardDials(t, f.adbPort) logs := filepath.Join(f.root, "logs") - if err := os.WriteFile(filepath.Join(logs, "modavd-ui.log.1"), []byte("older manager log\n"), 0o644); err != nil { - t.Fatal(err) + for name, text := range map[string]string{"modavd-ui.log.1": "older manager log\n", "adb-server.log": "private adb\n"} { + if err := os.WriteFile(filepath.Join(logs, name), []byte(text), 0o644); err != nil { + t.Fatal(err) + } } res, names, files := runCollect(t, f) s := summaryOf(t, files) wantSection := "\r\n== 管理器日志 ==\r\n管理器日志(modavd-ui.log):12 B\r\n管理器日志(上一份)(modavd-ui.log.1):18 B\r\n" + - "开机守护日志(modavd-bootwatch.log):15 B\r\n" + "开机守护日志(modavd-bootwatch.log):15 B\r\n私有 adb server 日志(adb-server.log):12 B\r\n\r\n" if !strings.Contains(s, wantSection) { t.Errorf("manager logs section:\n%s", s) } for name, want := range map[string]string{ "logs/modavd-ui.log": "manager log\n", "logs/modavd-ui.log.1": "older manager log\n", - "logs/modavd-bootwatch.log": "boot watch log\n", "adb/adb.log": "--- adb starting (pid 4242) ---\n", + "logs/modavd-bootwatch.log": "boot watch log\n", "logs/adb-server.log": "private adb\n", + "adb/adb.log": "--- adb starting (pid 4242) ---\n", } { if got, ok := files[name]; !ok || string(got) != want { t.Errorf("%s: %q (in zip %v), want %q", name, got, ok, want)