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:
parent
56bae4a384
commit
1a33fc2573
@ -789,6 +789,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
}
|
||||
stopDebugServer();
|
||||
abortActiveUpdateDownload();
|
||||
cancelPendingAsyncSaves();
|
||||
this.manager.prepareForShutdown();
|
||||
this.megaWebFallback.dispose();
|
||||
this.realDebridWebFallback.dispose();
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user