Close the collapse's two dropped guarantees: latch applyMode against re-entrancy, pin its project once; correct three overclaiming doc lines

This commit is contained in:
2026-08-06 05:07:31 -04:00
parent 8c06383fe7
commit 2269fd80b6
8 changed files with 136 additions and 54 deletions
+46 -16
View File
@@ -26,6 +26,7 @@
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountTracks
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetPlayStateEx
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
@@ -37,6 +38,7 @@
#define REAPERAPI_WANT_TrackList_AdjustWindows
#define REAPERAPI_WANT_UpdateArrange
#define REAPERAPI_WANT_UpdateTimeline
#define REAPERAPI_WANT_ValidatePtr2
// Lane minting: item-side lane reads/writes assigning each item to its mode's lane.
#define REAPERAPI_WANT_CountTrackMediaItems
#define REAPERAPI_WANT_GetTrackMediaItem
@@ -85,6 +87,19 @@ struct UiRefreshHold {
UiRefreshHold& operator=(const UiRefreshHold&) = delete;
};
// applyMode is reachable from the load tick, the actions and render_in_place, and its
// TrackFX_SetOffline calls are assumed to pump the message loop — so any of them can
// re-enter it mid-apply, planning against the same model the outer loop is iterating.
// An inner restoreTrack consuming a snapshot the outer sits mid-park over leaves that
// track flags-restored, chain-offline and snapshotless: refused forever. Fail closed.
bool g_applying = false;
struct ApplyLatch {
ApplyLatch() { g_applying = true; }
~ApplyLatch() { g_applying = false; }
ApplyLatch(const ApplyLatch&) = delete;
ApplyLatch& operator=(const ApplyLatch&) = delete;
};
// C_LANESCOLLAPSED=2: render a tool-split track like a normal single-lane
// track showing only the playing lane (SDK: 1=collapsed, 2=hidden-lanes-exist
// but displays as non-fixed-lane).
@@ -147,11 +162,6 @@ MediaTrack* resolve(const TrackByGuid& byGuid, const std::string& guid) {
return it == byGuid.end() ? nullptr : it->second; // stale/deleted GUID — pruned by being skipped
}
// Managed-lane application: LanePlayOps are keyed by the lane's DURABLE name but
// C_LANEPLAYS:N by current ordinal, which renumbers on reorder — so every write
// re-resolves durable key -> ordinal first, and a lane whose name lacks the
// managed prefix never enters the map and so can never be driven.
// Lane `laneIdx`'s durable name (P_LANENAME:n) on `tr`, or empty if unnamed /
// unavailable (non-fixed-lane track).
std::string laneName(MediaTrack* tr, int laneIdx) {
@@ -231,9 +241,6 @@ LaneApplyResult applyLaneOps(const TrackByGuid& handleByGuid,
return out;
}
// Managed-lane minting: the DECISION (which tracks split, which lanes, which item
// goes where) is planLaneMinting's; this shell only reads, calls and applies.
// One pass, so the assign loop needs no per-item re-scan.
std::map<std::string, MediaItem*> itemHandlesByGuid(MediaTrack* tr) {
std::map<std::string, MediaItem*> byGuid;
@@ -367,11 +374,22 @@ bool transportBlocksModeSwitch(ReaProject* proj) {
return (GetPlayStateEx(proj) & kTransportMoving) != 0;
}
bool modeApplyInProgress() { return g_applying; }
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) {
if (g_applying) return false; // see ApplyLatch; same fail-closed shape as the guards below
ApplyLatch latch;
if (!model.modes().contains(targetModeId)) {
return false; // reject before touching the project — no partial apply
}
// Pinned ONCE: begin and end sit a whole apply apart with the pump premise between
// them, so resolving "current project" independently at each would let a tab switch
// mid-apply open the block on one project and close it on another.
ReaProject* const project = proj ? proj : EnumProjects(-1, nullptr, 0);
if (!ValidatePtr2(nullptr, project, "ReaProject*")) return false;
// The discriminator behind both halves of the contract in view.h. A gate placed
// unconditionally here would break tagging and the project-load reapply during
// playback; a solo round on every reapply would flicker the user's solo on every
@@ -379,13 +397,13 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
const std::string outgoingModeId = model.activeModeId();
const bool realSwitch = targetModeId != outgoingModeId;
if (realSwitch && transportBlocksModeSwitch(proj)) {
if (realSwitch && transportBlocksModeSwitch(project)) {
return false; // same fail-closed shape as the mode-exists guard above
}
TrackHandles handleByGuid;
std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid);
const TrackByGuid trackByGuid = indexHandles(handleByGuid);
std::vector<TrackFolderEntry> entries = readFolderEntries(project, handleByGuid);
TrackByGuid trackByGuid = indexHandles(handleByGuid);
FolderTree tree = buildFolderTree(entries);
// Prune snapshots for tracks no longer in the live enumeration before
@@ -417,7 +435,7 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
FxRestoreDrops fxDrops;
int fxDropTracks = 0;
Undo_BeginBlock2(proj);
Undo_BeginBlock2(project);
{
UiRefreshHold uiHold; // every write below lands with the TCP/MCP frozen
@@ -438,7 +456,7 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
// Re-validated per track, not once at resolve: offlining a plugin is
// ASSUMED to pump the message loop, so a track deleted during an
// earlier iteration's FX writes would leave this handle dangling.
if (!tr || !trackStillLive(proj, tr)) continue; // stale/deleted GUID — prune
if (!tr || !trackStillLive(project, tr)) continue; // stale/deleted GUID — prune
const TrackApplyResult r = parkTrack(model, guid, tr, tp.flags);
if (r.refused) { refusedParkNames.push_back(trackDisplayName(tr)); continue; }
@@ -450,7 +468,7 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
if (tp.flags.empty()) continue;
const std::string& guid = tp.flags.front().guid;
MediaTrack* tr = resolve(trackByGuid, guid);
if (!tr || !trackStillLive(proj, tr)) continue; // stale/deleted GUID — prune
if (!tr || !trackStillLive(project, tr)) continue; // stale/deleted GUID — prune
const TrackApplyResult r = restoreTrack(model, guid, tr, tp.fxOffline, tp.flags);
if (r.wrote) wrote = true;
@@ -458,6 +476,16 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
if (r.drops.total() > 0) { fxDrops.add(r.drops); ++fxDropTracks; }
}
// The FX writes above are the only thing here assumed to pump the message
// loop, so this is the one point a handle taken before them can have died.
// Re-validated for the whole enumeration rather than per consumer: lane ops,
// parent visibility and the solo replay all write through pre-FX handles.
for (auto& kv : handleByGuid) {
if (trackStillLive(project, kv.second)) continue;
trackByGuid.erase(kv.first);
kv.second = nullptr; // view_solo's writers already skip a null handle
}
// MANAGED LANES: the active mode's lane plays+shows, every other managed
// lane is silenced+hidden. Empty on a D1-only project — byte-identical there.
const LaneApplyResult lanes = applyLaneOps(trackByGuid, plan.lanes);
@@ -502,11 +530,11 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
if (laneModeChanged) UpdateTimeline();
const bool mint = applyMintsUndoPoint(realSwitch, wrote);
Undo_EndBlock2(proj, mint ? undoLabel.c_str() : "", mint ? undoMask : 0);
Undo_EndBlock2(project, mint ? undoLabel.c_str() : "", mint ? undoMask : 0);
// After the undo block and the UI hold: a report is not a project write, and
// it must not join what a Ctrl-Z rolls back.
reportRefusedParks(proj, refusedParkNames);
reportRefusedParks(project, refusedParkNames);
reportFxRestoreDrops(fxDrops, fxDropTracks);
return true;
}
@@ -524,6 +552,8 @@ bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) {
const TrackByGuid trackByGuid = indexHandles(handleByGuid);
// `proj` deliberately NOT pinned the way applyMode pins it: nothing between this
// begin and its end pumps the message loop, so no tab switch can land between them.
Undo_BeginBlock2(proj);
const bool changed = applyMintPlan(model, plan, trackByGuid);