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: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ jobs:
python -B -I -X utf8 scripts/repo_checks.py
- name: script tests
run: python -B -I -X utf8 scripts/test_repo_checks.py
- name: trim measurement tests
run: python -B -I -X utf8 scripts/test_measure_trim.py

portable:
strategy:
Expand Down
4 changes: 4 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ modavd mcp --root D:\ModAVD

It supports both MCP 2026-07-28 (stateless, `server/discover`) and 2025-11-25 / 2025-06-18 (`initialize` handshake). The root folder is fixed at startup and is not a tool parameter. Tools: `list_instances`, `get_instance`, `check_host`, `create_instance`, `update_instance`, `delete_instance`, `start_instance` (with progress), `stop_instance`, `set_window_visible`, `shell`, `install_apk`, `disk_info` (data disk size), `trim_disk` (reclaim space while running; the emulator must support discard), `compact_disk` (shrink the data disk; the instance must be stopped), `get_app_proxy`, `set_app_proxy` (per-app proxy).

`disk <i> trim`, the GUI trim job, and `trim_disk` return after requesting asynchronous guest maintenance.
Success does not confirm that guest FITRIM finished or succeeded. The automatic trim log measures only the
adb/su command round trip. Stable discard counters alone cannot establish completion.

To register it in Claude Code (in PowerShell, `--` needs quotes, or it gets swallowed):

```powershell
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数]
| `start <i> [--window \| --headless] [--timeout 300] [--no-bridge]` | 开机(默认无头,`--window` 带窗口),等开完并做完开机后的设置,返回前确认 `/sdcard` 能写进文件(一般这时早已就绪;最多再等 60 秒,到点只警告);实例正在关机时(比如刚点了模拟器窗口的 ×)先等它关完(最多 30 秒)再开机;实例还没有执行桥时接着装上,`--no-bridge` 这次不装。运行中不换方式:实例已经在运行时,给了 `--window` / `--headless` 而它不是这种方式,就报错(退出码 5),不接管也不重启,要换先 `stop` 再 `start`;两个都不给就照原样接管 |
| `stop <i> [--force] [--cancel-create]`、`stop --all [--force]` | 关机:前台开着应用时先按 HOME,等 2 秒让它保存;再 sync,让安卓正常关机(前台应用先收到暂停、停止),不成再 sync + kill;`--force` 是 sync + 强杀。新建中默认拒绝,`--cancel-create` 显式取消;`--all` 也关闭新建中的实例,结果标记 `interrupted_create: true` |
| `bridge <i> install\|uninstall\|verify` | 装、卸、检查 arm64 执行桥;开机时会自动装,一般不用手动跑(见下) |
| `disk <i> trim` | 磁盘回收:在开着的实例里对数据盘触发一次 FITRIM,让模拟器释放已删文件占着的空间;模拟器太旧、不支持回收时返回不支持(退出码 4) |
| `disk <i> trim` | 磁盘回收:在开着的实例里对数据盘触发一次 FITRIM;成功只表示触发请求已返回,不等待或确认客体回收完成;模拟器太旧、不支持回收时返回不支持(退出码 4) |
| `disk <i> info` | 看数据盘的大小:逻辑大小、覆盖层在宿主上实际占的空间;关机且取得启动锁后才运行只读的 qcow2 元数据检查,给出已分配和估计可回收大小。整理中或锁不可用时,仅返回可读取的文件头、宿主占用和原因;不可读的数值省略,不修复镜像 |
| `disk <i> compact` | 整理已关机实例的数据盘;预计收益不足时跳过重写;转换后先校验再原子替换,原盘在替换成功前不动;报告前后占用和增减;大盘预算可达数十小时,正常整理基本不会超时,停止请取消;仅支持 NTFS/ReFS,拒绝快照或重写空间不足,详见[预算与结果契约](internal/emu/COMPACT.md) |
| `check [--memory M]` | 开机前的电脑检查 |
Expand All @@ -84,6 +84,10 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数]
- 根目录:`--root`,没给就用环境变量 `MODAVD_ROOT`,再没有就用 `modavd.exe` 所在目录或它的上一级。
- 0.3.0 起没有 `restart`:先 `stop` 再 `start`。旧的调用报用法错误(退出码 2),提示改用这两步。

`trim` 的 GUI 任务完成、MCP 成功返回和开机后自动回收日志都只表示触发请求已返回。
自动回收日志里的秒数是 adb/su 命令往返耗时。客体是否完成或失败,需要另看客体信号,
不能用暂时稳定的 discard 计数判定。

**arm64 执行桥**:

- 只对内置名单里的游戏生效,其余应用照旧走原来的转译层。桥装在实例自己的 system / vendor 覆盖层里,要 0.36 起的模拟器。
Expand Down
1 change: 1 addition & 0 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ func init() {
name: "disk", args: "<i> trim|info|compact", summary: "磁盘回收:触发回收或整理把客体已删文件占的空间还给宿主;看数据盘大小",
details: "trim:在客体的数据盘(/data)上触发一次回收(FITRIM,经 vold 空闲维护)," +
"让模拟器释放已删除文件在数据盘镜像里占着的空间。实例要开着并已开机完成;" +
"命令成功只表示已触发,不等待客体回收完成,也不确认回收成功。" +
"模拟器版本太旧、数据盘不支持回收时返回不支持(退出码 4),不会白跑。" +
"info:报数据盘的逻辑大小、在宿主上实际占的空间,以及(实例关机时)已分配的大小和估计能回收多少;" +
"实例开着时已分配和可回收的数要关机后才有,只读,不改任何东西。" +
Expand Down
101 changes: 101 additions & 0 deletions internal/emu/TRIM.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# trim 时机测量

本文记录 trim 请求的异步契约及复跑方法。具体版本的测量结果保存在 [ModAVD/ModAVD#95 的评论](https://github.com/ModAVD/ModAVD/issues/95#issuecomment-6654569636)中。

## 返回和日志分别表示什么

| 入口或信号 | 含义 |
| --- | --- |
| `modavd disk <i> trim` 成功及 `DiskTrimResult` | 管理器确认实例已开机、`vdc` 宣告支持 discard,随后 adb/su 中的 `sm idle-maint run` 成功返回。未等待或确认客体 FITRIM 成功。CLI 保留“已触发磁盘回收” |
| `diskTrimTimeout` 的 2 分钟、`autoTrimTimeout` 的 30 秒 | 请求往返的超时,不限制后台维护的执行时间 |
| 开机后自动 trim 日志里的秒数 | `runIdleMaint` 调用前后的宿主时间差,即 adb/su 请求往返,不是客体回收耗时。`diskTrimAt` 仍是上次自动尝试的时间 |
| GUI `disk-trim` 的 `done`、100%、`finished_at` | 宿主请求任务返回。时间差还包含管理器检查与任务开销。进行中显示“已进行”,请求结束后显示“已触发”和“触发请求用时” |
| MCP `trim_disk` 成功 | 同一个 `DiskTrim` 返回,没有额外客体完成检查。`discard_max_bytes` 是能力声明,不是本轮回收字节数 |
| vold `Starting trim of /data` | vold 开始这一轮 `/data` trim 尝试,并不证明已经成功提交 discard |
| vold `Trimmed … bytes on /data in …ms` | 对应 `/data` FITRIM 的明确成功返回信号。脚本要求窗口内只有一次开始,且开始、返回的进程和线程相同,返回日志必须排在开始日志之后 |
| vold `Trim failed on /data: …` | 该次 `/data` trim 失败。不能因为 `sm` 退出 0 就忽略 |
| vold `idle maintenance completed` | 维护线程结束,可能包含其它挂载点及失败的 `/data` trim。不能单独用来判定 `/data` 成功 |
| `/proc/diskstats` discard 计数 | 内核设备累计的 discard 完成请求数、合并数、512 字节扇区数及毫秒数。计数变化是旁证,暂时稳定不能证明本轮结束 |

调用链是 `internal/cli/commands.go`、`internal/ui/disk.go` 或 `internal/mcp/tools.go`
→ `internal/emu/disk.go:DiskTrim` → `runIdleMaint` → `Manager.su` → 私有 adb server。
自动触发来自 `internal/emu/autotrim.go`。管理器的这些路径都没有读取客体完成信号。
脚本用正式包的客体日志观测 Android 行为,不需要拉取 AOSP 源码。

FITRIM 返回也不等于宿主 qcow2 文件长度已缩小,日志中的字节数不能当作宿主释放字节数。
没有匹配的明确返回、日志缺失或出现重叠轮次时,脚本输出 `unknown`,不根据计数或等待时长补判成功。

## 复跑

需要 Windows、Python 3.10+、已补全组件的现有正式包,以及从待测源码构建的管理器。
不需要下载或同步 AOSP。先把正式包及 SDK **物理复制**到一个新的专用根,
不要复制已有 `avd`、实例配置或使用共享数据盘。为新根写入独立配置,例如:

```json
{"portBase":27400,"adbServerPort":27498,"scriptAdbServerPort":27499,"instances":{}}
```

先确认整段端口空闲,而且没有其它验收正在使用该环境。只有一套可用环境时排队。
以下端口是示例,复跑时需重新确认。脚本要求三个端口配置都是 JSON 整数,两种 adb server 端口必须不同,
且不占实例端口、生产段或 adb 扫描端口;整个 100 端口段也必须避开生产及 adb 扫描范围。
脚本在发出 adb 命令前检查整段与两种 server 端口的 TCP/UDP 占用,
只允许当前实例的专用 qemu 和本根 adb 在其指定 TCP 端口监听;未能核实进程路径时拒绝继续。
这只是开始时的快照,不能替代测试环境独占。

```powershell
$root = 'C:\Temp\modavd-trim-test'
$manager = (Resolve-Path '.\out\tools\modavd.exe').Path
& $manager --root $root create --index 0 --memory 4096 --cpu 4 --data-gb 16 --no-bridge
```

新建完成会正常关机。为排除开机后的自动请求,在**该专用根**的 `mas-avd.json` 中
将 `instances.0.diskTrimAt` 设为当前 Unix 秒,再开机,不改其它实例:

```powershell
$metaPath = Join-Path $root 'mas-avd.json'
$meta = Get-Content -LiteralPath $metaPath -Raw | ConvertFrom-Json -AsHashtable
$meta.instances['0'].diskTrimAt = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$meta | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $metaPath -Encoding utf8NoBOM
& $manager --root $root start 0 --headless --no-bridge

python -B -X utf8 scripts/measure_trim.py --root $root --manager $manager --command cli --write-mib 128 --exclusive-test-instance --output cli-work.json
python -B -X utf8 scripts/measure_trim.py --root $root --manager $manager --command cli --exclusive-test-instance --output cli-idle.json
python -B -X utf8 scripts/measure_trim.py --root $root --manager $manager --command sm --write-mib 128 --exclusive-test-instance --output sm-work.json
python -B -X utf8 scripts/measure_trim.py --root $root --manager $manager --command sm --exclusive-test-instance --output sm-idle-1.json
python -B -X utf8 scripts/measure_trim.py --root $root --manager $manager --command sm --exclusive-test-instance --output sm-idle-2.json
```

每次输出使用新文件名。`--write-mib` 默认为 0,最多 256 MiB;非零时,脚本只在
`/data/local/tmp/modavd-trim-measure-<随机值>` 新建目录,以 `/dev/urandom` 实际写入文件,
fsync、sync、记录 `du` 后删掉文件并再次 sync。最后只清理这个文件和空目录。
脚本不装应用、不整理磁盘、不换包、不关机。

`--exclusive-test-instance` 明确声明本实例由本次测试独占。根内的排他文件只防止两份测量脚本重叠,
不能阻止其它管理器或 adb 使用者。采样期间不要让其它会话对本实例 trim、compact、安装、换包或关机。
每轮完成后核对结果;若缺少明确结束信号,先查明是否仍有维护在执行,再触发下一轮。

脚本从 mountinfo 的 `/data` 根挂载读取 major:minor,递归读取 sysfs `slaves`,
保存每层设备名称、discard 上限并按设备号筛选 diskstats,不假定 `/data` 一定直接挂在 `vdc`。
logcat 经 `su` 仅读取 `main,system`,避免非 root logcat 的内核读取权限错误;dmesg 另经 su 采集。
每轮保存 boot ID、内核和二进制 SHA-256、vold 单调时间日志、dmesg 前后快照,
以及默认约 10 秒的 diskstats 采样。采样循环的 sleep 为 0.1 秒,实际间隔还包含读数开销,
以记录的客体 uptime 为准。采样有固定上限,主机断连也不会留下无限循环。

`command.sent_at` / `returned_at` 使用宿主 UTC,`round_trip_seconds` 使用宿主单调时钟。
CLI 模式测量整个 CLI 的检查和请求,不假装知道其内部 `sm` 精确发出时刻。
`sm` 模式的 stdout 另存客体 `SM_SENT`、`SM_RETURNED` 与 uptime,包住实际 `sm` 调用。
vold 与 diskstats 使用同一客体 uptime 时基,不将宿主墙钟直接减去客体时间。

根目录字段只记录目录名,不保存绝对路径。命令输出和完整 dmesg 仍可能包含路径、用户名或其它本机信息,
公开原始 JSON 前检查并脱敏。测试结束、确认采样退出后,再正常关闭专用实例及私有 adb server。

## 自动验证

```powershell
python -B -I -X utf8 scripts/test_measure_trim.py
go test -count=1 -run 'TestDiskJobs|TestDiskTrim|TestToolDefinitions|TestToolCalls' ./internal/ui ./internal/cli ./internal/mcp
```

解析测试覆盖明确返回、零字节返回、真实失败后维护仍结束、缺日志、旧日志、其它挂载点、
设备号、缺少 discard 字段、进程或线程不匹配、重叠轮次、窗口外日志及稳定计数。
GUI HTTP 测试检查完成消息只说“已触发”,MCP 测试检查工具公开说明,CLI 现有测试保留准确原文。
11 changes: 5 additions & 6 deletions internal/emu/autotrim.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package emu

// The automatic trim of the data disk after boot (ModAVD/ModAVD#2, design §3.3 / §3.6): once the boot setup is done
// and the emulator advertises discard on /data, the clusters the guest freed are handed back (the same guest trim as
// and the emulator advertises discard on /data, request reclamation of freed clusters (the same guest request as
// `modavd disk <i> trim`), at most once per 24 hours per instance, so nobody has to run it by hand.
//
// Strictly best effort and never in the boot's way: every problem (no discard, su failed, mas-avd.json unreadable or
Expand All @@ -21,9 +21,8 @@ const (
autoTrimInterval = 24 * time.Hour
// autoTrimProbeTimeout bounds the read of the discard limit.
autoTrimProbeTimeout = 10 * time.Second
// autoTrimTimeout bounds the trim itself, which the start waits for. Far shorter than the manual diskTrimTimeout:
// a boot must not wait minutes for it. A trim that did not finish in time is not tried again before the interval
// is over (see autoTrim).
// autoTrimTimeout bounds the sm request, which the start waits for. It does not
// bound or await vold's asynchronous FITRIM. See autoTrim for retry scheduling.
autoTrimTimeout = 30 * time.Second
)

Expand Down Expand Up @@ -90,9 +89,9 @@ func (m *Manager) autoTrim(ctx context.Context, i int, mode bootSetup) {
return
}
if res.Code == 0 {
m.Log.Infof("实例 %d 开机后自动回收磁盘空间(%.1f 秒)", i, time.Since(started).Seconds())
m.Log.Infof("实例 %d 开机后已触发磁盘回收(命令往返 %.1f 秒,未等待客体回收完成)", i, time.Since(started).Seconds())
} else {
m.Log.Warnf("实例 %d 开机后自动回收磁盘空间没成功,%.0f 小时后再试: %s", i, autoTrimInterval.Hours(),
m.Log.Warnf("实例 %d 开机后触发磁盘回收失败,%.0f 小时后再试: %s", i, autoTrimInterval.Hours(),
orText(tail(res.Out, 200), fmt.Sprintf("退出码 %d", res.Code)))
}
if err := m.writeDiskTrimAt(i, time.Now().Unix()); err != nil {
Expand Down
16 changes: 8 additions & 8 deletions internal/emu/disk.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,8 @@ const (
userdataBacking = "userdata-qemu.img"
)

// diskTrimTimeout bounds the guest idle-maintenance run. A first full trim of a
// large /data walks all the free space and can take a few seconds; later trims
// are quick. Generous so a big first pass does not time out.
// diskTrimTimeout bounds the sm request's adb/su round trip, not the asynchronous
// vold maintenance or FITRIM. A successful return does not confirm guest completion.
const diskTrimTimeout = 2 * time.Minute

// vdcDiscardMaxPath is the data disk's advertised discard limit in the guest. 0
Expand All @@ -46,12 +45,12 @@ const diskTrimTimeout = 2 * time.Minute
// backport, where a trim would silently do nothing.
const vdcDiscardMaxPath = "/sys/block/vdc/queue/discard_max_bytes"

// DiskTrimResult reports what `modavd disk <id> trim` did.
// DiskTrimResult reports a successful trim request, not guest FITRIM completion.
type DiskTrimResult struct {
Index int `json:"index"`
// DiscardMaxBytes is the data disk's advertised discard limit (guest
// /sys/block/vdc/queue/discard_max_bytes); >0 confirms the emulator's
// discard path is live.
// discard capability is advertised. It does not confirm a completed discard.
DiscardMaxBytes int64 `json:"discard_max_bytes"`
}

Expand All @@ -74,15 +73,16 @@ func (m *Manager) probeDiscardMax(ctx context.Context, i int, timeout time.Durat
return parseDiscardMaxBytes(probe.Out)
}

// runIdleMaint triggers the guest's vold idle maintenance (it runs fstrim on the mounted file systems). Shared by
// DiskTrim and the best-effort trim after boot (autotrim.go).
// runIdleMaint asks vold to run idle maintenance asynchronously. The result and timeout
// describe only the sm request. Shared by DiskTrim and the best-effort boot request.
func (m *Manager) runIdleMaint(ctx context.Context, i int, timeout time.Duration) adb.Result {
return m.su(ctx, i, "sm idle-maint run", timeout)
}

// DiskTrim triggers a guest FITRIM on /data (via vold idle maintenance) so the
// virtio-blk discard path frees the qcow2 clusters behind deleted files. The
// instance must be running and booted. errs.Unsupported when the instance is
// result confirms only that sm returned successfully, not that FITRIM finished or
// succeeded. The instance must be running and booted. errs.Unsupported when the instance is
// not up, or when the emulator does not advertise discard on the data disk (an
// old build); errs.Internal when the guest command fails.
func (m *Manager) DiskTrim(ctx context.Context, i int) (*DiskTrimResult, error) {
Expand Down
6 changes: 5 additions & 1 deletion internal/emu/qemu_img_lifetime_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ func lifetimeChild() int {
return 93
}
defer syscall.CloseHandle(h)
if err := os.WriteFile(path+".ready", []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil {
ready := path + ".ready"
if err := os.WriteFile(ready+".tmp", []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil {
return 94
}
if err := os.Rename(ready+".tmp", ready); err != nil {
return 94
}
deadline := time.Now().Add(time.Minute)
Expand Down
4 changes: 2 additions & 2 deletions internal/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ var instructions = fmt.Sprintf("Manages the ModAVD (patched Android Emulator) in
"is fixed by the server's --root; instances are numbered 0-%d except %d. Always shut instances down with "+
"stop_instance, which syncs the guest disk first: never kill emulator or qemu processes, that loses recently "+
"written game data. start_instance can take minutes and reports progress; delete_instance permanently removes "+
"an instance with all its game data. disk_info shows how big an instance's data disk is; trim_disk reclaims "+
"unused space of a running instance, compact_disk shrinks the file of a stopped one.", root.MaxIndex, root.ReservedIndex)
"an instance with all its game data. disk_info shows how big an instance's data disk is; trim_disk requests "+
"reclamation in a running instance without waiting for guest completion; compact_disk shrinks the file of a stopped one.", root.MaxIndex, root.ReservedIndex)

// progressEvery throttles progress notifications that do not complete a step.
const progressEvery = time.Second
Expand Down
Loading
Loading