Add temporary undo-span tracing for the mid-drain Ctrl-Z bug

One switch (REASAMPLER_UNDO_TRACE) gates every line. Traces span open/close
reasons, FX writes, UndoBlock depth, timer standoffs, and load-state hooks.
This commit is contained in:
2026-08-09 12:28:50 -04:00
parent d5ff476a19
commit b782fb2731
8 changed files with 167 additions and 21 deletions
+5 -1
View File
@@ -5,6 +5,7 @@
#include <utility>
#include "shell/undo/undo_trace.h" // TEMPORARY — see undo_trace.h; revert together
#include "shell/view/fx_reclaim.h" // fxReclaimEndUndoSpan — the span this must not nest over
#define REAPERAPI_MINIMAL
@@ -40,12 +41,13 @@ UndoBlock::UndoBlock(ReaProject* proj) : project_(proj) {
// outermost. Roll the depth back on any throw so the type degrades to "this one
// block failed to open," not "every block after it is now poisoned."
try {
if (g_depth++ == 0) fxReclaimEndUndoSpan();
if (g_depth++ == 0) fxReclaimEndUndoSpan("UndoBlock ctor");
Undo_BeginBlock2(proj);
} catch (...) {
--g_depth;
throw;
}
REASAMPLER_TRACE("[UndoBlock] CTOR proj=%s depth=%d", proj ? "real" : "active", g_depth);
}
UndoBlock::~UndoBlock() {
@@ -63,6 +65,8 @@ UndoBlock::~UndoBlock() {
if (!project_ || ValidatePtr2(nullptr, project_, "ReaProject*"))
Undo_EndBlock2(project_, label_.c_str(), mask_);
--g_depth;
REASAMPLER_TRACE("[UndoBlock] DTOR label=\"%s\" mask=%d depth=%d",
label_.empty() ? "(discard)" : label_.c_str(), mask_, g_depth);
}
void UndoBlock::recordPoint(std::string label, int mask) {
+54
View File
@@ -0,0 +1,54 @@
// See undo_trace.h. Compiled into the reaper_reasampler module; includes
// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). Isolated
// in its own TU so every call site needs only the trace-only header, not the REAPER
// ShowConsoleMsg want-define.
#include "shell/undo/undo_trace.h"
#if REASAMPLER_UNDO_TRACE
#include <chrono>
#include <cstdarg>
#include <cstdio>
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_ShowConsoleMsg
#include "reaper_plugin_functions.h"
namespace reasampler {
namespace {
unsigned long long g_seq = 0;
double startMs() {
static const double t0 = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
return t0;
}
double nowMs() {
return std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now().time_since_epoch())
.count() -
startMs();
}
} // namespace
void undoTrace(const char* fmt, ...) {
char body[256];
va_list args;
va_start(args, fmt);
std::vsnprintf(body, sizeof(body), fmt, args);
va_end(args);
char line[320];
std::snprintf(line, sizeof(line), "!SHOW:#%llu %.0fms %s\n", ++g_seq, nowMs(), body);
ShowConsoleMsg(line);
}
} // namespace reasampler
#endif // REASAMPLER_UNDO_TRACE
+29
View File
@@ -0,0 +1,29 @@
#pragma once
// TEMPORARY diagnostic tracing for the Design-View undo-span bug (many-undo-entries-on-
// Ctrl-Z-mid-drain). NOT for shipping. ONE switch below gates every trace line in the
// tree; flip REASAMPLER_UNDO_TRACE to 0, or delete this file pair plus the call sites
// that include it, to revert.
#define REASAMPLER_UNDO_TRACE 1
#if REASAMPLER_UNDO_TRACE
namespace reasampler {
// Emits one line via ShowConsoleMsg: "UT#<seq> <ms>ms <fmt...>\n", "!SHOW:"-prefixed so
// a trace point on a background tick never forces the console open. printf-style;
// sequence number and a monotonic millisecond timestamp are stamped here so lines sort
// and date themselves even pasted back out of order. Keep each `fmt` short — this has to
// read at a glance in REAPER's console.
void undoTrace(const char* fmt, ...);
} // namespace reasampler
#define REASAMPLER_TRACE(...) ::reasampler::undoTrace(__VA_ARGS__)
#else
#define REASAMPLER_TRACE(...) \
do { \
} while (0)
#endif
+43 -13
View File
@@ -12,6 +12,7 @@
#include "shell/persist/session.h" // flushViewState — the release's durable half
#include "shell/undo/undo_block.h" // undoBlockOpen — the reverse of the nesting rule
#include "shell/undo/undo_trace.h" // TEMPORARY — see undo_trace.h; revert together
#include "shell/view/view.h" // modeApplyInProgress, transportBlocksModeSwitch
#include "shell/view/view_park.h" // fxGuidString — the one FX-identity read
#include "shell/view/view_tracks.h"
@@ -46,11 +47,17 @@ UndoSpan g_span;
// unconditionally; the close reads the span's values before clearing it, because the
// clear has to precede the SDK call — nothing re-entered through Undo_EndBlock2 may
// find a span it could close a second time.
void openSpan(ReaProject* proj, const char* label, int mask) {
if (g_span.begin(proj, label, mask)) Undo_BeginBlock2(proj);
//
// `caller`/`reason` are TEMPORARY tracing plumbing only (see shell/undo/undo_trace.h) —
// they select nothing and gate nothing.
void openSpan(ReaProject* proj, const char* label, int mask, const char* caller) {
if (g_span.begin(proj, label, mask)) {
Undo_BeginBlock2(proj);
REASAMPLER_TRACE("[UndoSpan] OPEN caller=%s label=\"%s\" mask=%d", caller, label, mask);
}
}
void closeSpan() {
void closeSpan(const char* reason) {
if (!g_span.open()) return;
ReaProject* const proj = g_span.project();
const std::string label = g_span.label();
@@ -59,6 +66,8 @@ void closeSpan() {
// The span outlives its opener, so by here the project may be the one that just
// closed — its undo history went with it and there is no block left to end.
if (ValidatePtr2(nullptr, proj, "ReaProject*")) Undo_EndBlock2(proj, label.c_str(), mask);
REASAMPLER_TRACE("[UndoSpan] CLOSE reason=%s label=\"%s\" mask=%d",
reason, label.empty() ? "(discard)" : label.c_str(), mask);
}
// Monotonic and sub-millisecond: the drain's budget discriminates at 30ms, which
@@ -171,9 +180,12 @@ struct ReclaimHost {
// no-op under a switch's span, which is why the FX bit is added separately —
// here, at the first real write, so a switch whose parking never matured closes
// on the flags-and-items mask it opened with and serializes no FX state.
openSpan(project, kReclaimUndoLabel, UNDO_STATE_FX);
const bool spanWasOpenBeforeWrite = g_span.open();
openSpan(project, kReclaimUndoLabel, UNDO_STATE_FX, "drain");
g_span.widen(UNDO_STATE_FX);
TrackFX_SetOffline(tr, fxIndex, offline);
REASAMPLER_TRACE("[writeOffline] track=%s fx=%d offline=%d spanWasOpen=%d",
guid.c_str(), fxIndex, offline ? 1 : 0, spanWasOpenBeforeWrite ? 1 : 0);
return true;
}
@@ -241,10 +253,16 @@ bool fxReclaimDrainInProgress() { return g_draining; }
void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) {
// NO guard here may close the span: each names a caller that is still on the stack and
// still owns it — the outer drain, or the applyMode that opened it. All re-tick.
if (g_draining) return;
if (g_draining) {
REASAMPLER_TRACE("[Tick] STANDOFF guard=g_draining");
return;
}
// The model is mid-rewrite and its records are being consumed out from under any
// plan derived from it. The apply invalidates on its way out, so nothing is lost.
if (modeApplyInProgress()) return;
if (modeApplyInProgress()) {
REASAMPLER_TRACE("[Tick] STANDOFF guard=modeApplyInProgress");
return;
}
// The MIRROR of the rule that no block may open over the span: an extension undo
// block is open — a capture, a bank verb, an ingest — and its own message pump is how
// this tick arrived. openSpan from in there would begin at depth 2, and that block's
@@ -256,7 +274,10 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) {
// claim is this function's own control-flow order — this TU is REAPER-linked, off the
// pure fx_reclaim_tests target, so unlike the queue/span state machine above it, the
// ordering is DAW-verified, not unit-tested.
if (undoBlockOpen()) return;
if (undoBlockOpen()) {
REASAMPLER_TRACE("[Tick] STANDOFF guard=undoBlockOpen");
return;
}
DrainLatch latch;
// beginTick owns what a pin/project mismatch means. What is local to here: the model
@@ -270,7 +291,8 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) {
// rather than the queue's pin, which moves independently: a tab switch, a closed
// project, or no active project at all ends it where it stands rather than carrying
// it into another project's history — and the no-project arm below returns.
if (g_span.open() && g_span.project() != project) closeSpan();
if (g_span.open() && g_span.project() != project)
closeSpan(project ? "project retarget" : "no active project");
if (!g_queue.beginTick(project)) return;
// DERIVED, never persisted: recomputed from view state every time the queue was
@@ -284,19 +306,26 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) {
ReclaimHost host{project, &session};
const ReclaimRunResult run = g_queue.run(host);
reportDrops(project, run.drops);
if (spanEndsAfter(run)) closeSpan();
if (spanEndsAfter(run)) {
// Priority order for the trace label only, when more than one is true at once —
// does not affect which branch runs; spanEndsAfter already decided that.
const char* reason = run.aborted ? "drain aborted"
: run.transportDeferred ? "transport-deferred"
: "drain settled";
closeSpan(reason);
}
if (!run.aborted && run.settled) g_queue.goIdle();
}
void fxReclaimBeginUndoSpan(ReaProject* proj) {
openSpan(proj, "", 0);
openSpan(proj, "", 0, "action");
}
void fxReclaimLabelUndoSpan(const char* label, int mask) {
g_span.relabel(label ? label : "", mask);
}
void fxReclaimEndUndoSpan() { closeSpan(); }
void fxReclaimEndUndoSpan(const char* reason) { closeSpan(reason); }
void fxReclaimAbandonForStateLoad() {
// Close FIRST and unconditionally. The abandon below stops the drain and puts the loop
@@ -306,7 +335,8 @@ void fxReclaimAbandonForStateLoad() {
// confirmed case of that shape on the save side) — the span can never be open at depth
// > 0 in the first place: src/shell/view/CLAUDE.md's "NOTHING MAY OPEN AN UNDO BLOCK
// WHILE THE SPAN IS OPEN" invariant. closeSpan() just finds nothing to close there.
closeSpan();
REASAMPLER_TRACE("[abandon] spanOpen=%d", g_span.open() ? 1 : 0);
closeSpan("state load");
g_queue.abandon();
}
@@ -322,7 +352,7 @@ void fxReclaimFlushRelease(ReaSamplerSession& session) {
// absorb, and the arm is not what decides whether one is open. Same "depth > 0 never
// holds an open span" invariant as fxReclaimAbandonForStateLoad above makes this safe
// even when the save itself is issued from inside an extension UndoBlock.
closeSpan();
closeSpan("project save");
if (!g_releaseFlush.pendingFor(saving)) return;
// Promptless, unlike design_view_actions' persistViewState, which can open a modal
// Save-As. [verify — DAW] whether this write reaches the <EXTSTATE> block of the
+5 -1
View File
@@ -698,7 +698,11 @@ void fxReclaimLabelUndoSpan(const char* label, int mask);
// unload; and shell/undo's UndoBlock, at its OUTERMOST open. A leaked open block would
// swallow everything the user does afterwards, so nothing may return past a terminating
// condition without calling this.
void fxReclaimEndUndoSpan();
//
// `reason` is TEMPORARY tracing plumbing (see shell/undo/undo_trace.h) naming which
// terminating path called — it has no effect on behavior. Revert together with the rest
// of the undo-span tracing pass.
void fxReclaimEndUndoSpan(const char* reason);
// EVERY project-state load, undo-driven or not — Ctrl-Z, Ctrl-Shift-Z, a project open, a
// File > Revert, a new project. Ends the span, stops any drain on the stack, drops the
+1 -1
View File
@@ -126,7 +126,7 @@ ModeApplyResult applyModeImpl(ViewModeModel& model, const std::string& targetMod
// opens a block ends the span itself, first, at depth 0 — see this directory's
// CLAUDE.md. Both calls are idempotent; do not reintroduce either unconditionally.
if (realSwitch) {
fxReclaimEndUndoSpan();
fxReclaimEndUndoSpan("next apply supersedes");
// BEFORE the open, never after this function returns. The wakeup is what
// guarantees a tick, and a tick is the only thing that can close this span; a
// throw anywhere below would otherwise leave the block open with the reclaimer