From 9ae5941d1e3f99446fea42c7bb7a040550164dfe Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Fri, 13 Nov 2026 19:03:45 +0100 Subject: [PATCH] =?UTF-8?q?=E5=BC=80=E6=9C=BA=E5=AE=88=E6=8A=A4=E6=8A=8A?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E4=B9=9F=E7=99=BB=E8=AE=B0=E5=88=B0=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E7=9A=84=20adb=20server=EF=BC=885037=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用默认 adb server 找设备的工具(adb devices、MAA 的自动检测)原来看不到 ModAVD 的实例。开机守护每 30 秒看一次:5037 上有 server 又没列出这台,就替它发 host:emulator 登记;不起 adb.exe、不替别人起 server。mas-avd.json 写 shareDefaultAdbServer: false 可以关掉。 Refs ModAVD/ModAVD#196 --- README.en.md | 4 +- README.md | 4 +- internal/adb/default_server.go | 108 ++++++++++++++++++++++ internal/adb/default_server_test.go | 121 +++++++++++++++++++++++++ internal/emu/bootwatch.go | 59 ++++++++++++ internal/emu/bootwatch_share_test.go | 128 +++++++++++++++++++++++++++ internal/emu/portplan_test.go | 2 + internal/root/root.go | 12 +++ 8 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 internal/adb/default_server.go create mode 100644 internal/adb/default_server_test.go create mode 100644 internal/emu/bootwatch_share_test.go diff --git a/README.en.md b/README.en.md index 38bfefe..8656feb 100644 --- a/README.en.md +++ b/README.en.md @@ -170,7 +170,9 @@ On Apple Silicon macOS, downloads and imports use Android 15 Google Play `arm64- **Conventions** (the same as AUTO-MAS): -- adb only uses the root folder's private server (`adbServerPort` in `mas-avd.json`, default 20050) and never touches 5037. +- modavd's own adb commands only use the root folder's private server (`adbServerPort` in `mas-avd.json`, default 20050). + - While an instance runs, its boot guardian looks at the default adb server (5037) every 30 seconds. When something runs that server and it does not know the instance, the guardian registers the instance there, where it shows as `emulator-`. Tools that find devices with `adb devices` (MAA's auto-detection, for example) then see ModAVD's instances too (ModAVD/ModAVD#196). + - Nothing happens while no server runs on 5037: the guardian never starts one. To turn the registration off, write `"shareDefaultAdbServer": false` in `mas-avd.json`. - Ports: the console port of instance i is 20000 + 10 × i, adb is 1 more and gRPC 2 more; the index is 0–9 without 5. - A root folder for development or testing can move to its own port range with `"portBase": 21000` in `mas-avd.json` (a key of modavd's own; AUTO-MAS does not know it). Its instances then do not fight over ports with the instances of the same index in the root folder in daily use on the same computer (for example the one AUTO-MAS manages), and are not taken for them. - `portBase` must be a multiple of 100 between 1100 and 49000, excluding 5500. Other adb servers scan the 5500 range automatically; ranges at 49100 and above overlap Windows' default ephemeral ports. Existing configurations in those ranges still support status and shutdown, but block new starts. Shut down all instances before changing the range. The default remains 20000. With another range, both `adbServerPort` and `scriptAdbServerPort` must be set, and neither can be 20049 or 20050 or fall on an instance port of the 20000 range. diff --git a/README.md b/README.md index 07f17b7..cf85785 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,9 @@ Apple Silicon macOS 的下载与导入使用 Android 15 Google Play `arm64-v8a` **约定**(和 AUTO-MAS 一致): -- adb 只走根目录的私有 server(`mas-avd.json` 的 `adbServerPort`,默认 20050),不碰 5037。 +- modavd 自己的 adb 命令只走根目录的私有 server(`mas-avd.json` 的 `adbServerPort`,默认 20050)。 + - 实例开着时,开机守护每 30 秒看一下默认的 adb server(5037)。有人开着它、它又不认识这台实例,就替实例登记过去,那边显示成 `emulator-<控制台端口>`。这样用 `adb devices` 找设备的工具(比如 MAA 的自动检测)也能看到 ModAVD 的实例(ModAVD/ModAVD#196)。 + - 5037 上没有 server 时什么都不做,不替别人起 server。不想登记就在 `mas-avd.json` 里写 `"shareDefaultAdbServer": false`。 - 端口:实例 i 的控制台端口是 20000 + 10 × i,adb 加 1、gRPC 加 2,编号 0–9、不含 5。 - 开发、测试用的根目录可以在 `mas-avd.json` 里写 `"portBase": 21000`,换一段端口(这是 modavd 自己的键,AUTO-MAS 不认)。这样它的实例不会和同一台电脑上日常用的根目录(比如 AUTO-MAS 管的那个)里同编号的实例抢端口,也不会被认成那一台。 - `portBase` 只能是 1100–49000 之间 100 的整数倍,且不能是 5500。5500 段会被别的 adb server 自动扫描;49100 及以上会与 Windows 默认临时端口重叠。已有这些配置仍可查状态和关机,但不能再开机,先关掉全部实例再换段。默认值仍是 20000。设了别的端口段,`adbServerPort`、`scriptAdbServerPort` 都要配上,而且不能是 20049、20050,也不能落在 20000 段的实例端口上。 diff --git a/internal/adb/default_server.go b/internal/adb/default_server.go new file mode 100644 index 0000000..09ff510 --- /dev/null +++ b/internal/adb/default_server.go @@ -0,0 +1,108 @@ +package adb + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "time" +) + +// DefaultServerPort is adb's own default server port, the one other tools use when they run `adb devices` +// (ModAVD/ModAVD#196). Our instances register with the root's private server only; ShareWithDefaultServer also +// registers them here, so that tools which find devices through the default server see them. A variable so that tests +// use a fake server instead: no test may reach the real one. +var DefaultServerPort = 5037 + +// defaultServerTimeout bounds one look at the default server (connect, request, reply). +const defaultServerTimeout = 3 * time.Second + +// ShareWithDefaultServer registers instance console (its adb port is console + 1) with the adb server on +// DefaultServerPort when that server runs and does not list emulator- yet. It never starts that server: the +// tool that starts it gets our instance at the next call (the boot watch calls this every bootWatchShareInterval). +// Returns whether it registered the instance now; nothing listening is not an error. +func ShareWithDefaultServer(ctx context.Context, console int) (bool, error) { + serials, err := serverDevices(ctx, DefaultServerPort, defaultServerTimeout) + if err != nil { + if errors.Is(err, errNoServer) { + return false, nil + } + return false, err + } + serial := "emulator-" + strconv.Itoa(console) + for _, s := range serials { + if s == serial { + return false, nil + } + } + if err := Register(ctx, DefaultServerPort, console+1, defaultServerTimeout); err != nil { + return false, err + } + return true, nil +} + +// errNoServer: nothing accepts connections on the port. +var errNoServer = errors.New("no adb server") + +// serverDevices sends host:devices to the adb server on port and returns the serials it lists (any state). +func serverDevices(ctx context.Context, port int, timeout time.Duration) ([]string, error) { + addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) + dctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + var d net.Dialer + conn, err := d.DialContext(dctx, "tcp", addr) + if err != nil { + if ctx.Err() != nil { + return nil, canceled(ctx) + } + var op *net.OpError + if errors.As(err, &op) && op.Op == "dial" && dctx.Err() == nil { + return nil, errNoServer // refused: nothing listens + } + return nil, fmt.Errorf("连不上 %s: %w", addr, err) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(timeout)) + const msg = "host:devices" + if _, err := fmt.Fprintf(conn, "%04x%s", len(msg), msg); err != nil { + return nil, fmt.Errorf("向 %s 发送 %s 失败: %w", addr, msg, err) + } + status := make([]byte, 4) + if _, err := io.ReadFull(conn, status); err != nil { + return nil, fmt.Errorf("%s 没有回 %s: %w", addr, msg, err) + } + body, err := readHexPrefixed(conn) + if err != nil { + return nil, fmt.Errorf("%s 回的 %s 读不全: %w", addr, msg, err) + } + if string(status) != "OKAY" { + return nil, fmt.Errorf("%s 拒绝了 %s: %s %s", addr, msg, status, body) + } + var serials []string + for _, line := range strings.Split(body, "\n") { + if serial, _, ok := strings.Cut(strings.TrimSpace(line), "\t"); ok && serial != "" { + serials = append(serials, serial) + } + } + return serials, nil +} + +// readHexPrefixed reads one adb host-protocol string: 4 hex digits of length, then that many bytes. +func readHexPrefixed(r io.Reader) (string, error) { + head := make([]byte, 4) + if _, err := io.ReadFull(r, head); err != nil { + return "", err + } + n, err := strconv.ParseUint(string(head), 16, 16) + if err != nil { + return "", fmt.Errorf("长度 %q 不是十六进制", head) + } + body := make([]byte, n) + if _, err := io.ReadFull(r, body); err != nil { + return "", err + } + return string(body), nil +} diff --git a/internal/adb/default_server_test.go b/internal/adb/default_server_test.go new file mode 100644 index 0000000..da3bfeb --- /dev/null +++ b/internal/adb/default_server_test.go @@ -0,0 +1,121 @@ +package adb + +import ( + "bufio" + "context" + "fmt" + "io" + "net" + "testing" + "time" +) + +// fakeDefaultServer is a stand-in for the default adb server on a free port of 127.0.0.1: it answers host:devices with +// devices and records every other request. DefaultServerPort points at it for the test. +func fakeDefaultServer(t *testing.T, devices string) <-chan string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + old := DefaultServerPort + DefaultServerPort = ln.Addr().(*net.TCPAddr).Port + t.Cleanup(func() { DefaultServerPort = old }) + if DefaultServerPort == 5037 { + t.Fatal("测试端口不能是 5037") + } + got := make(chan string, 8) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + c.SetDeadline(time.Now().Add(5 * time.Second)) + msg, err := readHexPrefixed(bufio.NewReader(c)) + if err != nil { + return + } + if msg == "host:devices" { + fmt.Fprintf(c, "OKAY%04x%s", len(devices), devices) + return + } + got <- msg + io.Copy(io.Discard, c) + }(conn) + } + }() + return got +} + +func TestShareWithDefaultServerRegistersMissingInstance(t *testing.T) { + got := fakeDefaultServer(t, "emulator-5554\tdevice\n127.0.0.1:16384\toffline\n") + registered, err := ShareWithDefaultServer(context.Background(), 31020) + if err != nil || !registered { + t.Fatalf("ShareWithDefaultServer = %v, %v,应登记", registered, err) + } + select { + case msg := <-got: + if msg != "host:emulator:31021" { + t.Fatalf("登记发的是 %q,应是 host:emulator:31021(adb 端口 = 控制台 + 1)", msg) + } + case <-time.After(3 * time.Second): + t.Fatal("假 server 没收到登记") + } +} + +func TestShareWithDefaultServerSkipsKnownInstance(t *testing.T) { + for _, state := range []string{"device", "offline", "unauthorized"} { + got := fakeDefaultServer(t, "emulator-31020\t"+state+"\n") + registered, err := ShareWithDefaultServer(context.Background(), 31020) + if err != nil || registered { + t.Fatalf("%s: ShareWithDefaultServer = %v, %v,已经列着的不该再登记", state, registered, err) + } + select { + case msg := <-got: + t.Fatalf("%s: 不该再发 %q", state, msg) + case <-time.After(300 * time.Millisecond): + } + } +} + +func TestShareWithDefaultServerWithoutServer(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := ln.Addr().(*net.TCPAddr).Port + ln.Close() // nothing listens there now + old := DefaultServerPort + DefaultServerPort = port + t.Cleanup(func() { DefaultServerPort = old }) + registered, err := ShareWithDefaultServer(context.Background(), 31020) + if err != nil || registered { + t.Fatalf("没有 server 时 ShareWithDefaultServer = %v, %v,应什么都不做、不报错", registered, err) + } +} + +func TestServerDevicesRefusal(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { + c, err := ln.Accept() + if err != nil { + return + } + defer c.Close() + readHexPrefixed(bufio.NewReader(c)) + const why = "unknown host service" + fmt.Fprintf(c, "FAIL%04x%s", len(why), why) + }() + port := ln.Addr().(*net.TCPAddr).Port + if _, err := serverDevices(context.Background(), port, 2*time.Second); err == nil { + t.Fatal("FAIL 回复应报错") + } +} diff --git a/internal/emu/bootwatch.go b/internal/emu/bootwatch.go index 5cea526..bfaeab5 100644 --- a/internal/emu/bootwatch.go +++ b/internal/emu/bootwatch.go @@ -280,10 +280,18 @@ func RunBootWatch(args []string, w io.Writer) int { defer ticker.Stop() watch.watchMemory(ctx, ticker.C) }() + shareDone := make(chan struct{}) + go func() { + defer close(shareDone) + ticker := time.NewTicker(bootWatchShareInterval) + defer ticker.Stop() + watch.shareDefaultAdb(ctx, ticker.C) + }() for !q.Wait(bootWatchInterval) { watch.tick(ctx, time.Now()) } cancel() + <-shareDone <-memoryDone // write the summary before releasing the watch lock or ending the log m.Wait(10 * time.Second) log.Infof("实例 %d 的模拟器(qemu pid %d)已退出,开机守护结束", i, q.PID) @@ -300,6 +308,8 @@ type bootWatch struct { missingSince time.Time // Owned only by watchMemory, independently of guest reboot setup in tick. memory bootWatchMemory + // shareFailing: the last registration with the default adb server failed (warned once). Owned by shareDefaultAdb. + shareFailing bool } const ( @@ -401,6 +411,55 @@ func (w *bootWatch) logMemorySummary() { w.m.Log.Infof("%s;精确退出峰值未知", msg) } +// bootWatchShareInterval: how often the watch makes sure the default adb server (5037) knows the instance. A tool that +// starts that server finds the instance at most this long after. +var bootWatchShareInterval = 30 * time.Second + +// shareDefaultAdbServer is adb.ShareWithDefaultServer; tests replace it. +var shareDefaultAdbServer = adb.ShareWithDefaultServer + +// shareDefaultAdb registers the instance with the default adb server now and at every tick, until ctx ends (qemu +// exited). Its own goroutine: tick may block for minutes in a guest reboot setup. Skipped while a stop runs, before a +// start has finished on q, and when mas-avd.json turns it off; a failure is logged once until it works again. +func (w *bootWatch) shareDefaultAdb(ctx context.Context, ticks <-chan time.Time) { + for { + if ctx.Err() != nil { + return + } + w.shareDefaultAdbOnce(ctx) + select { + case <-ctx.Done(): + return + case <-ticks: + } + } +} + +// shareDefaultAdbOnce is one look of shareDefaultAdb; it reports whether it asked the default server. +func (w *bootWatch) shareDefaultAdbOnce(ctx context.Context) bool { + m, i := w.m, w.i + rec, ok := m.readBootRecord(i) + if !ok || !rec.names(w.q) || m.stopping(i) || !m.Root.ShareDefaultAdbServer() { + return false + } + console := m.Root.Ports().Console(i) + registered, err := shareDefaultAdbServer(ctx, console) + switch { + case err != nil: + if !w.shareFailing && ctx.Err() == nil { + m.Log.Warnf("实例 %d 没能登记到默认的 adb server(端口 %d),别的工具里可能看不到它: %v", i, + adb.DefaultServerPort, err) + } + w.shareFailing = true + case registered: + m.Log.Infof("实例 %d 已登记到默认的 adb server(端口 %d),那边是 emulator-%d", i, adb.DefaultServerPort, console) + w.shareFailing = false + default: + w.shareFailing = false + } + return true +} + // tick is one look of the watch at now: nothing while a start or a stop of the instance runs, or while the record does // not name q (no start has finished on it); otherwise, once Android has booted with another boot_id than the // record's, the start that sets the boot up again. diff --git a/internal/emu/bootwatch_share_test.go b/internal/emu/bootwatch_share_test.go new file mode 100644 index 0000000..da95b6d --- /dev/null +++ b/internal/emu/bootwatch_share_test.go @@ -0,0 +1,128 @@ +//go:build windows + +package emu + +import ( + "context" + "errors" + "strings" + "sync/atomic" + "testing" + "time" + + "modavd/internal/host" + "modavd/internal/jsonord" +) + +func stubShareDefaultAdb(t *testing.T, fn func(console int) (bool, error)) { + t.Helper() + old := shareDefaultAdbServer + shareDefaultAdbServer = func(_ context.Context, console int) (bool, error) { return fn(console) } + t.Cleanup(func() { shareDefaultAdbServer = old }) +} + +// The boot watch registers the instance with the default adb server only once a start has finished on its qemu, not +// while a stop runs, and not when mas-avd.json turns it off (ModAVD/ModAVD#196). A failure is warned once until a +// look works again. +func TestBootWatchShareDefaultAdbOnce(t *testing.T) { + m, logs := opsTestManager(t) + created, err := m.Create(context.Background(), CreateRequest{filesOnly: true}) + if err != nil { + t.Fatal(err) + } + i := created.Index + q := host.Process{PID: 4242, Created: 1_000_500} + console := m.Root.Ports().Console(i) + var calls atomic.Int32 + var fail atomic.Bool + var got atomic.Int32 + stubShareDefaultAdb(t, func(c int) (bool, error) { + calls.Add(1) + got.Store(int32(c)) + if fail.Load() { + return false, errors.New("boom") + } + return true, nil + }) + ctx := context.Background() + w := &bootWatch{m: m, i: i, q: q} + + if w.shareDefaultAdbOnce(ctx) { + t.Fatal("asked without a boot record") + } + m.writeBootRecord(i, bootRecord{QemuPID: 4100, QemuCreated: q.Created, BootID: testBootA}) + if w.shareDefaultAdbOnce(ctx) { + t.Fatal("asked with the record of another qemu") + } + m.writeBootRecord(i, bootRecord{QemuPID: q.PID, QemuCreated: q.Created, BootID: testBootA}) + if !w.shareDefaultAdbOnce(ctx) || calls.Load() != 1 || int(got.Load()) != console { + t.Fatalf("asked=%d console=%d, want 1 call for console %d", calls.Load(), got.Load(), console) + } + + l, ok, err := host.TryLock(m.lockPath(i, "stop")) + if err != nil || !ok { + t.Fatalf("stop lock: %v %v", ok, err) + } + if w.shareDefaultAdbOnce(ctx) { + t.Error("asked while a stop runs") + } + l.Unlock() + + fail.Store(true) + before := len(logs.Messages()) + for k := 0; k < 3; k++ { + if !w.shareDefaultAdbOnce(ctx) { + t.Fatal("a failing look should still ask") + } + } + if warned := logs.Messages()[before:]; len(warned) != 1 || !strings.Contains(warned[0], "没能登记到默认的 adb server") { + t.Fatalf("warnings after three failures: %q, want one", warned) + } + fail.Store(false) + w.shareDefaultAdbOnce(ctx) + fail.Store(true) + w.shareDefaultAdbOnce(ctx) + if n := len(logs.Messages()) - before; n != 2 { + t.Fatalf("a failure after a success should warn again: %d warnings", n) + } + + if err := m.Root.UpdateMetadata(func(o *jsonord.Obj) error { + o.Set("shareDefaultAdbServer", jsonord.NewBool(false)) + return nil + }); err != nil { + t.Fatal(err) + } + if w.shareDefaultAdbOnce(ctx) { + t.Error("asked although mas-avd.json turned it off") + } +} + +// The loop looks right away, again at every tick, and returns once ctx ends. +func TestBootWatchShareDefaultAdbLoop(t *testing.T) { + m := markerTestManager(t) + const i = 2 + q := host.Process{PID: 4242, Created: 1_000_500} + m.writeBootRecord(i, bootRecord{QemuPID: q.PID, QemuCreated: q.Created, BootID: testBootA}) + looks := make(chan struct{}, 8) + stubShareDefaultAdb(t, func(int) (bool, error) { looks <- struct{}{}; return false, nil }) + ctx, cancel := context.WithCancel(context.Background()) + ticks := make(chan time.Time) + done := make(chan struct{}) + go func() { defer close(done); (&bootWatch{m: m, i: i, q: q}).shareDefaultAdb(ctx, ticks) }() + wait := func(what string) { + select { + case <-looks: + case <-time.After(3 * time.Second): + t.Fatalf("no look %s", what) + } + } + wait("right away") + ticks <- time.Now() + wait("after a tick") + cancel() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("the loop did not return after cancel") + } +} diff --git a/internal/emu/portplan_test.go b/internal/emu/portplan_test.go index 6f43731..ad3d3ac 100644 --- a/internal/emu/portplan_test.go +++ b/internal/emu/portplan_test.go @@ -79,6 +79,8 @@ func TestMain(m *testing.M) { case "client": os.Exit(fakeAdbClientMain(os.Args[1:])) } + // No test may reach the machine's default adb server (5037): boot watches registering there are faked. + shareDefaultAdbServer = func(context.Context, int) (bool, error) { return false, nil } os.Exit(m.Run()) } diff --git a/internal/root/root.go b/internal/root/root.go index 39d53c1..738f51c 100644 --- a/internal/root/root.go +++ b/internal/root/root.go @@ -703,6 +703,18 @@ func (r *Root) ScriptAdbServerPort() int { return scriptAdbServerPort(r.runtimeMetadata()) } +// ShareDefaultAdbServer: also register running instances with the default adb server (port 5037) when one runs, so +// tools that look there (`adb devices`) find them (ModAVD/ModAVD#196). On unless mas-avd.json has +// "shareDefaultAdbServer": false. +func (r *Root) ShareDefaultAdbServer() bool { + v := r.runtimeMetadata().Get("shareDefaultAdbServer") + if v.Kind() != jsonord.Bool { + return true + } + b, _ := v.BoolVal() + return b +} + func adbServerPort(o *jsonord.Obj) int { if port, ok := metadataPort(o, "adbServerPort"); ok { return port