Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions main/services/local-backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export function isLocalBackupEnabled(): boolean {

export function setLocalBackupEnabled(enabled: boolean): void {
upsertSettings({ [LOCAL_BACKUP_SETTING_KEY]: enabled ? 'true' : 'false' });
if (!enabled) {
localBackup.cancel();
}
}

export function localBackupFileName(at: Date): string {
Expand Down Expand Up @@ -204,6 +207,10 @@ class LocalBackupService {
await this.running?.catch(() => {});
}

cancel(): void {
this.abortController?.abort();
}

runIfDue(): Promise<void> {
if (this.running || this.stopping) return this.running ?? Promise.resolve();
this.running = this.backupIfDue()
Expand All @@ -227,6 +234,10 @@ class LocalBackupService {
let name: string;
try {
await createBackup(staged, this.abortController.signal);
if (this.stopping || this.abortController?.signal.aborted || !isLocalBackupEnabled()) {
removeQuietly(staged);
return;
}
name = publishBackup(staged, dir);
} catch (error) {
removeQuietly(staged);
Expand Down
238 changes: 238 additions & 0 deletions tests/local-backup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,244 @@ async function main(): Promise<void> {
await localBackup.runIfDue();
assert.deepEqual(listLocalBackups(backupDir), []);

// Controlled delayed createBackup: disabling local backups while createBackup is in flight
// cancels active work, cleans up the staged file, does not publish to the backup directory,
// and does not prune existing backups.
ensureOwner();
for (const name of listLocalBackups(backupDir)) {
fs.unlinkSync(path.join(backupDir, name));
}
const existingRetentionBackups: string[] = [];
for (let day = 1; day <= LOCAL_BACKUP_RETENTION; day++) {
const name = localBackupFileName(new Date(Date.UTC(2026, 0, day, 3, 0, 0)));
fs.writeFileSync(path.join(backupDir, name), `retention-${day}`);
existingRetentionBackups.push(name);
}
const existingBackupsBefore = listLocalBackups(backupDir);
assert.equal(existingBackupsBefore.length, LOCAL_BACKUP_RETENTION);

setLocalBackupEnabled(true);
assert.equal(isLocalBackupEnabled(), true);

const dbModule = require('../main/db');
const originalCreateBackup = dbModule.createBackup;
let stagedPathSeen: string | null = null;
let abortSignalSeen: AbortSignal | null = null;
let releaseDelayedBackup!: () => void;
const backupReleasePromise = new Promise<void>((resolve) => {
releaseDelayedBackup = resolve;
});
let notifyDelayedBackupStarted!: () => void;
const backupStartedPromise = new Promise<void>((resolve) => {
notifyDelayedBackupStarted = resolve;
});

dbModule.createBackup = async (targetPath?: string, signal?: AbortSignal, _options?: any) => {
stagedPathSeen = targetPath ?? null;
abortSignalSeen = signal ?? null;
notifyDelayedBackupStarted();
await backupReleasePromise;
if (targetPath) {
fs.writeFileSync(targetPath, 'delayed-staged-content');
}
return { path: targetPath ?? '', schemaVersion: 118 };
};

try {
const inFlightRun = localBackup.runIfDue();
await backupStartedPromise;
assert.ok(stagedPathSeen, 'delayed createBackup received staged path');

// While createBackup is still awaiting completion, disable local backups via settings update (opt-out)
const optOutRes = await request(api).put('/api/db-tools/local-backup').send({ enabled: false });
assert.equal(optOutRes.status, 200);
assert.equal(optOutRes.body.enabled, false);
assert.equal(isLocalBackupEnabled(), false, 'local backup disabled during in-flight backup');
assert.equal(
abortSignalSeen?.aborted,
true,
'in-flight backup is signalled to abort when local backup is disabled',
);

// Allow createBackup to finish
releaseDelayedBackup();
await inFlightRun;

// Assert that the staged file is cleaned up, NO backup is published, and existing backups are not pruned
assert.equal(
fs.existsSync(stagedPathSeen!),
false,
'staged backup file is cleaned up after opt-out',
);
const backupsAfterFlight = listLocalBackups(backupDir);
assert.deepEqual(
backupsAfterFlight,
existingBackupsBefore,
'no backup file is published and pruneLocalBackups does not delete existing backups',
);
const leftoverTempFiles = fs.readdirSync(backupDir).filter((name) => name.startsWith('.'));
assert.deepEqual(leftoverTempFiles, [], 'no temporary files left behind in backup dir');

// Recheck !isLocalBackupEnabled() independently of abortSignal.aborted:
// if local_backup_enabled becomes 'false' in the database while createBackup is in flight
// without triggering abortController.abort(), the post-createBackup recheck still discards
// the staged snapshot without publishing or pruning.
setLocalBackupEnabled(true);
stagedPathSeen = null;
abortSignalSeen = null;
const backupReleaseOptOutOnly = new Promise<void>((resolve) => {
releaseDelayedBackup = resolve;
});
const backupStartedOptOutOnly = new Promise<void>((resolve) => {
notifyDelayedBackupStarted = resolve;
});
dbModule.createBackup = async (targetPath?: string, signal?: AbortSignal, _options?: any) => {
stagedPathSeen = targetPath ?? null;
abortSignalSeen = signal ?? null;
notifyDelayedBackupStarted();
await backupReleaseOptOutOnly;
if (targetPath) {
fs.writeFileSync(targetPath, 'delayed-staged-optout-only');
}
return { path: targetPath ?? '', schemaVersion: 118 };
};

const inFlightOptOutOnly = localBackup.runIfDue();
await backupStartedOptOutOnly;
getDatabase()
.prepare(
"INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('local_backup_enabled', 'false', ?)",
)
.run(now());
assert.equal(isLocalBackupEnabled(), false);
assert.equal(abortSignalSeen!.aborted, false, 'signal was not aborted for direct DB opt-out');
releaseDelayedBackup();
await inFlightOptOutOnly;
assert.equal(
fs.existsSync(stagedPathSeen!),
false,
'staged file removed on post-flight opt-out recheck',
);
assert.deepEqual(
listLocalBackups(backupDir),
existingBackupsBefore,
'post-flight !isLocalBackupEnabled() recheck prevents publishing and pruning',
);

// Recheck abortSignal.aborted independently of !isLocalBackupEnabled():
// if local backup is disabled (aborting the signal) and then re-enabled while the aborted
// createBackup is still unwinding, the aborted snapshot is still discarded.
setLocalBackupEnabled(true);
stagedPathSeen = null;
abortSignalSeen = null;
const backupReleaseAbortOnly = new Promise<void>((resolve) => {
releaseDelayedBackup = resolve;
});
const backupStartedAbortOnly = new Promise<void>((resolve) => {
notifyDelayedBackupStarted = resolve;
});
dbModule.createBackup = async (targetPath?: string, signal?: AbortSignal, _options?: any) => {
stagedPathSeen = targetPath ?? null;
abortSignalSeen = signal ?? null;
notifyDelayedBackupStarted();
await backupReleaseAbortOnly;
if (targetPath) {
fs.writeFileSync(targetPath, 'delayed-staged-abort-only');
}
return { path: targetPath ?? '', schemaVersion: 118 };
};

const inFlightAbortOnly = localBackup.runIfDue();
await backupStartedAbortOnly;
setLocalBackupEnabled(false);
assert.equal(abortSignalSeen!.aborted, true, 'disabling aborts in-flight signal');
setLocalBackupEnabled(true);
assert.equal(isLocalBackupEnabled(), true, 're-enabled before in-flight backup finishes');
releaseDelayedBackup();
await inFlightAbortOnly;
assert.equal(
fs.existsSync(stagedPathSeen!),
false,
'staged file removed on post-flight abort recheck',
);
assert.deepEqual(
listLocalBackups(backupDir),
existingBackupsBefore,
'post-flight abortSignal.aborted recheck prevents publishing and pruning',
);
} finally {
dbModule.createBackup = originalCreateBackup;
}

// Controlled delayed createBackup: stopping the service while createBackup is in flight
// aborts active work, cleans up the staged file, and does not publish.
ensureOwner();
for (const name of listLocalBackups(backupDir)) {
fs.unlinkSync(path.join(backupDir, name));
}
for (let day = 1; day <= LOCAL_BACKUP_RETENTION; day++) {
const name = localBackupFileName(new Date(Date.UTC(2026, 0, day, 3, 0, 0)));
fs.writeFileSync(path.join(backupDir, name), `retention-${day}`);
}
const existingBeforeStop = listLocalBackups(backupDir);
assert.equal(existingBeforeStop.length, LOCAL_BACKUP_RETENTION);
setLocalBackupEnabled(true);

let stagedPathSeenStop: string | null = null;
let abortSignalSeenStop: AbortSignal | null = null;
let releaseDelayedBackupStop!: () => void;
const backupReleasePromiseStop = new Promise<void>((resolve) => {
releaseDelayedBackupStop = resolve;
});
let notifyDelayedBackupStartedStop!: () => void;
const backupStartedPromiseStop = new Promise<void>((resolve) => {
notifyDelayedBackupStartedStop = resolve;
});

dbModule.createBackup = async (targetPath?: string, signal?: AbortSignal, _options?: any) => {
stagedPathSeenStop = targetPath ?? null;
abortSignalSeenStop = signal ?? null;
notifyDelayedBackupStartedStop();
await backupReleasePromiseStop;
if (targetPath) {
fs.writeFileSync(targetPath, 'delayed-staged-content-stop');
}
return { path: targetPath ?? '', schemaVersion: 118 };
};

try {
const inFlightRunStop = localBackup.runIfDue();
await backupStartedPromiseStop;
assert.ok(stagedPathSeenStop, 'delayed createBackup received staged path before stop');

const stopPromise = localBackup.stop();
assert.equal(abortSignalSeenStop?.aborted, true, 'stop aborts in-flight backup signal');

// Allow createBackup to finish while stop() is awaiting running
releaseDelayedBackupStop();
await stopPromise;
await inFlightRunStop;

assert.equal(
fs.existsSync(stagedPathSeenStop!),
false,
'staged backup file is cleaned up after stop()',
);
const backupsAfterStop = listLocalBackups(backupDir);
assert.deepEqual(
backupsAfterStop,
existingBeforeStop,
'no backup file is published when stopped during createBackup',
);
assert.deepEqual(
fs.readdirSync(backupDir).filter((name) => name.startsWith('.')),
[],
'no temporary files left behind in backup dir after stop',
);
} finally {
dbModule.createBackup = originalCreateBackup;
}

await localBackup.stop();
closeDatabase();
fs.rmSync(testDir, { recursive: true, force: true });
Expand Down
Loading