Drain deferred FX parks before the view model is serialized; re-validate track handles per intent; pin the restore-plan round trip

This commit is contained in:
2026-08-03 14:08:28 -04:00
parent 7169d7f22b
commit 78e5f06928
8 changed files with 147 additions and 15 deletions
+4 -2
View File
@@ -63,8 +63,10 @@ target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
# The deferred FX-park queue's re-entrancy rule is pure (header-inline, no REAPER # The deferred FX-park queue's re-entrancy rule is pure (header-inline, no REAPER
# types), so it is CTest-covered like a core/ module. Declared here rather than in a # types), so it is CTest-covered like a core/ module. Declared here rather than in a
# src/shell/view/CMakeLists.txt because that directory deliberately has none — its # src/shell/view/CMakeLists.txt because that directory deliberately has none — its
# TUs are compiled into this target directly. # TUs are compiled into this target directly. view_mode_model is linked for
reasampler_test(view_fx_park LINK fx_offline) # makeRestorePlan alone: the cancel path's round trip is a contract between the two,
# and pinning it against hand-built ops would not catch a change to either half.
reasampler_test(view_fx_park LINK fx_offline view_mode_model)
# Bank-package import: the promptless verb plus its action skin. Kept as its own # Bank-package import: the promptless verb plus its action skin. Kept as its own
# appended block rather than merged into the lists above, so the two package # appended block rather than merged into the lists above, so the two package
@@ -26,6 +26,7 @@
#include "shell/capture/track_guid.h" // shared MediaTrack* -> canonical GUID key #include "shell/capture/track_guid.h" // shared MediaTrack* -> canonical GUID key
#include "shell/panel/panel_window.h" // bankPanelInvalidate — footer toggle repaint #include "shell/panel/panel_window.h" // bankPanelInvalidate — footer toggle repaint
#include "shell/view/view.h" // applyMode + mintManagedLanes (D2 shell) #include "shell/view/view.h" // applyMode + mintManagedLanes (D2 shell)
#include "shell/view/view_fx_park.h" // drainDeferredFxParks — see persistViewState
#define REAPERAPI_MINIMAL #define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountSelectedTracks #define REAPERAPI_WANT_CountSelectedTracks
@@ -138,6 +139,12 @@ std::vector<RetagItem> selectedRetagItems() {
// the project is unsaved, prompts Save-As first (mirrors the flow capture uses) — // the project is unsaved, prompts Save-As first (mirrors the flow capture uses) —
// DAW-ONLY: Main_SaveProject(proj, true) blocks until the dialog is dismissed. // DAW-ONLY: Main_SaveProject(proj, true) blocks until the dialog is dismissed.
void persistViewState() { void persistViewState() {
// BEFORE the model is serialized, and before the Save-As below can write a
// .rpp: a deferred FX restore leaves the chain offline while the model has
// already dropped the snapshot that would replan it. Why that combination is
// unrecoverable on reopen is at drainDeferredFxParks.
drainDeferredFxParks();
if (!g_session->view().membership().empty()) { if (!g_session->view().membership().empty()) {
ReaProject* proj = EnumProjects(-1, nullptr, 0); ReaProject* proj = EnumProjects(-1, nullptr, 0);
if (proj) { if (proj) {
+2
View File
@@ -22,6 +22,7 @@
#include "shell/panel/panel_input.h" // bankPanelTailSetting #include "shell/panel/panel_input.h" // bankPanelTailSetting
#include "shell/persist/session.h" #include "shell/persist/session.h"
#include "shell/view/view.h" // applyMode / mintManagedLanes #include "shell/view/view.h" // applyMode / mintManagedLanes
#include "shell/view/view_fx_park.h" // drainDeferredFxParks
#define REAPERAPI_MINIMAL #define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountTrackMediaItems #define REAPERAPI_WANT_CountTrackMediaItems
@@ -210,6 +211,7 @@ void RunRenderTrackInPlace(ReaSamplerSession& session) {
// Persist outside the block. The offline render's own save gate already forced a // Persist outside the block. The offline render's own save gate already forced a
// saved project, so the Save-As-guarded persist the Design View actions need // saved project, so the Save-As-guarded persist the Design View actions need
// cannot have anything to prompt for here. // cannot have anything to prompt for here.
drainDeferredFxParks(); // the reapply above may have deferred a restore — see the contract there
session.saveToActiveProject(); session.saveToActiveProject();
if (!placed) { if (!placed) {
+14 -1
View File
@@ -54,7 +54,14 @@ decide membership or mode rules.
lost. What it does change is the undo record: the offline writes land outside lost. What it does change is the undo record: the offline writes land outside
the switch's undo block, so the tool no longer re-drives them on an undo or a the switch's undo block, so the tool no longer re-drives them on an undo or a
redo — what a Ctrl-Z then leaves the chain at is REAPER's own FX-state record, redo — what a Ctrl-Z then leaves the chain at is REAPER's own FX-state record,
`[verify — DAW]`. `[verify — DAW]`. **The idle tick is not the only drain point.** Any path that
serializes the view model drains synchronously first (`persistViewState`,
`render_in_place`), because a save landing between a restore's synchronous flag
writes and its drain would record offline FX beside a model that no longer
carries the snapshot to replan them — unrecoverable on reopen. So an
action-driven switch does pay the FX hitch before it returns; the repaint and
the undo block have both closed by then, which is what the deferral was for.
Any new caller that reapplies a mode and then persists inherits this obligation.
- **Stated DEVIATION — the undo mask does not keep FX out of a real switch.** The - **Stated DEVIATION — the undo mask does not keep FX out of a real switch.** The
apply mask (`kApplyUndoMask`) drops `UNDO_STATE_FX` and ORs it back in when a apply mask (`kApplyUndoMask`) drops `UNDO_STATE_FX` and ORs it back in when a
driven flag in that domain moved; the only such flag is `I_FXEN`, which every driven flag in that domain moved; the only such flag is `I_FXEN`, which every
@@ -118,6 +125,12 @@ applies the resulting lane state to live tracks.
under whatever mode id is active at that point, not the one the user undid back under whatever mode id is active at that point, not the one the user undid back
to. Pre-existing: `snapshots_` already carries this same model-vs-undo split; to. Pre-existing: `snapshots_` already carries this same model-vs-undo split;
the solo cache inherits it rather than introducing it. Not fixed here. the solo cache inherits it rather than introducing it. Not fixed here.
- An undo/redo also DISCARDS every pending FX intent (`discardDeferredFxParks`),
which is not the pure loss it reads as: the same tick reapplies the active mode
over the reloaded model, re-planning a park for every inactive leaf, so parked
FX converge on the following drain. The one case that does not self-heal is a
track whose reloaded model carries no snapshot — nothing plans its restore, so
FX left offline stay offline. Full contract at `discardDeferredFxParks`.
- `fx_offline`'s identity keying (`TrackFX_GetFXGUID`) assumes the GUID stays - `fx_offline`'s identity keying (`TrackFX_GetFXGUID`) assumes the GUID stays
attached to its plugin across a chain mutation while parked. That is attached to its plugin across a chain mutation while parked. That is
`[verify — DAW]` (see `fxGuidString` in `view_fx_park.cpp`) and SWS issue #802 is a `[verify — DAW]` (see `fxGuidString` in `view_fx_park.cpp`) and SWS issue #802 is a
+3 -3
View File
@@ -36,8 +36,7 @@
#define REAPERAPI_WANT_TrackList_AdjustWindows #define REAPERAPI_WANT_TrackList_AdjustWindows
#define REAPERAPI_WANT_UpdateArrange #define REAPERAPI_WANT_UpdateArrange
#define REAPERAPI_WANT_UpdateTimeline #define REAPERAPI_WANT_UpdateTimeline
// Lane minting (D2 Wave 3): item-side lane reads/writes to assign each item to // Lane minting: item-side lane reads/writes assigning each item to its mode's lane.
// its mode's managed lane.
#define REAPERAPI_WANT_CountTrackMediaItems #define REAPERAPI_WANT_CountTrackMediaItems
#define REAPERAPI_WANT_GetTrackMediaItem #define REAPERAPI_WANT_GetTrackMediaItem
#define REAPERAPI_WANT_GetMediaItemInfo_Value #define REAPERAPI_WANT_GetMediaItemInfo_Value
@@ -474,7 +473,8 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
// Enqueued FIRST: a park landing on this track's own pending restore // Enqueued FIRST: a park landing on this track's own pending restore
// cancels it, and those ops are then the only surviving record of the // cancels it, and those ops are then the only surviving record of the
// pre-park FX state. Snapshot ONCE — a snapshot already present means // pre-park FX state. Snapshot ONCE — a snapshot already present means
// the track is still parked, so its live flags read as parked. // the track is still parked, so recapturing would overwrite the true
// pre-park state with zeros and a later restore would hide it for good.
const std::vector<FxOfflineOp> cancelled = deferFxPark(proj, guid); const std::vector<FxOfflineOp> cancelled = deferFxPark(proj, guid);
if (model.snapshot(guid) == nullptr) if (model.snapshot(guid) == nullptr)
model.storeSnapshot(guid, snapshotTrack(tr, cancelled)); model.storeSnapshot(guid, snapshotTrack(tr, cancelled));
+28 -2
View File
@@ -20,6 +20,7 @@
#define REAPERAPI_WANT_TrackFX_GetFXGUID #define REAPERAPI_WANT_TrackFX_GetFXGUID
#define REAPERAPI_WANT_TrackFX_GetOffline #define REAPERAPI_WANT_TrackFX_GetOffline
#define REAPERAPI_WANT_TrackFX_SetOffline #define REAPERAPI_WANT_TrackFX_SetOffline
#define REAPERAPI_WANT_ValidatePtr2
#define REAPERAPI_WANT_guidToString #define REAPERAPI_WANT_guidToString
#include "reaper_plugin_functions.h" #include "reaper_plugin_functions.h"
@@ -29,6 +30,17 @@ namespace {
FxParkQueue g_queue; FxParkQueue g_queue;
ReaProject* g_owner = nullptr; // the project the pending intents were enqueued against ReaProject* g_owner = nullptr; // the project the pending intents were enqueued against
bool g_draining = false;
// Makes "one drain at a time" explicit rather than implied by the call sites.
// RAII because an apply can throw and a stuck flag would silence the queue for
// the rest of the session.
struct DrainScope {
DrainScope() { g_draining = true; }
~DrainScope() { g_draining = false; }
DrainScope(const DrainScope&) = delete;
DrainScope& operator=(const DrainScope&) = delete;
};
ReaProject* currentProject() { return EnumProjects(-1, nullptr, 0); } ReaProject* currentProject() { return EnumProjects(-1, nullptr, 0); }
@@ -127,6 +139,7 @@ void discardDeferredFxParks() {
void drainDeferredFxParks() { void drainDeferredFxParks() {
if (g_queue.empty()) return; if (g_queue.empty()) return;
if (g_draining) return; // re-entered mid-apply: those intents are the outer drain's next pass
if (g_owner != currentProject()) { if (g_owner != currentProject()) {
// The project the intents were planned against was closed or switched // The project the intents were planned against was closed or switched
@@ -139,9 +152,12 @@ void drainDeferredFxParks() {
} }
ReaProject* const proj = g_owner; ReaProject* const proj = g_owner;
const DrainScope scope;
// Detached before the first write: applying pumps the message loop (plugins // Detached before the first write: [verify — DAW] applying is ASSUMED to pump
// load and unload), so a re-entrant switch can enqueue while this runs. // the message loop (plugins load and unload), so a re-entrant switch can
// enqueue while this runs. Everything defensive below rests on that one
// assumption; each piece is correct regardless of whether it holds.
const std::vector<FxParkIntent> draining = g_queue.take(); const std::vector<FxParkIntent> draining = g_queue.take();
std::unordered_map<std::string, MediaTrack*> byGuid; std::unordered_map<std::string, MediaTrack*> byGuid;
@@ -159,6 +175,16 @@ void drainDeferredFxParks() {
for (const FxParkIntent& intent : draining) { for (const FxParkIntent& intent : draining) {
auto it = byGuid.find(intent.guid); auto it = byGuid.find(intent.guid);
if (it == byGuid.end()) continue; // track deleted since the switch — prune if (it == byGuid.end()) continue; // track deleted since the switch — prune
// Re-validated PER INTENT, not once above: under the same pumping
// assumption, a project closed or a track deleted between two applies
// leaves the handle resolved above dangling — a use-after-free, not a
// pruned intent. Same gate capture_realtime_shell's teardown uses; a null
// first argument validates the ReaProject* itself (SDK: proj is ignored
// when the pointer is a project).
if (!ValidatePtr2(nullptr, proj, "ReaProject*")) return;
if (!ValidatePtr2(proj, it->second, "MediaTrack*")) continue;
if (intent.park) { if (intent.park) {
applyPark(it->second); applyPark(it->second);
continue; continue;
+39 -4
View File
@@ -41,6 +41,11 @@ public:
// The caller needs them: that restore never ran, so the live chain still // The caller needs them: that restore never ran, so the live chain still
// reads the PARKED offline states and is no longer a source for a fresh // reads the PARKED offline states and is no longer a source for a fresh
// pre-park snapshot (see preParkFxFromCancelledRestore). // pre-park snapshot (see preParkFxFromCancelledRestore).
//
// The empty return is a LOAD-BEARING sentinel that deliberately conflates
// "nothing was cancelled" with "cancelled a restore carrying no ops": a
// zero-op restore held no FX state to hand back, so falling through to a
// live chain read is the same answer, not a worse one.
std::vector<FxOfflineOp> park(const std::string& guid) { std::vector<FxOfflineOp> park(const std::string& guid) {
FxParkIntent* held = find(guid); FxParkIntent* held = find(guid);
if (!held) { if (!held) {
@@ -73,10 +78,12 @@ public:
void clear() { pending_.clear(); } void clear() { pending_.clear(); }
// Detaches everything pending, leaving the queue able to accept intents // Detaches everything pending, leaving the queue able to accept intents
// enqueued WHILE the caller applies what it took. Applying loads/unloads // enqueued WHILE the caller applies what it took. [verify — DAW] applying
// plugins, which pumps the message loop, so a re-entrant switch can enqueue // loads/unloads plugins, which is ASSUMED to pump the message loop, so a
// mid-apply: iterating the live queue would dangle on the push_back, and // re-entrant switch can enqueue mid-apply: iterating the live queue would
// clearing it afterwards would discard whatever arrived during the apply. // dangle on the push_back, and clearing it afterwards would discard
// whatever arrived during the apply. The detach is correct either way; only
// the need for it is unconfirmed.
std::vector<FxParkIntent> take() { std::vector<FxParkIntent> take() {
std::vector<FxParkIntent> taken; std::vector<FxParkIntent> taken;
taken.swap(pending_); taken.swap(pending_);
@@ -109,6 +116,17 @@ struct PreParkFx {
// cancelled) means the caller reads the live chain instead. The restore's own // cancelled) means the caller reads the live chain instead. The restore's own
// keying travels with it so a slot-keyed snapshot lifted from a legacy // keying travels with it so a slot-keyed snapshot lifted from a legacy
// view_state does not silently become an identity-keyed one with no identities. // view_state does not silently become an identity-keyed one with no identities.
//
// FAITHFUL TO THE SNAPSHOT, NOT THE CHAIN. The ops describe the chain as it was
// at the ORIGINAL park; applyPark enumerates it again at drain time. So an FX
// added while the track was parked (a floating FX-chain window, ReaScript) is
// absent from this reconstruction yet IS offlined by the cancelling park's
// drain — and so never comes back online. Rare, and recoverable by hand in the
// FX chain, but specific to the deferral.
//
// `offline` is already boolean by the time it arrives: makeRestorePlan narrowed
// FxOfflineState's defensive int to FxOfflineOp's bool, so this widening back to
// int restores the type, not lost information.
inline PreParkFx preParkFxFromCancelledRestore(const std::vector<FxOfflineOp>& cancelled) { inline PreParkFx preParkFxFromCancelledRestore(const std::vector<FxOfflineOp>& cancelled) {
PreParkFx out; PreParkFx out;
if (cancelled.empty()) return out; if (cancelled.empty()) return out;
@@ -133,12 +151,29 @@ void deferFxRestore(ReaProject* proj, const std::string& guid, std::vector<FxOff
// dropped where the restore is planned (applyMode). Discards the queue unapplied // dropped where the restore is planned (applyMode). Discards the queue unapplied
// if the project it was enqueued against is no longer current (close / switch); // if the project it was enqueued against is no longer current (close / switch);
// an intent whose track is gone is pruned. Idle cost is one empty-queue test. // an intent whose track is gone is pruned. Idle cost is one empty-queue test.
// Re-entrant calls early-out: one drain at a time, the outer one owns the queue.
//
// Called on the idle tick AND synchronously before the view model is serialized
// (persistViewState, render_in_place). The second call is not an optimization:
// between a restore's synchronous flag writes and its drain the FX are still
// offline while the model has already dropped the snapshot that would replan
// them, so a save inside that window — deterministic under a custom action chain
// like "toggle mode; save project" — records offline FX beside a snapshot-free
// model, and nothing on reopen brings them back online. The cost is that an
// action-driven switch pays the FX hitch before it returns; its repaint and undo
// block have both closed by then, which is what the deferral was for.
void drainDeferredFxParks(); void drainDeferredFxParks();
// Drops every pending intent without applying it. Called when the model the // Drops every pending intent without applying it. Called when the model the
// intents were planned against has been replaced (project load/switch, undo/redo // intents were planned against has been replaced (project load/switch, undo/redo
// state restore) — applying them then would write the pre-reload plan over the // state restore) — applying them then would write the pre-reload plan over the
// project that replaced it. // project that replaced it.
//
// Not pure loss: the caller reapplies the active mode over the reloaded model
// immediately after, which re-plans a park for every inactive leaf, so parked FX
// converge on the following drain. The case that does NOT self-heal is a track
// whose reloaded model carries no snapshot — nothing plans a restore for it, so
// FX left offline stay offline.
void discardDeferredFxParks(); void discardDeferredFxParks();
} // namespace reasampler } // namespace reasampler
+50 -3
View File
@@ -8,6 +8,8 @@
#include "../src/shell/view/view_fx_park.h" #include "../src/shell/view/view_fx_park.h"
#include "core/view/view_mode_model.h" // makeRestorePlan — the ops' only producer
#include <cstdio> #include <cstdio>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -189,6 +191,48 @@ static void testSlotKeyedRestoreDoesNotBecomeIdentityKeyedWithNoIdentities() {
CHECK(fx.states.size() == 2 && fx.states[0].offline == 1 && fx.states[1].offline == 0); CHECK(fx.states.size() == 2 && fx.states[0].offline == 1 && fx.states[1].offline == 0);
} }
// -- the makeRestorePlan <-> preParkFxFromCancelledRestore round trip ---------
//
// The cancel path's whole premise is that a planned restore's ops are a LOSSLESS
// carrier of the snapshot's FX half. makeRestorePlan is their only producer, so
// the real claim is that the pair composes to the identity on (fxOffline,
// fxKeying). Asserting it against hand-built ops would let a change to
// makeRestorePlan's field mapping or op ordering pass with every test green.
static void testRestorePlanOpsRebuildTheIdentityKeyedSnapshotVerbatim() {
TrackSnapshot snap;
snap.fxKeying = FxKeying::Identity;
snap.fxOffline = {FxOfflineState{"{ONE}", 1}, FxOfflineState{"{TWO}", 0},
FxOfflineState{"{THREE}", 1}};
const TrackPlan plan = makeRestorePlan("{TRACK}", snap);
const PreParkFx rebuilt = preParkFxFromCancelledRestore(plan.fxOffline);
CHECK(rebuilt.keying == FxKeying::Identity);
// Three DISTINCT entries, compared as a sequence: a dropped fxGuid, a flipped
// offline, or a reordering each fail here.
CHECK(rebuilt.states == snap.fxOffline);
}
static void testRestorePlanOpsRebuildTheSlotKeyedSnapshotVerbatim() {
TrackSnapshot snap;
snap.fxKeying = FxKeying::Slot;
snap.fxOffline = {FxOfflineState{"", 0}, FxOfflineState{"", 1}, FxOfflineState{"", 1}};
const TrackPlan plan = makeRestorePlan("{TRACK}", snap);
// Slot keying addresses by POSITION, so the identity holds only while the op
// at index i carries slot i.
CHECK(plan.fxOffline.size() == 3);
CHECK(plan.fxOffline.size() == 3 && plan.fxOffline[0].slot == 0 &&
plan.fxOffline[1].slot == 1 && plan.fxOffline[2].slot == 2);
const PreParkFx rebuilt = preParkFxFromCancelledRestore(plan.fxOffline);
CHECK(rebuilt.keying == FxKeying::Slot);
CHECK(rebuilt.states == snap.fxOffline);
}
// -- re-entrancy ------------------------------------------------------------- // -- re-entrancy -------------------------------------------------------------
static void testTakeDetachesEverythingAndLeavesTheQueueEmpty() { static void testTakeDetachesEverythingAndLeavesTheQueueEmpty() {
@@ -205,9 +249,10 @@ static void testTakeDetachesEverythingAndLeavesTheQueueEmpty() {
} }
static void testIntentsArrivingDuringADrainSurviveIt() { static void testIntentsArrivingDuringADrainSurviveIt() {
// Applying an intent loads/unloads plugins, which pumps the message loop, so // [verify — DAW] applying an intent loads/unloads plugins, which is ASSUMED to
// a switch can re-enter and enqueue mid-drain. Those intents belong to the // pump the message loop, so a switch can re-enter and enqueue mid-drain. Those
// NEXT drain — the one in progress must neither see them nor discard them. // intents belong to the NEXT drain — the one in progress must neither see them
// nor discard them.
FxParkQueue q; FxParkQueue q;
q.park("{A}"); q.park("{A}");
@@ -250,6 +295,8 @@ int main() {
testCancelledRestoreOpsBecomeTheFreshSnapshotsFxHalf(); testCancelledRestoreOpsBecomeTheFreshSnapshotsFxHalf();
testNothingCancelledLeavesTheFxHalfToTheCaller(); testNothingCancelledLeavesTheFxHalfToTheCaller();
testSlotKeyedRestoreDoesNotBecomeIdentityKeyedWithNoIdentities(); testSlotKeyedRestoreDoesNotBecomeIdentityKeyedWithNoIdentities();
testRestorePlanOpsRebuildTheIdentityKeyedSnapshotVerbatim();
testRestorePlanOpsRebuildTheSlotKeyedSnapshotVerbatim();
testTakeDetachesEverythingAndLeavesTheQueueEmpty(); testTakeDetachesEverythingAndLeavesTheQueueEmpty();
testIntentsArrivingDuringADrainSurviveIt(); testIntentsArrivingDuringADrainSurviveIt();
testAReEntrantParkCancelsOnlyWhatIsStillPending(); testAReEntrantParkCancelsOnlyWhatIsStillPending();