Files
huya-go/internal/instance/instance.go
T

237 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}