Merge Design View hardening: reconcile orphaned snapshots on track delete

This commit is contained in:
2026-07-23 13:34:39 -04:00
4 changed files with 141 additions and 0 deletions
+10
View File
@@ -144,6 +144,16 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid); std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid);
FolderTree tree = buildFolderTree(entries); FolderTree tree = buildFolderTree(entries);
// Reconcile orphaned model state BEFORE planning: prune snapshots whose track was
// deleted from the project (its GUID no longer appears in the live enumeration).
// handleByGuid holds every currently-enumerated track GUID, so its keys are the
// authoritative live set. Membership is intentionally NOT pruned (undo-delete
// restores the same GUID — see ViewModeModel::reconcile). Because reapply-on-load
// routes through applyMode, this also reconciles on project open.
std::set<std::string> liveGuids;
for (const auto& kv : handleByGuid) liveGuids.insert(kv.first);
model.reconcile(liveGuids);
TogglePlan plan = model.planToggle(tree, targetModeId); TogglePlan plan = model.planToggle(tree, targetModeId);
Undo_BeginBlock2(proj); Undo_BeginBlock2(proj);
+15
View File
@@ -160,6 +160,21 @@ const TrackSnapshot* ViewModeModel::snapshot(const std::string& guid) const {
return it == snapshots_.end() ? nullptr : &it->second; return it == snapshots_.end() ? nullptr : &it->second;
} }
std::size_t ViewModeModel::reconcile(const std::set<std::string>& liveGuids) {
// Prune snapshots for GUIDs the project no longer contains (see header for the
// deliberate snapshot-yes / membership-no asymmetry and the undo-delete rationale).
std::size_t removed = 0;
for (auto it = snapshots_.begin(); it != snapshots_.end();) {
if (liveGuids.count(it->first) == 0) {
it = snapshots_.erase(it);
++removed;
} else {
++it;
}
}
return removed;
}
bool ViewModeModel::leafBelongsToMode(const std::string& guid, const std::string& modeId) const { bool ViewModeModel::leafBelongsToMode(const std::string& guid, const std::string& modeId) const {
const Membership* m = membership_.query(guid); const Membership* m = membership_.query(guid);
if (!m) return modeId == kArrangeModeId; // untagged ⇒ Arrange default if (!m) return modeId == kArrangeModeId; // untagged ⇒ Arrange default
+20
View File
@@ -267,6 +267,26 @@ public:
const TrackSnapshot* snapshot(const std::string& guid) const; const TrackSnapshot* snapshot(const std::string& guid) const;
const std::map<std::string, TrackSnapshot>& snapshots() const { return snapshots_; } const std::map<std::string, TrackSnapshot>& snapshots() const { return snapshots_; }
// Prunes orphaned per-track state: drops every snapshot whose GUID is NOT in
// `liveGuids` (the set of GUIDs the shell currently enumerates from the project).
// Returns the number of snapshots removed. The shell calls this before planning a
// toggle; because reapply-on-load also routes through the shell's applyMode, this
// reconciles on project open too.
//
// Why snapshots and NOT membership: a parked track's snapshot is dead weight once
// the track is deleted — it can never be restored, and if REAPER reuses that GUID
// for a different track a stale snapshot would drive an INCORRECT restore. So it
// must be pruned. Membership is deliberately KEPT: REAPER's undo of a track delete
// restores the SAME GUID, so dropping the Design tag on delete would silently lose
// it on undo-delete. Keeping membership means an undone delete brings the track
// back correctly tagged and it re-snapshots + re-parks cleanly on the next toggle.
// A genuinely-deleted-and-never-restored track leaves only a tiny dormant
// membership entry — acceptable, and far better than losing tags on undo. Folder
// RESTRUCTURE (moving tracks without deleting) is already self-healing: the tree is
// rebuilt from I_FOLDERDEPTH every toggle, so a restructure leaves every GUID live
// and reconcile is a no-op over it. This handles DELETION specifically.
std::size_t reconcile(const std::set<std::string>& liveGuids);
// Does `guid` belong to `modeId`? A leaf belongs if it is tagged into modeId, // Does `guid` belong to `modeId`? A leaf belongs if it is tagged into modeId,
// is show-both (belongs everywhere), or is untagged and modeId is Arrange (the // is show-both (belongs everywhere), or is untagged and modeId is Arrange (the
// default). Parent derivation is NOT applied here — this is the LEAF rule; use // default). Parent derivation is NOT applied here — this is the LEAF rule; use
+96
View File
@@ -579,6 +579,99 @@ static void testTaggedLeafBehaviorUnchangedWithUntagged() {
CHECK(!parkTargets(arrange, "{UNT}")); CHECK(!parkTargets(arrange, "{UNT}"));
} }
// -- 10. reconcile: prune orphaned snapshots on track delete -----------------
//
// Closes the "reconcile on delete/restructure" hardening item. reconcile prunes a
// snapshot whose GUID is not in the live set (its track was deleted while parked),
// preventing both the slow snapshot leak and an incorrect restore if REAPER reuses
// the GUID. Membership is deliberately KEPT (undo-delete restores the same GUID, so
// dropping the tag would silently lose it). A full live set is a no-op — this is why
// folder RESTRUCTURE, which leaves every GUID live, needs no special handling.
static void testReconcilePrunesOrphanedSnapshots() {
ViewModeModel vm;
// Two parked tracks (both snapshotted + tagged); {DEL} is about to be deleted.
vm.membership().tag("{LIVE}", kDesignModeId);
vm.membership().tag("{DEL}", kDesignModeId);
TrackSnapshot sLive; sLive.showInTcp = 1; sLive.fxOffline = {0, 1};
TrackSnapshot sDel; sDel.showInTcp = 1; sDel.fxEnable = 1;
vm.storeSnapshot("{LIVE}", sLive);
vm.storeSnapshot("{DEL}", sDel);
CHECK(vm.snapshots().size() == 2);
// {DEL} is deleted from the project ⇒ absent from the live GUID set.
std::set<std::string> liveGuids{"{LIVE}"};
std::size_t removed = vm.reconcile(liveGuids);
// The orphaned snapshot is pruned; the live one is retained verbatim.
CHECK(removed == 1);
CHECK(vm.snapshot("{DEL}") == nullptr);
const TrackSnapshot* kept = vm.snapshot("{LIVE}");
CHECK(kept != nullptr);
if (kept) CHECK(kept->showInTcp == 1 && kept->fxOffline.size() == 2);
// Membership is NOT pruned — the deleted GUID keeps its Design tag so an
// undo-delete (which restores the same GUID) brings the track back correctly
// tagged. This is the load-bearing design call.
CHECK(vm.membership().query("{DEL}") != nullptr);
CHECK(vm.leafBelongsToMode("{DEL}", kDesignModeId));
CHECK(vm.membership().query("{LIVE}") != nullptr);
}
static void testReconcileFullLiveSetIsNoOp() {
// The restructure case: tracks moved between folders but none deleted ⇒ every
// GUID stays live ⇒ reconcile prunes nothing.
ViewModeModel vm;
vm.storeSnapshot("{A}", TrackSnapshot{});
vm.storeSnapshot("{B}", TrackSnapshot{});
vm.storeSnapshot("{C}", TrackSnapshot{});
std::set<std::string> liveGuids{"{A}", "{B}", "{C}"};
std::size_t removed = vm.reconcile(liveGuids);
CHECK(removed == 0);
CHECK(vm.snapshots().size() == 3);
CHECK(vm.snapshot("{A}") && vm.snapshot("{B}") && vm.snapshot("{C}"));
// A superset of live GUIDs (tracks exist that were never parked) is also a no-op:
// reconcile only ever removes, never adds.
std::set<std::string> superset{"{A}", "{B}", "{C}", "{NEVER_PARKED}"};
CHECK(vm.reconcile(superset) == 0);
CHECK(vm.snapshots().size() == 3);
// Empty live set (whole project emptied) prunes everything.
CHECK(vm.reconcile(std::set<std::string>{}) == 3);
CHECK(vm.snapshots().empty());
}
static void testReconcileThenReparkLifecycleIntact() {
// No regression to the park/restore lifecycle: after reconcile prunes a deleted
// track's snapshot, a still-live tagged leaf toggled back to its mode still
// restores from its retained snapshot, and a re-park recaptures fresh state.
ViewModeModel vm;
FolderTree tree;
tree.nodes.push_back(FolderNode{"{DES}", "", false});
vm.membership().tag("{DES}", kDesignModeId);
TrackSnapshot snap; snap.showInTcp = 1; snap.mainSend = 0; snap.fxEnable = 1;
vm.storeSnapshot("{DES}", snap); // parked while in Arrange
vm.storeSnapshot("{ORPHAN}", TrackSnapshot{}); // a since-deleted parked track
// Reconcile with {DES} live, {ORPHAN} gone.
CHECK(vm.reconcile(std::set<std::string>{"{DES}"}) == 1);
CHECK(vm.snapshot("{ORPHAN}") == nullptr);
// Toggle back to Design: {DES} restores from its retained snapshot verbatim.
auto toDesign = vm.planToggle(tree, kDesignModeId);
const TrackPlan* r = restoreFor(toDesign, "{DES}");
CHECK(r != nullptr);
if (r) {
CHECK(flagValue(*r, Flag::ShowInTcp) == 1);
CHECK(flagValue(*r, Flag::MainSend) == 0);
CHECK(flagValue(*r, Flag::FxEnable) == 1);
}
}
// -- 8. nextModeId cycle (D4 toggle helper) ---------------------------------- // -- 8. nextModeId cycle (D4 toggle helper) ----------------------------------
static void testNextModeIdCycles() { static void testNextModeIdCycles() {
@@ -756,6 +849,9 @@ int main() {
testUntaggedLeavesManagedByModeSystem(); testUntaggedLeavesManagedByModeSystem();
testTaggedLeafBehaviorUnchangedWithUntagged(); testTaggedLeafBehaviorUnchangedWithUntagged();
testNestedToggleSnapshotSurvivesRepark(); testNestedToggleSnapshotSurvivesRepark();
testReconcilePrunesOrphanedSnapshots();
testReconcileFullLiveSetIsNoOp();
testReconcileThenReparkLifecycleIntact();
testNextModeIdCycles(); testNextModeIdCycles();
if (g_fail == 0) std::printf("All tests passed.\n"); if (g_fail == 0) std::printf("All tests passed.\n");