fix: a clock glitch is bridged and a channel is known by name, so a capture keeps its t0

Two defects behind one report ("a trigger during a capture overrides the first,
and the hold-off does not apply"); the second is also what made a spike in one
channel look like the acquisition resetting.

The trigger engine keyed its invalidation on the trigger channel's *index*, which
every rebuild of the channel list renumbers -- enabling a signal that sorts before
it is enough. Any rebuild therefore abandoned an open capture and set no hold-off,
so the next edge inside the post window became t0. It keys on the channel's name
now, which changes only when the source genuinely does.

A packet whose stamps jump more than 256 packet spans ahead is now rejected as a
fault and *bridged* at the declared cadence, instead of poisoning the monotonic
mark (which pinned every later packet flat at the glitched value) and condemning
the next good packet. Only four consecutive failures abandon the declared time
signal, and that fallback no longer wipes the rings: the arrival clock is anchored
to continue from the monotonic mark, so the history is still on one timeline --
the wipe is what made one glitching packet look like the stream restarting from 0.
The declared unit is resolved before those checks, since they weigh the stamps
against a cadence in seconds.

Guards: UDPSCOPE_PROBE_TRIGRENUM_MS prints t0_held=1 through the real widgets,
and the mock's --time-glitch-at/--time-glitch-by injects the clock fault.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-07 10:29:05 +02:00
1 parent 4003084648
commit f179fbbb44
13 files changed
+406 -96

No files matched your search

+93 -49
View File
@@ -208,47 +208,98 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
}
}
// A stream-time signal may be declared in a time unit rather than seconds ('ns' is
// the common one). The scale has to be known *here*, before the checks below, because
// they weigh the stamps' own spacing against the declared cadence, which is in
// seconds: on a 'ns' stream an unscaled comparison is off by a factor of 1e9, so
// every packet would read as an implausible jump.
double unit_scale = 1.0;
if (time_signal >= 0 && time_signal < static_cast<int>(model_.signals().size())) {
const std::string& unit = model_.signals()[static_cast<std::size_t>(time_signal)].unit;
if (unit == "ns") unit_scale = 1e-9;
else if (unit == "us" || unit == "\u00b5s" || unit == "\xce\xbc" "s") unit_scale = 1e-6;
else if (unit == "ms") unit_scale = 1e-3;
}
if (used_stream_time && n > 0) {
// One sample's worth of the time signal's *own* unit, which is what a synthesised
// continuation has to be spaced by and what a jump is measured against.
const double step = cadence / unit_scale;
// How far a packet's stamps may run ahead of the previous packet's last stamp
// before the jump is read as a fault in the producer's clock rather than a gap in
// the stream. Counted in packets, because that is what a loss burst costs: 256
// packet spans in one go is an outage, not loss. Letting a real glitch through is
// unrecoverable -- the monotonic clamp below then pins every later packet flat at
// the glitched value, so the whole stream freezes at one x.
constexpr double kGlitchPackets = 256.0;
// How many packets in a row must fail before the declared time signal is abandoned
// for the arrival clock. A glitch is one packet; a signal that is not a time base
// at all fails every one of them.
constexpr unsigned kFailureLimit = 4;
// A time signal that does not actually vary -- a vector time signal, whose
// values the client reads one element at a time, or a producer that leaves it
// zero -- would put every sample at one x and collapse the axis to a point.
// The arrival estimate is a worse clock but a usable one; a degenerate one is
// not a time base at all.
bool usable = std::isfinite(stamps_[0]);
const char* fault = nullptr;
double lo = stamps_[0];
double hi = stamps_[0];
for (std::uint32_t k = 1; usable && k < n; ++k) {
if (!std::isfinite(stamps_[0])) fault = "carries a stamp that is not finite";
for (std::uint32_t k = 1; fault == nullptr && k < n; ++k) {
if (!std::isfinite(stamps_[k])) {
usable = false;
fault = "carries a stamp that is not finite";
break;
}
lo = std::min(lo, stamps_[k]);
hi = std::max(hi, stamps_[k]);
}
if (usable && hi > lo) {
// The stamps must also ADVANCE across packets. A producer that restarts its
// stamps every packet (or a signal mislabelled as time) keeps handing back
// the same values; the max-clamp would then pin every sample at one x, and
// the trigger's hold-off clock with it -- the engine waited forever and no
// second event ever fired.
if (have_stream_anchor_ && !(stamps_[n - 1] > last_stream_anchor_)) {
usable = false;
} else {
last_stream_anchor_ = stamps_[n - 1];
have_stream_anchor_ = true;
if (fault == nullptr && !(hi > lo)) fault = "carries no span (every stamp is equal)";
if (fault == nullptr && have_stream_anchor_) {
// The stamps must also ADVANCE across packets, and by a plausible amount. A
// producer that restarts its stamps every packet (or a signal mislabelled as
// time) keeps handing back the same values; the max-clamp would then pin every
// sample at one x, and the trigger's hold-off clock with it -- the engine waited
// forever and no second event ever fired.
if (!(stamps_[n - 1] > last_stream_anchor_)) {
fault = "does not advance across packets";
} else if (stamps_[0] - last_stream_anchor_ > kGlitchPackets * double(n) * step) {
fault = "jumped implausibly far ahead of the previous packet";
}
}
if (!usable || !(hi > lo)) {
used_stream_time = false; // no span to time with; fall back to arrival
if (fault == nullptr) {
stamp_failures_ = 0;
last_stream_anchor_ = stamps_[n - 1];
have_stream_anchor_ = true;
} else if (have_stream_anchor_ && ++stamp_failures_ < kFailureLimit) {
// A short run of bad packets is bridged rather than believed: the stamps are
// replaced by a continuation of the accepted timeline at the declared cadence,
// so neither the monotonic mark nor the anchor is poisoned, and the next good
// packet -- which carries on from where the true timeline was -- is accepted
// normally. Believing one glitching packet instead is what condemned the *next*,
// perfectly good one and threw the whole stream onto the arrival clock.
const double from = last_stream_anchor_;
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = from + double(k + 1) * step;
last_stream_anchor_ = stamps_[n - 1];
static bool bridged = false;
if (!bridged) {
bridged = true;
std::fprintf(stderr,
"[udpscope] warning: the stream's time signal %s (%g..%g against a "
"previous %g); bridging that packet at the declared cadence and "
"keeping the stream clock.\n",
fault, lo, hi, from);
}
} else {
used_stream_time = false; // not a time base; fall back to arrival
static bool warned = false;
if (!warned) {
warned = true;
std::fprintf(stderr,
"[udpscope] warning: the stream's time signal carries no usable span "
"(%g..%g) or does not advance across packets; timing samples by "
"arrival instead. Check the time signal's mode and index in the "
"CONFIG dump above.\n",
lo, hi);
"[udpscope] warning: the stream's time signal %s (%g..%g); timing "
"samples by arrival instead. Check the time signal's mode and index "
"in the CONFIG dump above.\n",
fault, lo, hi);
}
}
}
@@ -306,33 +357,25 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
if (time_source_ == 0) {
time_source_ = used_stream_time ? 1 : 2;
} else if (time_source_ == 1 && !used_stream_time) {
// The declared time signal failed its checks: keep the arrival clock from here
// on, discard the stamps already recorded on the old timeline, and make the
// arrival clock continue from where the mark is, so the axis has no step.
// The declared time signal failed its checks for good: keep the arrival clock from
// here on, anchored so that it continues from the monotonic mark. The two timelines
// then share a zero *by construction*, which is the whole point of deriving the
// epoch from `last_stamp_` -- so the recorded history is still on one timeline and
// is kept. It used to be wiped here, on the reasoning that the old stamps belonged
// to a dead clock; they do not, and the wipe is what made a single glitching packet
// look like the stream restarting from zero.
time_source_ = 2;
const double end = frame->recv_time;
arrival_epoch_ = end - (last_stamp_ + model_.epoch());
arrival_prev_ = end;
last_stamp_ = 0.0;
history_reset_pending_ = true;
}
// A stream-time signal may be declared in a time unit rather than seconds
// ('ns' is the common one): scale it to seconds so the x axis is in seconds
// either way. The scale applies ONLY to stream time: the arrival fallback
// already produces seconds off the socket clock -- scaling those by the time
// signal's unit shrank the whole time base by 1e9 on 'ns' streams, which
// froze the trigger's hold-off clock and pinned the axis at ~0.
if (used_stream_time && time_signal >= 0 &&
time_signal < static_cast<int>(model_.signals().size())) {
double unit_scale = 1.0;
const std::string& unit = model_.signals()[static_cast<std::size_t>(time_signal)].unit;
if (unit == "ns") unit_scale = 1e-9;
else if (unit == "us" || unit == "\u00b5s" || unit == "\xce\xbc" "s") unit_scale = 1e-6;
else if (unit == "ms") unit_scale = 1e-3;
if (unit_scale != 1.0) {
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] *= unit_scale;
}
// The declared unit, applied last so the x axis is in seconds either way. It applies
// ONLY to stream time: the arrival fallback already produces seconds off the socket
// clock -- scaling those by the time signal's unit shrank the whole time base by 1e9
// on 'ns' streams, which froze the trigger's hold-off clock and pinned the axis at ~0.
if (used_stream_time && unit_scale != 1.0) {
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] *= unit_scale;
}
return n;
@@ -371,12 +414,6 @@ void Acquisition::on_data(const udps_frame_t* frame) {
}
const std::uint32_t n = build_stamps(frame, time_signal);
if (n == 0) return;
// A change of time source invalidated the recorded history: empty the rings before
// anything more goes in, so no reader can find two timelines under one index.
if (history_reset_pending_) {
history_reset_pending_ = false;
model_.reset_live_history_locked(); // this thread holds the model lock
}
if (model_.epoch() == 0.0) {
model_.set_epoch_if_unset(stamps_[0]);
@@ -427,7 +464,14 @@ void Acquisition::on_data(const udps_frame_t* frame) {
const double post_want = tc.post_span_s(event_span);
const double oldest_s =
store.time().size() > 0 ? double(store.time()[0]) : last_stamp_;
engine.configure(tc, post_want, event_span - post_want, oldest_s);
// The channel's *name* goes with it, because that is what identifies it across a
// rebuild of the channel list (see `TriggerEngine::configure`).
static const std::string kNoTriggerChannel;
const std::string& trig_name =
trig_channel >= 0 && trig_channel < static_cast<int>(channels.size())
? channels[static_cast<std::size_t>(trig_channel)].name
: kNoTriggerChannel;
engine.configure(tc, trig_name, post_want, event_span - post_want, oldest_s);
// The engine's armed state follows the panel's, both ways: without the disarm
// half, Disarm left the engine firing (its own `armed_` was only ever set), and
// re-entering trigger mode kept the stale prev/edge/hold-off state.
+5 -3
View File
@@ -70,6 +70,11 @@ class Acquisition {
/// included -- so the stream source is dropped in favour of arrival time.
double last_stream_anchor_ = 0.0;
bool have_stream_anchor_ = false;
/// Consecutive packets whose stream-time stamps failed those checks. A single bad
/// packet is a glitch in the producer's clock and is bridged at the declared cadence;
/// only a run of them means the declared signal is not a time base, and only then is
/// the arrival clock taken. Any accepted packet resets it.
unsigned stamp_failures_ = 0;
/// Arrival-clock origin: the first packet's receive time, so arrival stamps are
/// small seconds (the time ring stores f32, which cannot hold Unix epochs).
double arrival_epoch_ = 0.0;
@@ -85,9 +90,6 @@ class Acquisition {
/// signal carrying volt-like values, others from the arrival clock -- which is a
/// time base no reader can make sense of (see `build_stamps`).
int time_source_ = 0;
/// Set when the source changed under the recorded history: the rings have to be
/// emptied before more samples go in.
bool history_reset_pending_ = false;
std::vector<double> stamps_;
/// The decimation filter's state: a running sum per channel (and one for the shared
/// clock) over the group of base samples that becomes one stored sample. A boxcar
-14
View File
@@ -501,20 +501,6 @@ void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& l
if (last < first) last = first;
}
void ScopeModel::reset_live_history() {
std::lock_guard<std::mutex> lock(mutex_);
reset_live_history_locked();
}
void ScopeModel::reset_live_history_locked() {
BufferStore& src = live();
src.time().clear();
for (std::size_t c = 0; c < channels_.size(); ++c) {
src.values(c).clear();
src.channel(c).invalidate();
}
}
void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
// Timed from before the lock: the wait for it is part of what freezing a view costs the thread
// doing it, and this one is the UI's.
-14
View File
@@ -285,20 +285,6 @@ class ScopeModel {
/// is armed.
BufferStore& live();
/// Forgets the live buffer's history: every ring is emptied and every channel's
/// published window is dropped, so the two stay index-aligned. Used when the
/// acquisition has to change time source (see `Acquisition::build_stamps`): the
/// stamps already recorded are on the old timeline, and a ring holding two
/// timelines is not a time base at all -- the x axis, the crosshair and an export
/// all read it by index.
///
/// Takes `mutex()` itself: call it from the GTK thread. The acquisition thread
/// calls `reset_live_history_locked()` instead, already holding the lock (the
/// same split `publish_event()` follows).
void reset_live_history();
/// The body of the above for a caller that already holds `mutex()`.
void reset_live_history_locked();
/// Builds the captured event from the slice of the live buffer covering
/// `[edge - pre_s, edge + post_s]` in stream seconds, and publishes it atomically.
/// The stamps are re-based so the edge sits at x = 0. The plot binds to this
+4 -3
View File
@@ -12,9 +12,10 @@ double TriggerConfig::post_span_s(double buffer_span_s) const {
return buffer_span_s * (1.0 - pre);
}
void TriggerEngine::configure(const TriggerConfig& cfg, double post_seconds,
double pre_seconds, double oldest_s) {
const bool channel_changed = cfg.channel != cfg_.channel;
void TriggerEngine::configure(const TriggerConfig& cfg, const std::string& channel_name,
double post_seconds, double pre_seconds, double oldest_s) {
const bool channel_changed = channel_name != channel_name_;
if (channel_changed) channel_name_ = channel_name;
cfg_ = cfg;
post_seconds_ = post_seconds > 0.0 ? post_seconds : 0.0;
pre_seconds_ = pre_seconds > 0.0 ? pre_seconds : 0.0;
+14 -3
View File
@@ -4,6 +4,7 @@
#pragma once
#include <cstdint>
#include <string>
namespace udpscope {
@@ -39,7 +40,16 @@ struct TriggerConfig {
class TriggerEngine {
public:
/// Configures the channel and the post-trigger window. Resets the state when the
/// channel changes.
/// trigger moves to a different channel.
///
/// `channel_name` is that channel's name, and it -- not `cfg.channel` -- is what
/// identifies it here. The index is not stable: every rebuild of the channel list
/// renumbers it (enabling a signal that sorts before the trigger's own is enough),
/// and the model re-resolves the trigger by name afterwards. Keying the reset on the
/// index therefore made any such rebuild abandon an open capture *and set no
/// hold-off*, so the next edge inside the post window became t0 -- which is the
/// "re-triggers mid-capture and ignores the hold-off" defect. Choosing a genuinely
/// different source still resets, because the name changes with it.
///
/// `post_seconds` is the post-trigger window in **stream seconds**, the same clock
/// `process()` is fed and the same clock the x axis reads, so the capture covers
@@ -57,8 +67,8 @@ class TriggerEngine {
/// event cannot latch onto a half-filled pre window. `oldest_s` has to be the real
/// buffer's, not a timer since arming -- the ring keeps filling with the trigger
/// off, so the history is usually already there and waiting would be wrong.
void configure(const TriggerConfig& cfg, double post_seconds, double pre_seconds,
double oldest_s);
void configure(const TriggerConfig& cfg, const std::string& channel_name,
double post_seconds, double pre_seconds, double oldest_s);
/// Disarms without forgetting the configuration.
void disarm() noexcept { armed_ = false; pending_ = false; }
void arm(double now_s) noexcept;
@@ -95,6 +105,7 @@ class TriggerEngine {
private:
TriggerConfig cfg_{};
std::string channel_name_; ///< the trigger channel's stable identity (see `configure`).
bool armed_ = false;
bool have_prev_ = false;
double prev_ = 0.0;
+96
View File
@@ -8186,6 +8186,99 @@ gboolean arm_probe(gpointer user) {
return G_SOURCE_REMOVE;
}
/// The capture the renumber probe opened: the edge it opened on, and the latch count
/// before it, so step 3 can tell a new latch from the one already on screen.
double g_trigrenum_edge = 0.0;
std::uint64_t g_trigrenum_latches = 0;
/// Headless self-check, step 3: waits for the capture to latch and reports whether the
/// edge it latched on is still the one it opened on.
///
/// `t0_held=1` is the reading. With the engine's invalidation keyed on the trigger
/// channel's *index* rather than its name, step 2's renumber abandoned the capture and
/// set no hold-off, so the next edge inside the post window became t0 and this read 0.
gboolean trigrenum_probe_step3(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (a->model->trigger_latches().load(std::memory_order_relaxed) == g_trigrenum_latches)
return G_SOURCE_CONTINUE;
const double latched = a->model->trigger_time().load(std::memory_order_relaxed);
std::fprintf(stderr, "[trigrenum-probe] latched t0=%.4f opened on %.4f t0_held=%d\n", latched,
g_trigrenum_edge, int(std::fabs(latched - g_trigrenum_edge) < 1.0e-6));
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// Headless self-check, step 2: the moment a capture is open, enables a signal that sorts
/// *before* the trigger's own -- which is what renumbers the trigger channel under the
/// engine. Polls rather than times, so it lands inside the post window whatever the rate.
gboolean trigrenum_probe_step2(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (!a->model->trigger_pending()) return G_SOURCE_CONTINUE;
g_trigrenum_edge = a->model->capture_edge();
g_trigrenum_latches = a->model->trigger_latches().load(std::memory_order_relaxed);
int ch = -1;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
ch = a->model->trigger().channel;
}
std::fprintf(stderr, "[trigrenum-probe] capture open: t0=%.4f ch=%d -- enabling signal 1 now\n",
g_trigrenum_edge, ch);
std::fflush(stderr);
a->model->set_signal_enabled(1, true);
{
std::lock_guard<std::mutex> lock(a->model->mutex());
ch = a->model->trigger().channel;
}
std::fprintf(stderr, "[trigrenum-probe] after enable: trigger ch=%d channels=%zu\n", ch,
a->model->channel_count());
std::fflush(stderr);
g_timeout_add(25, trigrenum_probe_step3, a);
return G_SOURCE_REMOVE;
}
/// Headless self-check, step 1: the reported geometry, driven through the real widgets --
/// 1 s window, 10 % pre share, hold-off from `--holdoff` -- on the *square* alone, so that
/// enabling the sine later moves the trigger channel's index.
gboolean trigrenum_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->window_entry == nullptr) return G_SOURCE_REMOVE;
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->window_unit_drop), 2);
gtk_editable_set_text(GTK_EDITABLE(a->window_entry), "1");
on_window_entry_activate(GTK_ENTRY(a->window_entry), a);
// Signal 2 is the mock's square; signal 1 is its sine. Only the square to begin with.
a->model->set_signal_enabled(1, false);
a->model->set_signal_enabled(2, true);
a->model->set_signal_enabled(3, false);
if (a->model->channel_count() > 0) {
const ChannelInfo& c0 = a->model->channels()[0];
a->rows->assign(0, 0, c0.name, c0.name,
signal_color_index(a->model->signals(), c0.signal));
a->relayout_pending = true;
}
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_pre_spin), 10.0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin), 0.0);
publish_trigger_fields(a);
if (a->trigger_toggle != nullptr)
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
{
std::lock_guard<std::mutex> lock(a->model->mutex());
const TriggerConfig& tc = a->model->trigger();
std::fprintf(stderr,
"[trigrenum-probe] window=%.4f event_span=%.4f pre_pct=%.1f post=%.4f "
"holdoff=%.4f level=%.4g edge=%d mode=%d enabled=%d armed=%d ch=%d "
"channels=%zu\n",
a->rows->time_window(), a->model->event_span(), tc.pretrigger_pct,
tc.post_span_s(a->model->event_span()), tc.holdoff_s, tc.level, int(tc.edge),
int(tc.mode), int(tc.enabled), int(tc.armed), tc.channel,
a->model->channel_count());
}
std::fflush(stderr);
g_timeout_add(25, trigrenum_probe_step2, a);
return G_SOURCE_REMOVE;
}
/// Headless self-check: changes the trigger panel's two dropdowns, which is what a user
/// does to switch Normal/Single or the edge -- mid-capture, if the probe is timed into
/// one. Reaching the dropdowns directly is the point: their `notify::selected` handler
@@ -9581,6 +9674,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_ARM_TRIGGER_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), arm_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_TRIGRENUM_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), trigrenum_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_FIELDS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), fields_probe, &app);
}