fix: a recompute that renumbered its ring, and readouts frozen under a zoom

Three defects, all of them a window describing data that is not there.

A math edit refilled the derived ring with `clear()`, which renumbers it
from 0, leaving `produced()` short by the inputs' retained base. Since
`live_window_locked()` clamps against every channel's `produced()`, that
one lagging channel answered `n = 0` for the live view, every pause view
and every trigger capture until a whole ring of fresh data had gone by --
the reported freeze, invisible until the ring first wraps, hence its
intermittence. It now restarts with `clear_to(base)`, and
`ChannelBuffer::republish_from(base)` puts the published window back:
`acquire()` is incremental, so a rewrite in place published nothing and
the pyramid went on describing the samples the edit replaced.

The trigger's channel is an index, re-resolved by name after a renumbering
-- but only in `rebuild_enabled_locked()`, so a math add or delete went
through `set_math_channels()` with no restore, the index was clamped to
-1, and `process()` returned at once while the panel still named the
channel. The restore moves into `rebuild_channels()`, which *is* the
renumbering, so no caller can forget it.

And `update_measures()` suppressed its "the window moved" test while an
event was held -- the one mode where a gesture is the only thing that can
move it. Zooming a latched event left the pane describing the whole
captured event. A pinned window needs no exemption: it lands on the range
it already measured and fails the 2 % test by itself.

The checks that missed all three: `UDPSCOPE_PROBE_MATHWRAP_MS` edits a
graph whose ring has already wrapped, which `math_probe` structurally
cannot reach; `[measure-trace]` now prints the mode, the window on screen
and the window the numbers describe; and the dump's `marks_in_window`
counts against the window on screen instead of against the one the marks
were computed over, where it was a tautology.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-15 16:30:48 +02:00
1 parent 074cb611bc
commit d91957a2fe
15 files changed
+374 -45

No files matched your search

+37 -18
View File
@@ -449,6 +449,14 @@ void ScopeModel::recompute_math_locked(const std::vector<bool>& affected) {
const std::size_t slots = static_cast<std::size_t>(store.time().size());
if (slots == 0) return;
// The refill replays the inputs' *retained* window, which starts at this absolute index and not at
// zero, so an emptied derived ring has to resume its count there: `clear()` would renumber it from 0
// and leave its `produced()` short by `base` for a whole ring's worth of fresh data. `live_window_locked()`
// clamps the window against every channel's `produced()`, so that one lagging channel collapses the
// live view, every pause view and every trigger capture to nothing -- the "acquisition freezes after a
// math edit" defect, invisible until the ring has wrapped once (`base == 0` before that).
const std::size_t base = static_cast<std::size_t>(store.time().base());
std::vector<bool> derived(channels_.size(), false);
for (std::size_t m = 0; m < math_.size(); ++m) {
const int channel = m < math_channel_.size() ? math_channel_[m] : -1;
@@ -456,14 +464,18 @@ void ScopeModel::recompute_math_locked(const std::vector<bool>& affected) {
derived[static_cast<std::size_t>(channel)] = true;
// Only the affected channels start again. The rest keep their samples *and* their runtimes: their graphs
// are untouched, so the state they hold is the answer to the inputs they have always had.
if (affected[m]) store.values(static_cast<std::size_t>(channel)).clear();
if (!affected[m]) continue;
store.values(static_cast<std::size_t>(channel)).clear_to(base);
// And the published window with them: this is a rewrite in place, so `produced()` lands back where it
// started and the incremental `acquire()` would publish nothing at all -- leaving the pyramid, which is
// what the renderer and every measurement read, describing the samples the edit just replaced.
store.channel(static_cast<std::size_t>(channel)).republish_from(base);
}
// One bulk copy per channel per chunk, through the ring's own reader. A derived channel is fed a zero:
// `math_apply_locked()` overwrites its value as it walks the list in order, and reading its half-rebuilt
// ring here would read out of range.
constexpr std::size_t kChunk = 1u << 14;
const std::size_t base = static_cast<std::size_t>(store.time().base());
std::vector<std::vector<f32>> inputs(channels_.size());
for (std::size_t c = 0; c < channels_.size(); ++c) {
if (!derived[c]) inputs[c].resize(kChunk);
@@ -482,10 +494,22 @@ void ScopeModel::recompute_math_locked(const std::vector<bool>& affected) {
math_apply_locked(slot, &affected, /*timed=*/false);
}
}
// The pyramid is the consumer's and the tick publishes it; the samples are written here.
// The pyramid is the consumer's and the tick publishes it (`republish_from()` above put its window
// back at `base` so that publish is one window's worth of work); the samples are written here.
}
void ScopeModel::rebuild_channels() {
// The trigger names a channel by its display name, which is stable across the renumbering this
// function *is*; the index is not. Captured and restored here rather than at the call sites
// because every caller renumbers and every caller needs it: adding a math channel appends a
// signal and shifts nothing, but removing one, or a CONFIG that reorders the stream's own
// signals, moves every index after it. `rebuild_locked()` then only clamps an out-of-range
// index to -1, and `TriggerEngine::process()` returns immediately on a channel of -1 -- so the
// panel went on naming the channel while nothing could ever fire again.
const std::string trigger_name =
(trigger_.channel >= 0 && trigger_.channel < static_cast<int>(channels_.size()))
? channels_[static_cast<std::size_t>(trigger_.channel)].name
: std::string();
channels_.clear();
// Which channel each math channel ended up with, by name: a disabled one has none, and the
// producer must not push a derived sample into another channel's ring.
@@ -524,6 +548,15 @@ void ScopeModel::rebuild_channels() {
}
channels_.push_back(std::move(ch));
}
// A name that is no longer a channel -- its signal was disabled, or the stream stopped
// declaring it -- leaves the trigger unassigned, which is a wait rather than a silent
// mis-aim; re-enabling the signal brings it back here.
trigger_.channel = -1;
if (trigger_name.empty()) return;
for (std::size_t i = 0; i < channels_.size(); ++i) {
if (channels_[i].name == trigger_name) trigger_.channel = static_cast<int>(i);
}
}
void ScopeModel::rebuild_locked() {
@@ -639,22 +672,8 @@ bool ScopeModel::enable_locked(std::size_t signal, bool on) {
}
void ScopeModel::rebuild_enabled_locked() {
// The trigger names a channel by its display name, which is stable across
// renumbering: when its own signal is disabled the channel vanishes (the trigger
// then waits, and comes back when the signal is re-enabled). Read before
// `rebuild_channels()`, which is what renumbers.
const int old_trigger = trigger_.channel;
const std::string trigger_name =
(old_trigger >= 0 && old_trigger < static_cast<int>(channels_.size()))
? channels_[static_cast<std::size_t>(old_trigger)].name
: std::string();
// `rebuild_channels()` carries the trigger's channel across the renumbering by name.
rebuild_channels();
trigger_.channel = -1;
if (!trigger_name.empty()) {
for (std::size_t i = 0; i < channels_.size(); ++i) {
if (channels_[i].name == trigger_name) trigger_.channel = static_cast<int>(i);
}
}
rebuild_locked();
generation_.fetch_add(1, std::memory_order_release);
}