fix(backup): exclude snapshots from recovery copy, harden change walk
Migration recovery backups no longer re-copy Folio-Backups (up to 21x bloat). bookChangedSince tracks visited dev:ino so a symlink loop in the book cannot wedge the main-process interval tick. Snapshot toggle check aligned to === false; drop dead home-shortening branch in Settings.
This commit is contained in:
parent
542a545c0d
commit
b80aa798b2
4 changed files with 49 additions and 4 deletions
|
|
@ -49,7 +49,17 @@ export function ensureRecoveryBackup(bookPath: string): string | null {
|
|||
|
||||
fs.mkdirSync(getRecoveryRoot(), { recursive: true });
|
||||
try {
|
||||
fs.cpSync(key, dest, { recursive: true });
|
||||
// Entry-by-entry, skipping the snapshot dir: snapshots are expendable
|
||||
// copies already retained under their own root — re-backing them up here
|
||||
// would multiply the recovery backup up to (keep + 1)x the book size.
|
||||
// (One cpSync of the root is also refused by Node when the destination
|
||||
// would sit inside the source.)
|
||||
const entries = fs.readdirSync(key);
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
for (const e of entries) {
|
||||
if (e === SNAPSHOT_DIR_NAME) continue;
|
||||
fs.cpSync(path.join(key, e), path.join(dest, e), { recursive: true });
|
||||
}
|
||||
} catch (err) {
|
||||
// A half-copied directory is worse than none — it looks like a good
|
||||
// backup. Remove the partial copy, then surface the failure.
|
||||
|
|
@ -118,6 +128,9 @@ export function bookChangedSince(bookPath: string, sinceMs: number): boolean {
|
|||
const key = path.resolve(bookPath);
|
||||
const snapRoot = path.join(key, SNAPSHOT_DIR_NAME) + path.sep;
|
||||
const stack: string[] = [key];
|
||||
// dev:ino of visited directories — a symlink loop inside the book must not
|
||||
// wedge the main process in an infinite walk on every interval tick.
|
||||
const seen = new Set<string>();
|
||||
while (stack.length) {
|
||||
const cur = stack.pop() as string;
|
||||
let stat: fs.Stats;
|
||||
|
|
@ -128,6 +141,9 @@ export function bookChangedSince(bookPath: string, sinceMs: number): boolean {
|
|||
}
|
||||
if (stat.isDirectory()) {
|
||||
if ((cur + path.sep).startsWith(snapRoot)) continue;
|
||||
const id = `${stat.dev}:${stat.ino}`;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
let kids: string[];
|
||||
try {
|
||||
kids = fs.readdirSync(cur);
|
||||
|
|
|
|||
|
|
@ -901,7 +901,7 @@ setInterval(() => {
|
|||
snapshotLastRunMs = Date.now();
|
||||
return;
|
||||
}
|
||||
if (getSetting<boolean>("snapshotsEnabled", SNAPSHOT_DEFAULTS.snapshotsEnabled) !== true) return;
|
||||
if (getSetting<boolean>("snapshotsEnabled", SNAPSHOT_DEFAULTS.snapshotsEnabled) === false) return;
|
||||
const intervalMin = clampInt(
|
||||
getSetting("snapshotIntervalMin", SNAPSHOT_DEFAULTS.intervalMin),
|
||||
SNAPSHOT_DEFAULTS.intervalMin, 1, 1440
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ export function createSettingsFeature(ctx: BookCtx): SettingsFeature {
|
|||
let loaded = false;
|
||||
|
||||
function shortPath(p: string): string {
|
||||
const home = (window as unknown as { __folioHome?: string }).__folioHome;
|
||||
if (home && p.startsWith(home)) return "~" + p.slice(home.length);
|
||||
// The backend resolves the path; display it verbatim (no HOME shortening —
|
||||
// the renderer has no reliable home-dir lookup under context isolation).
|
||||
return p;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ const {
|
|||
createSnapshot,
|
||||
pruneSnapshots,
|
||||
bookChangedSince,
|
||||
ensureRecoveryBackup,
|
||||
getRecoveryRoot,
|
||||
_resetSessionBackupGuard,
|
||||
SNAPSHOT_DIR_NAME,
|
||||
} = backup;
|
||||
|
||||
|
|
@ -88,9 +91,35 @@ settings.delete("snapshotLocation");
|
|||
// missing dir -> null, no throw
|
||||
ok(createSnapshot(path.join(dir, "nope"), 5) === null, "missing book returns null");
|
||||
|
||||
// symlink loop inside the book must not hang the change check
|
||||
try {
|
||||
fs.symlinkSync(dir, path.join(dir, "chapters", "loop"), "dir");
|
||||
const t0 = Date.now();
|
||||
const changed = bookChangedSince(dir, Date.now() + 60_000);
|
||||
ok(changed === false && Date.now() - t0 < 5000, "symlink loop terminates without false positive");
|
||||
} catch {
|
||||
ok(true, "symlink loop test skipped (no symlink permission)");
|
||||
}
|
||||
|
||||
// migration recovery backup excludes the snapshot dir (no 21x bloat)
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "folio-fakehome-"));
|
||||
const origHome = process.env.HOME;
|
||||
process.env.HOME = home;
|
||||
try {
|
||||
_resetSessionBackupGuard();
|
||||
const dest = ensureRecoveryBackup(dir);
|
||||
ok(typeof dest === "string" && fs.existsSync(dest), "recovery backup created");
|
||||
ok(fs.existsSync(path.join(dest, "folio.json")), "recovery backup contains folio.json");
|
||||
ok(!fs.existsSync(path.join(dest, SNAPSHOT_DIR_NAME)), "recovery backup excludes snapshots");
|
||||
ok(getRecoveryRoot().startsWith(home), "recovery root honors HOME");
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
}
|
||||
|
||||
// cleanup
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
fs.rmSync(custom, { recursive: true, force: true });
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
cleanupProj();
|
||||
cleanupChap();
|
||||
cleanupBackup();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue