package instance import ( "bufio" "fmt" "os" "os/exec" "path/filepath" "strings" "testing" "time" ) // TestAcquireSeparatesConcurrentInstances 验证槽位分配:第一个进程用默认数据目录, // 第二个进程拿到 instance-2,锁释放后默认实例可被复用。 func TestAcquireSeparatesConcurrentInstances(t *testing.T) { baseDir := t.TempDir() t.Setenv("HUYA_DATA_DIR", baseDir) t.Setenv("HUYA_LOG_DIR", "") first, err := Acquire() if err != nil { t.Fatal(err) } defer first.Release() if first.Name() != "默认实例" || first.ID() != "default" { t.Fatalf("first instance = %q/%q", first.Name(), first.ID()) } if first.Paths().DataDir != baseDir { t.Fatalf("first dataDir = %q, want %q", first.Paths().DataDir, baseDir) } if first.Paths().LogDir != filepath.Join(baseDir, "logs") { t.Fatalf("first logDir = %q", first.Paths().LogDir) } second, err := Acquire() if err != nil { t.Fatal(err) } defer second.Release() wantSecondDir := filepath.Join(baseDir, "instances", "instance-2") if second.Name() != "实例 2" || second.ID() != "instance-2" { t.Fatalf("second instance = %q/%q", second.Name(), second.ID()) } if second.Paths().DataDir != wantSecondDir { t.Fatalf("second dataDir = %q, want %q", second.Paths().DataDir, wantSecondDir) } // 日志目录必须落在实例自己的目录里,否则多开时日志会交错。 if second.Paths().LogDir != filepath.Join(wantSecondDir, "logs") { t.Fatalf("second logDir = %q", second.Paths().LogDir) } first.Release() reused, err := Acquire() if err != nil { t.Fatal(err) } defer reused.Release() if reused.Name() != "默认实例" || reused.Paths().DataDir != baseDir { t.Fatalf("reused instance = %q/%q", reused.Name(), reused.Paths().DataDir) } } // TestWebviewDirsAreIsolated WebView2 一个用户数据目录只能有一个浏览器进程, // 每个实例必须是不同目录,否则第二个实例会偶发开不出窗口。 func TestWebviewDirsAreIsolated(t *testing.T) { baseDir := t.TempDir() t.Setenv("HUYA_DATA_DIR", baseDir) t.Setenv("HUYA_LOG_DIR", "") first, err := Acquire() if err != nil { t.Fatal(err) } defer first.Release() second, err := Acquire() if err != nil { t.Fatal(err) } defer second.Release() firstDir := first.WebviewUserDataDir() secondDir := second.WebviewUserDataDir() if firstDir == "" || secondDir == "" || firstDir == secondDir { t.Fatalf("webview dirs = %q / %q", firstDir, secondDir) } if want := filepath.Join(baseDir, "webview2"); firstDir != want { t.Fatalf("first webview dir = %q, want %q", firstDir, want) } if want := filepath.Join(baseDir, "instances", "instance-2", "webview2"); secondDir != want { t.Fatalf("second webview dir = %q, want %q", secondDir, want) } } // TestAcquireNeverSharesDefaultDir 保证实例占位失败时也不会退回默认目录。 // 退回默认目录会让两个进程共享日志文件与前端数据目录。 func TestAcquireNeverSharesDefaultDir(t *testing.T) { baseDir := t.TempDir() // 用一个普通文件占住 ,让 /instances/.locks 无法创建。 blocked := filepath.Join(baseDir, "blocked") if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil { t.Fatal(err) } t.Setenv("HUYA_DATA_DIR", blocked) t.Setenv("HUYA_LOG_DIR", "") lease, err := Acquire() if lease == nil { t.Fatal("实例占位必须始终返回可用目录") } defer lease.Release() if err == nil { t.Fatal("降级路径应当把原因返回给上层记录") } if lease.Name() != "临时实例" { t.Fatalf("降级实例名 = %q, want 临时实例", lease.Name()) } if lease.Paths().DataDir == blocked || !strings.Contains(lease.ID(), "instance-tmp-") { t.Fatalf("降级目录必须与本进程绑定且独立: %q", lease.Paths().DataDir) } } // TestReleaseIsIdempotent 验证重复释放不会出错(Shutdown 与 defer 可能都调用一次)。 func TestReleaseIsIdempotent(t *testing.T) { t.Setenv("HUYA_DATA_DIR", t.TempDir()) t.Setenv("HUYA_LOG_DIR", "") lease, err := Acquire() if err != nil { t.Fatal(err) } lease.Release() lease.Release() } // TestAcquireIsExclusiveAcrossProcesses 用两个真实进程验证「多开」:第二个进程不会拿到 // 与第一个进程相同的数据目录(否则会互相覆盖日志与前端数据)。 func TestAcquireIsExclusiveAcrossProcesses(t *testing.T) { if os.Getenv("HUYA_INSTANCE_HELPER") == "1" { runInstanceHelper() return } baseDir := t.TempDir() first := startInstanceHelper(t, baseDir) defer first.stop() firstName, firstID, firstDir := first.read(t) // 第一个进程已经拿到锁之后再启动第二个,避免出现「同时抢同一槽位」的随机性。 second := startInstanceHelper(t, baseDir) defer second.stop() _, secondID, secondDir := second.read(t) if firstID != "default" || firstName != "默认实例" { t.Fatalf("第一个进程实例 = %q/%q, want 默认实例/default", firstName, firstID) } if secondID != "instance-2" { t.Fatalf("第二个进程实例 = %q, want instance-2", secondID) } if firstDir == secondDir { t.Fatalf("两个进程的数据目录相同: %s", firstDir) } if !strings.Contains(secondDir, filepath.Join("instances", "instance-2")) { t.Fatalf("第二个进程数据目录 = %q", secondDir) } } // runInstanceHelper 是子进程分支:占位后打印实例信息,短暂保持占用再释放。 func runInstanceHelper() { lease, err := Acquire() if err != nil { fmt.Println("ERR:" + err.Error()) return } fmt.Printf("%s|%s|%s\n", lease.Name(), lease.ID(), lease.Paths().DataDir) time.Sleep(2 * time.Second) lease.Release() } type instanceHelper struct { cmd *exec.Cmd lines *bufio.Scanner } func startInstanceHelper(t *testing.T, baseDir string) *instanceHelper { t.Helper() cmd := exec.Command(os.Args[0], "-test.run=TestAcquireIsExclusiveAcrossProcesses") cmd.Env = append(os.Environ(), "HUYA_INSTANCE_HELPER=1", "HUYA_DATA_DIR="+baseDir, "HUYA_LOG_DIR=", ) stdout, err := cmd.StdoutPipe() if err != nil { t.Fatal(err) } cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { t.Fatal(err) } return &instanceHelper{cmd: cmd, lines: bufio.NewScanner(stdout)} } // read 读取子进程输出的 "名称|标识|数据目录"。 func (h *instanceHelper) read(t *testing.T) (name, id, dir string) { t.Helper() if !h.lines.Scan() { t.Fatalf("子进程未输出实例信息: %v", h.lines.Err()) } parts := strings.SplitN(strings.TrimSpace(h.lines.Text()), "|", 3) if len(parts) != 3 { t.Fatalf("子进程输出异常: %q", h.lines.Text()) } return parts[0], parts[1], parts[2] } func (h *instanceHelper) stop() { if h.cmd.Process != nil { _ = h.cmd.Wait() } }