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 });
|
fs.mkdirSync(getRecoveryRoot(), { recursive: true });
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
// A half-copied directory is worse than none — it looks like a good
|
// A half-copied directory is worse than none — it looks like a good
|
||||||
// backup. Remove the partial copy, then surface the failure.
|
// 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 key = path.resolve(bookPath);
|
||||||
const snapRoot = path.join(key, SNAPSHOT_DIR_NAME) + path.sep;
|
const snapRoot = path.join(key, SNAPSHOT_DIR_NAME) + path.sep;
|
||||||
const stack: string[] = [key];
|
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) {
|
while (stack.length) {
|
||||||
const cur = stack.pop() as string;
|
const cur = stack.pop() as string;
|
||||||
let stat: fs.Stats;
|
let stat: fs.Stats;
|
||||||
|
|
@ -128,6 +141,9 @@ export function bookChangedSince(bookPath: string, sinceMs: number): boolean {
|
||||||
}
|
}
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
if ((cur + path.sep).startsWith(snapRoot)) continue;
|
if ((cur + path.sep).startsWith(snapRoot)) continue;
|
||||||
|
const id = `${stat.dev}:${stat.ino}`;
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
let kids: string[];
|
let kids: string[];
|
||||||
try {
|
try {
|
||||||
kids = fs.readdirSync(cur);
|
kids = fs.readdirSync(cur);
|
||||||
|
|
|
||||||
|
|
@ -901,7 +901,7 @@ setInterval(() => {
|
||||||
snapshotLastRunMs = Date.now();
|
snapshotLastRunMs = Date.now();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (getSetting<boolean>("snapshotsEnabled", SNAPSHOT_DEFAULTS.snapshotsEnabled) !== true) return;
|
if (getSetting<boolean>("snapshotsEnabled", SNAPSHOT_DEFAULTS.snapshotsEnabled) === false) return;
|
||||||
const intervalMin = clampInt(
|
const intervalMin = clampInt(
|
||||||
getSetting("snapshotIntervalMin", SNAPSHOT_DEFAULTS.intervalMin),
|
getSetting("snapshotIntervalMin", SNAPSHOT_DEFAULTS.intervalMin),
|
||||||
SNAPSHOT_DEFAULTS.intervalMin, 1, 1440
|
SNAPSHOT_DEFAULTS.intervalMin, 1, 1440
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,8 @@ export function createSettingsFeature(ctx: BookCtx): SettingsFeature {
|
||||||
let loaded = false;
|
let loaded = false;
|
||||||
|
|
||||||
function shortPath(p: string): string {
|
function shortPath(p: string): string {
|
||||||
const home = (window as unknown as { __folioHome?: string }).__folioHome;
|
// The backend resolves the path; display it verbatim (no HOME shortening —
|
||||||
if (home && p.startsWith(home)) return "~" + p.slice(home.length);
|
// the renderer has no reliable home-dir lookup under context isolation).
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ const {
|
||||||
createSnapshot,
|
createSnapshot,
|
||||||
pruneSnapshots,
|
pruneSnapshots,
|
||||||
bookChangedSince,
|
bookChangedSince,
|
||||||
|
ensureRecoveryBackup,
|
||||||
|
getRecoveryRoot,
|
||||||
|
_resetSessionBackupGuard,
|
||||||
SNAPSHOT_DIR_NAME,
|
SNAPSHOT_DIR_NAME,
|
||||||
} = backup;
|
} = backup;
|
||||||
|
|
||||||
|
|
@ -88,9 +91,35 @@ settings.delete("snapshotLocation");
|
||||||
// missing dir -> null, no throw
|
// missing dir -> null, no throw
|
||||||
ok(createSnapshot(path.join(dir, "nope"), 5) === null, "missing book returns null");
|
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
|
// cleanup
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
fs.rmSync(custom, { recursive: true, force: true });
|
fs.rmSync(custom, { recursive: true, force: true });
|
||||||
|
fs.rmSync(home, { recursive: true, force: true });
|
||||||
cleanupProj();
|
cleanupProj();
|
||||||
cleanupChap();
|
cleanupChap();
|
||||||
cleanupBackup();
|
cleanupBackup();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue