fix: use atomic operations for concurrent download progress counter

Co-Authored-By: OWL <noreply@anthropic.com>
This commit is contained in:
2026-05-26 12:16:44 +03:00
parent c06c205b7b
commit 5f4ff47ce7

View File

@@ -9,6 +9,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/auth"
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/config"
@@ -28,16 +29,14 @@ type Game struct {
}
// Prepare downloads the manifest, syncs files, and resolves the Java binary.
// onProgress is called with (downloadedBytes, totalBytes) during file sync.
func (g *Game) Prepare(onProgress func(downloaded, total int64)) error {
// 1. Ensure instance directory exists.
if err := os.MkdirAll(g.InstanceDir, 0o755); err != nil {
return fmt.Errorf("creating instance dir: %w", err)
}
// 2. Download manifest.
// Download manifest.
manifestURL := fmt.Sprintf("%s/api/instances/%s/manifest.json",
g.ServerBaseURL, slugFromDir(g.InstanceDir))
g.ServerBaseURL, filepath.Base(g.InstanceDir))
manifestPath := filepath.Join(g.InstanceDir, "manifest.json")
if err := fetcher.Download(manifestURL, manifestPath, "", nil); err != nil {
@@ -50,14 +49,14 @@ func (g *Game) Prepare(onProgress func(downloaded, total int64)) error {
}
g.Manifest = m
// 3. Resolve Java.
// Resolve Java.
bin, err := java.Find(m.JavaVersion)
if err != nil {
return err
}
g.JavaBin = bin
// 4. Sync files (download missing / verify SHA-1).
// Sync files.
var totalBytes int64
for _, f := range m.Files {
totalBytes += f.Size
@@ -66,22 +65,19 @@ func (g *Game) Prepare(onProgress func(downloaded, total int64)) error {
var downloadedBytes int64
pool := fetcher.NewWorkerPool(4)
for i := range m.Files {
f := m.Files[i]
for _, f := range m.Files {
dest := filepath.Join(g.InstanceDir, f.Path)
// Skip if file exists and hash matches.
if existing, err := os.Stat(dest); err == nil {
if existing.Size() == f.Size {
if got, err := fileHash(dest); err == nil && got == f.Hash {
downloadedBytes += f.Size
// Skip existing verified files.
if existing, err := os.Stat(dest); err == nil && existing.Size() == f.Size {
if got, err := sha1File(dest); err == nil && got == f.Hash {
atomic.AddInt64(&downloadedBytes, f.Size)
if onProgress != nil {
onProgress(downloadedBytes, totalBytes)
onProgress(atomic.LoadInt64(&downloadedBytes), totalBytes)
}
continue
}
}
}
pool.Submit(func() {
url := f.URL
@@ -89,29 +85,21 @@ func (g *Game) Prepare(onProgress func(downloaded, total int64)) error {
url = fmt.Sprintf("%s/files/%s", g.ServerBaseURL, f.Hash)
}
_ = fetcher.Download(url, dest, f.Hash, func(n, _ int64) {
downloadedBytes += n
atomic.AddInt64(&downloadedBytes, n)
if onProgress != nil {
onProgress(downloadedBytes, totalBytes)
onProgress(atomic.LoadInt64(&downloadedBytes), totalBytes)
}
})
})
}
pool.Wait()
// 5. Soft-delete unknown mods.
if err := g.cleanupUnknownMods(); err != nil {
return fmt.Errorf("cleaning up unknown mods: %w", err)
return g.cleanupUnknownMods()
}
return nil
}
// cleanupUnknownMods moves files from mods/ that are not in the manifest
// into mods_backup/ (soft deletion / self-healing).
// cleanupUnknownMods moves untracked files from mods/ to mods_backup/.
func (g *Game) cleanupUnknownMods() error {
modsDir := filepath.Join(g.InstanceDir, "mods")
backupDir := filepath.Join(g.InstanceDir, "mods_backup")
entries, err := os.ReadDir(modsDir)
if err != nil {
if os.IsNotExist(err) {
@@ -120,20 +108,16 @@ func (g *Game) cleanupUnknownMods() error {
return err
}
// Build set of known mod filenames.
known := make(map[string]bool)
for _, f := range g.Manifest.ModFiles() {
known[filepath.Base(f.Path)] = true
}
backupDir := filepath.Join(g.InstanceDir, "mods_backup")
for _, entry := range entries {
if entry.IsDir() {
if entry.IsDir() || known[entry.Name()] {
continue
}
if known[entry.Name()] {
continue
}
// Move to backup.
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return err
}
@@ -158,24 +142,18 @@ func (g *Game) BuildCommand() (*exec.Cmd, error) {
return nil, fmt.Errorf("no active session — login required")
}
// Build classpath.
var classpathEntries []string
for _, lib := range g.Manifest.LibraryFiles() {
classpathEntries = append(classpathEntries, filepath.Join(g.InstanceDir, lib.Path))
}
// Add instance libraries dir.
classpathEntries = append(classpathEntries, filepath.Join(g.InstanceDir, "libraries", "*"))
classpath := strings.Join(classpathEntries, string(os.PathListSeparator))
classpathStr := strings.Join(classpathEntries, string(os.PathListSeparator))
// Authlib-injector path.
authlibPath := filepath.Join(g.InstanceDir, "authlib-injector.jar")
// Interpolation context.
vars := map[string]string{
"player_name": g.Session.Username,
"auth_uuid": g.Session.UUID,
"auth_access_token": g.Session.AccessToken,
"classpath": classpath,
"classpath": classpathStr,
"java_path": g.JavaBin,
"game_directory": g.InstanceDir,
"assets_root": filepath.Join(g.InstanceDir, "assets"),
@@ -192,10 +170,9 @@ func (g *Game) BuildCommand() (*exec.Cmd, error) {
"resolution_height": "720",
}
// Assemble arguments.
var args []string
// JVM args.
// JVM memory + flags.
args = append(args, fmt.Sprintf("-Xms%dM", g.MemoryMB))
args = append(args, fmt.Sprintf("-Xmx%dM", g.MemoryMB))
for _, a := range g.Manifest.Launch.JVMArgs {
@@ -207,14 +184,13 @@ func (g *Game) BuildCommand() (*exec.Cmd, error) {
// Authlib-injector.
if g.Manifest.Launch.AuthLibInjector != "" {
authlibPath := filepath.Join(g.InstanceDir, "authlib-injector.jar")
args = append(args, fmt.Sprintf("-javaagent:%s=%s",
authlibPath, g.Manifest.Launch.AuthLibInjector))
}
// Classpath.
args = append(args, "-cp", classpath)
// Main class.
// Classpath + main class.
args = append(args, "-cp", classpathStr)
args = append(args, g.Manifest.Launch.MainClass)
// Game args.
@@ -227,7 +203,6 @@ func (g *Game) BuildCommand() (*exec.Cmd, error) {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd, nil
}
@@ -248,13 +223,8 @@ func interpolate(s string, vars map[string]string) string {
return s
}
// slugFromDir extracts the modpack slug from the instance directory path.
func slugFromDir(dir string) string {
return filepath.Base(dir)
}
// fileHash is a local alias to avoid import cycle — computes SHA-1 hex of a file.
func fileHash(path string) (string, error) {
// sha1File computes the SHA-1 hex digest of a file.
func sha1File(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err