Make config persistence robust and history DB concurrency-safe

- Config saves are atomic (temp file + fsync + os.replace); a crash or
  power loss mid-write no longer truncates the file, which previously
  caused a silent reset to defaults on next launch.
- Stored config is merged over a deep copy of the defaults: keys added
  in newer versions resolve properly instead of returning None, and the
  nested cached_versions dict is no longer shared with (and mutated on)
  the class-level default dict.
- History SQLite connection enables WAL and a 5s busy timeout so the
  download thread can record entries while the history dialog reads
  without "database is locked" errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-25 01:31:49 +02:00
parent ebb9422591
commit 288d30ad8b
2 changed files with 24 additions and 4 deletions
+20 -4
View File
@@ -49,7 +49,9 @@ Exceptions
when possible. when possible.
""" """
import copy
import json import json
import os
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
@@ -102,6 +104,11 @@ class ConfigManager:
"ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0}, "ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
"ffmpeg": {"version": None, "path": None, "last_check": 0, "path_mtime": 0}, "ffmpeg": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
}, },
"advanced": {
# Allow falling back to a system-installed yt-dlp from PATH when
# the app-managed, SHA256-verified binary is absent
"allow_system_ytdlp": False,
},
} }
@classmethod @classmethod
@@ -115,13 +122,17 @@ class ConfigManager:
if cls._config_file.exists(): if cls._config_file.exists():
try: try:
with open(cls._config_file, "r", encoding="utf-8") as f: with open(cls._config_file, "r", encoding="utf-8") as f:
cls._settings = json.load(f) stored = json.load(f)
# Merge on top of defaults so keys added in newer versions
# exist without call sites needing `or <default>` fallbacks
cls._settings = copy.deepcopy(cls._default_config)
cls._settings.update(stored)
logger.info("Config loaded from file.") logger.info("Config loaded from file.")
except json.JSONDecodeError: except json.JSONDecodeError:
cls._settings = cls._default_config.copy() cls._settings = copy.deepcopy(cls._default_config)
logger.warning("Config file corrupt, loaded defaults.") logger.warning("Config file corrupt, loaded defaults.")
else: else:
cls._settings = cls._default_config.copy() cls._settings = copy.deepcopy(cls._default_config)
cls._save() cls._save()
logger.info("Config file not found, created default config.") logger.info("Config file not found, created default config.")
@@ -136,8 +147,13 @@ class ConfigManager:
""" """
with cls._lock: with cls._lock:
try: try:
with open(cls._config_file, "w", encoding="utf-8") as f: # Atomic write: a crash mid-save must not truncate the config
tmp_file = cls._config_file.with_suffix(".json.tmp")
with open(tmp_file, "w", encoding="utf-8") as f:
json.dump(cls._settings, f, indent=4) json.dump(cls._settings, f, indent=4)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_file, cls._config_file)
logger.debug("Config saved to file.") logger.debug("Config saved to file.")
except (OSError, PermissionError) as e: except (OSError, PermissionError) as e:
logger.exception(f"Failed to save config: {e}") logger.exception(f"Failed to save config: {e}")
+4
View File
@@ -72,6 +72,10 @@ class HistoryManager:
if cls._connection is None: if cls._connection is None:
cls._connection = sqlite3.connect(cls._db_file, check_same_thread=False) cls._connection = sqlite3.connect(cls._db_file, check_same_thread=False)
cls._connection.row_factory = sqlite3.Row cls._connection.row_factory = sqlite3.Row
# WAL lets the download thread write while the history
# dialog reads; busy_timeout avoids "database is locked"
cls._connection.execute("PRAGMA journal_mode=WAL")
cls._connection.execute("PRAGMA busy_timeout=5000")
cursor = cls._connection.cursor() cursor = cls._connection.cursor()