From 705497a861d7566c0243c928779260ec3f527569 Mon Sep 17 00:00:00 2001
From: Ji Mao Cai <44921326+jinghero@users.noreply.github.com>
Date: Thu, 5 Nov 2026 10:18:54 +0800
Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=9D=E6=8A=A4=E6=96=B0=E5=BB=BA?=
=?UTF-8?q?=E5=AE=9E=E4=BE=8B=E5=85=8D=E4=BA=8E=E8=AF=AF=E5=85=B3=E6=9C=BA?=
=?UTF-8?q?=E5=B9=B6=E6=8F=90=E4=BE=9B=E6=98=BE=E5=BC=8F=E5=8F=96=E6=B6=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.en.md | 4 +-
README.md | 4 +-
internal/cli/commands.go | 13 +-
internal/cli/format.go | 3 +
internal/cli/stop_create_test.go | 55 +++++
internal/emu/bridge.go | 2 +-
internal/emu/createcancel.go | 81 +++++++
internal/emu/createinit.go | 20 +-
internal/emu/createinit_test.go | 3 +-
internal/emu/crud.go | 4 +-
internal/emu/manager.go | 3 +-
internal/emu/start.go | 3 +
internal/emu/stop.go | 19 +-
internal/emu/stop_create_test.go | 291 +++++++++++++++++++++++
internal/emu/types.go | 12 +-
internal/mcp/mcp_test.go | 6 +-
internal/mcp/stop_create_windows_test.go | 46 ++++
internal/mcp/tools.go | 11 +-
internal/ui/API.md | 2 +-
internal/ui/handlers.go | 5 +-
internal/ui/jobs_test.go | 37 +++
internal/ui/stop_create_windows_test.go | 54 +++++
internal/ui/web/js/actions.js | 1 +
internal/ui/web/js/cards.js | 4 +-
scripts/test_create_cancel.mjs | 72 ++++++
25 files changed, 723 insertions(+), 32 deletions(-)
create mode 100644 internal/cli/stop_create_test.go
create mode 100644 internal/emu/createcancel.go
create mode 100644 internal/emu/stop_create_test.go
create mode 100644 internal/mcp/stop_create_windows_test.go
create mode 100644 internal/ui/stop_create_windows_test.go
create mode 100644 scripts/test_create_cancel.mjs
diff --git a/README.en.md b/README.en.md
index 1458cad..a6952f6 100644
--- a/README.en.md
+++ b/README.en.md
@@ -67,7 +67,7 @@ modavd [--root
] [--json] [--quiet] [--verbose] [args]
| `proxy [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 --yes` | Delete a stopped instance, together with its game data |
| `start [--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 [--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 [--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 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) |
@@ -118,7 +118,7 @@ modavd [--root ] [--json] [--quiet] [--verbose] [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 --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..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.
diff --git a/README.md b/README.md
index a986b44..0da4f51 100644
--- a/README.md
+++ b/README.md
@@ -67,7 +67,7 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数]
| `proxy [on\|off\|test] [--server URL\|auto] [--add 包名] [--remove 包名] [--apps 包名]` | 按应用走代理:名单里的应用走代理,其余直连(见下) |
| `delete --yes` | 删除已关机的实例,连同游戏数据 |
| `start [--window \| --headless] [--timeout 300] [--no-bridge]` | 开机(默认无头,`--window` 带窗口),等开完并做完开机后的设置,返回前确认 `/sdcard` 能写进文件(一般这时早已就绪;最多再等 60 秒,到点只警告);实例正在关机时(比如刚点了模拟器窗口的 ×)先等它关完(最多 30 秒)再开机;实例还没有执行桥时接着装上,`--no-bridge` 这次不装。运行中不换方式:实例已经在运行时,给了 `--window` / `--headless` 而它不是这种方式,就报错(退出码 5),不接管也不重启,要换先 `stop` 再 `start`;两个都不给就照原样接管 |
-| `stop [--force]`、`stop --all` | 关机:前台开着应用时先按 HOME,等 2 秒让它保存;再 sync,让安卓正常关机(前台应用先收到暂停、停止),不成再 sync + kill;`--force` 是 sync + 强杀 |
+| `stop [--force] [--cancel-create]`、`stop --all [--force]` | 关机:前台开着应用时先按 HOME,等 2 秒让它保存;再 sync,让安卓正常关机(前台应用先收到暂停、停止),不成再 sync + kill;`--force` 是 sync + 强杀。新建中默认拒绝,`--cancel-create` 显式取消;`--all` 也关闭新建中的实例,结果标记 `interrupted_create: true` |
| `bridge install\|uninstall\|verify` | 装、卸、检查 arm64 执行桥;开机时会自动装,一般不用手动跑(见下) |
| `disk trim` | 磁盘回收:在开着的实例里对数据盘触发一次 FITRIM,让模拟器释放已删文件占着的空间;模拟器太旧、不支持回收时返回不支持(退出码 4) |
| `disk info` | 看数据盘的大小:逻辑大小、覆盖层在宿主上实际占的空间;实例关机时再给已分配的大小和估计能回收多少(开着时这两个数要关机后才有);只读 |
@@ -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 --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..creationState` 等键里,做完就删掉。调用方(AUTO-MAS)认不出的 `status` 取值一律按忙处理。
- 进度:命令行逐步打印到 stderr,每行带「新建中 NN%」;MCP 的 `create_instance` 发进度通知(带 progressToken 时);管理器在卡片上显示「新建中 NN%」和进度条(别处发起的新建也显示),「更多」里可以取消新建,关掉窗口会在后台做完。
diff --git a/internal/cli/commands.go b/internal/cli/commands.go
index 3f55353..369d164 100644
--- a/internal/cli/commands.go
+++ b/internal/cli/commands.go
@@ -70,6 +70,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)"}
@@ -173,10 +174,12 @@ func init() {
run: runStart, human: humanOf(formatStart),
},
{
- name: "stop", args: " [--force] | stop --all [--force]", summary: "关机",
+ name: "stop", args: " [--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,
@@ -670,7 +673,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())
@@ -707,6 +710,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
diff --git a/internal/cli/format.go b/internal/cli/format.go
index 3374052..48abf15 100644
--- a/internal/cli/format.go
+++ b/internal/cli/format.go
@@ -434,6 +434,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)
}
diff --git a/internal/cli/stop_create_test.go b/internal/cli/stop_create_test.go
new file mode 100644
index 0000000..1877022
--- /dev/null
+++ b/internal/cli/stop_create_test.go
@@ -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)
+ }
+ }
+ }
+ })
+ }
+}
diff --git a/internal/emu/bridge.go b/internal/emu/bridge.go
index 83781d8..edb509e 100644
--- a/internal/emu/bridge.go
+++ b/internal/emu/bridge.go
@@ -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
}
diff --git a/internal/emu/createcancel.go b/internal/emu/createcancel.go
new file mode 100644
index 0000000..5be69d8
--- /dev/null
+++ b/internal/emu/createcancel.go
@@ -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, "", "", "")
+}
diff --git a/internal/emu/createinit.go b/internal/emu/createinit.go
index 8ba784a..a3bd1ed 100644
--- a/internal/emu/createinit.go
+++ b/internal/emu/createinit.go
@@ -14,6 +14,7 @@ package emu
// the lock .mas_.create.lock, held for the whole setup. "creating" without anybody holding that lock is a create
// that was killed half way: it reads as failed. While it runs, the setup's percentage (createProgress, by the steps
// done) is in .mas_.create.progress, which status / list read as creation_progress.
+// An explicit stop cancellation uses .mas_.create.cancel to notify the creator across processes (createcancel.go).
import (
"bytes"
@@ -329,7 +330,14 @@ func (m *Manager) setupNew(ctx context.Context, i int, cfg *avd.Ini, req CreateR
timeout = DefaultCreateTimeout
}
cctx, cancel := context.WithTimeout(ctx, timeout)
- defer cancel()
+ canceled := m.watchCreateCancel(cctx, cancel, i)
+ defer func() {
+ cancel()
+ <-canceled
+ if err := os.Remove(m.createCancelPath(i)); err != nil && !errors.Is(err, fs.ErrNotExist) {
+ m.Log.Warnf("实例 %d 没清理新建取消标记: %v", i, err)
+ }
+ }()
bridge := m.bridgeWanted(i, cfg)
// the progress is also kept on disk, for status / list in other processes (AUTO-MAS, the manager)
defer m.removeCreateProgress(i)
@@ -375,6 +383,9 @@ func (m *Manager) setupNew(ctx context.Context, i int, cfg *avd.Ini, req CreateR
if err == nil && stopErr != nil {
err = errs.Wrap(errs.As(stopErr).Code, stopErr, fmt.Sprintf("实例 %d 初始化做完了,但没能正常关机: %s", i, errs.As(stopErr).Message))
}
+ if err == nil {
+ err = m.finishCreation(cctx, i)
+ }
if err != nil {
switch {
case ctx.Err() == nil && cctx.Err() == context.DeadlineExceeded:
@@ -400,9 +411,6 @@ func (m *Manager) setupNew(ctx context.Context, i int, cfg *avd.Ini, req CreateR
return nil, &errs.Error{Code: e.Code, Message: fmt.Sprintf("魔改 AVD 实例 %d 新建失败:%s", i, e.Message),
Advice: advice, Cause: err}
}
- if err := m.setCreation(i, "", "", ""); err != nil {
- return nil, err
- }
prog.stage("done", fmt.Sprintf("实例 %d 新建完成,已关机;第一次开机不用再做初始化", i))
m.Log.Infof("实例 %d 新建完成", i)
return m.Info(ctx, i)
@@ -432,12 +440,12 @@ func (m *Manager) checkCreated(ctx context.Context, i int, bridge bool, sr *Star
// stopAndWait shuts instance i down normally (sync first; Stop's own fallbacks) and waits for its qemu to exit.
func (m *Manager) stopAndWait(ctx context.Context, i int) error {
- if _, running, err := m.findQemu(i); err != nil {
+ if running, err := createRunning(m, i); err != nil {
return err
} else if !running {
return nil
}
- if _, err := m.Stop(ctx, i, StopOptions{}); err != nil {
+ if _, err := m.Stop(ctx, i, StopOptions{creating: true}); err != nil {
return err
}
return m.waitQemuGone(ctx, i)
diff --git a/internal/emu/createinit_test.go b/internal/emu/createinit_test.go
index 63b2295..9c8cc74 100644
--- a/internal/emu/createinit_test.go
+++ b/internal/emu/createinit_test.go
@@ -197,7 +197,8 @@ func TestCreateSetsUp(t *testing.T) {
_, e4 := m.Bridge(ctx, i, "verify")
j := i
_, e5 := m.Create(ctx, CreateRequest{Index: &j})
- refused = []error{e1, e2, e3, e4, e5}
+ _, e6 := m.Stop(ctx, i, StopOptions{})
+ refused = []error{e1, e2, e3, e4, e5, e6}
return &StartResult{HostIME: true}, nil
}
h.install(t)
diff --git a/internal/emu/crud.go b/internal/emu/crud.go
index df4f94a..77a1633 100644
--- a/internal/emu/crud.go
+++ b/internal/emu/crud.go
@@ -76,7 +76,7 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*InstanceInfo,
// Held from before the files exist until the setup is done: nobody else starts, changes or deletes the instance
// meanwhile, and status tells "creating" from a create that was killed (createinit.go).
- lock, err := m.tryInstanceLock(i, "create", "正在新建")
+ lock, err := m.lockCreation(ctx, i)
if err != nil {
return nil, err
}
@@ -162,7 +162,7 @@ func (m *Manager) resumeCreate(ctx context.Context, req CreateRequest) (*Instanc
if err != nil {
return nil, err
}
- lock, err := m.tryInstanceLock(i, "create", "正在新建")
+ lock, err := m.lockCreation(ctx, i)
if err != nil {
return nil, err
}
diff --git a/internal/emu/manager.go b/internal/emu/manager.go
index 4f2a9ab..1fa38a0 100644
--- a/internal/emu/manager.go
+++ b/internal/emu/manager.go
@@ -361,7 +361,7 @@ func (m *Manager) guestLockPath(i int) string {
return filepath.Join(m.Root.AvdHome(), "."+root.AvdName(i)+".guest.lock")
}
-// removeLockFiles deletes the lock files, the setup marker, create's progress file and the boot record of instance i
+// removeLockFiles deletes the lock files, the setup marker, create's progress / cancellation files and the boot record
// (after Delete); best effort.
func (m *Manager) removeLockFiles(i int) {
for _, kind := range []string{"start", "boot", "stop", "watch", "bridge", "create", "proxy"} {
@@ -369,6 +369,7 @@ func (m *Manager) removeLockFiles(i int) {
}
os.Remove(m.setupMarkerPath(i))
os.Remove(m.createProgressPath(i))
+ os.Remove(m.createCancelPath(i))
os.Remove(m.guestLockPath(i))
m.removeBootRecord(i)
}
diff --git a/internal/emu/start.go b/internal/emu/start.go
index 34de7d4..a48c553 100644
--- a/internal/emu/start.go
+++ b/internal/emu/start.go
@@ -94,6 +94,9 @@ func (m *Manager) Start(ctx context.Context, i int, opt StartOptions) (*StartRes
if err != nil {
return nil, err
}
+ if opt.creating && m.createCanceled(i) {
+ return nil, errs.New(errs.Canceled, "新建初始化已被关机打断")
+ }
// A new instance boots only for create's own setup until that is done (createinit.go); the boot watch of that
// boot's qemu may adopt it again.
if !opt.creating && opt.watchQemu == nil {
diff --git a/internal/emu/stop.go b/internal/emu/stop.go
index 7504bac..e8bea98 100644
--- a/internal/emu/stop.go
+++ b/internal/emu/stop.go
@@ -48,6 +48,7 @@ package emu
import (
"context"
"fmt"
+ "os"
"strings"
"time"
@@ -85,7 +86,8 @@ var stopOpsOf = (*Manager).stopOpsFor
// Stop shuts instance i down (see StopResult.Method). Stopping an offline instance is not an error, but stopping one
// whose start has not launched qemu yet is errs.Busy (see waitStartingQemu). errs.Internal when Windows refuses to
-// end qemu (see qemuNotEnded).
+// end qemu (see qemuNotEnded). An external stop of a creating instance is errs.Busy unless CancelCreate is explicit;
+// Force only selects the shutdown method. Internal setup shutdowns and bridge restarts use creating instead.
func (m *Manager) Stop(ctx context.Context, i int, opt StopOptions) (*StopResult, error) {
if err := requireWindows(); err != nil {
return nil, err
@@ -102,8 +104,21 @@ func (m *Manager) Stop(ctx context.Context, i int, opt StopOptions) (*StopResult
}
defer l.Unlock()
+ // Check after taking the stop lock: a creation may have begun while we waited for another stop.
+ state, _, _ := mm.creation(i)
+ creating := state == creationCreating && !opt.creating
+ if creating && !opt.CancelCreate {
+ return nil, errs.Newf(errs.Busy, "魔改 AVD 实例 %d 正在新建(后台开机做初始化),不能关机", i).
+ WithAdvice(fmt.Sprintf("等新建做完;进度用 modavd status 查看;确实要打断请用 modavd stop %d --cancel-create", i))
+ }
+ if creating {
+ if err := os.WriteFile(mm.createCancelPath(i), nil, 0o600); err != nil {
+ return nil, errs.Wrap(errs.Internal, err, "请求取消新建失败")
+ }
+ }
+
started := time.Now()
- res := &StopResult{Index: i, Name: in.Name}
+ res := &StopResult{Index: i, Name: in.Name, InterruptedCreate: creating}
p, ok, err := mm.qemuOf(i)
if err != nil {
return nil, err
diff --git a/internal/emu/stop_create_test.go b/internal/emu/stop_create_test.go
new file mode 100644
index 0000000..2a99494
--- /dev/null
+++ b/internal/emu/stop_create_test.go
@@ -0,0 +1,291 @@
+//go:build windows
+
+package emu
+
+import (
+ "context"
+ "os"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "modavd/internal/errs"
+ "modavd/internal/logx"
+ "modavd/internal/win"
+)
+
+func TestStopCreationPolicy(t *testing.T) {
+ for _, tc := range []struct {
+ name, state string
+ locked, running bool
+ opt StopOptions
+ busy, interrupted bool
+ }{
+ {name: "creating", state: creationCreating, locked: true, running: true, busy: true},
+ {name: "force does not cancel", state: creationCreating, locked: true, running: true, opt: StopOptions{Force: true}, busy: true},
+ {name: "before qemu", state: creationCreating, locked: true, busy: true},
+ {name: "explicit cancel", state: creationCreating, locked: true, running: true, opt: StopOptions{CancelCreate: true}, interrupted: true},
+ {name: "explicit force cancel", state: creationCreating, locked: true, running: true, opt: StopOptions{CancelCreate: true, Force: true}, interrupted: true},
+ {name: "own shutdown", state: creationCreating, locked: true, running: true, opt: StopOptions{creating: true}},
+ {name: "ready", running: true},
+ {name: "failed", state: creationFailed, running: true},
+ {name: "abandoned create", state: creationCreating, running: true},
+ {name: "offline"},
+ {name: "cancel offline", state: creationCreating, locked: true, opt: StopOptions{CancelCreate: true}, interrupted: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ m := New(managerTestRoot(t, 3), nil)
+ if err := m.setCreation(3, tc.state, "", ""); err != nil {
+ t.Fatal(err)
+ }
+ if tc.locked {
+ l, ok, err := win.TryLock(m.lockPath(3, "create"))
+ if err != nil || !ok {
+ t.Fatalf("create lock: %v %v", ok, err)
+ }
+ t.Cleanup(l.Unlock)
+ }
+ // A fresh manager, as in another CLI / MCP / GUI process, must see the protection too.
+ m = New(m.Root, nil)
+ f := &stopFake{syncOK: true}
+ savedScan, savedOps := qemuScan, stopOpsOf
+ t.Cleanup(func() { qemuScan, stopOpsOf = savedScan, savedOps })
+ scans := 0
+ qemuScan = func([]int) map[int]win.Process {
+ scans++
+ if tc.running {
+ return map[int]win.Process{3: {PID: 4242}}
+ }
+ return map[int]win.Process{}
+ }
+ stopOpsOf = func(*Manager, context.Context, int, win.Process) stopOps { return f.ops() }
+ r, err := m.Stop(context.Background(), 3, tc.opt)
+ if tc.busy {
+ if r != nil || errs.CodeOf(err) != errs.Busy || scans != 0 || len(f.calls) != 0 {
+ t.Fatalf("rejected stop: %+v %v, scans %d, calls %v", r, err, scans, f.calls)
+ }
+ for _, text := range []string{"等新建做完", "modavd status", "--cancel-create"} {
+ if !strings.Contains(errs.As(err).Advice, text) {
+ t.Errorf("advice %q lacks %q", errs.As(err).Advice, text)
+ }
+ }
+ } else {
+ if err != nil || r.InterruptedCreate != tc.interrupted {
+ t.Fatalf("stop: %+v %v", r, err)
+ }
+ want := []string(nil)
+ method := "already_offline"
+ if tc.running {
+ want, method = []string{"prepare", "home", "sync", "graceful"}, "graceful"
+ if tc.opt.Force {
+ want, method = []string{"prepare", "sync", "force_kill"}, "force"
+ }
+ }
+ if !slices.Equal(f.calls, want) || r.Method != method {
+ t.Fatalf("method %s, calls %v, want %s %v", r.Method, f.calls, method, want)
+ }
+ }
+ if got := m.creationRecorded(3); got != tc.state {
+ t.Fatalf("stop changed creation metadata: %q", got)
+ }
+ })
+ }
+}
+
+// The real shutdown used by create must pass its own guard, without marking the boot as interrupted.
+func TestCreateOwnStop(t *testing.T) {
+ m, _ := opsTestManager(t)
+ h := &createHooks{}
+ h.install(t)
+ running := true
+ createRunning = func(*Manager, int) (bool, error) { return running, nil }
+ savedOps := stopOpsOf
+ t.Cleanup(func() { stopOpsOf = savedOps })
+ qemuScan = func([]int) map[int]win.Process {
+ if running {
+ return map[int]win.Process{6: {PID: 4242}}
+ }
+ return map[int]win.Process{}
+ }
+ f := &stopFake{syncOK: true, onGraceful: func() { running = false }}
+ stopOpsOf = func(*Manager, context.Context, int, win.Process) stopOps { return f.ops() }
+ createStop = (*Manager).stopAndWait
+ i := 6
+ info, err := m.Create(context.Background(), CreateRequest{Index: &i, NoBridge: true})
+ if err != nil || info.Status != StatusOffline || info.Creation != "" {
+ t.Fatalf("create: %+v %v", info, err)
+ }
+ if !slices.Equal(f.calls, []string{"prepare", "home", "sync", "graceful"}) {
+ t.Fatalf("shutdown calls %v", f.calls)
+ }
+}
+
+func TestCancelCreateCanResume(t *testing.T) {
+ m, _ := opsTestManager(t)
+ h := &createHooks{}
+ h.install(t)
+ var stopped *StopResult
+ savedOps := stopOpsOf
+ t.Cleanup(func() { stopOpsOf = savedOps })
+ f := &stopFake{syncOK: true}
+ stopOpsOf = func(*Manager, context.Context, int, win.Process) stopOps { return f.ops() }
+ h.start = func(m *Manager, ctx context.Context, i int, _ StartOptions) (*StartResult, error) {
+ qemuScan = func([]int) map[int]win.Process { return map[int]win.Process{i: {PID: 4242}} }
+ var err error
+ stopped, err = New(m.Root, nil).Stop(ctx, i, StopOptions{CancelCreate: true})
+ if err != nil {
+ return nil, err
+ }
+ qemuScan = func([]int) map[int]win.Process { return map[int]win.Process{} }
+ // The boot wait notices that its qemu exited during initialization.
+ return nil, errs.New(errs.LaunchFailed, "模拟器在初始化时退出了")
+ }
+ i := 6
+ if _, err := m.Create(context.Background(), CreateRequest{Index: &i, NoBridge: true}); errs.CodeOf(err) != errs.LaunchFailed {
+ t.Fatalf("interrupted create: %v", err)
+ }
+ if stopped == nil || !stopped.InterruptedCreate || stopped.Method != "graceful" || !stopped.Synced {
+ t.Fatalf("interruption result: %+v", stopped)
+ }
+ if info, err := m.Info(context.Background(), i); err != nil || info.Status != StatusCreateFailed {
+ t.Fatalf("after interruption: %+v %v", info, err)
+ }
+ h.start = nil
+ info, err := m.Create(context.Background(), CreateRequest{Index: &i, Resume: true})
+ if err != nil || info.Status != StatusOffline || info.Creation != "" {
+ t.Fatalf("resume: %+v %v", info, err)
+ }
+}
+
+func TestCreateCancellationBetweenBootsAndAtCompletion(t *testing.T) {
+ for _, phase := range []string{"before next boot", "final shutdown"} {
+ t.Run(phase, func(t *testing.T) {
+ m, _ := opsTestManager(t)
+ h := &createHooks{}
+ h.install(t)
+ var stopped *StopResult
+ interrupt := func(m *Manager, i int) {
+ var err error
+ stopped, err = New(m.Root, nil).Stop(context.Background(), i, StopOptions{CancelCreate: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ if phase == "before next boot" {
+ h.start = func(m *Manager, ctx context.Context, i int, _ StartOptions) (*StartResult, error) {
+ interrupt(m, i)
+ // No QEMU is running between bridge boots. A new internal boot must also honor cancellation.
+ if _, err := m.Start(ctx, i, StartOptions{creating: true}); errs.CodeOf(err) != errs.Canceled {
+ t.Fatalf("another boot after cancellation: %v", err)
+ }
+ select {
+ case <-ctx.Done():
+ return nil, ctxErr(ctx)
+ case <-time.After(2 * time.Second):
+ t.Fatal("creator did not receive cancellation")
+ }
+ return nil, nil
+ }
+ } else {
+ // The setup / verification succeeded; only its final shutdown is left. It must not erase a late cancel.
+ h.onStop = interrupt
+ }
+ i := 6
+ if _, err := m.Create(context.Background(), CreateRequest{Index: &i, NoBridge: true}); errs.CodeOf(err) != errs.Canceled {
+ t.Fatalf("create: %v", err)
+ }
+ if stopped == nil || !stopped.InterruptedCreate || stopped.Method != "already_offline" {
+ t.Fatalf("cancel without qemu: %+v", stopped)
+ }
+ if info, err := m.Info(context.Background(), i); err != nil || info.Status != StatusCreateFailed {
+ t.Fatalf("canceled status: %+v %v", info, err)
+ }
+ if m.createCanceled(i) {
+ t.Fatal("cancellation file left after the creator exited")
+ }
+ // Also recover a file left by a creator that was killed before cleanup.
+ if err := os.WriteFile(m.createCancelPath(i), nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ h.start, h.onStop = nil, nil
+ if info, err := m.Create(context.Background(), CreateRequest{Index: &i, Resume: true}); err != nil || info.Status != StatusOffline {
+ t.Fatalf("resume after cancel: %+v %v", info, err)
+ }
+ })
+ }
+}
+
+func TestCreationBridgeRestartPassesStopGuard(t *testing.T) {
+ m := New(managerTestRoot(t, 3), nil)
+ if err := m.setCreation(3, creationCreating, "", ""); err != nil {
+ t.Fatal(err)
+ }
+ l, ok, err := win.TryLock(m.lockPath(3, "create"))
+ if err != nil || !ok {
+ t.Fatalf("lock: %v %v", ok, err)
+ }
+ t.Cleanup(l.Unlock)
+ savedOps, savedScan := stopOpsOf, qemuScan
+ t.Cleanup(func() { stopOpsOf, qemuScan = savedOps, savedScan })
+ qemuScan = func([]int) map[int]win.Process { return map[int]win.Process{3: {PID: 4242}} }
+ f := &stopFake{syncOK: true}
+ stopOpsOf = func(*Manager, context.Context, int, win.Process) stopOps { return f.ops() }
+ // This minimal root has no SDK for the subsequent Start. The shutdown must pass first.
+ _, err = m.bridgeRestart(context.Background(), 3, StartOptions{creating: true})
+ if err == nil || errs.CodeOf(err) == errs.Busy || !slices.Equal(f.calls, []string{"prepare", "home", "sync", "graceful"}) {
+ t.Fatalf("bridge restart: %v, calls %v", err, f.calls)
+ }
+ if m.createCanceled(3) {
+ t.Fatal("internal bridge restart requested external cancellation")
+ }
+}
+
+func TestFinishCreationWaitsForCancellation(t *testing.T) {
+ m := New(managerTestRoot(t, 3), nil)
+ if err := m.setCreation(3, creationCreating, "", ""); err != nil {
+ t.Fatal(err)
+ }
+ stop, ok, err := win.TryLock(m.lockPath(3, "stop"))
+ if err != nil || !ok {
+ t.Fatalf("stop lock: %v %v", ok, err)
+ }
+ t.Cleanup(stop.Unlock)
+ waiting := make(chan struct{}, 1)
+ m.Log = logx.New(func(_ logx.Level, message string) {
+ if strings.Contains(message, "等它做完再继续") {
+ waiting <- struct{}{}
+ }
+ })
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ done := make(chan error, 1)
+ go func() { done <- m.finishCreation(ctx, 3) }()
+ select {
+ case <-waiting:
+ case err := <-done:
+ t.Fatalf("completion passed a running stop: %v", err)
+ case <-ctx.Done():
+ t.Fatal("completion never tried the stop lock")
+ }
+ if err := os.WriteFile(m.createCancelPath(3), nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ stop.Unlock()
+ if err := <-done; errs.CodeOf(err) != errs.Canceled || m.creationRecorded(3) != creationCreating {
+ t.Fatalf("late cancellation erased by completion: %v, state %q", err, m.creationRecorded(3))
+ }
+}
+
+func TestDeleteClearsStaleCreateCancel(t *testing.T) {
+ m := New(managerTestRoot(t, 3), nil)
+ if err := os.WriteFile(m.createCancelPath(3), nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := m.Delete(context.Background(), 3); err != nil {
+ t.Fatal(err)
+ }
+ if m.createCanceled(3) {
+ t.Fatal("cancellation file left after deletion")
+ }
+}
diff --git a/internal/emu/types.go b/internal/emu/types.go
index d82bcb8..07a08d1 100644
--- a/internal/emu/types.go
+++ b/internal/emu/types.go
@@ -213,6 +213,10 @@ type StartResult struct {
type StopOptions struct {
// Force: skip the Android shutdown; sync, then kill qemu (AUTO-MAS ForceKillOnClose).
Force bool
+ // CancelCreate: allow interrupting a creation. Independent of Force: normally shut Android down first.
+ CancelCreate bool
+ // creating: the setup's own shutdown or bridge restart, while it holds the create lock.
+ creating bool
}
type StopResult struct {
@@ -225,9 +229,11 @@ type StopResult struct {
Method string `json:"method"`
// Synced: a sync ran before qemu was ended (always true for graceful; for sync_kill, the one before the shutdown
// command or the one before the kill).
- Synced bool `json:"synced"`
- Seconds float64 `json:"seconds"`
- Warnings []string `json:"warnings"`
+ Synced bool `json:"synced"`
+ // InterruptedCreate: this external stop requested cancellation of an active creation, including between boots.
+ InterruptedCreate bool `json:"interrupted_create"`
+ Seconds float64 `json:"seconds"`
+ Warnings []string `json:"warnings"`
// PID of the qemu this stop ended; 0 when it was already offline.
PID int `json:"pid"`
// Process is that qemu (PID + creation time), for callers in this process that must wait until it is really gone
diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go
index 001b286..7c1f3c4 100644
--- a/internal/mcp/mcp_test.go
+++ b/internal/mcp/mcp_test.go
@@ -776,6 +776,8 @@ func TestToolCalls(t *testing.T) {
{"start_instance", map[string]any{"index": 1, "window": false}, "start 1"},
{"stop_instance", map[string]any{"index": 1, "force": true}, "stop 1"},
{"stop_instance", map[string]any{"index": 1}, "stop 1"},
+ {"stop_instance", map[string]any{"index": 1, "cancel_create": true}, "stop 1"},
+ {"stop_instance", map[string]any{"index": 1, "cancel_create": false}, "stop 1"},
{"set_window_visible", map[string]any{"index": 1, "visible": false}, "window 1 false"},
{"shell", map[string]any{"index": 1, "command": "getprop sys.boot_completed", "timeout_s": 10}, "shell 1"},
{"install_apk", map[string]any{"index": 1, "apk_path": `D:\apk\game.apk`}, "install 1 D:\\apk\\game.apk"},
@@ -836,7 +838,8 @@ func TestToolCalls(t *testing.T) {
if s := f.starts[2]; s.Window || !s.Headless {
t.Fatalf("window=false %+v", s)
}
- if !f.stops[0].Force || f.stops[1].Force {
+ if !f.stops[0].Force || f.stops[0].CancelCreate || f.stops[1].Force || f.stops[1].CancelCreate ||
+ !f.stops[2].CancelCreate || f.stops[2].Force || f.stops[3].CancelCreate {
t.Fatalf("stop options %+v", f.stops)
}
if f.timeout != 10*time.Second {
@@ -870,6 +873,7 @@ func TestToolErrors(t *testing.T) {
{"delete_instance", map[string]any{"index": 2}, errs.Usage, "缺少参数 confirm"},
{"update_instance", map[string]any{"index": 2}, errs.Usage, "至少给一项"},
{"start_instance", map[string]any{"index": 1, "window": "yes"}, errs.Usage, "必须是 true 或 false"},
+ {"stop_instance", map[string]any{"index": 1, "cancel_create": "yes"}, errs.Usage, "必须是 true 或 false"},
{"start_instance", map[string]any{"index": 1, "timeout_s": 0}, errs.InvalidArgument, "timeout_s"},
{"shell", map[string]any{"index": 1, "command": " "}, errs.InvalidArgument, "command 不能为空"},
{"shell", map[string]any{"index": 1, "command": 5}, errs.Usage, "参数 command 必须是字符串"},
diff --git a/internal/mcp/stop_create_windows_test.go b/internal/mcp/stop_create_windows_test.go
new file mode 100644
index 0000000..dc50a85
--- /dev/null
+++ b/internal/mcp/stop_create_windows_test.go
@@ -0,0 +1,46 @@
+package mcp
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "modavd/internal/avd"
+ "modavd/internal/emu"
+ "modavd/internal/logx"
+ "modavd/internal/root"
+ "modavd/internal/win"
+)
+
+func TestStopCreatingInstance(t *testing.T) {
+ r, err := root.New(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := r.EnsureMetadata(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := avd.Create(r, 3, avd.CreateOptions{MemoryMB: 3072, CPU: 4, DataPartitionGB: 64, FPS: 60, Creating: true}); err != nil {
+ t.Fatal(err)
+ }
+ l, ok, err := win.TryLock(filepath.Join(r.AvdHome(), ".mas_3.create.lock"))
+ if err != nil || !ok {
+ t.Fatalf("create lock: %v %v", ok, err)
+ }
+ t.Cleanup(l.Unlock)
+ h := newHarness(t, nil, func(s *server) {
+ s.newBackend = func(log *logx.Logger) (backend, error) { return emu.New(r, log), nil }
+ })
+ for n, args := range []map[string]any{
+ {"index": 3}, {"index": 3, "force": true}, {"index": 3, "cancel_create": true},
+ } {
+ text, isErr, sc := toolText(t, h.call(n, "stop_instance", args, nil))
+ if n < 2 {
+ if !isErr || !strings.Contains(text, "--cancel-create") || !strings.HasSuffix(text, "错误码: busy") {
+ t.Fatalf("default stop: %s", text)
+ }
+ } else if isErr || sc["interrupted_create"] != true || sc["method"] != "already_offline" {
+ t.Fatalf("explicit cancel: %s, %+v", text, sc)
+ }
+ }
+}
diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go
index 2b69107..e1e1b42 100644
--- a/internal/mcp/tools.go
+++ b/internal/mcp/tools.go
@@ -344,11 +344,14 @@ func toolDefs() []*toolDef {
"Shut an instance down safely: Android shutdown, falling back to sync and console kill; force=true "+
"skips the Android shutdown but still syncs first. Always use this instead of killing emulator or "+
"qemu processes: a kill without sync loses recently written game data. Stopping an offline "+
- "instance is not an error. Takes up to about 30 s.",
+ "instance is not an error. A creating instance returns busy unless cancel_create=true, even with force=true. "+
+ "Interrupting creation leaves it create_failed; retry with the CLI's create --resume. Takes up to about 30 s.",
mutating(true, true), reflect.TypeFor[emu.StopResult](), []prop{
indexProp(true),
{"force", false, map[string]any{"type": "boolean", "default": false,
"description": "Skip the Android shutdown: sync the guest disk, then kill the emulator."}},
+ {"cancel_create", false, map[string]any{"type": "boolean", "default": false,
+ "description": "Allow interrupting creation. Still shuts Android down normally unless force=true; the result marks interrupted_create."}},
},
func(ctx context.Context, b backend, a args, _ func(emu.Progress)) (any, error) {
i, err := a.reqInt("index")
@@ -359,7 +362,11 @@ func toolDefs() []*toolDef {
if err != nil {
return nil, err
}
- return b.Stop(ctx, i, emu.StopOptions{Force: force != nil && *force})
+ cancelCreate, err := a.optBool("cancel_create")
+ if err != nil {
+ return nil, err
+ }
+ return b.Stop(ctx, i, emu.StopOptions{Force: force != nil && *force, CancelCreate: cancelCreate != nil && *cancelCreate})
}),
newTool("set_window_visible", "Show or hide window",
diff --git a/internal/ui/API.md b/internal/ui/API.md
index cbe5891..aa462fc 100644
--- a/internal/ui/API.md
+++ b/internal/ui/API.md
@@ -39,7 +39,7 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…","
| PATCH | `/api/instances/{i}` | `{title?, memory_mb?, cpu?, fps?}` | `UpdateResult` |
| DELETE | `/api/instances/{i}?confirm=1` | 不带 `confirm=1` 回 400 `usage` | `DeleteResult` |
| POST | `/api/instances/{i}/start` | `{}` | 202 `Job`:一律带窗口开机(管理器不开无头的,无头开机只走命令行);实例已经以无头方式在运行时,任务失败,code 是 `busy`(运行中不切换) |
-| POST | `/api/instances/{i}/stop` | `{force?: bool}` | 202 `Job` |
+| POST | `/api/instances/{i}/stop` | `{force?: bool, cancel_create?: bool}` | 202 `Job`;新建中默认 busy,显式 `cancel_create: true` 才取消;`force` 只管是否跳过 Android 正常关机。被取消的新建在结果里标记 `interrupted_create: true` |
| POST | `/api/instances/{i}/install` | `multipart/form-data`,文件字段 `apk` | 202 `Job`(服务端存到临时文件,任务结束删掉) |
| POST | `/api/instances/{i}/disk-info` | `{}` | 202 `Job`(`kind` 是 `disk-info`):数据盘的占用,随时可以查(开机时 `allocated_bytes`、`reclaimable_bytes` 不返回,`needs_shutdown` 为 `true`);结果是 emu 的 `DiskInfoResult` |
| POST | `/api/instances/{i}/disk-trim` | `{}` | 202 `Job`(`kind` 是 `disk-trim`):让开着的安卓把删掉的文件占的空间还给模拟器(电脑上的文件要整理后才变小);实例没开机完成、或模拟器版本不支持时任务失败,code 是 `unsupported`;结果是 `DiskTrimResult` |
diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go
index 904e815..d3d4b2f 100644
--- a/internal/ui/handlers.go
+++ b/internal/ui/handlers.go
@@ -177,7 +177,8 @@ func (s *server) handleStop(w http.ResponseWriter, r *http.Request) {
return
}
var body struct {
- Force bool `json:"force"`
+ Force bool `json:"force"`
+ CancelCreate bool `json:"cancel_create"`
}
if !decode(w, r, &body) {
return
@@ -185,7 +186,7 @@ func (s *server) handleStop(w http.ResponseWriter, r *http.Request) {
s.respondJob(w)(s.startJob(kindStop, i, func(ctx context.Context, j *job, b backend) (any, error) {
s.stopStarts(ctx, j, i)
s.setProgress(j, jobProgress{Step: "stop", Message: fmt.Sprintf("实例 %d 正在关机", i), Total: progressTotal})
- res, err := b.Stop(ctx, i, emu.StopOptions{Force: body.Force})
+ res, err := b.Stop(ctx, i, emu.StopOptions{Force: body.Force, CancelCreate: body.CancelCreate})
if err != nil {
return nil, err
}
diff --git a/internal/ui/jobs_test.go b/internal/ui/jobs_test.go
index 240ec4e..16de7a4 100644
--- a/internal/ui/jobs_test.go
+++ b/internal/ui/jobs_test.go
@@ -3,6 +3,7 @@
package ui
import (
+ "context"
"fmt"
"net/http"
"slices"
@@ -179,6 +180,42 @@ func TestCancelJob(t *testing.T) {
http.StatusForbidden, "forbidden")
}
+func TestStopCancelCreateOption(t *testing.T) {
+ for _, body := range []string{`{}`, `{"force":true}`, `{"cancel_create":true}`} {
+ t.Run(body, func(t *testing.T) {
+ h := newHarness(t, nil)
+ var j jobView
+ expect(t, h.do("POST", "/api/instances/3/stop", body), http.StatusAccepted, &j)
+ h.waitJob(j.ID, stateIs(stateDone))
+ h.f.mu.Lock()
+ opt := h.f.stops[0]
+ h.f.mu.Unlock()
+ if opt.CancelCreate != (body == `{"cancel_create":true}`) || opt.Force != (body == `{"force":true}`) {
+ t.Fatalf("stop options %+v", opt)
+ }
+ })
+ }
+}
+
+func TestCancelCreateJob(t *testing.T) {
+ h := newHarness(t, nil)
+ j, err := h.s.startJob(kindCreate, 3, func(ctx context.Context, _ *job, _ backend) (any, error) {
+ <-ctx.Done()
+ return nil, canceledErr(ctx)
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ expectError(t, h.do("POST", "/api/instances/3/stop", `{}`), http.StatusConflict, errs.Busy)
+ expect(t, h.do("POST", "/api/jobs/"+j.ID+"/cancel", nil), http.StatusOK, nil)
+ h.waitJob(j.ID, stateIs(stateCanceled))
+ h.f.mu.Lock()
+ defer h.f.mu.Unlock()
+ if len(h.f.stops) != 0 {
+ t.Fatalf("job cancel sent stop: %+v", h.f.stops)
+ }
+}
+
// TestStartWithWindowAndFailedJob: the manager boots with the window only (no headless option, no restart, no
// showing or hiding windows); emu's error becomes the job's.
func TestStartWithWindowAndFailedJob(t *testing.T) {
diff --git a/internal/ui/stop_create_windows_test.go b/internal/ui/stop_create_windows_test.go
new file mode 100644
index 0000000..bc1c547
--- /dev/null
+++ b/internal/ui/stop_create_windows_test.go
@@ -0,0 +1,54 @@
+package ui
+
+import (
+ "net/http"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "modavd/internal/avd"
+ "modavd/internal/emu"
+ "modavd/internal/errs"
+ "modavd/internal/logx"
+ "modavd/internal/win"
+)
+
+func TestStopCreatingInstance(t *testing.T) {
+ h := newHarness(t, func(opt *Options) {
+ r := opt.Root
+ if _, err := r.EnsureMetadata(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := avd.Create(r, 3, avd.CreateOptions{MemoryMB: 3072, CPU: 4, DataPartitionGB: 64, FPS: 60, Creating: true}); err != nil {
+ t.Fatal(err)
+ }
+ l, ok, err := win.TryLock(filepath.Join(r.AvdHome(), ".mas_3.create.lock"))
+ if err != nil || !ok {
+ t.Fatalf("create lock: %v %v", ok, err)
+ }
+ t.Cleanup(l.Unlock)
+ opt.newBackend = func(log *logx.Logger) backend { return managerBackend{emu.New(r, log)} }
+ })
+ for n, body := range []string{`{}`, `{"force":true}`, `{"cancel_create":true}`} {
+ var j jobView
+ expect(t, h.do("POST", "/api/instances/3/stop", body), http.StatusAccepted, &j)
+ if n < 2 {
+ failed := h.waitJob(j.ID, stateIs(stateFailed))
+ if failed.Error == nil || failed.Error.Code != errs.Busy || !strings.Contains(failed.Error.Advice, "--cancel-create") {
+ t.Fatalf("default stop: %+v", failed)
+ }
+ } else {
+ finished := h.waitJob(j.ID, func(v jobView) bool { return v.State != stateRunning })
+ if finished.State != stateDone {
+ t.Fatalf("external cancel: %+v", finished)
+ }
+ var done struct {
+ Result emu.StopResult `json:"result"`
+ }
+ expect(t, h.do("GET", "/api/jobs/"+j.ID, nil), http.StatusOK, &done)
+ if !done.Result.InterruptedCreate || done.Result.Method != "already_offline" {
+ t.Fatalf("external cancel: %+v", done.Result)
+ }
+ }
+ }
+}
diff --git a/internal/ui/web/js/actions.js b/internal/ui/web/js/actions.js
index 1918252..ca050cd 100644
--- a/internal/ui/web/js/actions.js
+++ b/internal/ui/web/js/actions.js
@@ -68,6 +68,7 @@ async function run(i, kind, body) {
// the manager boots with the window only (the server makes it so); there is no restart and no headless boot here
export const start = (i) => run(i, 'start', {});
export const stop = (i, force = false) => run(i, 'stop', { force });
+export const cancelCreate = (i) => run(i, 'stop', { cancel_create: true });
// 重试初始化: the setup of a new instance that failed (its job is a create job)
export const resumeCreate = (i) => run(i, 'resume', {});
diff --git a/internal/ui/web/js/cards.js b/internal/ui/web/js/cards.js
index 9658b7c..251d163 100644
--- a/internal/ui/web/js/cards.js
+++ b/internal/ui/web/js/cards.js
@@ -294,8 +294,8 @@ function menuItems(c) {
return [
v.key === 'starting' && { label: '关机', icon: 'power', run: () => A.stop(i) },
// create's setup: our own create job is cancelled, one of another process (CLI, AUTO-MAS) is ended by a normal
- // stop; either way the instance is shut down normally and reads create_failed (resumable)
- v.key === 'creating' ? { label: '取消新建', icon: 'x', run: () => (v.job && v.job.kind === 'create' ? A.cancelJob(v.job.id) : A.stop(i)) }
+ // stop with explicit cancellation; either way it shuts down normally and reads create_failed (resumable)
+ v.key === 'creating' ? { label: '取消新建', icon: 'x', run: () => (v.job && v.job.kind === 'create' ? A.cancelJob(v.job.id) : A.cancelCreate(i)) }
: { label: '强制关机', icon: 'bolt', disabled: !running || v.key === 'stopping', run: () => A.stop(i, true) },
{ sep: true },
{ label: '安装 APK…', icon: 'download', disabled: inst.status !== 'online' || v.kind === 'install', run: () => pickApk(i) },
diff --git a/scripts/test_create_cancel.mjs b/scripts/test_create_cancel.mjs
new file mode 100644
index 0000000..b821f6a
--- /dev/null
+++ b/scripts/test_create_cancel.mjs
@@ -0,0 +1,72 @@
+// Run: node --experimental-vm-modules --test scripts/test_create_cancel.mjs
+// Execute the actual card menu and actions, stubbing only their browser / HTTP dependencies.
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { test } from 'node:test';
+import vm from 'node:vm';
+
+async function harness() {
+ const requests = [];
+ const context = vm.createContext({ requestAnimationFrame() {} });
+ const base = new URL('../internal/ui/web/js/', import.meta.url);
+ const cache = new Map();
+ const noop = () => {};
+ const stubs = {
+ 'api.js': { api: { post: async (path, body) => {
+ requests.push({ path, body });
+ return { id: 'j2', kind: 'stop', state: 'running', index: 3 };
+ } } },
+ 'dom.js': Object.fromEntries(['h', 'icon', 'btn', 'ibtn', 'keyed', 'setText', 'attr'].map(k => [k, noop])),
+ 'format.js': Object.fromEntries(['label', 'uptime', 'stripInst', 'pct', 'cfgText', 'quoted', 'dur', 'diskInfoText', 'diskTrimText', 'diskCompactText'].map(k => [k, noop])),
+ 'ui.js': { toast: noop, toastError: e => { throw e; }, copyText: noop, openMenu: noop },
+ 'forms.js': { openSettings: noop, openDelete: noop },
+ 'proxy.js': { openProxy: noop, markOf: noop },
+ 'viewer.js': { openViewer: noop, openLogs: noop },
+ 'panels.js': { openActivity: noop },
+ };
+ async function load(name) {
+ if (cache.has(name)) return cache.get(name);
+ const values = stubs[name];
+ const mod = values
+ ? new vm.SyntheticModule(Object.keys(values), function() {
+ for (const [k, v] of Object.entries(values)) this.setExport(k, v);
+ }, { context })
+ : new vm.SourceTextModule(await readFile(new URL(name, base), 'utf8') +
+ (name === 'cards.js' ? '\nexport { menuItems };' : ''), { context });
+ cache.set(name, mod);
+ await mod.link(specifier => load(specifier.replace(/^\.\//, '')));
+ return mod;
+ }
+ const cards = await load('cards.js');
+ await cards.evaluate();
+ const state = cache.get('store.js').namespace.S;
+ return { cards: cards.namespace, state, requests };
+}
+
+test('cancel creation started by another process requests explicit graceful interruption', async () => {
+ const h = await harness();
+ const menu = h.cards.menuItems({ i: 3, inst: { index: 3, status: 'creating', running: true } });
+ await menu.find(x => x.label === '取消新建').run();
+ assert.equal(h.requests.length, 1);
+ assert.equal(h.requests[0].path, '/api/instances/3/stop');
+ assert.equal(h.requests[0].body.cancel_create, true);
+ assert.notEqual(h.requests[0].body.force, true);
+});
+
+test('cancel this window creation cancels its own job', async () => {
+ const h = await harness();
+ h.state.jobs.set('j1', { id: 'j1', kind: 'create', index: 3, state: 'running' });
+ const menu = h.cards.menuItems({ i: 3, inst: { index: 3, status: 'creating', running: true } });
+ await menu.find(x => x.label === '取消新建').run();
+ assert.equal(h.requests.length, 1);
+ assert.equal(h.requests[0].path, '/api/jobs/j1/cancel');
+});
+
+test('ordinary force stop does not opt into canceling creation', async () => {
+ const h = await harness();
+ const menu = h.cards.menuItems({ i: 3, inst: { index: 3, status: 'online', running: true } });
+ await menu.find(x => x.label === '强制关机').run();
+ assert.equal(h.requests[0].path, '/api/instances/3/stop');
+ assert.equal(h.requests[0].body.force, true);
+ assert.notEqual(h.requests[0].body.cancel_create, true);
+});
From 81ca06917ce1c09c746b9cf9e5912da48a04ce95 Mon Sep 17 00:00:00 2001
From: Ji Mao Cai <44921326+jinghero@users.noreply.github.com>
Date: Thu, 5 Nov 2026 15:52:52 +0800
Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=8F=96=E6=B6=88?=
=?UTF-8?q?=E5=90=8E=E7=9A=84=E5=85=B3=E6=9C=BA=E6=8F=90=E7=A4=BA=E5=B9=B6?=
=?UTF-8?q?=E5=B0=86=E8=8F=9C=E5=8D=95=E6=B5=8B=E8=AF=95=E6=8E=A5=E5=85=A5?=
=?UTF-8?q?=20CI?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Refs ModAVD/ModAVD#103
---
.github/workflows/ci.yml | 13 +++++++++++
internal/emu/createinit.go | 5 +++++
internal/emu/createinit_test.go | 38 +++++++++++++++++++++++++++++++++
3 files changed, 56 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 96d93e9..61268c9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -100,3 +100,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
diff --git a/internal/emu/createinit.go b/internal/emu/createinit.go
index a3bd1ed..f7dbc68 100644
--- a/internal/emu/createinit.go
+++ b/internal/emu/createinit.go
@@ -393,6 +393,11 @@ func (m *Manager) setupNew(ctx context.Context, i int, cfg *avd.Ini, req CreateR
case ctx.Err() != nil && errs.CodeOf(err) != errs.Canceled:
err = errs.Wrap(errs.Canceled, err, "已取消")
}
+ // Start (including a bridge restart) can say it left qemu running. The
+ // cleanup above has since stopped it; persist the final state instead.
+ if errs.CodeOf(err) == errs.Canceled && stopErr == nil {
+ err = errs.Wrap(errs.Canceled, err, "新建初始化已取消,已关机")
+ }
logFile := ""
if sr != nil {
logFile = sr.LogFile
diff --git a/internal/emu/createinit_test.go b/internal/emu/createinit_test.go
index 9c8cc74..e03f449 100644
--- a/internal/emu/createinit_test.go
+++ b/internal/emu/createinit_test.go
@@ -5,6 +5,7 @@ package emu
import (
"context"
"errors"
+ "fmt"
"os"
"path/filepath"
"slices"
@@ -366,6 +367,43 @@ func TestCreateTimeout(t *testing.T) {
}
}
+// A bridge restart can embed Start's "emulator left running" advice in its error.
+// The creation's cleanup changes that fact, so its final error must describe the
+// state after cleanup, while retaining the original cause for diagnostics.
+func TestCreateCanceledReportsFinalShutdown(t *testing.T) {
+ for _, stopped := range []bool{true, false} {
+ t.Run(fmt.Sprint(stopped), func(t *testing.T) {
+ m, _ := opsTestManager(t)
+ startErr := errs.New(errs.Canceled, "装执行桥时关机再开机失败: 已取消。模拟器没有关:稍后再启动")
+ h := &createHooks{running: true}
+ h.start = func(*Manager, context.Context, int, StartOptions) (*StartResult, error) {
+ return nil, startErr
+ }
+ if !stopped {
+ h.stopErr = errs.New(errs.Timeout, "关机超时")
+ }
+ h.install(t)
+ i := 6
+ _, err := m.Create(context.Background(), CreateRequest{Index: &i})
+ if errs.CodeOf(err) != errs.Canceled || !errors.Is(err, startErr) || h.stopped != 1 {
+ t.Fatalf("create: %v; shutdown calls %d", err, h.stopped)
+ }
+ info, ierr := m.Info(context.Background(), i)
+ if ierr != nil || info.Status != StatusCreateFailed || info.Creation != creationFailed {
+ t.Fatalf("persisted state: %+v %v", info, ierr)
+ }
+ if stopped {
+ if strings.Contains(info.CreationError, "模拟器没有关") || !strings.Contains(info.CreationError, "已关机") ||
+ !strings.Contains(errs.As(err).Message, "已关机") {
+ t.Fatalf("stale startup state after successful cleanup: %q; %v", info.CreationError, err)
+ }
+ } else if strings.Contains(info.CreationError, "已关机") || info.CreationError != startErr.Message {
+ t.Fatalf("must not claim a failed cleanup stopped Android: %q", info.CreationError)
+ }
+ })
+ }
+}
+
// oneBoot is what Start reports for one boot of the setup: the boot wait (polled by the clock), then the steps after
// it. firstBoot adds the first-boot setup.
func oneBoot(firstBoot bool) []Progress {