fix(persistence): one buffer owns a path's recovery slot (Q#AS13)
Addresses the PR #100 review round 3. pmacs.buffer.from_file does not dedup, so two buffers can visit one path. Ownership was tracked as a path-wide `owned: HashSet<path_hash>`, which made the duplicate case silently corrupting: both dirty buffers queued a write to autosave/<same hash>, the later write won on disk, and BOTH were recorded in `written` --- so the loser skipped future sweeps while its contents were unrecoverable. The path-wide set also let either buffer's save/kill retire the other's recovery. A recovery file must stay keyed by path (a later session knows only paths, never old BufferIds), so two divergent buffers cannot both be protected under one key. Ownership is now `owner: path_hash -> BufferId`: - the first modified buffer to reach a free slot claims it, including within a single pass (the write loop updates `owner`, so the gather loop tracks slots queued this pass --- otherwise two duplicates both queue a write); - any other buffer on that path is counted `conflicted` and reported ("autosave paused for N buffer(s): another buffer is visiting the same file"), never silently mis-protected. It records no `written` entry, so it re-attempts each sweep instead of believing itself saved; - `discard_buffer` (save/kill) retires ONLY slots this buffer owns, which now enforces both invariants at once: an unowned slot is unclaimed crash data (Q#AS12), and a slot owned by another buffer is that buffer's recovery; - saving or killing the owner releases the slot; the duplicate claims it on the next sweep; - `recover-file` adopting into a buffer makes that buffer the owner --- the file's contents are now its contents, and the previous owner truthfully becomes conflicted. sweep() now returns (written, blocked, conflicted). Its gather phase is extracted into `gather()` (clippy too-many-lines). This is honest rather than clever: pmacs cannot protect two divergent buffers over one file, and now says so instead of pretending. Tests (autosave_acceptance now 27): duplicate_buffers_on_one_path_conflict_instead_of_corrupting (owner's copy on disk; the dup never wins the slot by editing), a_duplicate_buffers_save_does_not_retire_the_owners_recovery, killing_the_owner_frees_the_slot_for_the_duplicate. Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 27 + 8 units; desktop 11; persistence 5; m7_8 5; GPU 58; git diff --check clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b8a296f0a3
commit
e173f61a61
|
|
@ -48,19 +48,26 @@ function pmacs.autosave.interval_ms(ms)
|
||||||
return interval
|
return interval
|
||||||
end
|
end
|
||||||
|
|
||||||
-- sweep() --- force a pass now. Returns (written, blocked). `blocked`
|
-- sweep() --- force a pass now. Returns (written, blocked, conflicted).
|
||||||
-- counts buffers whose sweep was refused because an unclaimed crash
|
-- blocked --- an unclaimed crash recovery sits at the buffer's key.
|
||||||
-- recovery sits at their key: overwriting it would destroy exactly what
|
-- Overwriting it would destroy exactly what autosave
|
||||||
-- autosave protects. Recovering or discarding that copy resumes autosave.
|
-- protects; recovering or discarding it resumes autosave.
|
||||||
|
-- conflicted --- another buffer already owns that file's recovery slot.
|
||||||
|
-- Recovery files are keyed by path, so when two buffers
|
||||||
|
-- visit one file only the first can be protected.
|
||||||
function pmacs.autosave.sweep()
|
function pmacs.autosave.sweep()
|
||||||
if not enabled then return 0, 0 end
|
if not enabled then return 0, 0, 0 end
|
||||||
local written, blocked = pmacs.autosave._sweep()
|
local written, blocked, conflicted = pmacs.autosave._sweep()
|
||||||
if blocked and blocked > 0 then
|
if blocked and blocked > 0 then
|
||||||
pmacs.editor.set_status(
|
pmacs.editor.set_status(
|
||||||
"autosave paused for " .. blocked .. " file(s) with unclaimed recovery"
|
"autosave paused for " .. blocked .. " file(s) with unclaimed recovery"
|
||||||
.. " --- M-x recover-file or M-x discard-recovery")
|
.. " --- M-x recover-file or M-x discard-recovery")
|
||||||
|
elseif conflicted and conflicted > 0 then
|
||||||
|
pmacs.editor.set_status(
|
||||||
|
"autosave paused for " .. conflicted .. " buffer(s): another buffer"
|
||||||
|
.. " is visiting the same file")
|
||||||
end
|
end
|
||||||
return written, blocked
|
return written, blocked, conflicted
|
||||||
end
|
end
|
||||||
|
|
||||||
local function basename(path)
|
local function basename(path)
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,37 @@ not autosaved — and the user is told so, every sweep. Losing the new
|
||||||
edits to a second crash is recoverable by retyping; losing the original
|
edits to a second crash is recoverable by retyping; losing the original
|
||||||
crash copy is not.
|
crash copy is not.
|
||||||
|
|
||||||
|
### Q#AS13 — One buffer owns a path's recovery slot
|
||||||
|
|
||||||
|
`pmacs.buffer.from_file` does **not** dedup: a second buffer can visit an
|
||||||
|
already-open path. The recovery file must stay keyed by path — a later
|
||||||
|
session knows only paths, never old `BufferId`s — so two dirty duplicates
|
||||||
|
cannot both be protected under one key. The naive behavior (finding) is
|
||||||
|
the worst one: both write to the same key, the later write wins on disk,
|
||||||
|
and *both* buffers are recorded as protected, so the loser silently skips
|
||||||
|
future sweeps while its contents are unrecoverable. Either buffer's
|
||||||
|
save/kill could also retire the other's copy.
|
||||||
|
|
||||||
|
So ownership is `path_hash → BufferId`, not a path-wide set:
|
||||||
|
|
||||||
|
- the **first** modified buffer to reach a free slot claims it (including
|
||||||
|
within a single sweep pass — the write loop updates `owner`, so the
|
||||||
|
gather loop tracks slots queued this pass);
|
||||||
|
- any other buffer on that path is counted **`conflicted`** and reported —
|
||||||
|
*"autosave paused for N buffer(s): another buffer is visiting the same
|
||||||
|
file"* — never silently mis-protected. It records no `written` entry, so
|
||||||
|
it re-attempts each sweep rather than believing itself saved;
|
||||||
|
- `discard_buffer` (save/kill) retires **only slots this buffer owns**, so
|
||||||
|
a duplicate cannot delete the owner's recovery;
|
||||||
|
- when the owner is saved or killed, the slot is released and the
|
||||||
|
duplicate claims it on the next sweep;
|
||||||
|
- `recover-file` adopting into a buffer makes *that* buffer the owner —
|
||||||
|
the file's contents are now its contents, and the previous owner
|
||||||
|
truthfully becomes conflicted.
|
||||||
|
|
||||||
|
This is honest rather than clever: pmacs cannot protect two divergent
|
||||||
|
buffers over one file, and says so.
|
||||||
|
|
||||||
### Q#AS7 — Cleanup lifecycle (keyed by buffer, not by a captured path)
|
### Q#AS7 — Cleanup lifecycle (keyed by buffer, not by a captured path)
|
||||||
|
|
||||||
- **`buffer.after-save`** → `discard_buffer(active buffer)`.
|
- **`buffer.after-save`** → `discard_buffer(active buffer)`.
|
||||||
|
|
@ -468,6 +499,10 @@ commands, cleanup wiring) → tests.
|
||||||
adopted copy *is* retired, not left to be re-offered.
|
adopted copy *is* retired, not left to be re-offered.
|
||||||
- **Explicit `discard-recovery` on a still-dirty buffer** → the next
|
- **Explicit `discard-recovery` on a still-dirty buffer** → the next
|
||||||
sweep re-protects it at once, with no intervening edit.
|
sweep re-protects it at once, with no intervening edit.
|
||||||
|
- **Two dirty buffers on one path (Q#AS13)** → `(written, blocked,
|
||||||
|
conflicted) == (1, 0, 1)`; the owner's copy is on disk; the duplicate
|
||||||
|
never wins the slot by editing, its save never retires the owner's
|
||||||
|
copy, and killing the owner frees the slot for it.
|
||||||
- **Path change**: rename a buffer's path (`set_buffer_path`) without
|
- **Path change**: rename a buffer's path (`set_buffer_path`) without
|
||||||
editing it → next sweep writes the new key **and** removes the old
|
editing it → next sweep writes the new key **and** removes the old
|
||||||
recovery file (the `(path_hash, revision)` cache).
|
recovery file (the `(path_hash, revision)` cache).
|
||||||
|
|
|
||||||
228
src/autosave.rs
228
src/autosave.rs
|
|
@ -18,7 +18,7 @@
|
||||||
//! Framing: docs/autosave-recovery-framing.md.
|
//! Framing: docs/autosave-recovery-framing.md.
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use mlua::Lua;
|
use mlua::Lua;
|
||||||
|
|
@ -139,16 +139,26 @@ struct CacheInner {
|
||||||
/// remembers *where a buffer's recovery currently lives*, which is
|
/// remembers *where a buffer's recovery currently lives*, which is
|
||||||
/// what makes cleanup work after a rename.
|
/// what makes cleanup work after a rename.
|
||||||
written: HashMap<BufferId, (String, u64)>,
|
written: HashMap<BufferId, (String, u64)>,
|
||||||
/// Path hashes whose recovery file **this session** wrote or has
|
/// Which buffer owns each recovery slot: `path_hash → BufferId`
|
||||||
/// adopted (Q#AS12).
|
/// (Q#AS12, Q#AS13).
|
||||||
///
|
///
|
||||||
/// A recovery file we did *not* write is unclaimed crash data. If the
|
/// Two roles in one map:
|
||||||
/// user reopens the file and starts editing, sweeping would overwrite
|
///
|
||||||
/// the crash copy with the current buffer — destroying exactly what
|
/// * **Absent** = the recovery file at that hash (if any) is
|
||||||
/// autosave exists to protect. So an unowned recovery file *blocks*
|
/// *unclaimed crash data* — this session did not write it. Sweeping
|
||||||
/// the sweep for that buffer until `recover-file` adopts it or
|
/// would overwrite the crash copy with the current buffer,
|
||||||
/// `discard-recovery` removes it.
|
/// destroying exactly what autosave protects. So it blocks the
|
||||||
owned: HashSet<String>,
|
/// sweep until `recover-file` adopts it or `discard-recovery`
|
||||||
|
/// removes it, and neither save nor kill may delete it.
|
||||||
|
/// * **Present** = the slot belongs to exactly *one* buffer. A
|
||||||
|
/// recovery file is keyed by path (a later session knows only
|
||||||
|
/// paths, never old `BufferId`s), but `pmacs.buffer.from_file` can
|
||||||
|
/// open a *second* buffer on the same path. Both cannot be
|
||||||
|
/// protected under one key: the later write would win on disk while
|
||||||
|
/// both buffers believed themselves saved. So the first modified
|
||||||
|
/// buffer claims the slot and any other buffer on that path is
|
||||||
|
/// reported as conflicted, not silently mis-protected.
|
||||||
|
owner: HashMap<String, BufferId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encode a header + contents into the one-file envelope.
|
/// Encode a header + contents into the one-file envelope.
|
||||||
|
|
@ -238,26 +248,103 @@ struct Pending {
|
||||||
/// Unlike desktop-save this is **not** daemon-gated — autosave is
|
/// Unlike desktop-save this is **not** daemon-gated — autosave is
|
||||||
/// per-buffer, not per-frontend, and a daemon holds the unsaved work.
|
/// per-buffer, not per-frontend, and a daemon holds the unsaved work.
|
||||||
///
|
///
|
||||||
/// Returns `(written, blocked)` — `blocked` counts buffers whose sweep
|
/// Returns `(written, blocked, conflicted)`:
|
||||||
/// was refused because an **unclaimed** recovery file already sits at
|
///
|
||||||
/// their key (Q#AS12).
|
/// * `blocked` — an **unclaimed** recovery file already sits at the
|
||||||
|
/// buffer's key (Q#AS12): crash data this session did not write.
|
||||||
|
/// * `conflicted` — another buffer already owns that path's recovery
|
||||||
|
/// slot (Q#AS13): two buffers visit the same file and only one can be
|
||||||
|
/// protected under a path-keyed recovery file.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// A state-write failure. Individual buffers never abort the pass.
|
/// A state-write failure. Individual buffers never abort the pass.
|
||||||
pub fn sweep(lua: &Lua) -> Result<(usize, usize), String> {
|
pub fn sweep(lua: &Lua) -> Result<(usize, usize, usize), String> {
|
||||||
let Some(base) = base_dir(lua) else {
|
let Some(base) = base_dir(lua) else {
|
||||||
return Ok((0, 0));
|
return Ok((0, 0, 0));
|
||||||
};
|
};
|
||||||
let core = lua
|
let core = lua
|
||||||
.app_data_ref::<SharedCore>()
|
.app_data_ref::<SharedCore>()
|
||||||
.ok_or("no editor core")?
|
.ok_or("no editor core")?
|
||||||
.clone();
|
.clone();
|
||||||
|
let gathered = gather(lua, &core, &base)?;
|
||||||
|
let Gathered {
|
||||||
|
writes,
|
||||||
|
orphans,
|
||||||
|
live,
|
||||||
|
blocked,
|
||||||
|
conflicted,
|
||||||
|
} = gathered;
|
||||||
|
|
||||||
// Gather under a borrow; do all IO after releasing it.
|
let mut written = 0usize;
|
||||||
|
{
|
||||||
|
let cache = lua
|
||||||
|
.app_data_ref::<AutosaveCache>()
|
||||||
|
.ok_or("no autosave cache")?;
|
||||||
|
let mut cache = cache.0.borrow_mut();
|
||||||
|
// A buffer whose path moved leaves its old recovery behind.
|
||||||
|
for old in orphans {
|
||||||
|
let _ = crate::state::remove(&base, &format!("autosave/{old}"));
|
||||||
|
cache.owner.remove(&old);
|
||||||
|
}
|
||||||
|
// GC: a buffer that left the registry (killed) takes its recovery
|
||||||
|
// copy with it. This is the backstop that covers `[new file]`
|
||||||
|
// buffers, which fire no `after-load` and so never get a
|
||||||
|
// per-buffer removal callback registered. Only the slot's owner
|
||||||
|
// may retire it.
|
||||||
|
let dead: Vec<(BufferId, String)> = cache
|
||||||
|
.written
|
||||||
|
.iter()
|
||||||
|
.filter(|(id, _)| !live.contains(id))
|
||||||
|
.map(|(id, (hash, _))| (*id, hash.clone()))
|
||||||
|
.collect();
|
||||||
|
for (id, hash) in dead {
|
||||||
|
if cache.owner.get(&hash) == Some(&id) {
|
||||||
|
let _ = crate::state::remove(&base, &format!("autosave/{hash}"));
|
||||||
|
cache.owner.remove(&hash);
|
||||||
|
}
|
||||||
|
cache.written.remove(&id);
|
||||||
|
}
|
||||||
|
for p in writes {
|
||||||
|
let header = Header {
|
||||||
|
version: AUTOSAVE_VERSION,
|
||||||
|
path: p.path,
|
||||||
|
origin: p.origin,
|
||||||
|
};
|
||||||
|
let bytes = encode(&header, &p.contents)?;
|
||||||
|
crate::state::write_private(&base, &format!("autosave/{}", p.path_hash), &bytes)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
cache.owner.insert(p.path_hash.clone(), p.id);
|
||||||
|
cache.written.insert(p.id, (p.path_hash, p.revision));
|
||||||
|
written += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((written, blocked, conflicted))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one pass of the registry decided, before any IO.
|
||||||
|
struct Gathered {
|
||||||
|
writes: Vec<Pending>,
|
||||||
|
/// Recovery keys left behind by buffers whose path moved.
|
||||||
|
orphans: Vec<String>,
|
||||||
|
/// Every buffer still in the registry (drives the dead-buffer GC).
|
||||||
|
live: Vec<BufferId>,
|
||||||
|
blocked: usize,
|
||||||
|
conflicted: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk the registry under a single borrow and decide what to write.
|
||||||
|
/// All IO happens in [`sweep`] after this returns, because a recovery
|
||||||
|
/// write must not run while the core is borrowed.
|
||||||
|
fn gather(lua: &Lua, core: &SharedCore, base: &Path) -> Result<Gathered, String> {
|
||||||
let mut writes: Vec<Pending> = Vec::new();
|
let mut writes: Vec<Pending> = Vec::new();
|
||||||
let mut orphans: Vec<String> = Vec::new();
|
let mut orphans: Vec<String> = Vec::new();
|
||||||
let mut live: Vec<BufferId> = Vec::new();
|
let mut live: Vec<BufferId> = Vec::new();
|
||||||
let mut blocked = 0usize;
|
let mut blocked = 0usize;
|
||||||
|
let mut conflicted = 0usize;
|
||||||
|
// Slots claimed earlier in *this* pass. `owner` is only updated in the
|
||||||
|
// write loop, so without this two dirty duplicates of one path would
|
||||||
|
// both queue a write to the same key.
|
||||||
|
let mut queued: HashMap<String, BufferId> = HashMap::new();
|
||||||
{
|
{
|
||||||
let cache = lua
|
let cache = lua
|
||||||
.app_data_ref::<AutosaveCache>()
|
.app_data_ref::<AutosaveCache>()
|
||||||
|
|
@ -279,6 +366,31 @@ pub fn sweep(lua: &Lua) -> Result<(usize, usize), String> {
|
||||||
let path_s = path.display().to_string();
|
let path_s = path.display().to_string();
|
||||||
let path_hash = sha256_hex(&path_s);
|
let path_hash = sha256_hex(&path_s);
|
||||||
let revision = buf.revision();
|
let revision = buf.revision();
|
||||||
|
// Exactly one buffer may own a path's recovery slot (Q#AS13):
|
||||||
|
// the file is keyed by path, so a second buffer on the same
|
||||||
|
// path cannot also be protected — the later write would win on
|
||||||
|
// disk while both believed themselves saved.
|
||||||
|
let slot_owner = cache
|
||||||
|
.owner
|
||||||
|
.get(&path_hash)
|
||||||
|
.or_else(|| queued.get(&path_hash));
|
||||||
|
match slot_owner {
|
||||||
|
Some(&owner_id) if owner_id != id => {
|
||||||
|
conflicted += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Some(_) => {} // we already own the slot
|
||||||
|
None => {
|
||||||
|
// Unowned. Never clobber unclaimed crash data (Q#AS12):
|
||||||
|
// a recovery file this session did not write is the
|
||||||
|
// crash copy the user has not recovered yet.
|
||||||
|
if crate::state::exists(base, &format!("autosave/{path_hash}")).unwrap_or(false)
|
||||||
|
{
|
||||||
|
blocked += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some((prev_hash, prev_rev)) = cache.written.get(&id) {
|
if let Some((prev_hash, prev_rev)) = cache.written.get(&id) {
|
||||||
if prev_hash == &path_hash && *prev_rev == revision {
|
if prev_hash == &path_hash && *prev_rev == revision {
|
||||||
continue; // unchanged since its last copy
|
continue; // unchanged since its last copy
|
||||||
|
|
@ -288,16 +400,7 @@ pub fn sweep(lua: &Lua) -> Result<(usize, usize), String> {
|
||||||
orphans.push(prev_hash.clone());
|
orphans.push(prev_hash.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Never clobber unclaimed crash data (Q#AS12). A recovery file
|
queued.insert(path_hash.clone(), id);
|
||||||
// this session did not write is the crash copy the user has
|
|
||||||
// not recovered yet; overwriting it with the current buffer
|
|
||||||
// would destroy exactly what autosave protects.
|
|
||||||
if !cache.owned.contains(&path_hash)
|
|
||||||
&& crate::state::exists(&base, &format!("autosave/{path_hash}")).unwrap_or(false)
|
|
||||||
{
|
|
||||||
blocked += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let len = buf.len();
|
let len = buf.len();
|
||||||
let mut contents = vec![0u8; usize::try_from(len).unwrap_or(0)];
|
let mut contents = vec![0u8; usize::try_from(len).unwrap_or(0)];
|
||||||
if len > 0 {
|
if len > 0 {
|
||||||
|
|
@ -314,46 +417,13 @@ pub fn sweep(lua: &Lua) -> Result<(usize, usize), String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for old in orphans {
|
Ok(Gathered {
|
||||||
let _ = crate::state::remove(&base, &format!("autosave/{old}"));
|
writes,
|
||||||
}
|
orphans,
|
||||||
|
live,
|
||||||
let mut written = 0usize;
|
blocked,
|
||||||
{
|
conflicted,
|
||||||
let cache = lua
|
})
|
||||||
.app_data_ref::<AutosaveCache>()
|
|
||||||
.ok_or("no autosave cache")?;
|
|
||||||
let mut cache = cache.0.borrow_mut();
|
|
||||||
// GC: a buffer that left the registry (killed) takes its recovery
|
|
||||||
// copy with it. This is the backstop that covers `[new file]`
|
|
||||||
// buffers, which fire no `after-load` and so never get a
|
|
||||||
// per-buffer removal callback registered.
|
|
||||||
let dead: Vec<(BufferId, String)> = cache
|
|
||||||
.written
|
|
||||||
.iter()
|
|
||||||
.filter(|(id, _)| !live.contains(id))
|
|
||||||
.map(|(id, (hash, _))| (*id, hash.clone()))
|
|
||||||
.collect();
|
|
||||||
for (id, hash) in dead {
|
|
||||||
let _ = crate::state::remove(&base, &format!("autosave/{hash}"));
|
|
||||||
cache.written.remove(&id);
|
|
||||||
cache.owned.remove(&hash);
|
|
||||||
}
|
|
||||||
for p in writes {
|
|
||||||
let header = Header {
|
|
||||||
version: AUTOSAVE_VERSION,
|
|
||||||
path: p.path,
|
|
||||||
origin: p.origin,
|
|
||||||
};
|
|
||||||
let bytes = encode(&header, &p.contents)?;
|
|
||||||
crate::state::write_private(&base, &format!("autosave/{}", p.path_hash), &bytes)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
cache.owned.insert(p.path_hash.clone());
|
|
||||||
cache.written.insert(p.id, (p.path_hash, p.revision));
|
|
||||||
written += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok((written, blocked))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Claim `buffer`'s recovery file for this session (Q#AS12). Called by
|
/// Claim `buffer`'s recovery file for this session (Q#AS12). Called by
|
||||||
|
|
@ -381,7 +451,12 @@ pub fn adopt(lua: &Lua, id: BufferId) {
|
||||||
drop(core);
|
drop(core);
|
||||||
if let Some(cache) = lua.app_data_ref::<AutosaveCache>() {
|
if let Some(cache) = lua.app_data_ref::<AutosaveCache>() {
|
||||||
let mut cache = cache.0.borrow_mut();
|
let mut cache = cache.0.borrow_mut();
|
||||||
cache.owned.insert(entry.0.clone());
|
// Recovering into this buffer makes it the slot's owner — its
|
||||||
|
// contents are now what the file holds. Any previous owner of the
|
||||||
|
// slot (a duplicate buffer on the same path) loses the claim and
|
||||||
|
// will report as conflicted on the next sweep, which is truthful:
|
||||||
|
// the file no longer corresponds to it.
|
||||||
|
cache.owner.insert(entry.0.clone(), id);
|
||||||
cache.written.insert(id, entry);
|
cache.written.insert(id, entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -401,7 +476,7 @@ pub fn discard_path(lua: &Lua, path: &Path) -> bool {
|
||||||
let hash = sha256_hex(&path.display().to_string());
|
let hash = sha256_hex(&path.display().to_string());
|
||||||
if let Some(cache) = lua.app_data_ref::<AutosaveCache>() {
|
if let Some(cache) = lua.app_data_ref::<AutosaveCache>() {
|
||||||
let mut cache = cache.0.borrow_mut();
|
let mut cache = cache.0.borrow_mut();
|
||||||
cache.owned.remove(&hash);
|
cache.owner.remove(&hash);
|
||||||
cache.written.retain(|_, (h, _)| h != &hash);
|
cache.written.retain(|_, (h, _)| h != &hash);
|
||||||
}
|
}
|
||||||
discard(&base, path)
|
discard(&base, path)
|
||||||
|
|
@ -448,18 +523,21 @@ pub fn discard_buffer(lua: &Lua, id: BufferId) {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut cache = cache.0.borrow_mut();
|
let mut cache = cache.0.borrow_mut();
|
||||||
keys.retain(|h| cache.owned.contains(h));
|
// Retire only slots **this buffer** owns. Two guards in one check:
|
||||||
|
// * an unowned slot is unclaimed crash data — saving or killing the
|
||||||
|
// buffer you reopened after a crash must not destroy it (Q#AS12);
|
||||||
|
// * a slot owned by a *different* buffer belongs to that buffer's
|
||||||
|
// recovery — a duplicate buffer on the same path must not retire
|
||||||
|
// it (Q#AS13).
|
||||||
|
keys.retain(|h| cache.owner.get(h) == Some(&id));
|
||||||
for hash in &keys {
|
for hash in &keys {
|
||||||
let _ = crate::state::remove(&base, &format!("autosave/{hash}"));
|
let _ = crate::state::remove(&base, &format!("autosave/{hash}"));
|
||||||
cache.owned.remove(hash);
|
cache.owner.remove(hash);
|
||||||
}
|
}
|
||||||
// The skip-cache entry only ever names a key we owned, so it goes
|
// This buffer's own bookkeeping goes regardless: it is being saved or
|
||||||
// whenever we retired that key — and a buffer that owned nothing has
|
// killed, so any skip-cache entry for it is spent.
|
||||||
// no entry to drop.
|
|
||||||
if !keys.is_empty() {
|
|
||||||
cache.written.remove(&id);
|
cache.written.remove(&id);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Every open file buffer that has a recovery file, with its status
|
/// Every open file buffer that has a recovery file, with its status
|
||||||
/// (Q#AS6). Enumerating in Rust is what makes this cover argv
|
/// (Q#AS6). Enumerating in Rust is what makes this cover argv
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,21 @@ fn sweep2(s: &EditorState) -> (i64, i64) {
|
||||||
eval(s, "return pmacs.autosave.sweep()")
|
eval(s, "return pmacs.autosave.sweep()")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Force a sweep; returns `(written, blocked, conflicted)`.
|
||||||
|
fn sweep3(s: &EditorState) -> (i64, i64, i64) {
|
||||||
|
eval(s, "return pmacs.autosave.sweep()")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open `path`, dirty it, then open a SECOND buffer on the same path via
|
||||||
|
/// `from_file` (which does not dedup) and dirty that differently.
|
||||||
|
/// Returns with the duplicate active.
|
||||||
|
fn two_buffers_one_path(s: &EditorState, path: &str) {
|
||||||
|
exec(s, &format!("_G.a = pmacs.buffer.find_or_open({path:?})"));
|
||||||
|
exec(s, "pmacs.window.buffer():insert(0, 'AAA ')");
|
||||||
|
exec(s, &format!("_G.b = pmacs.buffer.from_file({path:?})"));
|
||||||
|
exec(s, "pmacs.window.buffer():insert(0, 'BBB ')");
|
||||||
|
}
|
||||||
|
|
||||||
fn recovered(s: &EditorState, path: &str) -> Vec<u8> {
|
fn recovered(s: &EditorState, path: &str) -> Vec<u8> {
|
||||||
let b: mlua::String = eval(
|
let b: mlua::String = eval(
|
||||||
s,
|
s,
|
||||||
|
|
@ -290,6 +305,84 @@ fn sweep_never_overwrites_unclaimed_crash_recovery() {
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_buffers_on_one_path_conflict_instead_of_corrupting() {
|
||||||
|
let dir = fresh_state_dir();
|
||||||
|
let s = editor(&dir);
|
||||||
|
let f = write_file(&dir, "a.txt", "on disk\n");
|
||||||
|
two_buffers_one_path(&s, &f);
|
||||||
|
|
||||||
|
// A recovery file is keyed by path, so only ONE of the two dirty
|
||||||
|
// buffers can be protected. The first claims the slot; the other is
|
||||||
|
// reported, never silently mis-protected.
|
||||||
|
let (written, blocked, conflicted) = sweep3(&s);
|
||||||
|
assert_eq!((written, blocked, conflicted), (1, 0, 1));
|
||||||
|
assert_eq!(
|
||||||
|
recovered(&s, &f),
|
||||||
|
b"AAA on disk\n",
|
||||||
|
"the slot's owner is what is on disk"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The loser must NOT be marked protected: it keeps conflicting, and
|
||||||
|
// its contents never silently overwrite the owner's copy.
|
||||||
|
let (written, _, conflicted) = sweep3(&s);
|
||||||
|
assert_eq!(
|
||||||
|
(written, conflicted),
|
||||||
|
(0, 1),
|
||||||
|
"owner unchanged, dup still conflicts"
|
||||||
|
);
|
||||||
|
exec(&s, "pmacs.window.switch_buffer(_G.b)");
|
||||||
|
exec(&s, "pmacs.window.buffer():insert(0, 'more ')");
|
||||||
|
let (written, _, conflicted) = sweep3(&s);
|
||||||
|
assert_eq!(
|
||||||
|
(written, conflicted),
|
||||||
|
(0, 1),
|
||||||
|
"editing the dup does not win the slot"
|
||||||
|
);
|
||||||
|
assert_eq!(recovered(&s, &f), b"AAA on disk\n", "owner's copy intact");
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_duplicate_buffers_save_does_not_retire_the_owners_recovery() {
|
||||||
|
let dir = fresh_state_dir();
|
||||||
|
let s = editor(&dir);
|
||||||
|
let f = write_file(&dir, "a.txt", "on disk\n");
|
||||||
|
two_buffers_one_path(&s, &f);
|
||||||
|
assert_eq!(sweep3(&s), (1, 0, 1));
|
||||||
|
let owner_copy = recovered(&s, &f);
|
||||||
|
|
||||||
|
// Save the DUPLICATE. Its cleanup must not touch the other buffer's
|
||||||
|
// recovery — that copy is the only record of the owner's unsaved work.
|
||||||
|
exec(&s, "pmacs.window.switch_buffer(_G.b)");
|
||||||
|
exec(&s, "pmacs.command.invoke('buffer.save')");
|
||||||
|
assert_ne!(status(&s, &f), "none", "the owner's recovery survives");
|
||||||
|
assert_eq!(recovered(&s, &f), owner_copy);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn killing_the_owner_frees_the_slot_for_the_duplicate() {
|
||||||
|
let dir = fresh_state_dir();
|
||||||
|
let s = editor(&dir);
|
||||||
|
let f = write_file(&dir, "a.txt", "on disk\n");
|
||||||
|
two_buffers_one_path(&s, &f);
|
||||||
|
assert_eq!(sweep3(&s), (1, 0, 1));
|
||||||
|
|
||||||
|
// Killing the owner retires its copy and releases the slot; the
|
||||||
|
// duplicate can then claim it and finally be protected.
|
||||||
|
exec(&s, "pmacs.buffer.kill(_G.a)");
|
||||||
|
assert_eq!(status(&s, &f), "none", "owner's copy retired with it");
|
||||||
|
let (written, blocked, conflicted) = sweep3(&s);
|
||||||
|
assert_eq!(
|
||||||
|
(written, blocked, conflicted),
|
||||||
|
(1, 0, 0),
|
||||||
|
"dup claims the slot"
|
||||||
|
);
|
||||||
|
assert_eq!(recovered(&s, &f), b"BBB on disk\n");
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn saving_without_recovering_preserves_unclaimed_crash_data() {
|
fn saving_without_recovering_preserves_unclaimed_crash_data() {
|
||||||
let dir = fresh_state_dir();
|
let dir = fresh_state_dir();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue