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
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,16 @@ jobs:
run: go vet ./...
- name: go test
run: go test ./...

create-cancel:
runs-on: windows-2025
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
- name: create cancellation menu tests
run: node --experimental-vm-modules --test scripts/test_create_cancel.mjs
4 changes: 2 additions & 2 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ modavd [--root <dir>] [--json] [--quiet] [--verbose] <command> [args]
| `proxy <i> [on\|off\|test] [--server URL\|auto] [--add PKG] [--remove PKG] [--apps PKG]` | Per-app proxy: the listed apps go through a proxy, the others direct (see below) |
| `delete <i> --yes` | Delete a stopped instance, together with its game data |
| `start <i> [--window \| --headless] [--timeout 300] [--no-bridge]` | Start (headless by default, with the window for `--window`), wait until boot finishes and the post-boot setup is done, and before returning confirm that files can be written to `/sdcard` (normally it is ready long before this; it waits up to 60 more seconds and only warns at the deadline). If the instance is shutting down (for example, right after a click on the emulator window's ×), it first waits for the shutdown to finish (up to 30 seconds) and then starts it. If the instance has no execution bridge yet, install it afterward; `--no-bridge` skips that this time. No switch while it runs: if the instance is already running and `--window` / `--headless` asks for the other way, it fails (exit code 5) without taking it over or restarting it; to switch, `stop` and then `start`. With neither, it is taken over as it runs |
| `stop <i> [--force]`, `stop --all` | Shut down: an app in the foreground is first sent home and given 2 s to save; then sync and ask Android for a clean shutdown (the foreground app is paused and stopped first), and if that fails, sync + kill; `--force` is sync + force-kill |
| `stop <i> [--force] [--cancel-create]`, `stop --all [--force]` | Shut down: an app in the foreground is first sent home and given 2 s to save; then sync and ask Android for a clean shutdown (the foreground app is paused and stopped first), and if that fails, sync + kill; `--force` is sync + force-kill. Creating instances are refused unless `--cancel-create` is given. `--all` also stops creating instances and marks their results with `interrupted_create: true` |
| `bridge <i> install\|uninstall\|verify` | Install, uninstall or check the arm64 execution bridge; it is installed automatically at start, so you normally do not run it by hand (see below) |
| `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) |
Expand Down Expand Up @@ -118,7 +118,7 @@ modavd [--root <dir>] [--json] [--quiet] [--verbose] <command> [args]
- These files may contain account information and stay on this computer. In text mode, the last line of the error names the directory.
- Running instances gain `started_at_ms` (Unix milliseconds); the original `started_at` stays. `window` (from 0.3.0) is `true` while it runs with the emulator window, `false` when headless or not running. `stop --all` returns `ok:true` and the result list even if some instances failed, and exits with the exit code of the first failure.
- **Create (from 0.2.0)**: after creating the files, `create` starts the instance once in the background, headless, and finishes the post-boot setup, the host IME, first-boot initialization and the execution bridge (with one or two cold boots in between). Then it syncs first, shuts down cleanly and confirms that qemu has exited; only then is creation complete. This usually takes a few minutes (`--timeout` covers the whole process, default 1200 seconds; at the deadline it shuts down cleanly first and then records a failure). After that, the first `start` is as fast as usual. Instances created before 0.2.0 are unaffected and still finish this at their first `start`.
- During this, `status` in `status` / `list` / `info` is `creating` and `busy` is `creating` (`running` / `booted` of that boot may be `true`; go by `status`), and `creation_progress` (from 0.3.0) is the percentage done (0–100, counted by the steps done, not by time); `start`, `set`, `bridge`, `delete` and a `create` with the same index return instance busy (exit code 5). `stop` is not refused: it ends the creation (recorded as failed).
- During this, `status` in `status` / `list` / `info` is `creating` and `busy` is `creating` (`running` / `booted` of that boot may be `true`; go by `status`), and `creation_progress` (from 0.3.0) is the percentage done (0–100, counted by the steps done, not by time); `start`, `set`, `bridge`, `delete` and a `create` with the same index return instance busy (exit code 5). A single-instance `stop` also returns busy, even with `--force`. To cancel, use `stop <i> --cancel-create` or MCP `stop_instance` with `cancel_create: true`. It still syncs and shuts Android down normally; the creator receives cancellation and records `create_failed`, and the stop result has `interrupted_create: true`. `stop --all` allows cancellation automatically and marks each result; the field is `false` for instances that are not being created.
- A failure (including the create process being killed) is `create_failed`; `creation_error` gives the reason and `creation_log` is the log of that boot. After that, `start`, `set` and `bridge` return `not_ready` (exit code 4). Use `create --resume N` to redo the initialization (an emulator the failed one left running is shut down cleanly first), or `delete N --yes` to delete the instance.
- The new fields `creation` (`""` / `creating` / `failed`), `creation_error` and `creation_log` are always present. The state is kept in keys such as `instances.<i>.creationState` in `mas-avd.json` and removed when creation is done. Callers (AUTO-MAS) should treat any `status` value they do not recognize as busy.
- Progress: the CLI prints each step to stderr, each line starting with 「新建中 NN%」 (creating, NN %); MCP's `create_instance` sends progress notifications (when a progressToken is given); the manager shows 「新建中 NN%」 and a progress bar on the card (also for a creation started elsewhere), offers to cancel it under "More" (`更多`), and closing the window lets it finish in the background.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数]
| `proxy <i> [on\|off\|test] [--server URL\|auto] [--add 包名] [--remove 包名] [--apps 包名]` | 按应用走代理:名单里的应用走代理,其余直连(见下) |
| `delete <i> --yes` | 删除已关机的实例,连同游戏数据 |
| `start <i> [--window \| --headless] [--timeout 300] [--no-bridge]` | 开机(默认无头,`--window` 带窗口),等开完并做完开机后的设置,返回前确认 `/sdcard` 能写进文件(一般这时早已就绪;最多再等 60 秒,到点只警告);实例正在关机时(比如刚点了模拟器窗口的 ×)先等它关完(最多 30 秒)再开机;实例还没有执行桥时接着装上,`--no-bridge` 这次不装。运行中不换方式:实例已经在运行时,给了 `--window` / `--headless` 而它不是这种方式,就报错(退出码 5),不接管也不重启,要换先 `stop` 再 `start`;两个都不给就照原样接管 |
| `stop <i> [--force]`、`stop --all` | 关机:前台开着应用时先按 HOME,等 2 秒让它保存;再 sync,让安卓正常关机(前台应用先收到暂停、停止),不成再 sync + kill;`--force` 是 sync + 强杀 |
| `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> info` | 看数据盘的大小:逻辑大小、覆盖层在宿主上实际占的空间;实例关机时再给已分配的大小和估计能回收多少(开着时这两个数要关机后才有);只读 |
Expand Down Expand Up @@ -121,7 +121,7 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数]
- 这些文件可能带账号信息,只存在本机。文本模式出错时,最后一行写这个目录。
- 运行中的实例增加 `started_at_ms`(Unix 毫秒),原来的 `started_at` 保留。`window`(0.3.0 起):带窗口运行时为 `true`,无头运行或没开机时为 `false`。`stop --all` 即使部分失败也返回 `ok:true` 和结果列表,同时以第一个失败的退出码结束。
- **新建(0.2.0 起)**:`create` 建好文件后在后台无头开机一次,做完开机设置、宿主输入法、首次初始化和执行桥(中间冷启动一两次),再先 sync 后正常关机、确认 qemu 退出,才算新建完成,一般几分钟(`--timeout` 管整个过程,默认 1200 秒,到点先正常关机再记为失败)。之后第一次 `start` 和平常一样快。0.2.0 以前建的实例不受影响,照旧在第一次 `start` 时补做。
- 这期间 `status` / `list` / `info` 的 `status` 是 `creating`、`busy` 是 `creating`(这次开机的 `running` / `booted` 可能是 `true`,以 `status` 为准),`creation_progress`(0.3.0 起)是做完的百分比(0–100,按做完的步骤算,不是按时间);`start`、`set`、`bridge`、`delete`、同编号的 `create` 返回实例忙(退出码 5)。`stop` 不拦,等于中止新建(记为失败)。
- 这期间 `status` / `list` / `info` 的 `status` 是 `creating`、`busy` 是 `creating`(这次开机的 `running` / `booted` 可能是 `true`,以 `status` 为准),`creation_progress`(0.3.0 起)是做完的百分比(0–100,按做完的步骤算,不是按时间);`start`、`set`、`bridge`、`delete`、同编号的 `create` 返回实例忙(退出码 5)。单实例 `stop` 也返回 busy,`--force` 不绕过保护。确实要取消,用 `stop <i> --cancel-create`,或 MCP `stop_instance` 的 `cancel_create: true`;照常先 sync、正常关机,创建流程收到取消后记为 `create_failed`,结果带 `interrupted_create: true`。`stop --all` 自动允许取消,结果逐台标记;不在新建中的实例为 `false`。
- 失败(含新建的进程被结束)是 `create_failed`,`creation_error` 写原因、`creation_log` 是那次开机的日志;这时 `start`、`set`、`bridge` 返回 `not_ready`(退出码 4)。用 `create --resume N` 重做初始化(上次留下的模拟器还开着就先正常关掉),或者 `delete N --yes` 删掉。
- 新字段 `creation`(`""` / `creating` / `failed`)、`creation_error`、`creation_log` 一直出现;状态记在 `mas-avd.json` 的 `instances.<i>.creationState` 等键里,做完就删掉。调用方(AUTO-MAS)认不出的 `status` 取值一律按忙处理。
- 进度:命令行逐步打印到 stderr,每行带「新建中 NN%」;MCP 的 `create_instance` 发进度通知(带 progressToken 时);管理器在卡片上显示「新建中 NN%」和进度条(别处发起的新建也显示),「更多」里可以取消新建,关掉窗口会在后台做完。
Expand Down
13 changes: 9 additions & 4 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ var (
optCreateTimeout = flagSpec{"timeout", kindInt, "秒", "新建后的初始化最多做多少秒(默认 1200;到点先正常关机,记为新建失败)"}
optResume = flagSpec{"resume", kindInt, "N", "重做编号 N 的新建初始化(上次失败或被打断的,它开的模拟器还开着就先正常关机;其他选项不用)"}
optForce = flagSpec{"force", kindBool, "", "跳过 Android 正常关机:先 sync,再直接结束"}
optCancelCreate = flagSpec{"cancel-create", kindBool, "", "允许打断新建中的初始化,之后用 create --resume N 重做"}
optAll = flagSpec{"all", kindBool, "", "同时关掉所有在运行的实例"}
optShellTO = flagSpec{"timeout", kindInt, "秒", "命令最多执行多少秒(不给用默认值)"}
optCheckMB = flagSpec{"memory", kindString, "M", "按这个实例内存判断电脑内存够不够(默认 6G)"}
Expand Down Expand Up @@ -174,10 +175,12 @@ func init() {
run: runStart, human: humanOf(formatStart),
},
{
name: "stop", args: "<i> [--force] | stop --all [--force]", summary: "关机",
name: "stop", args: "<i> [--force] [--cancel-create] | stop --all [--force]", summary: "关机",
details: "先让 Android 正常关机,不成再 sync 后结束 qemu;--force 跳过正常关机,先 sync 再结束。" +
"--all 按进程表找出所有在运行的实例,同时关掉,结果分 results 和 failed,有失败时退出码是第一个失败的退出码。",
flags: []flagSpec{optForce, optAll},
"新建中的实例默认报 busy,--force 也不例外;确实要打断请加 --cancel-create,之后用 create --resume N 重做。" +
"--all 按进程表找出所有在运行的实例,同时关掉(包括新建中的),被打断的结果标记 interrupted_create: true;" +
"结果分 results 和 failed,有失败时退出码是第一个失败的退出码。",
flags: []flagSpec{optForce, optAll, optCancelCreate},
posNames: []string{"实例编号"}, minPos: 0, maxPos: 1,
final: true,
run: runStop, human: humanStop, exit: exitStop,
Expand Down Expand Up @@ -673,7 +676,7 @@ func runStart(c *cmdCtx) (any, error) {
}

func runStop(c *cmdCtx) (any, error) {
opt := emu.StopOptions{Force: c.inv.boolOpt("force")}
opt := emu.StopOptions{Force: c.inv.boolOpt("force"), CancelCreate: c.inv.boolOpt("cancel-create")}
if c.inv.boolOpt("all") {
if len(c.inv.pos) > 0 {
return nil, errs.New(errs.Usage, "stop --all 不能再给实例编号").WithAdvice(findCommand("stop").usageAdvice())
Expand Down Expand Up @@ -710,6 +713,8 @@ func runStop(c *cmdCtx) (any, error) {
// adbd, before the first stop begins; and a server it started would make each Stop trust it and skip its own "start
// the server and register" step, so an instance not registered yet would be killed without a sync.
func stopAll(c *cmdCtx, m manager, opt emu.StopOptions) (*StopAllResult, error) {
// A power action must also stop creations, with the same sync / graceful shutdown as other instances.
opt.CancelCreate = true
running, err := m.Running(c.ctx)
if err != nil {
return nil, err
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,9 @@ func stopLine(r *emu.StopResult) string {

func formatStop(w io.Writer, r *emu.StopResult) {
fmt.Fprintln(w, stopLine(r))
if r.InterruptedCreate {
fmt.Fprintf(w, " 已打断新建初始化,用 modavd create --resume %d 重做\n", r.Index)
}
writeWarnings(w, r.Warnings)
}

Expand Down
55 changes: 55 additions & 0 deletions internal/cli/stop_create_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//go:build windows

package cli

import (
"context"
"slices"
"strings"
"testing"

"modavd/internal/emu"
"modavd/internal/errs"
)

func TestStopCancelCreate(t *testing.T) {
for _, args := range [][]string{
{"stop", "3"}, {"stop", "3", "--force"}, {"stop", "3", "--cancel-create"},
{"stop", "--all"}, {"stop", "--all", "--force"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
h := newHarness()
h.fake.running = func(context.Context) ([]int, error) { return []int{3}, nil }
cancel := slices.Contains(args, "--all") || slices.Contains(args, "--cancel-create")
h.fake.stop = func(_ context.Context, i int, opt emu.StopOptions) (*emu.StopResult, error) {
if opt.CancelCreate != cancel || opt.Force != slices.Contains(args, "--force") {
t.Errorf("options %+v", opt)
}
if !opt.CancelCreate {
return nil, errs.New(errs.Busy, "正在新建").WithAdvice("用 --cancel-create 打断")
}
return &emu.StopResult{Index: i, Method: "graceful", Synced: true, InterruptedCreate: true}, nil
}
for _, jsonMode := range []bool{false, true} {
argv := append([]string{}, args...)
if jsonMode {
argv = append(argv, "--json")
}
r := h.run(t, nil, argv...)
if !cancel {
if r.code != 5 || !strings.Contains(r.stdout+r.stderr, "--cancel-create") {
t.Fatalf("busy: %+v", r)
}
} else {
want := "已打断新建初始化"
if jsonMode {
want = `"interrupted_create":true`
}
if r.code != 0 || !strings.Contains(r.stdout, want) {
t.Fatalf("canceled: %+v", r)
}
}
}
})
}
}
2 changes: 1 addition & 1 deletion internal/emu/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ func (m *Manager) bridgeUpdateBlocked(ctx context.Context, i int) (mountBlocker,

// bridgeRestart shuts instance i down and boots it cold, without the bridge step (the caller does that).
func (m *Manager) bridgeRestart(ctx context.Context, i int, opt StartOptions) (*StartResult, error) {
sr, err := m.Stop(ctx, i, StopOptions{})
sr, err := m.Stop(ctx, i, StopOptions{creating: opt.creating})
if err != nil {
return nil, err
}
Expand Down
81 changes: 81 additions & 0 deletions internal/emu/createcancel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package emu

import (
"context"
"errors"
"io/fs"
"os"
"path/filepath"
"time"

"modavd/internal/errs"
"modavd/internal/root"
"modavd/internal/win"
)

// A stop requests cancellation on disk so the creator, even in another process, also stops between boots and cannot
// report success after an interrupted final shutdown. The stop lock serializes requests with the start / end of an
// attempt; the create lock keeps its cancellation file from being reused by a later attempt.
func (m *Manager) createCancelPath(i int) string {
return filepath.Join(m.Root.AvdHome(), "."+root.AvdName(i)+".create.cancel")
}

func (m *Manager) createCanceled(i int) bool { return fileOrDirExists(m.createCancelPath(i)) }

// lockCreation clears a dead attempt's cancellation before publishing the new attempt to Stop.
func (m *Manager) lockCreation(ctx context.Context, i int) (*win.FileLock, error) {
stop, err := m.lock(ctx, i, "stop", stopLockWait, "正在关机")
if err != nil {
return nil, err
}
defer stop.Unlock()
lock, err := m.tryInstanceLock(i, "create", "正在新建")
if err != nil {
return nil, err
}
if err := os.Remove(m.createCancelPath(i)); err != nil && !errors.Is(err, fs.ErrNotExist) {
lock.Unlock()
return nil, errs.Wrap(errs.Internal, err, "清理新建取消标记失败")
}
return lock, nil
}

// watchCreateCancel stops the setup's context when another process requests cancellation. Its caller must stop
// and join the watcher before removing the file or releasing the create lock.
func (m *Manager) watchCreateCancel(ctx context.Context, cancel context.CancelFunc, i int) <-chan struct{} {
done := make(chan struct{})
go func() {
defer close(done)
tick := time.NewTicker(100 * time.Millisecond)
defer tick.Stop()
for {
if m.createCanceled(i) {
cancel()
return
}
select {
case <-ctx.Done():
return
case <-tick.C:
}
}
}()
return done
}

// finishCreation shares the stop lock with external cancellation: either a request wins and this fails, or this
// finishes first and a later stop sees a ready, offline instance. A late cancel can never be erased by success.
func (m *Manager) finishCreation(ctx context.Context, i int) error {
stop, err := m.lock(ctx, i, "stop", stopLockWait, "正在关机")
if err != nil {
return err
}
defer stop.Unlock()
if m.createCanceled(i) {
return errs.New(errs.Canceled, "新建初始化已被关机打断")
}
if err := ctxErr(ctx); err != nil {
return err
}
return m.setCreation(i, "", "", "")
}
Loading
Loading