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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
- name: display coordinate tests
run: node --test scripts/test_display_tap.mjs
- name: disk UI tests
run: node --test scripts/test_disk_info.mjs scripts/test_compact_format.mjs
run: node --test scripts/test_disk_info.mjs scripts/test_compact_format.mjs scripts/test_card_cfg.mjs
- name: gofmt
shell: pwsh
run: |
Expand Down
Binary file modified cmd/modavd-gui/rsrc_windows_amd64.syso
Binary file not shown.
2 changes: 1 addition & 1 deletion internal/buildinfo/buildinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ package buildinfo
// Version of modavd itself, kept in the source rather than set with -ldflags -X: release builds must come out byte
// for byte the same for the same source (Mirror 酱 ships per-file deltas, and a version stamped per package would
// change the exe in every release). The ModAVD package version comes from the root at run time.
var Version = "0.8.0"
var Version = "0.9.0"

// Protocol is the version of the machine-readable contract: the commands, the JSON written by `--json`, the exit codes
// and the MCP tool schemas. Callers such as AUTO-MAS check it (`modavd version --json`) before relying on a field.
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,8 @@ func formatList(w io.Writer, r *emu.ListResult) {
rows := [][]string{{"编号", "名称", "状态", "adb 地址", "内存", "核数", "帧率", "备注"}}
for _, in := range r.Instances {
title := in.Title
if title == "" {
title = in.Name
if title == "" || title == in.Name { // no title of its own: emu-N, as the manager shows it (mas_N is the file name)
title = fmt.Sprintf("emu-%d", in.Index)
}
rows = append(rows, []string{
strconv.Itoa(in.Index), title, statusLabel(in.Status, in.CreationProgress), in.AdbAddress,
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func TestFormatListTable(t *testing.T) {
!strings.Contains(table[1], "127.0.0.1:20001") || !strings.Contains(table[1], "6 GB") {
t.Errorf("row 0 = %q", table[1])
}
if !strings.Contains(table[2], "mas_3") || !strings.Contains(table[2], "开机中") ||
if strings.Contains(table[2], "mas_3") || !strings.Contains(table[2], "emu-3") || !strings.Contains(table[2], "开机中") ||
!strings.Contains(table[2], "正在开机,未初始化,软件渲染") || !strings.Contains(table[2], "4 GB") {
t.Errorf("row 1 = %q", table[2])
}
Expand Down
40 changes: 40 additions & 0 deletions internal/emu/bootwatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@ package emu
// The watch never waits: while another start or a stop runs it looks again at the next tick, and a start that sets up
// a boot writes its record, so a boot is never set up twice. Its messages go to <root>\logs\modavd-bootwatch.log (one
// JSON object per line, as `--json` on stderr).
//
// Being the one process that lives as long as the emulator, the watch also writes the emulator's memory to that log
// every bootWatchMemoryInterval (logMemory).

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
Expand Down Expand Up @@ -261,8 +265,10 @@ func RunBootWatch(args []string, w io.Writer) int {
}
cancel()
}()
watch.logMemory(time.Now())
for !q.Wait(bootWatchInterval) {
watch.tick(ctx, time.Now())
watch.logMemory(time.Now())
}
m.Wait(10 * time.Second)
log.Infof("实例 %d 的模拟器(qemu pid %d)已退出,开机守护结束", i, q.PID)
Expand All @@ -277,6 +283,40 @@ type bootWatch struct {
// 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
// memoryAt: when logMemory wrote its last line (zero: not yet).
memoryAt time.Time
}

// bootWatchMemoryInterval: how often the watch writes the emulator's memory to its log.
const bootWatchMemoryInterval = 10 * time.Minute

// The memory counters behind logMemory; tests replace them.
var (
processMemory = host.GetProcessMemory
systemMemory = host.GetSystemMemory
)

// logMemory writes one line with the memory of qemu and of the computer, at the watch's start and then every
// bootWatchMemoryInterval: a report that the computer got slow after hours of running can then be told apart into
// the emulator's commit growing (a leak on the host side) and the computer simply being short of memory. Nothing
// where the counters cannot be read.
func (w *bootWatch) logMemory(now time.Time) {
if !w.memoryAt.IsZero() && now.Sub(w.memoryAt) < bootWatchMemoryInterval {
return
}
pm, err := processMemory(w.q)
if err != nil {
return
}
w.memoryAt = now
const mb = 1 << 20
msg := fmt.Sprintf("实例 %d 模拟器内存(qemu pid %d):提交 %d MB(最高 %d MB),工作集 %d MB", w.i, w.q.PID,
pm.Commit/mb, pm.PeakCommit/mb, pm.WorkingSet/mb)
if sm, err := systemMemory(); err == nil {
msg += fmt.Sprintf(";电脑:物理内存可用 %d / %d MB,提交 %d / %d MB", sm.AvailPhys/mb, sm.TotalPhys/mb,
sm.Committed/mb, sm.CommitLimit/mb)
}
w.m.Log.Infof("%s", msg)
}

// 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
Expand Down
76 changes: 76 additions & 0 deletions internal/emu/bootwatch_memory_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//go:build windows

package emu

import (
"errors"
"os"
"strings"
"testing"
"time"

"modavd/internal/host"
"modavd/internal/logx"
)

// logMemory writes a line at the first look and then once per bootWatchMemoryInterval, with qemu's and the computer's
// numbers in MB; nothing while qemu's counters cannot be read, and only qemu's part without the computer's.
func TestBootWatchLogMemory(t *testing.T) {
savedP, savedS := processMemory, systemMemory
t.Cleanup(func() { processMemory, systemMemory = savedP, savedS })
const mb = 1 << 20
var perr, serr error
processMemory = func(p host.Process) (host.ProcessMemory, error) {
if p.PID != 4242 {
t.Errorf("asked for pid %d", p.PID)
}
return host.ProcessMemory{Commit: 6100 * mb, PeakCommit: 6200 * mb, WorkingSet: 4300 * mb}, perr
}
systemMemory = func() (host.SystemMemory, error) {
return host.SystemMemory{TotalPhys: 15728 * mb, AvailPhys: 4400 * mb, CommitLimit: 41574 * mb, Committed: 38600 * mb}, serr
}
m, lines := recordingManager()
w := &bootWatch{m: m, i: 3, q: host.Process{PID: 4242, Created: 1}}
t0 := time.Date(2026, 11, 12, 20, 0, 0, 0, time.UTC)

perr = errors.New("gone")
w.logMemory(t0)
if len(*lines) != 0 {
t.Fatalf("logged without counters: %+v", *lines)
}
perr = nil
w.logMemory(t0)
w.logMemory(t0.Add(bootWatchMemoryInterval - time.Second))
if len(*lines) != 1 {
t.Fatalf("lines = %+v, want one", *lines)
}
want := "实例 3 模拟器内存(qemu pid 4242):提交 6100 MB(最高 6200 MB),工作集 4300 MB;电脑:物理内存可用 4400 / 15728 MB,提交 38600 / 41574 MB"
if got := (*lines)[0]; got.level != logx.Info || got.msg != want {
t.Errorf("line = %v %q\nwant %q", got.level, got.msg, want)
}
serr = errors.New("no")
w.logMemory(t0.Add(bootWatchMemoryInterval))
if len(*lines) != 2 || !strings.HasSuffix((*lines)[1].msg, "工作集 4300 MB") {
t.Errorf("without the computer's numbers: %+v", *lines)
}
}

// The real counters read this test process and the computer.
func TestMemoryCounters(t *testing.T) {
p, ok := host.FindProcess(uint32(os.Getpid()))
if !ok {
t.Fatal("own process not found")
}
pm, err := host.GetProcessMemory(p)
if err != nil || pm.Commit == 0 || pm.WorkingSet == 0 || pm.PeakCommit < pm.Commit {
t.Errorf("process memory = %+v, %v", pm, err)
}
sm, err := host.GetSystemMemory()
if err != nil || sm.TotalPhys == 0 || sm.AvailPhys > sm.TotalPhys || sm.CommitLimit < sm.TotalPhys ||
sm.Committed == 0 || sm.Committed > sm.CommitLimit {
t.Errorf("system memory = %+v, %v", sm, err)
}
if _, err := host.GetProcessMemory(host.Process{PID: p.PID, Created: p.Created + 1}); err == nil {
t.Error("a reused PID read as the process")
}
}
14 changes: 0 additions & 14 deletions internal/emu/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,20 +79,6 @@ func (m *Manager) Check(ctx context.Context, opt CheckOptions) (*CheckResult, er
return res, nil
}

// RootReady reports whether the checks shared by every instance passed. Memory
// depends on the selected instance and is checked again before every launch.
func (r *CheckResult) RootReady() bool {
for k, item := range r.Items {
if item.ID == "memory" {
continue
}
if _, failed := firstBlockingFailure(r.Items[k : k+1]); failed {
return false
}
}
return true
}

// ComponentStatuses is root's component list in the JSON shape of check's "components" (every field always present),
// which `components status` returns too. Never nil.
func ComponentStatuses(comps []root.ComponentStatus) []ComponentStatus {
Expand Down
16 changes: 16 additions & 0 deletions internal/host/memory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package host

// ProcessMemory is the memory of one process, in bytes.
type ProcessMemory struct {
Commit uint64 // private commit charge (Task Manager's "Commit size")
PeakCommit uint64 // the highest Commit has been
WorkingSet uint64 // in physical memory now
}

// SystemMemory is the memory of the computer, in bytes.
type SystemMemory struct {
TotalPhys uint64
AvailPhys uint64
CommitLimit uint64 // physical memory plus page files
Committed uint64
}
6 changes: 6 additions & 0 deletions internal/host/unsupported_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ func SetAffinity(Process, uint64) error { return errUnsupported }
// GetAffinity calls GetProcessAffinityMask on Windows.
func GetAffinity(Process) (uint64, error) { return 0, errUnsupported }

// GetProcessMemory calls GetProcessMemoryInfo on Windows.
func GetProcessMemory(Process) (ProcessMemory, error) { return ProcessMemory{}, errUnsupported }

// GetSystemMemory calls GlobalMemoryStatusEx on Windows.
func GetSystemMemory() (SystemMemory, error) { return SystemMemory{}, errUnsupported }

// L3Groups lists the L3 caches of the host.
func L3Groups() []L3Group { return nil }

Expand Down
12 changes: 12 additions & 0 deletions internal/host/windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ func FindProcess(pid uint32) (Process, bool) { return win.FindProcess(pid) }
func ProcessName(pid uint32) string { return win.ProcessName(pid) }
func KillTree(p Process) error { return win.KillTree(p) }

// GetProcessMemory reads the memory counters of p; an error when p is gone or cannot be opened.
func GetProcessMemory(p Process) (ProcessMemory, error) {
commit, peak, ws, err := p.Memory()
return ProcessMemory{Commit: commit, PeakCommit: peak, WorkingSet: ws}, err
}

// GetSystemMemory reads the computer's physical memory and commit charge.
func GetSystemMemory() (SystemMemory, error) {
total, avail, limit, committed, err := win.SystemMemory()
return SystemMemory{TotalPhys: total, AvailPhys: avail, CommitLimit: limit, Committed: committed}, err
}

func StartDetached(exe string, args []string, opt StartOptions) (Process, error) {
return win.StartDetached(exe, args, opt)
}
Expand Down
63 changes: 63 additions & 0 deletions internal/report/collect.go
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,62 @@ func otherRootsInstance(plan root.PortPlan, avd string, port int) bool {
return err == nil && port != plan.Console(i)
}

// topMemoryCount: how many programs topMemory lists.
const topMemoryCount = 10

// topMemory lists the programs that hold the most memory, processes of one name added up, by commit: when the
// computer is short of memory the list says whether it is the emulator or something else. No section when no
// process's memory could be read.
func (c *collector) topMemory(procs []Process) {
type sum struct {
name string
n int
commit, workingSet uint64
}
byName := map[string]*sum{}
var total uint64
for _, p := range procs {
if p.CommitBytes == 0 {
continue
}
key := strings.ToLower(p.Name)
s := byName[key]
if s == nil {
s = &sum{name: p.Name}
byName[key] = s
}
s.n++
s.commit += p.CommitBytes
s.workingSet += p.WorkingSetBytes
total += p.CommitBytes
}
if len(byName) == 0 {
return
}
sums := make([]*sum, 0, len(byName))
for _, s := range byName {
sums = append(sums, s)
}
sort.Slice(sums, func(a, b int) bool {
if sums[a].commit != sums[b].commit {
return sums[a].commit > sums[b].commit
}
return sums[a].name < sums[b].name
})
c.section(fmt.Sprintf("占内存最多的程序(按提交排,前 %d 个;同名进程合在一起)", topMemoryCount))
for k, s := range sums {
if k == topMemoryCount {
break
}
count := ""
if s.n > 1 {
count = fmt.Sprintf("(%d 个进程)", s.n)
}
c.L(fmt.Sprintf("%s%s:提交 %s,工作集 %s", s.name, count, formatSize(float64(s.commit)), formatSize(float64(s.workingSet))))
}
c.L(fmt.Sprintf("读得到的 %d 个程序合计提交 %s", len(sums), formatSize(float64(total))))
}

func (c *collector) runningAndAdb() error {
c.section("正在运行的 qemu-system 进程")
qs, err := c.h.Processes(func(name string) bool { return hasPrefixFold(name, "qemu-system") })
Expand All @@ -751,6 +807,10 @@ func (c *collector) runningAndAdb() error {
ct = q.Started.Local().Format("2006-01-02 15:04:05")
}
c.L(fmt.Sprintf("pid %d 启动于 %s", q.PID, ct))
if q.CommitBytes != 0 {
c.L(fmt.Sprintf(" 内存:提交 %s,工作集 %s(隔 10 分钟的记录在 modavd-bootwatch.log)",
formatSize(float64(q.CommitBytes)), formatSize(float64(q.WorkingSetBytes))))
}
cmd := q.CommandLine
if cmd == "" {
c.L(" qemu 命令行读不到(多半是模拟器以管理员身份运行),现场按 adb 设备列表推算")
Expand All @@ -775,6 +835,9 @@ func (c *collector) runningAndAdb() error {
}
}
c.running = running
if all, err := c.h.Processes(func(string) bool { return true }); err == nil {
c.topMemory(all)
}

c.section("正在监听的 adb server")
ls, err := c.adbListeners()
Expand Down
3 changes: 3 additions & 0 deletions internal/report/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ type Process struct {
Started time.Time // zero when unknown
CommandLine string // "" when unreadable
ExePath string // "" when unreadable
// CommitBytes, WorkingSetBytes: private commit charge and working set; both 0 when unreadable.
CommitBytes uint64
WorkingSetBytes uint64
}

type Listener struct {
Expand Down
3 changes: 3 additions & 0 deletions internal/report/host_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,9 @@ func (h *winHost) Processes(match func(name string) bool) ([]Process, error) {
} else {
q.ExePath, _ = p.ImagePath()
}
if pm, err := host.GetProcessMemory(p); err == nil {
q.CommitBytes, q.WorkingSetBytes = pm.Commit, pm.WorkingSet
}
out = append(out, q)
}
return out, nil
Expand Down
Loading
Loading