--buffer-samples (k/M/G) replaces --buffer-mb: the request is rounded down to a power of two for every signal and the shared time ring. A separate memory limit (--memory-limit-mb, a Preferences row, default 8 GiB) halves the plan until the real ring bytes fit and says so on stderr and in the status line. The per-source key buffer_mb becomes buffer_samples; an old buffer_mb is ignored. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
200 lines
10 KiB
C++
200 lines
10 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
//
|
|
// The status bar and the detail popover: the one-line glance with its activity badge, the popover\n// behind the ⓘ, the short-lived notices, and the starvation detector that says a stream stopped.
|
|
|
|
#include "ui_app.hpp"
|
|
|
|
#include <gtk/gtk.h>
|
|
|
|
#include <string>
|
|
|
|
namespace udpscope {
|
|
namespace ui_impl {
|
|
|
|
void on_popover_closed(GtkPopover* popover, gpointer) {
|
|
// Unparent, one idle later. `gtk_widget_set_parent()` took the reference the popover was created
|
|
// with (GTK4 widgets come with a floating reference, sunk by the first parent), so unparenting
|
|
// drops it to zero and destroys the popover -- a following `g_object_unref()` would be a
|
|
// use-after-free, which is why there is none.
|
|
//
|
|
// The *deferral* is not tidiness either: this runs inside the `closed` emission, which GTK raises
|
|
// from `gtk_popover_popdown()`, and destroying a widget in the middle of its own signal leaves the
|
|
// popdown path running on freed memory -- a `Gtk-CRITICAL` from `gtk_popover_get_autohide`, on
|
|
// every menu entry that dismisses its popover, which is every entry there is. The idle holds the
|
|
// reference (`g_object_ref` in, `g_object_unref` out) so the popover outlives its own emission.
|
|
g_idle_add_full(
|
|
G_PRIORITY_DEFAULT_IDLE,
|
|
+[](gpointer p) -> gboolean {
|
|
gtk_widget_unparent(GTK_WIDGET(p));
|
|
return G_SOURCE_REMOVE;
|
|
},
|
|
g_object_ref(popover), g_object_unref);
|
|
}
|
|
|
|
/// What the activity badge is saying. `Lit`/`Dim` are the two halves of the blink, so
|
|
/// the badge is only *blinking* while the pipeline is actually running; a steady badge is
|
|
/// a stopped one, which is the state worth noticing on a glance. `Connecting` is the
|
|
/// half-way state -- the socket answered but the stream has sent no CONFIG, so there is no
|
|
/// signal list and nothing to acquire -- and it is steady yellow rather than green,
|
|
/// because a blinking green badge over a source that has sent nothing claims a working
|
|
/// pipeline.
|
|
/// The badge is one glyph in one of four colours. It must stay the *same glyph*: the
|
|
/// status row sits above the plot, and a badge that changed width as it blinked would
|
|
/// move the label beside it and re-lay out the plot twice a second.
|
|
const char* badge_markup(BadgePhase phase) {
|
|
switch (phase) {
|
|
case BadgePhase::Lit:
|
|
return "<span foreground=\"#45E045\">\u25CF</span>";
|
|
case BadgePhase::Dim:
|
|
return "<span foreground=\"#1C5A1C\">\u25CF</span>";
|
|
case BadgePhase::Connecting:
|
|
return "<span foreground=\"#E0B020\">\u25CF</span>";
|
|
case BadgePhase::Idle:
|
|
break;
|
|
}
|
|
return "<span foreground=\"#5A5A5A\">\u25CF</span>";
|
|
}
|
|
|
|
/// A short-lived line in front of the status text: the outcome of an action that the
|
|
/// picture does not show by itself (a drop that changed nothing, an export that wrote a
|
|
/// file). `tick()` clears it when it expires.
|
|
void set_notice(App& app, std::string what, int seconds) {
|
|
app.notice = std::move(what);
|
|
app.notice_until_us = g_get_monotonic_time() + seconds * G_TIME_SPAN_SECOND;
|
|
}
|
|
|
|
/// The widest window that is worth offering. `--max-window` only: the buffer no longer
|
|
/// sets the ceiling, because a window too long to store at the wire rate is decimated on
|
|
/// ingest to fit rather than refused (see `plan_partition()`). It used to be capped to
|
|
/// `buffered_span()` as well, which is what made a 600 s view of a 1 MS/s stream
|
|
/// unreachable.
|
|
double window_limit(const App& app) { return app.model->config().max_window_s; }
|
|
|
|
|
|
/// Re-clamps the window against the current cap. Called once at start-up and again when a
|
|
/// CONFIG changes how much history one buffer holds -- never on a mere enable or calibration
|
|
/// change, so those cannot silently re-anchor the view.
|
|
void refresh_window_limits(App& app) {
|
|
const double limit = window_limit(app);
|
|
if (std::fabs(limit - app.window_limit_s) < 1.0e-9) return;
|
|
app.window_limit_s = limit;
|
|
apply_time_window(app, app.rows->time_window());
|
|
}
|
|
|
|
/// Sets the popover's seven value labels. Never creates a widget: `popover_rebuilds` exists to
|
|
/// assert that, because the popover is visible when this runs and a rebuild would relayout it.
|
|
void update_status_popover(App& app, const udpscope::StatusSnapshot& s) {
|
|
if (app.popover_label[4] == nullptr) return;
|
|
char buf[256];
|
|
|
|
if (!app.waveform_name.empty()) {
|
|
// Not "waiting, no config": a file is not a source that has not answered yet.
|
|
std::snprintf(buf, sizeof(buf), "file: %s", app.waveform_name.c_str());
|
|
} else {
|
|
std::snprintf(buf, sizeof(buf), "%s, %s", s.connected ? "connected" : "waiting",
|
|
s.have_config ? "configured" : "no config");
|
|
}
|
|
gtk_label_set_text(GTK_LABEL(app.popover_label[4]), buf);
|
|
|
|
std::snprintf(buf, sizeof(buf), "packets %llu · frames %llu · gaps %llu",
|
|
static_cast<unsigned long long>(s.packets),
|
|
static_cast<unsigned long long>(s.frames),
|
|
static_cast<unsigned long long>(s.gaps));
|
|
gtk_label_set_text(GTK_LABEL(app.popover_label[5]), buf);
|
|
|
|
std::snprintf(buf, sizeof(buf), "%zu signals · %zu channels", s.signals, s.channels);
|
|
gtk_label_set_text(GTK_LABEL(app.popover_label[6]), buf);
|
|
|
|
std::snprintf(buf, sizeof(buf), "%s samples/signal · %s · %.0f s span · time x%llu",
|
|
udpscope::format_samples(s.buffer_samples).c_str(),
|
|
s.buffer_bytes > 0 ? udpscope::format_bytes(s.buffer_bytes).c_str() : "not planned",
|
|
s.span_s, static_cast<unsigned long long>(s.time_stride));
|
|
gtk_label_set_text(GTK_LABEL(app.popover_label[7]), buf);
|
|
// The reason the figure is not the one asked for, where there is one: a smaller ring than the
|
|
// source form promised, with the limit that did it, as the label's tooltip.
|
|
gtk_widget_set_tooltip_text(
|
|
app.popover_label[7],
|
|
s.memory_limited
|
|
? ("limited to " + udpscope::format_samples(s.buffer_samples) + " samples by the " +
|
|
udpscope::format_bytes(s.memory_limit_bytes) + " memory limit (" +
|
|
udpscope::format_samples(s.buffer_requested) + " requested)")
|
|
.c_str()
|
|
: nullptr);
|
|
|
|
const std::string stored = udpscope::format_rate(s.stored_rate_hz);
|
|
const std::string wire = udpscope::format_rate(s.declared_rate_hz);
|
|
std::snprintf(buf, sizeof(buf), "stored %s (wire %s)", stored.c_str(), wire.c_str());
|
|
gtk_label_set_text(GTK_LABEL(app.popover_label[8]), buf);
|
|
|
|
std::snprintf(buf, sizeof(buf), "%d traces · %zu samples", s.plot1_traces, s.plot1_samples);
|
|
gtk_label_set_text(GTK_LABEL(app.popover_label[9]), buf);
|
|
|
|
std::snprintf(buf, sizeof(buf), "%d", s.reconnects);
|
|
gtk_label_set_text(GTK_LABEL(app.popover_label[10]), buf);
|
|
}
|
|
|
|
/// Recognises the "the source is serving someone else" state and says so.
|
|
///
|
|
/// A UDPStreamer over unicast keeps one session: a second client's CONNECT displaces the
|
|
/// first, the first sees `silence_timeout_s` of no data and reconnects, and the two take
|
|
/// turns. Nothing in the client can give both of them the stream -- the wire has one
|
|
/// destination -- so the useful thing is to name the situation: a reconnect with no frames
|
|
/// delivered in the same second is that fight, or a server that keeps dropping us, and
|
|
/// neither is visible in an empty plot.
|
|
///
|
|
/// Called once a second from the frame tick.
|
|
void note_starvation(App& app) {
|
|
StreamStats& st = app.model->stats();
|
|
const std::uint64_t frames = st.frames.load(std::memory_order_relaxed);
|
|
const std::uint64_t reconnects = st.reconnects.load(std::memory_order_relaxed);
|
|
const bool connected = st.connected.load(std::memory_order_relaxed);
|
|
|
|
// "Connected, but nothing is arriving" is a *rate* fact, not a reconnect one: a source that
|
|
// answers CONNECT and then goes quiet -- the unicast case where another client took the session --
|
|
// never reconnects while it is silent, so requiring a reconnect made the notice invisible in
|
|
// exactly the state it describes. What it takes is `frames` not moving across three consecutive
|
|
// one-second samples: one sample would call a source slower than the sample interval starved, and
|
|
// three still clears the moment anything arrives.
|
|
const bool idle = app.starve_have_prev && frames == app.starve_frames;
|
|
app.starve_still = (connected && !app.acq->paused() && idle) ? app.starve_still + 1 : 0;
|
|
app.starve_frames = frames;
|
|
app.starve_have_prev = true;
|
|
|
|
if (app.starve_still < 3) {
|
|
app.starve_reported = false;
|
|
// **A `return`, not just a branch.** The notice and its line used to sit outside this test, so
|
|
// they were re-armed every frame and the status bar carried "no data is arriving" while the
|
|
// stream was arriving perfectly well.
|
|
return;
|
|
}
|
|
// Two diagnoses, split on `have_config`, because they are not the same situation. A source
|
|
// that sent a CONFIG and then went quiet is the unicast fight: the signal list is here, so
|
|
// something took the stream away. One that has sent no CONFIG at all never had the stream to
|
|
// lose -- there is no signal list, so nothing can be enabled and no DATA frame is owed -- and
|
|
// naming that "connected, but no data is arriving" blamed a second reader that does not exist.
|
|
// It is still the same stall test: a CONFIG that arrives promptly leaves no notice at all.
|
|
const bool have_config = st.have_config.load(std::memory_order_relaxed);
|
|
app.notice =
|
|
have_config
|
|
? "connected, but no data is arriving: another reader may hold the source (a unicast "
|
|
"source serves one client at a time) -- use --multicast to watch from several UIs"
|
|
: "trying to connect: the source answered, but has sent no configuration, so there is "
|
|
"no signal list yet -- check that the streamer is running and serving this port";
|
|
app.notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
|
|
if (!app.starve_reported) {
|
|
app.starve_reported = true;
|
|
std::fprintf(stderr,
|
|
have_config
|
|
? "[udpscope] starved: connected (config=1), reconnects=%llu, no new frames "
|
|
"in the last second -- a unicast source serves one client at a time\n"
|
|
: "[udpscope] connecting: connected (config=0), reconnects=%llu, no "
|
|
"configuration from the stream -- there is no signal list to acquire\n",
|
|
static_cast<unsigned long long>(reconnects));
|
|
std::fflush(stderr);
|
|
}
|
|
}
|
|
|
|
} // namespace ui_impl
|
|
} // namespace udpscope
|