Template tests / tests (pull_request) Failing after 33s
Phase 1/2 of the improvement plan (PR 6 of the sequence). Recovery and
resource-limit hardening for the storage-adjacent modules.
ZIP resource limits (core/zip.js):
- unzipSync now enforces entry-count, total compressed, total inflated, and
per-entry inflated budgets, and caps inflation with inflateRawSync
maxOutputLength so a deflate bomb can't exhaust memory. Exact inflated-size
match (not "at least") and CRC verification are kept. Import uses the
default limits.
Transactional archive import (core/archive.js):
- The import validates the guide AND every step before writing anything, then
stages the whole guide in a temp directory and publishes it with a single
atomic rename. A corrupt step no longer leaves a partial guide in the
library; a failure cleans up the staging directory.
Atomic snapshot restore (core/snapshots.js):
- Restore extracts and validates into a temp directory first; only then does
it swap content in, moving live content aside so a mid-swap failure rolls
back. A corrupt/truncated snapshot can no longer destroy the live guide
(the old restore deleted live content before extracting).
- Fixed snapshot filename collisions: names kept milliseconds so two backups
in the same second no longer overwrite each other.
Automatic backups (core/snapshots.js):
- Implemented the previously-dead backups.automatic/everyNSaves/keepLast
settings: autoSnapshotIfDue snapshots every N saves and prunes to keepLast,
wired into the save choke point in main.js. Never throws — a backup failure
cannot break the save that triggered it.
Exclusive locks (core/locks.js):
- acquireLock uses O_CREAT|O_EXCL (flag 'wx') so only one writer wins the
race; the old read-then-write left a window where two writers both believed
they held the lock. Added a per-acquisition token so release only removes
the exact lock it took (never one a force-steal replaced). Same-process
re-acquire still succeeds; cross-process fresh locks conflict.
Search reconciliation (core/search.js):
- New reconcile(store) rebuilds/repairs the index against the library at
startup using per-guide fingerprints (updatedAt+revision): reindexes new/
changed guides, drops entries for deleted ones, and exposes a recovery
status ('ok'|'reset'|'reconciled'). A missing/corrupt/version-mismatched
index recovers instead of silently returning nothing. Wired into startup.
Recovery surface:
- New recovery:status IPC + preload method returns quarantined files (this
session) and the search index status so the UI can surface data issues.
Tests: ZIP bomb/limits, transactional import abort with no partial guide,
atomic snapshot restore preserving the live guide on corruption, exclusive
lock conflict/steal/release-by-token, same-process re-acquire, search
reconcile (rebuild/drop/reindex/corrupt-reset), and automatic backup
cadence/pruning. 255 unit tests pass; startup smoke and workflow E2E pass.
Co-Authored-By: Claude Fable 5 <[email protected]>
158 lines
6.3 KiB
JavaScript
158 lines
6.3 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { zipDirSync, extractZipSync } = require('./zip');
|
|
const { atomicWriteFileSync, readJsonSync } = require('./util');
|
|
const { validateGuide } = require('./schema');
|
|
|
|
/**
|
|
* Snapshot backups: a zip of the guide directory (excluding history/) stored
|
|
* under <guide>/history/snapshots/. Used for automated backups and manual
|
|
* backup/restore.
|
|
*/
|
|
|
|
function snapshotsDir(store, guideId) {
|
|
return path.join(store.guideDir(guideId), 'history', 'snapshots');
|
|
}
|
|
|
|
function snapshotName(label) {
|
|
// Keep milliseconds: stripping them made two snapshots taken within the same
|
|
// second collide on filename (the second silently overwrote the first, so
|
|
// rapid automatic backups produced only one file). ms keeps names unique and
|
|
// still chronologically sortable.
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
return label ? `${stamp}-${label.replace(/[^A-Za-z0-9_-]+/g, '_')}.zip` : `${stamp}.zip`;
|
|
}
|
|
|
|
function createSnapshot(store, guideId, { label = '', keepLast = 0 } = {}) {
|
|
const guideDir = store.guideDir(guideId);
|
|
if (!fs.existsSync(path.join(guideDir, 'guide.json'))) throw new Error(`guide not found: ${guideId}`);
|
|
const buf = zipDirSync(guideDir, {
|
|
filter: (rel) => rel !== 'history' && !rel.startsWith('history/'),
|
|
});
|
|
const dir = snapshotsDir(store, guideId);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const name = snapshotName(label);
|
|
atomicWriteFileSync(path.join(dir, name), buf);
|
|
if (keepLast > 0) pruneSnapshots(store, guideId, keepLast);
|
|
return name;
|
|
}
|
|
|
|
function listSnapshots(store, guideId) {
|
|
const dir = snapshotsDir(store, guideId);
|
|
if (!fs.existsSync(dir)) return [];
|
|
return fs.readdirSync(dir).filter((n) => n.endsWith('.zip')).sort().reverse();
|
|
}
|
|
|
|
function pruneSnapshots(store, guideId, keepLast) {
|
|
const all = listSnapshots(store, guideId);
|
|
for (const name of all.slice(keepLast)) {
|
|
fs.rmSync(path.join(snapshotsDir(store, guideId), name), { force: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restore a snapshot: replaces the guide's current content (guide.json and
|
|
* steps/) with the snapshot's, keeping the history/ directory intact.
|
|
*
|
|
* The extraction is staged and validated BEFORE any live content is touched:
|
|
* a corrupt or truncated snapshot can no longer destroy the current guide.
|
|
* The swap itself moves the old content aside, moves the new content in, then
|
|
* deletes the old — so a failure mid-swap leaves a recoverable state.
|
|
*/
|
|
function restoreSnapshot(store, guideId, name) {
|
|
const file = path.join(snapshotsDir(store, guideId), path.basename(name));
|
|
if (!fs.existsSync(file)) throw new Error(`snapshot not found: ${name}`);
|
|
const buf = fs.readFileSync(file);
|
|
const guideDir = store.guideDir(guideId);
|
|
|
|
// 1. Extract + validate into a temp staging dir. Nothing live is touched yet.
|
|
const staging = `${guideDir}.restoring-${Date.now()}`;
|
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
try {
|
|
fs.mkdirSync(staging, { recursive: true });
|
|
extractZipSync(buf, staging);
|
|
const guideJson = path.join(staging, 'guide.json');
|
|
if (!fs.existsSync(guideJson)) throw new Error('snapshot is missing guide.json');
|
|
validateGuide(readJsonSync(guideJson)); // throws on a corrupt snapshot
|
|
} catch (err) {
|
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
throw new Error(`snapshot restore aborted (snapshot invalid): ${err.message}`);
|
|
}
|
|
|
|
// 2. Snapshot the pre-restore state so the restore is itself undoable.
|
|
createSnapshot(store, guideId, { label: 'pre-restore' });
|
|
|
|
// 3. Swap in the validated content, preserving history/. Move live content
|
|
// aside first so we can roll back if a step fails.
|
|
const backup = `${guideDir}.prev-${Date.now()}`;
|
|
const liveEntries = fs.readdirSync(guideDir).filter((e) => e !== 'history');
|
|
fs.mkdirSync(backup, { recursive: true });
|
|
try {
|
|
for (const entry of liveEntries) {
|
|
fs.renameSync(path.join(guideDir, entry), path.join(backup, entry));
|
|
}
|
|
for (const entry of fs.readdirSync(staging)) {
|
|
if (entry === 'history') continue;
|
|
fs.renameSync(path.join(staging, entry), path.join(guideDir, entry));
|
|
}
|
|
} catch (err) {
|
|
// Roll back: restore whatever we moved aside.
|
|
for (const entry of fs.readdirSync(backup)) {
|
|
const dest = path.join(guideDir, entry);
|
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
fs.renameSync(path.join(backup, entry), dest);
|
|
}
|
|
fs.rmSync(backup, { recursive: true, force: true });
|
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
throw err;
|
|
}
|
|
fs.rmSync(backup, { recursive: true, force: true });
|
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
return store.getGuide(guideId);
|
|
}
|
|
|
|
/**
|
|
* Automatic backup policy. Every guide keeps a small save counter in its
|
|
* history dir; once `everyNSaves` saves accumulate (and backups.automatic is
|
|
* on) an automatic snapshot is taken and old ones pruned to backups.keepLast.
|
|
* Returns the snapshot name when one was taken, else null. Never throws — a
|
|
* backup failure must not break the save that triggered it.
|
|
*/
|
|
function autoSnapshotIfDue(store, guideId, settings) {
|
|
try {
|
|
const backups = (settings && settings.get && settings.get('backups')) || {};
|
|
if (backups.automatic === false) return null;
|
|
const everyN = Number.isInteger(backups.everyNSaves) && backups.everyNSaves > 0 ? backups.everyNSaves : 25;
|
|
const keepLast = Number.isInteger(backups.keepLast) && backups.keepLast > 0 ? backups.keepLast : 10;
|
|
|
|
const dir = path.join(store.guideDir(guideId), 'history');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const counterFile = path.join(dir, 'autosave-counter.json');
|
|
let count = 0;
|
|
try {
|
|
count = JSON.parse(fs.readFileSync(counterFile, 'utf8')).count || 0;
|
|
} catch { count = 0; }
|
|
count += 1;
|
|
|
|
if (count >= everyN) {
|
|
createSnapshot(store, guideId, { label: 'auto', keepLast });
|
|
count = 0;
|
|
atomicWriteFileSync(counterFile, JSON.stringify({ count }));
|
|
return true;
|
|
}
|
|
atomicWriteFileSync(counterFile, JSON.stringify({ count }));
|
|
return null;
|
|
} catch (err) {
|
|
// Best effort: report, never break the caller's save.
|
|
console.error(`[stepforge] automatic backup failed for ${guideId}: ${err && err.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
createSnapshot, listSnapshots, pruneSnapshots, restoreSnapshot, snapshotsDir,
|
|
autoSnapshotIfDue,
|
|
};
|