新增实例槽位隔离与结构化日志,多开不再共用日志与账号

This commit is contained in:
yml2213
2026-10-14 21:32:49 +08:00
parent f96fa7d858
commit 8c4b15066f
27 changed files with 2011 additions and 468 deletions
+236
View File
@@ -0,0 +1,236 @@
// Package instance 负责多开场景下的实例槽位分配与数据目录隔离。
//
// 每个进程启动时占用一个「实例槽位」:1 号槽位是默认实例(沿用历史数据目录),
// 2 号及以后各自拿到 instances/instance-N 目录,互不共享配置、账号与日志文件。
// 槽位通过独立的锁文件互斥,进程退出(或崩溃后文件句柄被系统回收)即可被下一个
// 进程复用。
package instance
import (
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
const (
// appDirName 用户配置目录下的应用目录名。
appDirName = "huya-go"
// logSubDir 默认的日志子目录名。
logSubDir = "logs"
// maxSlots 单机允许同时运行的实例数上限。
maxSlots = 64
)
// 单次实例分配里允许的「意外错误」重试预算与间隔。
// 杀毒软件/索引器可能短暂独占刚创建的锁文件,稍后重试同一个槽位即可,
// 不该直接跳到别的槽位,也不该让整个分配失败。
const (
slotLockRetryBudget = 12
slotLockRetryWait = 80 * time.Millisecond
)
// Paths 是某个实例独占的运行路径。
type Paths struct {
// DataDir 实例数据目录,同时用作 WebView2 用户数据目录的父目录。
DataDir string
// LogDir 实例日志目录。
LogDir string
}
// Lease 代表当前进程持有的实例槽位;必须在程序退出时释放。
type Lease struct {
paths Paths
name string
id string
lock instanceLock
releaseOnce sync.Once
}
// Acquire 按顺序占用空闲实例。第一个进程使用默认实例,后续进程分别使用实例 2、
// 实例 3,以隔离数据目录与日志文件。
//
// 返回的 lease 一定可用:即使所有槽位都拿不到(锁目录不可写、槽位全部异常等),
// 也会退回到一个「进程唯一」的临时实例目录,并把原因作为 error 返回供上层记录。
// 绝不退回默认目录——那样两个进程会共享 localStorage 数据目录与日志文件,
// 导致账号互相覆盖与日志交错。
func Acquire() (*Lease, error) {
baseDir := baseDataDir()
instanceRoot := filepath.Join(baseDir, "instances")
lockDir := filepath.Join(instanceRoot, ".locks")
if err := osMkdirAll(lockDir); err != nil {
return fallbackLease(baseDir, fmt.Errorf("创建实例锁目录失败: %w", err))
}
var scanErr error
scanRetries := slotLockRetryBudget
for slot := 1; slot <= maxSlots; slot++ {
lockPath := filepath.Join(lockDir, instanceLockFileName(slot))
lock, acquired, err := tryAcquireInstanceLock(lockPath)
for err != nil && scanRetries > 0 {
scanRetries--
time.Sleep(slotLockRetryWait)
lock, acquired, err = tryAcquireInstanceLock(lockPath)
}
if err != nil {
// 单个槽位异常不该让整个分配失败:记录原因后继续试下一个槽位。
if scanErr == nil {
scanErr = fmt.Errorf("锁定%s失败: %w", instanceName(slot), err)
}
continue
}
if !acquired {
continue
}
dataDir := baseDir
if slot > 1 {
dataDir = filepath.Join(instanceRoot, instanceID(slot))
}
if err := osMkdirAll(dataDir); err != nil {
_ = lock.Close()
if scanErr == nil {
scanErr = fmt.Errorf("创建%s数据目录失败: %w", instanceName(slot), err)
}
continue
}
return &Lease{
paths: pathsForDataDir(dataDir),
name: instanceName(slot),
id: instanceID(slot),
lock: lock,
}, nil
}
return fallbackLease(baseDir, fmt.Errorf("最多支持同时运行 %d 个实例(%v)", maxSlots, scanErr))
}
// fallbackLease 构造兜底实例:使用进程唯一的临时目录,保证与其它实例互不干扰。
// 即使该目录也创建不出来,也只返回这个「本进程独占」的路径(日志会降级为仅内存),
// 绝不退回默认目录——退回默认目录会让两个进程共用同一个日志文件与前端数据目录。
func fallbackLease(baseDir string, cause error) (*Lease, error) {
id := fmt.Sprintf("instance-tmp-%d", os.Getpid())
dataDir := filepath.Join(baseDir, "instances", id)
_ = osMkdirAll(dataDir)
return &Lease{
paths: pathsForDataDir(dataDir),
name: "临时实例",
id: id,
}, cause
}
// Paths 返回当前实例独立的数据路径。
func (l *Lease) Paths() Paths {
if l == nil {
return Paths{}
}
return l.paths
}
// Name 返回面向用户的实例名称。
func (l *Lease) Name() string {
if l == nil {
return ""
}
return l.name
}
// ID 返回实例的稳定标识,用于给前端本地存储分片(不含空格与中文)。
func (l *Lease) ID() string {
if l == nil {
return ""
}
return l.id
}
// WebviewUserDataDir 返回当前实例独占的 WebView2 用户数据目录。
//
// Wails 在 WebviewUserDataPath 为空时会把用户数据目录放在 %APPDATA%\<可执行文件名>,
// 所有实例共用同一个目录;而 WebView2 的一个用户数据目录只能有一个浏览器进程,
// 第二个实例创建环境时会偶发失败 —— 表现就是「有时候开不出新实例,必须全部关掉重开」。
// 这里给每个实例一个独立目录,从根上消除这个竞争。
func (l *Lease) WebviewUserDataDir() string {
if l == nil || l.paths.DataDir == "" {
return ""
}
return filepath.Join(l.paths.DataDir, "webview2")
}
// Release 释放实例锁,允许新启动的程序复用该实例。
func (l *Lease) Release() {
if l == nil || l.lock == nil {
return
}
l.releaseOnce.Do(func() {
_ = l.lock.Close()
})
}
func pathsForDataDir(dataDir string) Paths {
logDir := filepath.Join(dataDir, logSubDir)
if value := os.Getenv("HUYA_LOG_DIR"); value != "" {
logDir = resolvePath(value)
}
return Paths{DataDir: dataDir, LogDir: logDir}
}
// DefaultLogDir 返回不绑定实例槽位的默认日志目录,供 CLI 等单次运行入口使用。
func DefaultLogDir() string {
return pathsForDataDir(baseDataDir()).LogDir
}
// DefaultDataDir 返回不绑定实例槽位的默认数据目录,供 CLI 等单次运行入口使用。
func DefaultDataDir() string {
return baseDataDir()
}
// baseDataDir 返回实例目录的根。
// 默认放在当前用户的配置目录,而不是工作目录,避免应用包目录无写权限。
func baseDataDir() string {
if value := os.Getenv("HUYA_DATA_DIR"); value != "" {
return resolvePath(value)
}
return resolvePath(filepath.Join(defaultAppDataDir(), appDirName))
}
func defaultAppDataDir() string {
if dir, err := os.UserConfigDir(); err == nil && dir != "" {
return dir
}
if home, err := os.UserHomeDir(); err == nil && home != "" {
return filepath.Join(home, ".config")
}
return "."
}
func resolvePath(path string) string {
if filepath.IsAbs(path) {
return path
}
if abs, err := filepath.Abs(path); err == nil {
return abs
}
return path
}
func instanceName(slot int) string {
if slot <= 1 {
return "默认实例"
}
return fmt.Sprintf("实例 %d", slot)
}
func instanceID(slot int) string {
if slot <= 1 {
return "default"
}
return fmt.Sprintf("instance-%d", slot)
}
func instanceLockFileName(slot int) string {
if slot <= 1 {
return "default.lock"
}
return fmt.Sprintf("instance-%d.lock", slot)
}
+221
View File
@@ -0,0 +1,221 @@
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()
// 用一个普通文件占住 <dataDir>,让 <dataDir>/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()
}
}
+12
View File
@@ -0,0 +1,12 @@
package instance
import "os"
// instanceLock 抽象「进程独占某个槽位」的能力,由各平台实现。
type instanceLock interface {
Close() error
}
func osMkdirAll(path string) error {
return os.MkdirAll(path, 0o700)
}
+38
View File
@@ -0,0 +1,38 @@
//go:build !windows
package instance
import (
"errors"
"os"
"syscall"
)
type unixInstanceLock struct {
file *os.File
}
// tryAcquireInstanceLock 用 flock 非阻塞独占锁文件。
// 第二个参数表示「锁被别的进程持有」,此时调用方应继续扫描下一个槽位。
func tryAcquireInstanceLock(path string) (instanceLock, bool, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, false, err
}
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
_ = file.Close()
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
return nil, false, nil
}
return nil, false, err
}
return &unixInstanceLock{file: file}, true, nil
}
func (l *unixInstanceLock) Close() error {
if l == nil || l.file == nil {
return nil
}
_ = syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
return l.file.Close()
}
+43
View File
@@ -0,0 +1,43 @@
//go:build windows
package instance
import (
"errors"
"golang.org/x/sys/windows"
)
type windowsInstanceLock struct {
handle windows.Handle
}
// tryAcquireInstanceLock 用共享模式为 0 的 CreateFile 独占锁文件:
// 已被占用的锁文件会返回 ERROR_SHARING_VIOLATION,此时调用方继续扫描下一个槽位。
func tryAcquireInstanceLock(path string) (instanceLock, bool, error) {
handle, err := windows.CreateFile(
windows.StringToUTF16Ptr(path),
windows.GENERIC_READ|windows.GENERIC_WRITE,
0,
nil,
windows.OPEN_ALWAYS,
windows.FILE_ATTRIBUTE_NORMAL,
0,
)
if err != nil {
if errors.Is(err, windows.ERROR_SHARING_VIOLATION) || errors.Is(err, windows.ERROR_ACCESS_DENIED) {
return nil, false, nil
}
return nil, false, err
}
return &windowsInstanceLock{handle: handle}, true, nil
}
func (l *windowsInstanceLock) Close() error {
if l == nil || l.handle == windows.InvalidHandle {
return nil
}
err := windows.CloseHandle(l.handle)
l.handle = windows.InvalidHandle
return err
}