237 lines
7.0 KiB
Go
237 lines
7.0 KiB
Go
// 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)
|
||
}
|