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

+122
View File
@@ -1170,6 +1170,122 @@ gboolean math_probe(gpointer user) {
return G_SOURCE_REMOVE;
}
// The one state `math_probe` structurally cannot reach: an edit of a graph whose ring has already
// *wrapped*. Every edit that probe makes follows a structural change, which replans the buffer and
// restarts every ring at absolute 0 -- and at `base == 0` the recompute's two renumbering bugs are
// both identities. Run it with a small `--buffer-mb` and give it time to saturate; `base=0` in its
// second line means the run was too short or the buffer too large to have read anything.
int g_mathwrap_index = -1;
double g_mathwrap_y_before = 0.0;
int g_mathwrap_polls = 0;
gboolean mathwrap_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || g_mathwrap_index < 0) return G_SOURCE_REMOVE;
u64 produced = 0;
u64 slots = 0;
plot::Range y{0.0, 0.0};
{
std::lock_guard<std::mutex> lock(a->model->mutex());
const std::size_t idx = static_cast<std::size_t>(g_mathwrap_index);
if (idx >= a->model->channel_count()) return G_SOURCE_REMOVE;
produced = a->model->live().values(idx).produced();
slots = a->model->live().time().produced();
y = a->model->live().channel(idx).y_bounds();
}
// Two readings, one per half of the recompute. `produced` against `slots` is the ring's absolute
// count: short by `base` is a channel the live window clamps everything else down to, which is the
// reported freeze. `y` is the *pyramid's*, so it reads the published window rather than the samples,
// and the edit **shrinks** the gain (2 -> 0.5) for that reading to exist at all: a pyramid that was
// not republished still describes the replaced +-2 samples, and a window holding both would quote
// the larger -- so a growing gain reads correct either way.
std::fprintf(stderr,
"[mathwrap-probe] after edit: produced=%llu slots=%llu (want equal) y=[%.4f,%.4f] "
"(want +-0.5, was +-%.4f)\n",
static_cast<unsigned long long>(produced), static_cast<unsigned long long>(slots),
y.min, y.max, g_mathwrap_y_before);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean mathwrap_probe_wait(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->math.empty()) return G_SOURCE_REMOVE;
u64 base = 0;
u64 slots = 0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
base = a->model->live().time().base();
slots = a->model->live().time().produced();
const std::size_t idx = static_cast<std::size_t>(g_mathwrap_index);
if (g_mathwrap_index >= 0 && idx < a->model->channel_count()) {
g_mathwrap_y_before = a->model->live().channel(idx).y_bounds().max;
}
}
// Polled rather than timed: the wrap is a function of the rate and of `--buffer-mb`, and a fixed
// delay that fell short would print a passing line having tested nothing.
if (base == 0 && ++g_mathwrap_polls < 200) return G_SOURCE_CONTINUE;
std::fprintf(stderr, "[mathwrap-probe] wrapped: base=%llu slots=%llu (want base > 0)\n",
static_cast<unsigned long long>(base), static_cast<unsigned long long>(slots));
std::fflush(stderr);
// The edit itself: the *same* channel list with one node's gain changed, which is the in-place
// path -- `set_math_channels()` replans only when the names or the enables moved.
for (MathNode& n : a->math.front().nodes) {
if (n.op == MathOp::Gain) n.gain = 0.5;
}
apply_math_channels(*a);
// After a tick, because the pyramid half of the reading is published by one.
g_timeout_add(300, mathwrap_probe_report, a);
return G_SOURCE_REMOVE;
}
gboolean mathwrap_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// Its own signal and its own graph: a probe must not depend on another's side effects, and this one
// needs a source whose extremes are known (a unit sine doubled reads +-2) for the pyramid reading.
{
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == "sine") a->model->set_signal_enabled(i, true);
}
}
std::string name;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
if (a->model->channels().empty()) return G_SOURCE_REMOVE;
name = a->model->channels().front().name;
}
MathChannel gain;
gain.name = "math wrap";
gain.enabled = true;
// Field by field: a partial brace-initialiser would add a fourth build warning, and the baseline
// is exactly three.
MathNode src;
src.op = MathOp::Source;
src.source = name;
MathNode op;
op.op = MathOp::Gain;
op.in_a = 0;
op.gain = 2.0;
gain.nodes = {src, op};
gain.output = 1;
a->math.assign(1, gain);
apply_math_channels(*a);
{
std::lock_guard<std::mutex> lock(a->model->mutex());
for (std::size_t c = 0; c < a->model->channels().size(); ++c) {
if (a->model->channels()[c].name == gain.name) g_mathwrap_index = static_cast<int>(c);
}
}
std::fprintf(stderr, "[mathwrap-probe] installed 'math wrap' on '%s': channel=%d\n", name.c_str(),
g_mathwrap_index);
std::fflush(stderr);
if (g_mathwrap_index < 0) return G_SOURCE_REMOVE;
g_timeout_add(100, mathwrap_probe_wait, a);
return G_SOURCE_REMOVE;
}
gboolean measure_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
@@ -2768,6 +2884,12 @@ gboolean measures_probe_step(gpointer user) {
rebuild_measures_pane(*a);
std::fprintf(stderr, "[measures-probe] added %zu measurements on '%s'\n", a->measures.size(),
signal.c_str());
// This is the only probe set that asks for every type, and so the only one that places a
// *time* mark: `pulse duration`, `period` and `frequency` mark the crossings they were read
// from, and those are the marks `marks_in_window` counts. Without the trace the run reports
// the count with nothing to attribute it to -- which is how a window that lagged the screen
// read `0/6` with no way to tell a stale recompute from a mark placed correctly.
g_timeout_add(250, measure_trace, a);
measures_probe_next(*a, 1, 400);
break;
}