package config import ( "fmt" "os" "path/filepath" "sync" "time" "douyu_go/internal/constants" "douyu_go/internal/types" ) const maxInstanceSlots = 256 // 单次实例分配里允许的"意外错误"重试预算与间隔。 // 杀毒软件/索引器可能在锁文件刚创建时短暂独占它,稍后重试同一个槽位即可, // 不该直接跳到别的槽位或让整个分配失败。 const ( slotLockRetryBudget = 12 slotLockRetryWait = 80 * time.Millisecond ) // InstanceLease 代表当前进程持有的实例槽位;必须在程序退出时释放。 type InstanceLease struct { paths types.AppPaths name string lock instanceLock releaseOnce sync.Once } // AcquireInstancePaths 按顺序占用空闲实例。第一个进程使用默认实例, // 后续进程分别使用实例 2、实例 3,以隔离账号、配置与运行数据。 // // 返回的 lease 一定可用:即使所有槽位都拿不到(锁目录不可写、槽位全部异常等), // 也会退回到一个"进程唯一"的临时实例目录,并把原因作为 error 返回供上层记录。 // 绝不退回默认实例目录——那样两个进程会共享 accounts.txt、config.json, // 同秒启动时还会写进同一个日志文件,导致账号互相覆盖与日志交错。 func AcquireInstancePaths() (*InstanceLease, error) { baseDir := defaultDataDir() 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 <= maxInstanceSlots; 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, fmt.Sprintf("instance-%d", slot)) } paths := appPathsForDataDir(dataDir, slot == 1) if slot > 1 && isWailsDev() && os.Getenv("DOUYU_LOG_DIR") == "" { paths.LogDir = resolvePath(filepath.Join(constants.DefaultLogDir, "instances", fmt.Sprintf("instance-%d", slot))) } return &InstanceLease{ paths: paths, name: instanceName(slot), lock: lock, }, nil } return fallbackLease(baseDir, fmt.Errorf("最多支持同时运行 %d 个实例(%v)", maxInstanceSlots, scanErr)) } // fallbackLease 构造兜底实例:使用进程唯一的临时目录,保证与其它实例互不干扰。 func fallbackLease(baseDir string, cause error) (*InstanceLease, error) { dataDir := filepath.Join(baseDir, "instances", fmt.Sprintf("instance-tmp-%d", os.Getpid())) return &InstanceLease{ paths: appPathsForDataDir(dataDir, false), name: "临时实例", }, cause } // Paths 返回当前实例独立的数据路径。 func (l *InstanceLease) Paths() types.AppPaths { if l == nil { return types.AppPaths{} } return l.paths } // Name 返回面向用户的实例名称。 func (l *InstanceLease) Name() string { if l == nil { return "" } return l.name } // WebviewUserDataDir 返回当前实例独占的 WebView2 用户数据目录。 // // Wails 在 WebviewUserDataPath 为空时会把用户数据目录放在 %APPDATA%\<可执行文件名>, // 所有实例共用同一个目录;而 WebView2 的一个用户数据目录只能有一个浏览器进程, // 第二个实例创建环境时会偶发失败 —— 表现就是"有时候开不出新实例,必须全部关掉重开"。 // 这里给每个实例一个独立目录,从根上消除这个竞争。 func (l *InstanceLease) WebviewUserDataDir() string { if l == nil || l.paths.DataDir == "" { return "" } return filepath.Join(l.paths.DataDir, "webview2") } // Release 释放实例锁,允许新启动的程序复用该实例。 func (l *InstanceLease) Release() { if l == nil || l.lock == nil { return } l.releaseOnce.Do(func() { _ = l.lock.Close() }) } func instanceName(slot int) string { if slot <= 1 { return "默认实例" } return fmt.Sprintf("实例 %d", slot) } func instanceLockFileName(slot int) string { if slot <= 1 { return "default.lock" } return fmt.Sprintf("instance-%d.lock", slot) }