// 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) }