From 725db9d86b138a9fa0f5f43259c752377b53361e Mon Sep 17 00:00:00 2001
From: Ji Mao Cai <44921326+jinghero@users.noreply.github.com>
Date: Thu, 5 Nov 2026 10:39:23 +0800
Subject: [PATCH 1/3] fix: rotate host logcat captures at 256 MiB
---
README.en.md | 1 +
README.md | 1 +
cmd/modavd-gui/main.go | 2 +
internal/cli/cli.go | 4 +-
internal/emu/logcat.go | 197 +++++++++++++++++
internal/emu/logcat_test.go | 368 +++++++++++++++++++++++++++++++
internal/emu/logs.go | 50 ++++-
internal/emu/manager.go | 2 +-
internal/emu/portplan_test.go | 29 +++
scripts/test_logcat_rotation.ps1 | 112 ++++++++++
10 files changed, 752 insertions(+), 14 deletions(-)
create mode 100644 internal/emu/logcat.go
create mode 100644 internal/emu/logcat_test.go
create mode 100644 scripts/test_logcat_rotation.ps1
diff --git a/README.en.md b/README.en.md
index db1cec8..d804bf2 100644
--- a/README.en.md
+++ b/README.en.md
@@ -135,6 +135,7 @@ modavd [--root
] [--json] [--quiet] [--verbose] [args]
- When it is not valid, `status`, `list` and `info` still read the instances in the 20000 range, while starting and creating are refused (`invalid_argument`), and `check` shows a 「端口段」 (port range) row saying why.
- Shut down every instance of the root folder before you change it. Do not add a root folder that sets `portBase` to AUTO-MAS.
- The emulator, the adb server and logcat are all launched through an intermediate process that exits immediately, so they are not in modavd's process tree: if the caller ends modavd (Ctrl+C, the MCP client disconnects), the emulator is not taken down with it.
+- A separate host process writes logcat to `logs\logcat-mas_-.txt` and continues after the start command exits. At 256 MiB it rotates at the next newline and writes the same adb stream to a new file. Closed segments follow the existing limits of 10 per instance and 1 GiB across `logs\`; the active file is excluded.
- After a successful start, each instance also gets a boot guardian that is likewise outside the process tree and exits when qemu exits. After the guest restarts on its own (qemu keeps running), the guardian redoes the post-boot tuning and the Vulkan patch, and never launches a new emulator.
- Starts and shutdowns of the same instance queue on `\avd\.mas_.{start,boot,stop}.lock`: a start waits for the previous start and shutdown to finish; a shutdown can interrupt an instance that is starting.
diff --git a/README.md b/README.md
index 720bd98..f499d4b 100644
--- a/README.md
+++ b/README.md
@@ -138,6 +138,7 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数]
- 不合规时,`status`、`list`、`info` 照常按 20000 段看,开机和新建都拒绝(`invalid_argument`),`check` 多一行「端口段」说明原因。
- 改之前先关掉这个根目录的所有实例。设了 `portBase` 的根目录不要加进 AUTO-MAS。
- 模拟器、adb server、logcat 都经一个立即退出的中间进程拉起,不在 modavd 的进程树里:调用方结束 modavd(Ctrl+C、MCP 客户端断开)不会带走模拟器。
+- 宿主 logcat 由独立采集进程写入 `logs\logcat-mas_-<时间>.txt`,启动命令退出后仍继续。每满 256 MiB 在下一处换行切分,同一条 adb 输出流接着写新文件;关闭的分段每台留最新 10 份,并计入 `logs\` 的 1 GiB 清理上限,正在写的文件不计入。
- 开机成功后,每台实例另有一个同样不在进程树里的开机守护,随 qemu 退出而结束。客体自己重启(qemu 没退)后,它补做开机调优和 Vulkan 补丁,绝不拉起新的模拟器。
- 同一实例的开机、关机按 `<根>\avd\.mas_.{start,boot,stop}.lock` 排队:开机等前一次开机和关机做完;关机可以打断正在开机的实例。
diff --git a/cmd/modavd-gui/main.go b/cmd/modavd-gui/main.go
index dd7612d..0c76ec3 100644
--- a/cmd/modavd-gui/main.go
+++ b/cmd/modavd-gui/main.go
@@ -50,6 +50,8 @@ func main() {
os.Exit(emu.RunVulkanProbe(os.Stdout))
case emu.BootWatchArg:
os.Exit(emu.RunBootWatch(os.Args[2:], os.Stderr))
+ case emu.LogcatCaptureArg:
+ os.Exit(emu.RunLogcatCapture(os.Args[2:], os.Stderr))
case versionArg:
os.Exit(printVersion())
}
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index cf0a928..2160009 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -66,7 +66,7 @@
//
// Hidden subcommands, routed before any flag parsing or root lookup: __spawn (win.SpawnHelperMain, the helper of
// win.StartOrphan), __vkprobe (emu.RunVulkanProbe, the Vulkan probe child process) and __bootwatch
-// (emu.RunBootWatch).
+// (emu.RunBootWatch), and __logcat (emu.RunLogcatCapture, the host logcat writer).
package cli
import (
@@ -160,6 +160,8 @@ func Main(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return emu.RunVulkanProbe(stdout)
case emu.BootWatchArg:
return emu.RunBootWatch(args[1:], stderr)
+ case emu.LogcatCaptureArg:
+ return emu.RunLogcatCapture(args[1:], stderr)
}
}
// StartOrphan re-runs this executable as the spawn helper.
diff --git a/internal/emu/logcat.go b/internal/emu/logcat.go
new file mode 100644
index 0000000..3ad182e
--- /dev/null
+++ b/internal/emu/logcat.go
@@ -0,0 +1,197 @@
+package emu
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "modavd/internal/logx"
+ "modavd/internal/root"
+ "modavd/internal/win"
+)
+
+// LogcatCaptureArg is routed by both the console and GUI executables, before normal command parsing.
+const LogcatCaptureArg = "__logcat"
+
+// Rotate at the first newline at or beyond 256 MiB, so a record never spans two files.
+const logcatSegmentBytes int64 = 256 << 20
+
+// logcatWriter owns the only host output handle. adb keeps writing to the same pipe during every rotation.
+// The next file is opened before the previous one is closed; pruning therefore always sees the active file busy.
+type logcatWriter struct {
+ dir string
+ i int
+ limit int64
+ file *os.File
+ size int64
+ lineStart bool
+ nextStamp time.Time
+ prune func()
+}
+
+func newLogcatWriter(dir string, i int, limit int64, prune func()) (*logcatWriter, error) {
+ if limit <= 0 {
+ return nil, fmt.Errorf("logcat segment size must be positive")
+ }
+ w := &logcatWriter{dir: dir, i: i, limit: limit, nextStamp: time.Now().Truncate(time.Second), prune: prune}
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return nil, err
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, err
+ }
+ prefix := "logcat-" + root.AvdName(i) + "-"
+ for _, e := range entries {
+ if !strings.HasPrefix(e.Name(), prefix) || !strings.HasSuffix(e.Name(), ".txt") {
+ continue
+ }
+ stamp, err := time.ParseInLocation("20060102-150405", strings.TrimSuffix(strings.TrimPrefix(e.Name(), prefix), ".txt"), time.Local)
+ if err == nil && !stamp.Before(w.nextStamp) {
+ w.nextStamp = stamp.Add(time.Second)
+ }
+ }
+ if err := w.rotate(); err != nil {
+ return nil, err
+ }
+ return w, nil
+}
+
+func (w *logcatWriter) rotate() error {
+ // Keep the existing timestamp-only naming contract, including collect-info's readers. Exclusive creation
+ // prevents truncation on a same-second restart or fast rotation; occupied seconds advance by one.
+ stamp := time.Now().Truncate(time.Second)
+ if stamp.Before(w.nextStamp) {
+ stamp = w.nextStamp
+ }
+ var next *os.File
+ for {
+ path := filepath.Join(w.dir, "logcat-"+root.AvdName(w.i)+"-"+stamp.Format("20060102-150405")+".txt")
+ var err error
+ next, err = os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
+ if errors.Is(err, os.ErrExist) {
+ stamp = stamp.Add(time.Second)
+ continue
+ }
+ if err != nil {
+ return err
+ }
+ break
+ }
+ old := w.file
+ w.file, w.size, w.lineStart, w.nextStamp = next, 0, true, stamp.Add(time.Second)
+ if old != nil {
+ if err := old.Close(); err != nil {
+ return err
+ }
+ }
+ if w.prune != nil {
+ w.prune()
+ }
+ return nil
+}
+
+func (w *logcatWriter) Write(p []byte) (int, error) {
+ written := 0
+ for len(p) > 0 {
+ if w.size >= w.limit && w.lineStart {
+ if err := w.rotate(); err != nil {
+ return written, err
+ }
+ }
+ n := len(p)
+ if room := w.limit - w.size; room > 0 {
+ if int64(n) > room {
+ n = int(room)
+ }
+ } else if end := bytes.IndexByte(p, '\n'); end >= 0 {
+ n = end + 1
+ }
+ got, err := w.file.Write(p[:n])
+ w.size += int64(got)
+ written += got
+ if got > 0 {
+ w.lineStart = p[got-1] == '\n'
+ }
+ p = p[got:]
+ if err != nil {
+ return written, err
+ }
+ if got != n {
+ return written, io.ErrShortWrite
+ }
+ }
+ return written, nil
+}
+
+func (w *logcatWriter) Close() error { return w.file.Close() }
+
+// RunLogcatCapture is an orphaned host writer, not a guest logcat -f (which would write inside Android).
+// A per-instance lifetime lock prevents duplicate helpers while adb starts or a previous helper drains its pipe.
+// Killing a stale adb client on re-registration closes this pipe and releases the lock before its replacement.
+func RunLogcatCapture(args []string, stderr io.Writer) int {
+ return runLogcatCapture(args, stderr, logcatSegmentBytes)
+}
+
+func runLogcatCapture(args []string, stderr io.Writer, limit int64) int {
+ if len(args) != 2 {
+ return 2
+ }
+ i, err := strconv.Atoi(args[1])
+ if err != nil || !root.ValidIndex(i) {
+ return 2
+ }
+ r, err := root.New(args[0])
+ if err != nil {
+ return 1
+ }
+ m := New(r, logx.New(logx.JSONSink(stderr, logx.Info)))
+ l, free, err := win.Lock(m.lockPath(i, "logcat"), 5*time.Second)
+ if err != nil || !free {
+ return 1
+ }
+ defer l.Unlock()
+ // A legacy capture, or a helper started concurrently, may already have an adb client.
+ if running, err := m.findLogcatProcesses(r.Ports().Serial(i)); err != nil || len(running) > 0 {
+ return 1
+ }
+ w, err := newLogcatWriter(r.LogsDir(), i, limit, func() { m.pruneLogs(i) })
+ if err != nil {
+ return 1
+ }
+ defer w.Close()
+ if err := captureLogcat(r.AdbExe(), logcatArgs(r.AdbServerPort(), r.Ports().Serial(i)), m.ADB.Env(), w); err != nil {
+ m.Log.Warnf("实例 %d 客体日志采集已结束: %v", i, err)
+ return 1
+ }
+ return 0
+}
+
+func captureLogcat(exe string, args, env []string, output io.Writer) error {
+ r, pipe, err := os.Pipe()
+ if err != nil {
+ return err
+ }
+ defer r.Close()
+ cmd := exec.Command(exe, args...)
+ cmd.Dir, cmd.Env, cmd.Stdout, cmd.Stderr = filepath.Dir(exe), env, pipe, pipe
+ win.HideConsoleCmd(cmd)
+ if err := cmd.Start(); err != nil {
+ pipe.Close()
+ return err
+ }
+ pipe.Close()
+ _, copyErr := io.Copy(output, r)
+ if copyErr != nil {
+ // Do not leave a live adb capture after a disk error: ensureLogcat must be able to start it again.
+ cmd.Process.Kill()
+ }
+ return errors.Join(copyErr, cmd.Wait())
+}
diff --git a/internal/emu/logcat_test.go b/internal/emu/logcat_test.go
new file mode 100644
index 0000000..5cc5db3
--- /dev/null
+++ b/internal/emu/logcat_test.go
@@ -0,0 +1,368 @@
+//go:build windows
+
+package emu
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "testing"
+ "time"
+
+ "modavd/internal/logx"
+ "modavd/internal/win"
+)
+
+func logcatTestDir(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ // Windows may briefly report ERROR_DIR_NOT_EMPTY after a segment was deleted. Go's TempDir cleanup does not
+ // retry that error. Retry only that transient here; a persistent failure still fails the test.
+ t.Cleanup(func() {
+ deadline := time.Now().Add(2 * time.Second)
+ for {
+ err := os.RemoveAll(dir)
+ if !errors.Is(err, syscall.ERROR_DIR_NOT_EMPTY) || time.Now().After(deadline) {
+ if err != nil {
+ t.Errorf("logcat test cleanup: %v", err)
+ }
+ return
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ })
+ return dir
+}
+
+func numberedLog(i, count int) []byte {
+ var b bytes.Buffer
+ for n := 0; n < count; n++ {
+ fmt.Fprintf(&b, "instance=%d sequence=%06d payload=abcdefghijk\n", i, n)
+ }
+ return b.Bytes()
+}
+
+func readLogcatSegments(t *testing.T, dir string, i int) ([]byte, []string) {
+ t.Helper()
+ paths, err := filepath.Glob(filepath.Join(dir, fmt.Sprintf("logcat-mas_%d-*.txt", i)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ slices.Sort(paths)
+ var all []byte
+ for _, path := range paths {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(b) > 0 && b[len(b)-1] != '\n' {
+ t.Fatalf("record split across files: %s", path)
+ }
+ all = append(all, b...)
+ }
+ return all, paths
+}
+
+// Polling may overlap pruneLogDir's Windows delete-access probe or observe the middle of an adb write. Only the
+// final comparison below treats a snapshot as a complete stream, once the producer is waiting at its gate.
+func logcatTestContains(dir string, i int, marker string, minSegments int) bool {
+ paths, _ := filepath.Glob(filepath.Join(dir, fmt.Sprintf("logcat-mas_%d-*.txt", i)))
+ if len(paths) < minSegments {
+ return false
+ }
+ for _, path := range paths {
+ if b, err := os.ReadFile(path); err == nil && bytes.Contains(b, []byte(marker)) {
+ return true
+ }
+ }
+ return false
+}
+
+func TestLogcatRotationPreservesNumberedLines(t *testing.T) {
+ for _, chunk := range []int{1, 7, 63, 4096} {
+ t.Run(strconv.Itoa(chunk), func(t *testing.T) {
+ dir := logcatTestDir(t)
+ w, err := newLogcatWriter(dir, 2, 127, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { w.Close() })
+ input := numberedLog(2, 60)
+ for p := input; len(p) > 0; {
+ n := min(chunk, len(p))
+ if got, err := w.Write(p[:n]); err != nil || got != n {
+ t.Fatalf("write %d: %d, %v", n, got, err)
+ }
+ p = p[n:]
+ }
+ got, paths := readLogcatSegments(t, dir, 2)
+ if len(paths) < 3 || !bytes.Equal(got, input) {
+ t.Fatalf("%d segments; numbered stream changed: got %d, want %d bytes", len(paths), len(got), len(input))
+ }
+ for _, path := range paths[:len(paths)-1] {
+ st, _ := os.Stat(path)
+ if st.Size() < 127 || st.Size() > 127+int64(len(numberedLog(2, 1))) {
+ t.Fatalf("segment size %d", st.Size())
+ }
+ }
+ })
+ }
+}
+
+func TestLogcatRotationRetentionAndActiveHandle(t *testing.T) {
+ for _, total := range []bool{false, true} {
+ t.Run(fmt.Sprint(total), func(t *testing.T) {
+ dir := logcatTestDir(t)
+ var pruneErr error
+ w, err := newLogcatWriter(dir, 0, 90, func() {
+ keep, maxBytes := 2, int64(1<<30)
+ if total {
+ keep, maxBytes = 100, 180
+ }
+ _, _, pruneErr = pruneLogDir(dir, 0, keep, maxBytes, logx.Nop())
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { w.Close() })
+ if _, err := w.Write(numberedLog(0, 80)); err != nil || pruneErr != nil {
+ t.Fatalf("write/prune: %v / %v", err, pruneErr)
+ }
+ _, paths := readLogcatSegments(t, dir, 0)
+ if len(paths) > 3 || len(paths) < 2 {
+ t.Fatalf("old segments not pruned: %q", paths)
+ }
+ // Force even the active file to be the oldest and over both limits. Its handle must protect it.
+ active := w.file.Name()
+ old := time.Unix(1, 0)
+ if err := os.Chtimes(active, old, old); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := pruneLogDir(dir, 0, 0, 0, logx.Nop()); err != nil {
+ t.Fatal(err)
+ }
+ if busy, _ := fileInUse(active); !busy {
+ t.Fatal("current file is not protected")
+ }
+ files, err := managedLogFiles(dir)
+ if err != nil || len(files) != 1 || files[0].path != active {
+ t.Fatalf("current file/old-segment cleanup: %v, %v", files, err)
+ }
+ })
+ }
+}
+
+func TestLogcatRotationInstancesAndSameSecondRestart(t *testing.T) {
+ dir := logcatTestDir(t)
+ var wg sync.WaitGroup
+ for _, i := range []int{0, 1} {
+ wg.Go(func() {
+ for range 2 {
+ w, err := newLogcatWriter(dir, i, 95, nil)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ _, err = w.Write(numberedLog(i, 20))
+ if err := errors.Join(err, w.Close()); err != nil {
+ t.Error(err)
+ }
+ }
+ })
+ }
+ wg.Wait()
+ for _, i := range []int{0, 1} {
+ got, paths := readLogcatSegments(t, dir, i)
+ want := bytes.Repeat(numberedLog(i, 20), 2)
+ if len(paths) < 6 || !bytes.Equal(got, want) {
+ t.Fatalf("instance %d mixed, truncated or repeated its stream", i)
+ }
+ }
+}
+
+func TestLogcatRotationRestartAfterPruningKeepsNameOrder(t *testing.T) {
+ dir := logcatTestDir(t)
+ w, err := newLogcatWriter(dir, 0, 90, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := w.Write(numberedLog(0, 20)); err != nil {
+ t.Fatal(err)
+ }
+ last := filepath.Base(w.file.Name())
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := pruneLogDir(dir, 0, 1, 1<<30, logx.Nop()); err != nil {
+ t.Fatal(err)
+ }
+ w, err = newLogcatWriter(dir, 0, 90, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer w.Close()
+ if next := filepath.Base(w.file.Name()); next <= last {
+ t.Fatalf("restart reused an earlier timestamp: %s <= %s", next, last)
+ }
+}
+
+func TestLogcatRotationFailureKeepsPreviousFile(t *testing.T) {
+ dir := logcatTestDir(t)
+ w, err := newLogcatWriter(dir, 0, 1, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer w.Close()
+ if _, err := w.Write([]byte("first\n")); err != nil {
+ t.Fatal(err)
+ }
+ // Make opening the next segment fail, without changing the already open file.
+ w.dir = w.file.Name()
+ if n, err := w.Write([]byte("second\n")); err == nil || n != 0 {
+ t.Fatalf("failed rotation reported %d, %v", n, err)
+ }
+ got, err := os.ReadFile(w.file.Name())
+ if err != nil || string(got) != "first\n" {
+ t.Fatalf("previous segment changed: %q, %v", got, err)
+ }
+}
+
+type logcatFailWriter struct{}
+
+func (logcatFailWriter) Write([]byte) (int, error) { return 0, os.ErrPermission }
+
+func TestLogcatWriteFailureEndsAdb(t *testing.T) {
+ r, _ := portPlanRoot(t)
+ t.Setenv(fakeLogcatStreamEnv, filepath.Join(r.Dir, "never-emit"))
+ m := New(r, nil)
+ done := make(chan error, 1)
+ go func() {
+ done <- captureLogcat(r.AdbExe(), logcatArgs(r.AdbServerPort(), r.Ports().Serial(6)), m.ADB.Env(), logcatFailWriter{})
+ }()
+ select {
+ case err := <-done:
+ if !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("write error lost: %v", err)
+ }
+ case <-time.After(5 * time.Second):
+ clients, _ := m.findLogcatProcesses(r.Ports().Serial(6))
+ endProcesses(clients, time.Second)
+ t.Fatal("writer failure left adb running")
+ }
+ if clients, err := m.findLogcatProcesses(r.Ports().Serial(6)); err != nil || len(clients) != 0 {
+ t.Fatalf("capture still active: %v, %v", clients, err)
+ }
+}
+
+func TestLogcatCaptureRejectsBadArguments(t *testing.T) {
+ for _, args := range [][]string{nil, {"missing-index"}, {t.TempDir(), "5"}, {t.TempDir(), "-1"}, {t.TempDir(), "not-a-number"}} {
+ if code := RunLogcatCapture(args, &bytes.Buffer{}); code != 2 {
+ t.Errorf("args %q: exit %d", args, code)
+ }
+ }
+ if code := RunLogcatCapture([]string{filepath.Join(t.TempDir(), "missing"), "0"}, &bytes.Buffer{}); code != 1 {
+ t.Fatalf("missing root: exit %d", code)
+ }
+}
+
+const (
+ fakeLogcatStreamEnv = "MODAVD_TEST_LOGCAT_STREAM"
+ fakeLogcatLaunchArg = "__test_logcat_launch"
+)
+
+func fakeLogcatStream(args []string) int {
+ serial := args[slices.Index(args, "-s")+1]
+ for n := 0; n < 15; n++ {
+ fmt.Printf("%s sequence=%06d payload=abcdefghijk\n", serial, n)
+ time.Sleep(20 * time.Millisecond)
+ }
+ // A new file from the test asks for more records after the launching CLI has exited and after rotation.
+ gate := os.Getenv(fakeLogcatStreamEnv)
+ for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); {
+ if _, err := os.Stat(gate); err == nil {
+ fmt.Printf("%s after-cli-exit\n", serial)
+ for {
+ time.Sleep(time.Second)
+ }
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ return 1
+}
+
+func TestLogcatOrphanSurvivesLauncherAndRestartsOnce(t *testing.T) {
+ r, _ := portPlanRoot(t)
+ gate := filepath.Join(r.Dir, "emit-after-cli-exit")
+ t.Setenv(fakeLogcatStreamEnv, gate)
+ m := New(r, nil)
+ t.Cleanup(func() {
+ clients, _ := m.findLogcatProcesses(r.Ports().Serial(6))
+ endProcesses(clients, time.Second)
+ waitLogcatTest(t, func() bool { return !m.lockHeld(6, "logcat") })
+ })
+ self, _ := os.Executable()
+ launch := func() {
+ t.Helper()
+ cmd := exec.Command(self, fakeLogcatLaunchArg, r.Dir, "6")
+ win.HideConsoleCmd(cmd)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("launcher: %v, %s", err, out)
+ }
+ }
+ launch() // the process that called ensureLogcat has now exited
+ waitLogcatTest(t, func() bool {
+ return logcatTestContains(r.LogsDir(), 6, "sequence=000014 payload=abcdefghijk\n", 3)
+ })
+ if err := os.WriteFile(gate, nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ waitLogcatTest(t, func() bool {
+ return logcatTestContains(r.LogsDir(), 6, "after-cli-exit\n", 3)
+ })
+ got, _ := readLogcatSegments(t, r.LogsDir(), 6)
+ var want strings.Builder
+ for n := 0; n < 15; n++ {
+ fmt.Fprintf(&want, "emulator-21060 sequence=%06d payload=abcdefghijk\n", n)
+ }
+ want.WriteString("emulator-21060 after-cli-exit\n")
+ if string(got) != want.String() {
+ t.Fatalf("orphan stream changed: %q", got)
+ }
+ clients, err := m.findLogcatProcesses(r.Ports().Serial(6))
+ if err != nil || len(clients) != 1 {
+ t.Fatalf("active clients: %v, %v", clients, err)
+ }
+ launch() // adopting the running capture must not start another helper/client
+ endProcesses(clients, 2*time.Second) // same EOF as a stopped emulator or a stale-server client
+ // Start immediately, while the old writer may still be draining and releasing its lock.
+ launch()
+ waitLogcatTest(t, func() bool {
+ live, _ := m.findLogcatProcesses(r.Ports().Serial(6))
+ return len(live) == 1 && live[0].PID != clients[0].PID
+ })
+ if path := m.ensureLogcat(context.Background(), 6); path == "" {
+ t.Fatal("restarted capture did not expose its current file")
+ }
+}
+
+func waitLogcatTest(t *testing.T, check func() bool) {
+ t.Helper()
+ for deadline := time.Now().Add(10 * time.Second); ; {
+ if check() {
+ return
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("timed out waiting for logcat")
+ }
+ time.Sleep(25 * time.Millisecond)
+ }
+}
diff --git a/internal/emu/logs.go b/internal/emu/logs.go
index 60f89b0..d6d0337 100644
--- a/internal/emu/logs.go
+++ b/internal/emu/logs.go
@@ -78,6 +78,11 @@ func pruneLogDir(dir string, i, keep int, maxBytes int64, log *logx.Logger) (rem
var mine []logFile
for _, f := range files {
if f.dir == kind.dir && strings.HasPrefix(f.name, kind.prefix) && strings.HasSuffix(f.name, kind.suffix) {
+ if !f.dir {
+ if busy, vanished := fileInUse(f.path); busy || vanished {
+ continue
+ }
+ }
mine = append(mine, f)
}
}
@@ -339,6 +344,15 @@ func (m *Manager) ensureLogcat(ctx context.Context, i int) string {
defer logcatMu.Unlock()
serial := m.Root.Ports().Serial(i)
running, err := m.findLogcatProcesses(serial)
+ // Another process may have started the writer but not adb yet, or killed stale adb while its writer drains.
+ // Let that transition finish before deciding whether to create a replacement.
+ for deadline := time.Now().Add(5 * time.Second); err == nil && len(running) == 0 && m.lockHeld(i, "logcat"); {
+ if ctx.Err() != nil || time.Now().After(deadline) {
+ return ""
+ }
+ time.Sleep(20 * time.Millisecond)
+ running, err = m.findLogcatProcesses(serial)
+ }
if err != nil {
m.Log.Warnf("实例 %d 读不出宿主进程表,这次不起客体日志落盘(免得起两份): %v", i, err)
return ""
@@ -357,21 +371,33 @@ func (m *Manager) ensureLogcat(ctx context.Context, i int) string {
if ctx.Err() != nil {
return ""
}
- m.pruneLogs(i)
- stamp := time.Now().Format("20060102-150405")
- path := filepath.Join(m.Root.LogsDir(), "logcat-"+root.AvdName(i)+"-"+stamp+".txt")
- exe := m.Root.AdbExe()
- // StartOrphan creates the file and its directory
- if _, err := win.StartOrphan(exe, logcatArgs(m.Root.AdbServerPort(), serial), win.StartOptions{
- Dir: filepath.Dir(exe),
- Env: m.ADB.Env(),
- OutputPath: path,
- }); err != nil {
+ // The writer must outlive this CLI, just like adb. The helper owns both the pipe and the rotating files.
+ exe, err := os.Executable()
+ if err != nil {
+ m.Log.Warnf("实例 %d 找不到日志采集程序: %v", i, err)
+ return ""
+ }
+ p, err := win.StartOrphan(exe, []string{LogcatCaptureArg, m.Root.Dir, strconv.Itoa(i)}, win.StartOptions{Env: m.ADB.Env()})
+ if err != nil {
m.Log.Warnf("实例 %d 客体日志落盘没起来: %v", i, err)
return ""
}
- m.Log.Infof("实例 %d 客体日志 → %s", i, path)
- return path
+ // Wait for the helper to hold the output before reporting it. Its per-instance lock also covers the interval
+ // before adb appears in the process table, including starts from another manager process.
+ for deadline := time.Now().Add(10 * time.Second); ; {
+ if clients, err := m.findLogcatProcesses(serial); err == nil && len(clients) > 0 {
+ if path := openLogFile(m.Root.LogsDir(), "logcat-"+root.AvdName(i)+"-", ".txt"); path != "" {
+ m.Log.Infof("实例 %d 客体日志 → %s", i, path)
+ return path
+ }
+ }
+ if !p.Alive() || ctx.Err() != nil || time.Now().After(deadline) {
+ break
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ m.Log.Warnf("实例 %d 客体日志采集没有就绪", i)
+ return ""
}
// openLogFile is the newest * of the logs dir that some process holds open (a running capture, the
diff --git a/internal/emu/manager.go b/internal/emu/manager.go
index 2cdac6e..d87beda 100644
--- a/internal/emu/manager.go
+++ b/internal/emu/manager.go
@@ -294,7 +294,7 @@ func tail(s string, n int) string {
// ---- locks -------------------------------------------------------------------------------------------------------
// lockPath is \avd\.mas_..lock, kind "start" (held for a whole start), "boot" (from launch until
-// Android booted: no re-registration then), "stop" or "watch" (held by the boot watch, see bootwatch.go).
+// Android booted: no re-registration then), "stop", "watch" (bootwatch.go), or "logcat" (the capture writer's life).
func (m *Manager) lockPath(i int, kind string) string {
return filepath.Join(m.Root.AvdHome(), "."+root.AvdName(i)+"."+kind+".lock")
}
diff --git a/internal/emu/portplan_test.go b/internal/emu/portplan_test.go
index bbc3468..d0b3934 100644
--- a/internal/emu/portplan_test.go
+++ b/internal/emu/portplan_test.go
@@ -11,6 +11,7 @@ import (
"net"
"os"
"path/filepath"
+ "slices"
"strconv"
"strings"
"testing"
@@ -37,6 +38,31 @@ const (
)
func TestMain(m *testing.M) {
+ if len(os.Args) > 1 {
+ switch os.Args[1] {
+ case LogcatCaptureArg:
+ limit := logcatSegmentBytes
+ if os.Getenv(fakeLogcatStreamEnv) != "" {
+ limit = 128
+ }
+ os.Exit(runLogcatCapture(os.Args[2:], os.Stderr, limit))
+ case win.SpawnHelperArg:
+ os.Exit(win.SpawnHelperMain(os.Args[2:], os.Stdout))
+ case fakeLogcatLaunchArg:
+ r, err := root.New(os.Args[2])
+ if err != nil {
+ os.Exit(1)
+ }
+ i, _ := strconv.Atoi(os.Args[3])
+ win.OrphanHelper, _ = os.Executable()
+ path := New(r, nil).ensureLogcat(context.Background(), i)
+ fmt.Println(path)
+ if path == "" {
+ os.Exit(1)
+ }
+ os.Exit(0)
+ }
+ }
switch os.Getenv(fakeAdbEnv) {
case "server", "qemu":
os.Exit(fakeAdbServerMain())
@@ -121,6 +147,9 @@ func startFake(t *testing.T, exe, mode string, listen int, args ...string) win.P
}
func fakeAdbClientMain(args []string) int {
+ if slices.Contains(args, "logcat") && os.Getenv(fakeLogcatStreamEnv) != "" {
+ return fakeLogcatStream(args)
+ }
line, _ := json.Marshal(args)
if f, err := os.OpenFile(os.Getenv(fakeAdbLogEnv), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644); err == nil {
f.Write(append(line, '\n'))
diff --git a/scripts/test_logcat_rotation.ps1 b/scripts/test_logcat_rotation.ps1
new file mode 100644
index 0000000..f356e07
--- /dev/null
+++ b/scripts/test_logcat_rotation.ps1
@@ -0,0 +1,112 @@
+# Run only against an already-created, disposable instance with its own root, SDK and port plan.
+# Exercises the production 256 MiB threshold twice; writes about 580 MB and stops the instance when finished.
+param(
+ [Parameter(Mandatory)][string]$Root,
+ [Parameter(Mandatory)][string]$Binary,
+ [int]$Index = 0,
+ [int]$Count = 190000
+)
+$ErrorActionPreference = 'Stop'
+$Root = (Resolve-Path -LiteralPath $Root).Path
+$Binary = (Resolve-Path -LiteralPath $Binary).Path
+$meta = Get-Content -LiteralPath (Join-Path $Root 'mas-avd.json') -Raw | ConvertFrom-Json
+if (-not $meta.portBase -or $meta.portBase -eq 20000 -or $meta.sdkRoot) {
+ throw 'Use a disposable root with its own SDK and nondefault port plan.'
+}
+if ($Index -lt 0 -or $Index -gt 9 -or $Index -eq 5) { throw 'Invalid instance index' }
+$adb = Join-Path $Root 'sdk/platform-tools/adb.exe'
+$serial = 'emulator-' + ($meta.portBase + 10 * $Index)
+$server = [string]$meta.adbServerPort
+$logs = Join-Path $Root 'logs'
+$token = 'rotation97-' + [guid]::NewGuid().ToString('N')
+$evidence = Join-Path $Root $token
+New-Item -ItemType Directory -Path $evidence | Out-Null
+
+function Invoke-CLI([string]$Verb) {
+ $stdout = Join-Path $evidence "$Verb-$([guid]::NewGuid().ToString('N')).out"
+ $stderr = "$stdout.err"
+ $argv = @('--root', ('"' + $Root + '"'), '--json', $Verb, [string]$Index)
+ if ($Verb -eq 'start') { $argv += @('--headless', '--no-bridge', '--timeout', '180') }
+ $p = Start-Process -FilePath $Binary -ArgumentList $argv -WindowStyle Hidden -PassThru `
+ -RedirectStandardOutput $stdout -RedirectStandardError $stderr
+ $p.WaitForExit()
+ if ($p.ExitCode -ne 0) { throw "$Verb failed: $(Get-Content -LiteralPath $stderr -Raw)" }
+ if (Get-Process -Id $p.Id -ErrorAction SilentlyContinue) { throw 'Launching CLI still runs' }
+ return $p.Id
+}
+
+function Get-Captures {
+ @(Get-CimInstance Win32_Process -Filter "Name='adb.exe'" | Where-Object {
+ $_.ExecutablePath -eq $adb -and $_.CommandLine -match "-P $server " -and
+ $_.CommandLine -match "-s $serial logcat "
+ })
+}
+
+function Wait-For([scriptblock]$Check) {
+ $deadline = [DateTime]::UtcNow.AddSeconds(20)
+ while (-not (& $Check)) {
+ if ([DateTime]::UtcNow -gt $deadline) { throw 'Logcat condition timed out' }
+ Start-Sleep -Milliseconds 100
+ }
+}
+
+try {
+ $launcher = Invoke-CLI start
+ Wait-For { @(Get-Captures).Count -eq 1 }
+ $capture = (Get-Captures)[0]
+ # The CLI has exited before any numbered records are generated. One toybox log process reads all stdin.
+ # Pace the stream so the host can drain logd without guest buffer overruns.
+ $guest = @'
+awk 'BEGIN { p=""; for(j=0;j<3000;j++)p=p"x"; for(i=0;i
Date: Thu, 5 Nov 2026 17:46:31 +0800
Subject: [PATCH 2/3] fix: end logcat capture with its qemu process
---
README.en.md | 2 +-
README.md | 2 +-
cmd/modavd-gui/main.go | 2 -
internal/emu/logcat.go | 31 +++++++--
internal/emu/logcat_test.go | 81 ++++++++++++++++++++--
internal/emu/logs.go | 12 ++--
internal/emu/manager.go | 2 +-
internal/emu/portplan_test.go | 6 ++
scripts/test_logcat_rotation.ps1 | 112 -------------------------------
9 files changed, 117 insertions(+), 133 deletions(-)
delete mode 100644 scripts/test_logcat_rotation.ps1
diff --git a/README.en.md b/README.en.md
index d804bf2..ea38719 100644
--- a/README.en.md
+++ b/README.en.md
@@ -135,7 +135,7 @@ modavd [--root ] [--json] [--quiet] [--verbose] [args]
- When it is not valid, `status`, `list` and `info` still read the instances in the 20000 range, while starting and creating are refused (`invalid_argument`), and `check` shows a 「端口段」 (port range) row saying why.
- Shut down every instance of the root folder before you change it. Do not add a root folder that sets `portBase` to AUTO-MAS.
- The emulator, the adb server and logcat are all launched through an intermediate process that exits immediately, so they are not in modavd's process tree: if the caller ends modavd (Ctrl+C, the MCP client disconnects), the emulator is not taken down with it.
-- A separate host process writes logcat to `logs\logcat-mas_-.txt` and continues after the start command exits. At 256 MiB it rotates at the next newline and writes the same adb stream to a new file. Closed segments follow the existing limits of 10 per instance and 1 GiB across `logs\`; the active file is excluded.
+- A separate `tools\modavd.exe` process writes logcat to `logs\logcat-mas_-.txt` and continues after the start command or manager exits. When that boot's qemu exits, the capture process ends its own adb client and exits. At 256 MiB it rotates at the next newline and writes the same adb stream to a new file. Closed segments follow the existing limits of 10 per instance and 1 GiB across `logs\`; the active file is excluded.
- After a successful start, each instance also gets a boot guardian that is likewise outside the process tree and exits when qemu exits. After the guest restarts on its own (qemu keeps running), the guardian redoes the post-boot tuning and the Vulkan patch, and never launches a new emulator.
- Starts and shutdowns of the same instance queue on `\avd\.mas_.{start,boot,stop}.lock`: a start waits for the previous start and shutdown to finish; a shutdown can interrupt an instance that is starting.
diff --git a/README.md b/README.md
index f499d4b..d916eb1 100644
--- a/README.md
+++ b/README.md
@@ -138,7 +138,7 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数]
- 不合规时,`status`、`list`、`info` 照常按 20000 段看,开机和新建都拒绝(`invalid_argument`),`check` 多一行「端口段」说明原因。
- 改之前先关掉这个根目录的所有实例。设了 `portBase` 的根目录不要加进 AUTO-MAS。
- 模拟器、adb server、logcat 都经一个立即退出的中间进程拉起,不在 modavd 的进程树里:调用方结束 modavd(Ctrl+C、MCP 客户端断开)不会带走模拟器。
-- 宿主 logcat 由独立采集进程写入 `logs\logcat-mas_-<时间>.txt`,启动命令退出后仍继续。每满 256 MiB 在下一处换行切分,同一条 adb 输出流接着写新文件;关闭的分段每台留最新 10 份,并计入 `logs\` 的 1 GiB 清理上限,正在写的文件不计入。
+- 宿主 logcat 由 `tools\modavd.exe` 的独立采集进程写入 `logs\logcat-mas_-<时间>.txt`,启动命令或管理器退出后仍继续;这次开机的 qemu 退出后,采集进程主动结束自己的 adb 并退出。每满 256 MiB 在下一处换行切分,同一条 adb 输出流接着写新文件;关闭的分段每台留最新 10 份,并计入 `logs\` 的 1 GiB 清理上限,正在写的文件不计入。
- 开机成功后,每台实例另有一个同样不在进程树里的开机守护,随 qemu 退出而结束。客体自己重启(qemu 没退)后,它补做开机调优和 Vulkan 补丁,绝不拉起新的模拟器。
- 同一实例的开机、关机按 `<根>\avd\.mas_.{start,boot,stop}.lock` 排队:开机等前一次开机和关机做完;关机可以打断正在开机的实例。
diff --git a/cmd/modavd-gui/main.go b/cmd/modavd-gui/main.go
index 0c76ec3..dd7612d 100644
--- a/cmd/modavd-gui/main.go
+++ b/cmd/modavd-gui/main.go
@@ -50,8 +50,6 @@ func main() {
os.Exit(emu.RunVulkanProbe(os.Stdout))
case emu.BootWatchArg:
os.Exit(emu.RunBootWatch(os.Args[2:], os.Stderr))
- case emu.LogcatCaptureArg:
- os.Exit(emu.RunLogcatCapture(os.Args[2:], os.Stderr))
case versionArg:
os.Exit(printVersion())
}
diff --git a/internal/emu/logcat.go b/internal/emu/logcat.go
index 3ad182e..ad2d819 100644
--- a/internal/emu/logcat.go
+++ b/internal/emu/logcat.go
@@ -2,6 +2,7 @@ package emu
import (
"bytes"
+ "context"
"errors"
"fmt"
"io"
@@ -17,7 +18,7 @@ import (
"modavd/internal/win"
)
-// LogcatCaptureArg is routed by both the console and GUI executables, before normal command parsing.
+// LogcatCaptureArg is routed by the root's console executable before normal command parsing.
const LogcatCaptureArg = "__logcat"
// Rotate at the first newline at or beyond 256 MiB, so a record never spans two files.
@@ -135,17 +136,19 @@ func (w *logcatWriter) Close() error { return w.file.Close() }
// RunLogcatCapture is an orphaned host writer, not a guest logcat -f (which would write inside Android).
// A per-instance lifetime lock prevents duplicate helpers while adb starts or a previous helper drains its pipe.
-// Killing a stale adb client on re-registration closes this pipe and releases the lock before its replacement.
+// It also watches the qemu identity, so even a stale adb client is ended when this boot's qemu exits.
func RunLogcatCapture(args []string, stderr io.Writer) int {
return runLogcatCapture(args, stderr, logcatSegmentBytes)
}
func runLogcatCapture(args []string, stderr io.Writer, limit int64) int {
- if len(args) != 2 {
+ if len(args) != 4 {
return 2
}
i, err := strconv.Atoi(args[1])
- if err != nil || !root.ValidIndex(i) {
+ pid, pidErr := strconv.ParseUint(args[2], 10, 32)
+ created, createdErr := strconv.ParseUint(args[3], 10, 64)
+ if errors.Join(err, pidErr, createdErr) != nil || !root.ValidIndex(i) || pid == 0 || created == 0 {
return 2
}
r, err := root.New(args[0])
@@ -158,6 +161,10 @@ func runLogcatCapture(args []string, stderr io.Writer, limit int64) int {
return 1
}
defer l.Unlock()
+ q := win.Process{PID: uint32(pid), Created: created}
+ if !q.Alive() {
+ return 0
+ }
// A legacy capture, or a helper started concurrently, may already have an adb client.
if running, err := m.findLogcatProcesses(r.Ports().Serial(i)); err != nil || len(running) > 0 {
return 1
@@ -167,20 +174,30 @@ func runLogcatCapture(args []string, stderr io.Writer, limit int64) int {
return 1
}
defer w.Close()
- if err := captureLogcat(r.AdbExe(), logcatArgs(r.AdbServerPort(), r.Ports().Serial(i)), m.ADB.Env(), w); err != nil {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go func() {
+ for !q.Wait(100 * time.Millisecond) {
+ if ctx.Err() != nil {
+ return
+ }
+ }
+ cancel()
+ }()
+ if err := captureLogcat(ctx, r.AdbExe(), logcatArgs(r.AdbServerPort(), r.Ports().Serial(i)), m.ADB.Env(), w); err != nil && ctx.Err() == nil {
m.Log.Warnf("实例 %d 客体日志采集已结束: %v", i, err)
return 1
}
return 0
}
-func captureLogcat(exe string, args, env []string, output io.Writer) error {
+func captureLogcat(ctx context.Context, exe string, args, env []string, output io.Writer) error {
r, pipe, err := os.Pipe()
if err != nil {
return err
}
defer r.Close()
- cmd := exec.Command(exe, args...)
+ cmd := exec.CommandContext(ctx, exe, args...)
cmd.Dir, cmd.Env, cmd.Stdout, cmd.Stderr = filepath.Dir(exe), env, pipe, pipe
win.HideConsoleCmd(cmd)
if err := cmd.Start(); err != nil {
diff --git a/internal/emu/logcat_test.go b/internal/emu/logcat_test.go
index 5cc5db3..7e963e7 100644
--- a/internal/emu/logcat_test.go
+++ b/internal/emu/logcat_test.go
@@ -246,7 +246,7 @@ func TestLogcatWriteFailureEndsAdb(t *testing.T) {
m := New(r, nil)
done := make(chan error, 1)
go func() {
- done <- captureLogcat(r.AdbExe(), logcatArgs(r.AdbServerPort(), r.Ports().Serial(6)), m.ADB.Env(), logcatFailWriter{})
+ done <- captureLogcat(context.Background(), r.AdbExe(), logcatArgs(r.AdbServerPort(), r.Ports().Serial(6)), m.ADB.Env(), logcatFailWriter{})
}()
select {
case err := <-done:
@@ -264,12 +264,14 @@ func TestLogcatWriteFailureEndsAdb(t *testing.T) {
}
func TestLogcatCaptureRejectsBadArguments(t *testing.T) {
- for _, args := range [][]string{nil, {"missing-index"}, {t.TempDir(), "5"}, {t.TempDir(), "-1"}, {t.TempDir(), "not-a-number"}} {
+ for _, args := range [][]string{nil, {"missing-index"}, {t.TempDir(), "5", "1", "1"},
+ {t.TempDir(), "-1", "1", "1"}, {t.TempDir(), "not-a-number", "1", "1"},
+ {t.TempDir(), "0", "0", "1"}, {t.TempDir(), "0", "1", "0"}} {
if code := RunLogcatCapture(args, &bytes.Buffer{}); code != 2 {
t.Errorf("args %q: exit %d", args, code)
}
}
- if code := RunLogcatCapture([]string{filepath.Join(t.TempDir(), "missing"), "0"}, &bytes.Buffer{}); code != 1 {
+ if code := RunLogcatCapture([]string{filepath.Join(t.TempDir(), "missing"), "0", "1", "1"}, &bytes.Buffer{}); code != 1 {
t.Fatalf("missing root: exit %d", code)
}
}
@@ -301,6 +303,11 @@ func fakeLogcatStream(args []string) int {
func TestLogcatOrphanSurvivesLauncherAndRestartsOnce(t *testing.T) {
r, _ := portPlanRoot(t)
+ copyTestExe(t, filepath.Join(r.Dir, "tools", "modavd.exe"))
+ q := fakeQemu(t, filepath.Join(r.Dir, "fake-qemu"), 6, 21060, false, "")
+ savedScan := qemuScan
+ qemuScan = func([]int) map[int]win.Process { return map[int]win.Process{6: q} }
+ t.Cleanup(func() { qemuScan = savedScan })
gate := filepath.Join(r.Dir, "emit-after-cli-exit")
t.Setenv(fakeLogcatStreamEnv, gate)
m := New(r, nil)
@@ -312,7 +319,7 @@ func TestLogcatOrphanSurvivesLauncherAndRestartsOnce(t *testing.T) {
self, _ := os.Executable()
launch := func() {
t.Helper()
- cmd := exec.Command(self, fakeLogcatLaunchArg, r.Dir, "6")
+ cmd := exec.Command(self, fakeLogcatLaunchArg, r.Dir, "6", strconv.FormatUint(uint64(q.PID), 10), strconv.FormatUint(q.Created, 10))
win.HideConsoleCmd(cmd)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("launcher: %v, %s", err, out)
@@ -341,6 +348,13 @@ func TestLogcatOrphanSurvivesLauncherAndRestartsOnce(t *testing.T) {
if err != nil || len(clients) != 1 {
t.Fatalf("active clients: %v, %v", clients, err)
}
+ helper, ok := win.FindProcess(clients[0].PPID)
+ if !ok {
+ t.Fatal("cannot find logcat helper")
+ }
+ if path, err := helper.ImagePath(); err != nil || !strings.EqualFold(path, filepath.Join(r.Dir, "tools", "modavd.exe")) {
+ t.Fatalf("capture did not use the root's CLI: %s, %v", path, err)
+ }
launch() // adopting the running capture must not start another helper/client
endProcesses(clients, 2*time.Second) // same EOF as a stopped emulator or a stale-server client
// Start immediately, while the old writer may still be draining and releasing its lock.
@@ -354,6 +368,65 @@ func TestLogcatOrphanSurvivesLauncherAndRestartsOnce(t *testing.T) {
}
}
+func TestLogcatQemuExitEndsStubbornAdbAndReleasesFiles(t *testing.T) {
+ r, _ := portPlanRoot(t)
+ copyTestExe(t, filepath.Join(r.Dir, "tools", "modavd.exe"))
+ q := fakeQemu(t, filepath.Join(r.Dir, "fake-qemu"), 6, 21060, false, "")
+ gate := filepath.Join(r.Dir, "keep-adb-running")
+ if err := os.WriteFile(gate, nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv(fakeLogcatStreamEnv, gate)
+ m := New(r, nil)
+ var helper win.Process
+ t.Cleanup(func() {
+ clients, _ := m.findLogcatProcesses(r.Ports().Serial(6))
+ endProcesses(clients, time.Second)
+ endProcesses([]win.Process{helper}, time.Second)
+ })
+ self, _ := os.Executable()
+ cmd := exec.Command(self, fakeLogcatLaunchArg, r.Dir, "6", strconv.FormatUint(uint64(q.PID), 10), strconv.FormatUint(q.Created, 10))
+ win.HideConsoleCmd(cmd)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("launcher: %v, %s", err, out)
+ }
+ waitLogcatTest(t, func() bool { return logcatTestContains(r.LogsDir(), 6, "after-cli-exit\n", 3) })
+ clients, err := m.findLogcatProcesses(r.Ports().Serial(6))
+ if err != nil || len(clients) != 1 {
+ t.Fatalf("active clients: %v, %v", clients, err)
+ }
+ var ok bool
+ helper, ok = win.FindProcess(clients[0].PPID)
+ if !ok {
+ t.Fatal("cannot find logcat helper")
+ }
+ // The fake adb deliberately never observes qemu's exit: only the writer's qemu watch can end it.
+ if err := q.Kill(); err != nil || !q.Wait(time.Second) {
+ t.Fatalf("end fake qemu: %v", err)
+ }
+ if !helper.Wait(3 * time.Second) {
+ t.Fatal("qemu exited but logcat helper kept running and blocking its executable")
+ }
+ if clients[0].Alive() || m.lockHeld(6, "logcat") {
+ t.Fatal("qemu exit left adb or the capture lock alive")
+ }
+ _, paths := readLogcatSegments(t, r.LogsDir(), 6)
+ for _, path := range paths {
+ if busy, _ := fileInUse(path); busy {
+ t.Fatalf("capture still holds %s", path)
+ }
+ }
+ // A reused PID must not adopt a different boot, even when that PID is alive.
+ selfProc, _ := win.FindProcess(uint32(os.Getpid()))
+ if code := RunLogcatCapture([]string{r.Dir, "6", strconv.Itoa(os.Getpid()), strconv.FormatUint(selfProc.Created+1, 10)}, &bytes.Buffer{}); code != 0 {
+ t.Fatalf("stale qemu identity: exit %d", code)
+ }
+ m.removeLockFiles(6)
+ if _, err := os.Stat(m.lockPath(6, "logcat")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("delete left capture lock: %v", err)
+ }
+}
+
func waitLogcatTest(t *testing.T, check func() bool) {
t.Helper()
for deadline := time.Now().Add(10 * time.Second); ; {
diff --git a/internal/emu/logs.go b/internal/emu/logs.go
index d6d0337..c198262 100644
--- a/internal/emu/logs.go
+++ b/internal/emu/logs.go
@@ -371,13 +371,15 @@ func (m *Manager) ensureLogcat(ctx context.Context, i int) string {
if ctx.Err() != nil {
return ""
}
- // The writer must outlive this CLI, just like adb. The helper owns both the pipe and the rotating files.
- exe, err := os.Executable()
- if err != nil {
- m.Log.Warnf("实例 %d 找不到日志采集程序: %v", i, err)
+ q, found, err := m.qemuOf(i)
+ if err != nil || !found || q.Created == 0 || !q.Alive() {
+ m.Log.Warnf("实例 %d 找不到仍在运行的 qemu,这次不起客体日志采集", i)
return ""
}
- p, err := win.StartOrphan(exe, []string{LogcatCaptureArg, m.Root.Dir, strconv.Itoa(i)}, win.StartOptions{Env: m.ADB.Env()})
+ // The writer must outlive this CLI, just like adb. The helper owns both the pipe and the rotating files.
+ exe := filepath.Join(m.Root.Dir, "tools", "modavd.exe")
+ p, err := win.StartOrphan(exe, []string{LogcatCaptureArg, m.Root.Dir, strconv.Itoa(i),
+ strconv.FormatUint(uint64(q.PID), 10), strconv.FormatUint(q.Created, 10)}, win.StartOptions{Env: m.ADB.Env()})
if err != nil {
m.Log.Warnf("实例 %d 客体日志落盘没起来: %v", i, err)
return ""
diff --git a/internal/emu/manager.go b/internal/emu/manager.go
index d87beda..f1839b4 100644
--- a/internal/emu/manager.go
+++ b/internal/emu/manager.go
@@ -368,7 +368,7 @@ func (m *Manager) guestLockPath(i int) string {
// 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", "disk"} {
+ for _, kind := range []string{"start", "boot", "stop", "watch", "logcat", "bridge", "create", "proxy", "disk"} {
os.Remove(m.lockPath(i, kind))
}
os.Remove(m.setupMarkerPath(i))
diff --git a/internal/emu/portplan_test.go b/internal/emu/portplan_test.go
index d0b3934..4f89b0c 100644
--- a/internal/emu/portplan_test.go
+++ b/internal/emu/portplan_test.go
@@ -54,6 +54,11 @@ func TestMain(m *testing.M) {
os.Exit(1)
}
i, _ := strconv.Atoi(os.Args[3])
+ pid, _ := strconv.ParseUint(os.Args[4], 10, 32)
+ created, _ := strconv.ParseUint(os.Args[5], 10, 64)
+ qemuScan = func([]int) map[int]win.Process {
+ return map[int]win.Process{i: {PID: uint32(pid), Created: created}}
+ }
win.OrphanHelper, _ = os.Executable()
path := New(r, nil).ensureLogcat(context.Background(), i)
fmt.Println(path)
@@ -232,6 +237,7 @@ func portPlanRoot(t *testing.T) (*root.Root, string) {
// What status reports follows too.
func TestPortPlanReachesEveryAdbCall(t *testing.T) {
r, log := portPlanRoot(t)
+ copyTestExe(t, filepath.Join(r.Dir, "tools", "modavd.exe"))
ctx := context.Background()
m := New(r, logx.Nop())
six := 6
diff --git a/scripts/test_logcat_rotation.ps1 b/scripts/test_logcat_rotation.ps1
deleted file mode 100644
index f356e07..0000000
--- a/scripts/test_logcat_rotation.ps1
+++ /dev/null
@@ -1,112 +0,0 @@
-# Run only against an already-created, disposable instance with its own root, SDK and port plan.
-# Exercises the production 256 MiB threshold twice; writes about 580 MB and stops the instance when finished.
-param(
- [Parameter(Mandatory)][string]$Root,
- [Parameter(Mandatory)][string]$Binary,
- [int]$Index = 0,
- [int]$Count = 190000
-)
-$ErrorActionPreference = 'Stop'
-$Root = (Resolve-Path -LiteralPath $Root).Path
-$Binary = (Resolve-Path -LiteralPath $Binary).Path
-$meta = Get-Content -LiteralPath (Join-Path $Root 'mas-avd.json') -Raw | ConvertFrom-Json
-if (-not $meta.portBase -or $meta.portBase -eq 20000 -or $meta.sdkRoot) {
- throw 'Use a disposable root with its own SDK and nondefault port plan.'
-}
-if ($Index -lt 0 -or $Index -gt 9 -or $Index -eq 5) { throw 'Invalid instance index' }
-$adb = Join-Path $Root 'sdk/platform-tools/adb.exe'
-$serial = 'emulator-' + ($meta.portBase + 10 * $Index)
-$server = [string]$meta.adbServerPort
-$logs = Join-Path $Root 'logs'
-$token = 'rotation97-' + [guid]::NewGuid().ToString('N')
-$evidence = Join-Path $Root $token
-New-Item -ItemType Directory -Path $evidence | Out-Null
-
-function Invoke-CLI([string]$Verb) {
- $stdout = Join-Path $evidence "$Verb-$([guid]::NewGuid().ToString('N')).out"
- $stderr = "$stdout.err"
- $argv = @('--root', ('"' + $Root + '"'), '--json', $Verb, [string]$Index)
- if ($Verb -eq 'start') { $argv += @('--headless', '--no-bridge', '--timeout', '180') }
- $p = Start-Process -FilePath $Binary -ArgumentList $argv -WindowStyle Hidden -PassThru `
- -RedirectStandardOutput $stdout -RedirectStandardError $stderr
- $p.WaitForExit()
- if ($p.ExitCode -ne 0) { throw "$Verb failed: $(Get-Content -LiteralPath $stderr -Raw)" }
- if (Get-Process -Id $p.Id -ErrorAction SilentlyContinue) { throw 'Launching CLI still runs' }
- return $p.Id
-}
-
-function Get-Captures {
- @(Get-CimInstance Win32_Process -Filter "Name='adb.exe'" | Where-Object {
- $_.ExecutablePath -eq $adb -and $_.CommandLine -match "-P $server " -and
- $_.CommandLine -match "-s $serial logcat "
- })
-}
-
-function Wait-For([scriptblock]$Check) {
- $deadline = [DateTime]::UtcNow.AddSeconds(20)
- while (-not (& $Check)) {
- if ([DateTime]::UtcNow -gt $deadline) { throw 'Logcat condition timed out' }
- Start-Sleep -Milliseconds 100
- }
-}
-
-try {
- $launcher = Invoke-CLI start
- Wait-For { @(Get-Captures).Count -eq 1 }
- $capture = (Get-Captures)[0]
- # The CLI has exited before any numbered records are generated. One toybox log process reads all stdin.
- # Pace the stream so the host can drain logd without guest buffer overruns.
- $guest = @'
-awk 'BEGIN { p=""; for(j=0;j<3000;j++)p=p"x"; for(i=0;i
Date: Thu, 5 Nov 2026 17:52:18 +0800
Subject: [PATCH 3/3] test: exclude directory setup from readStable timing
---
internal/guest/readstable_windows_test.go | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/internal/guest/readstable_windows_test.go b/internal/guest/readstable_windows_test.go
index 7cc14f8..98975f4 100644
--- a/internal/guest/readstable_windows_test.go
+++ b/internal/guest/readstable_windows_test.go
@@ -46,8 +46,9 @@ func TestReadStableWaitsForSharingViolation(t *testing.T) {
}
// Other errors are not retried.
+ missing := filepath.Join(testutil.TempDir(t), "missing.sh")
t0 = time.Now()
- if _, err := readStable(filepath.Join(testutil.TempDir(t), "missing.sh")); !errors.Is(err, fs.ErrNotExist) {
+ if _, err := readStable(missing); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("missing: %v", err)
}
if d := time.Since(t0); d > 40*time.Millisecond {