Fix: Einstellungen gehen nicht mehr durch zeitgleiches Speichern verloren (Lost Update)

Der asynchrone Settings-Writer hatte — anders als der Session-Writer — keinen
Generations-Schutz. Lief eine periodische async Settings-Speicherung gerade,
während gleichzeitig synchron saveSettings() lief (z.B. Nutzer ändert eine
Option, oder ein Settings-Backup wird wiederhergestellt), konnte der async
rename die frisch synchron geschriebene Datei wieder mit dem ALTEN Stand
überschreiben. Die gerade gespeicherte Änderung war damit auf der Platte (und im
.bak) verloren — bis zur nächsten Speicherung.

Fix: Der Settings-Pfad spiegelt jetzt exakt den bereits abgesicherten
Session-Pfad:
- Eigener Generations-Zähler syncSettingsSaveGeneration; saveSettings() (sync)
  erhöht ihn.
- writeSettingsPayload bekommt die zum Zeitpunkt des Aufrufs erfasste Generation
  und bricht vor rename UND vor dem EXDEV-Copy ab, wenn inzwischen eine
  synchrone Speicherung passiert ist (generation < aktuell).
- saveSettingsAsync/saveSettingsPayloadAsync trägt die ORIGINAL-Generation auch
  durch die Warteschlange (vorher wurde beim Abarbeiten der Queue eine frische
  Generation erfasst → hätte den Schutz ausgehebelt).
- Eigener Zähler statt Wiederverwendung von syncSaveGeneration, damit eine
  synchrone Settings-Speicherung keine laufenden async SESSION-Schreibvorgänge
  fälschlich verwirft (keine Datei-übergreifende Kopplung).

Zusätzlich (verwandt): shutdown() ruft jetzt cancelPendingAsyncSaves() vor der
finalen synchronen Speicherung auf, damit eine noch laufende/eingereihte async
Settings-Schreibung den Shutdown-Save nicht mehr überholen kann.

Test: in-flight + eingereihte async Settings-Speicherung (ALT), dann synchron
NEU — nach dem Settle steht NEU auf der Platte. Ohne den Generations-Schutz
gewinnt ALT (rot bewiesen).
This commit is contained in:
Sucukdeluxe 2026-06-17 04:57:23 +02:00
parent 56bae4a384
commit 1a33fc2573
3 changed files with 52 additions and 9 deletions

View File

@ -789,6 +789,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
}
stopDebugServer();
abortActiveUpdateDownload();
cancelPendingAsyncSaves();
this.manager.prepareForShutdown();
this.megaWebFallback.dispose();
this.realDebridWebFallback.dispose();

View File

@ -897,6 +897,7 @@ function readSessionFile(filePath: string): SessionState | null {
}
export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
syncSettingsSaveGeneration += 1;
ensureBaseDir(paths.baseDir);
if (fs.existsSync(paths.configFile)) {
try {
@ -917,17 +918,26 @@ export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
}
let asyncSettingsSaveRunning = false;
let asyncSettingsSaveQueued: { paths: StoragePaths; settings: AppSettings } | null = null;
let asyncSettingsSaveQueued: { paths: StoragePaths; payload: string; generation: number } | null = null;
let syncSettingsSaveGeneration = 0;
async function writeSettingsPayload(paths: StoragePaths, payload: string): Promise<void> {
async function writeSettingsPayload(paths: StoragePaths, payload: string, generation: number): Promise<void> {
await fs.promises.mkdir(paths.baseDir, { recursive: true });
await fsp.copyFile(paths.configFile, `${paths.configFile}.bak`).catch(() => {});
const tempPath = `${paths.configFile}.settings.tmp`;
await fsp.writeFile(tempPath, payload, "utf8");
if (generation < syncSettingsSaveGeneration) {
await fsp.rm(tempPath, { force: true }).catch(() => {});
return;
}
try {
await fsp.rename(tempPath, paths.configFile);
} catch (renameError: unknown) {
if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") {
if (generation < syncSettingsSaveGeneration) {
await fsp.rm(tempPath, { force: true }).catch(() => {});
return;
}
await fsp.copyFile(tempPath, paths.configFile);
await fsp.rm(tempPath, { force: true }).catch(() => {});
} else {
@ -937,16 +947,14 @@ async function writeSettingsPayload(paths: StoragePaths, payload: string): Promi
}
}
export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise<void> {
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
const payload = JSON.stringify(persisted, safeJsonReplacer, 2);
async function saveSettingsPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise<void> {
if (asyncSettingsSaveRunning) {
asyncSettingsSaveQueued = { paths, settings };
asyncSettingsSaveQueued = { paths, payload, generation };
return;
}
asyncSettingsSaveRunning = true;
try {
await writeSettingsPayload(paths, payload);
await writeSettingsPayload(paths, payload, generation);
} catch (error) {
logger.error(`Async Settings-Save fehlgeschlagen: ${String(error)}`);
} finally {
@ -954,11 +962,18 @@ export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettin
if (asyncSettingsSaveQueued) {
const queued = asyncSettingsSaveQueued;
asyncSettingsSaveQueued = null;
void saveSettingsAsync(queued.paths, queued.settings);
void saveSettingsPayloadAsync(queued.paths, queued.payload, queued.generation);
}
}
}
export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise<void> {
const generation = syncSettingsSaveGeneration;
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
const payload = JSON.stringify(persisted, safeJsonReplacer, 2);
await saveSettingsPayloadAsync(paths, payload, generation);
}
export function emptySession(): SessionState {
return {
version: 2,
@ -1122,6 +1137,7 @@ export function cancelPendingAsyncSaves(): void {
asyncSaveQueued = null;
asyncSettingsSaveQueued = null;
syncSaveGeneration += 1;
syncSettingsSaveGeneration += 1;
}
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {

View File

@ -8,9 +8,13 @@ import {
createStoragePaths,
emptySession,
loadSession,
loadSettings,
saveSession,
saveSessionAsync
saveSessionAsync,
saveSettings,
saveSettingsAsync
} from "../src/main/storage";
import { defaultSettings } from "../src/main/constants";
const tempDirs: string[] = [];
@ -129,4 +133,26 @@ describe("session restart loss", () => {
const loaded = loadSession(paths);
expect(Object.keys(loaded.packages).sort()).toEqual(["A", "B"]);
});
it("does not let an in-flight/queued async settings save clobber a newer synchronous saveSettings", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-settings-race-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
cancelPendingAsyncSaves();
await settle(50);
const withName = (name: string) => ({ ...defaultSettings(), packageName: name });
saveSettings(paths, withName("OLD"));
const inflight = saveSettingsAsync(paths, withName("OLD"));
const queued = saveSettingsAsync(paths, withName("OLD"));
saveSettings(paths, withName("NEW"));
await inflight;
await queued;
await settle();
expect(loadSettings(paths).packageName).toBe("NEW");
});
});