Feature/source plugins #1

Merged
martino merged 130 commits from feature/source-plugins into main 2026-11-04 07:35:21 +01:00
20 changed files with 516 additions and 134 deletions
Showing only changes of commit 034c82f7d7 - Show all commits

No files matched your search

+34 -5
View File
@@ -39,6 +39,25 @@ double parse_double(const char* name, const std::string& v) {
} }
} }
/// A count with an optional decimal suffix: `250000`, `64k`, `100M`, `1.5G`. k, M and G are 10^3, 10^6
/// and 10^9 -- a *sample* count, not a byte size -- and either case is accepted.
std::uint64_t parse_count(const char* name, const std::string& v) {
std::string digits = v;
double scale = 1.0;
if (!digits.empty()) {
switch (digits.back()) {
case 'k': case 'K': scale = 1.0e3; break;
case 'm': case 'M': scale = 1.0e6; break;
case 'g': case 'G': scale = 1.0e9; break;
default: break;
}
if (scale != 1.0) digits.pop_back();
}
const double d = parse_double(name, digits) * scale;
if (!(d >= 1.0) || d > 1.0e12) fail(std::string(name) + " out of range: " + v);
return static_cast<std::uint64_t>(d + 0.5);
}
} // namespace } // namespace
SourceConfig Config::source_config() const { SourceConfig Config::source_config() const {
@@ -70,7 +89,11 @@ void print_usage(std::FILE* out, const char* argv0) {
" --source NAME source kind (%s; default udps). --server/--port/\n" " --source NAME source kind (%s; default udps). --server/--port/\n"
" --multicast/--interface configure udps\n" " --multicast/--interface configure udps\n"
" --source-opt K=V a setting for the source kind; repeatable\n" " --source-opt K=V a setting for the source kind; repeatable\n"
" --buffer-mb MB size of each of the two sample buffers (default 200)\n" " --buffer-samples N samples kept per acquired signal, e.g. 100M or 250000 (k, M\n"
" and G are decimal; default 10M). Rounded down to a power\n"
" of two: 100M gives 67108864\n"
" --memory-limit-mb MB hard ceiling on the sample rings, all signals together\n"
" (default 8192); the request is halved until it fits\n"
" --max-window SEC largest time window the toolbar offers (default 600)\n" " --max-window SEC largest time window the toolbar offers (default 600)\n"
" --holdoff SEC default trigger hold-off (default 0.010)\n" " --holdoff SEC default trigger hold-off (default 0.010)\n"
" --recv-buffer BYTES SO_RCVBUF for the receiver socket (default 8388608)\n" " --recv-buffer BYTES SO_RCVBUF for the receiver socket (default 8388608)\n"
@@ -125,10 +148,16 @@ Config parse_args(int argc, char** argv) {
} else if (a == "--source-opt" || a.rfind("--source-opt=", 0) == 0) { } else if (a == "--source-opt" || a.rfind("--source-opt=", 0) == 0) {
const std::string v = value_of(argc, argv, i, "--source-opt"); const std::string v = value_of(argc, argv, i, "--source-opt");
if (!c.source_opts.parse_assignment(v)) fail("--source-opt must be key=value: " + v); if (!c.source_opts.parse_assignment(v)) fail("--source-opt must be key=value: " + v);
} else if (a == "--buffer-mb" || a.rfind("--buffer-mb=", 0) == 0) { } else if (a == "--buffer-samples" || a.rfind("--buffer-samples=", 0) == 0) {
c.buffer_mb = parse_double("--buffer-mb", value_of(argc, argv, i, "--buffer-mb")); c.buffer_samples = parse_count("--buffer-samples", value_of(argc, argv, i, "--buffer-samples"));
c.buffer_mb_specified = true; c.buffer_samples_specified = true;
if (c.buffer_mb < 1.0) fail("--buffer-mb must be at least 1"); if (c.buffer_samples < 128) fail("--buffer-samples must be at least 128");
} else if (a == "--memory-limit-mb" || a.rfind("--memory-limit-mb=", 0) == 0) {
const double mb =
parse_double("--memory-limit-mb", value_of(argc, argv, i, "--memory-limit-mb"));
if (!(mb >= 1.0) || mb > 1.0e9) fail("--memory-limit-mb must be 1..1000000000 (MiB)");
c.memory_limit_mb = static_cast<std::uint64_t>(mb);
c.memory_limit_specified = true;
} else if (a == "--max-window" || a.rfind("--max-window=", 0) == 0) { } else if (a == "--max-window" || a.rfind("--max-window=", 0) == 0) {
c.max_window_s = parse_double("--max-window", value_of(argc, argv, i, "--max-window")); c.max_window_s = parse_double("--max-window", value_of(argc, argv, i, "--max-window"));
if (c.max_window_s <= 0.0) fail("--max-window must be positive"); if (c.max_window_s <= 0.0) fail("--max-window must be positive");
+16 -8
View File
@@ -11,7 +11,7 @@
namespace udpscope { namespace udpscope {
/// Everything the command line can say. Defaults are the ones the spec names /// Everything the command line can say. Defaults are the ones the spec names
/// (200 MB per buffer, 10 ms trigger hold-off) unless the user overrides them. /// (10 M samples per signal, 10 ms trigger hold-off) unless the user overrides them.
struct Config { struct Config {
std::string server = "127.0.0.1"; ///< IPv4 dotted quad of the stream source. std::string server = "127.0.0.1"; ///< IPv4 dotted quad of the stream source.
std::uint16_t port = 8000; ///< UDP port (unicast) or TCP port (multicast). std::uint16_t port = 8000; ///< UDP port (unicast) or TCP port (multicast).
@@ -40,13 +40,21 @@ struct Config {
std::string session_host() const; std::string session_host() const;
std::uint16_t session_port() const; std::uint16_t session_port() const;
/// Size of *each* of the two sample buffers, in megabytes. The application /// The sample budget: how many samples to keep for **every acquired signal**, and for the shared
/// always allocates two identical buffers (spec item 1). /// time ring. The rings are powers of two, so the request is rounded *down* to one (`buffer_ticks()`
double buffer_mb = 200.0; /// in model.hpp) -- "up to" this many -- and the UI shows both figures. 10 M asks for 8 388 608.
/// Whether `--buffer-mb` was given. A stored configuration's buffer size fills in only when the std::uint64_t buffer_samples = 10'000'000;
/// flag was *absent*: an explicit flag beats a saved setting, and "not asked" is not the same /// Whether `--buffer-samples` was given. A stored configuration's sample budget fills in only when
/// question as "asked for 200". /// the flag was *absent*: an explicit flag beats a saved setting, and "not asked" is not the same
bool buffer_mb_specified = false; /// question as "asked for 10 M".
bool buffer_samples_specified = false;
/// The hard ceiling on the ring storage, in MiB, for the whole application rather than per source.
/// `plan_partition()` halves the rings until they fit under it, so it can clip `buffer_samples`.
/// Default 8 GiB; the preferences carry it between runs and `--memory-limit-mb` overrides it.
std::uint64_t memory_limit_mb = 8192;
/// Whether `--memory-limit-mb` was given, so the stored preference only fills the gap.
bool memory_limit_specified = false;
/// Largest time window the toolbar offers, in seconds. The buffer partition is /// Largest time window the toolbar offers, in seconds. The buffer partition is
/// sized to hold at least this much time (spec: decimation). /// sized to hold at least this much time (spec: decimation).
+7 -5
View File
@@ -328,7 +328,7 @@ std::vector<KnownSource> known_sources() {
s.port = port; s.port = port;
s.config = name; s.config = name;
s.comment = st.comment; s.comment = st.comment;
s.buffer_mb = st.buffer_mb; s.buffer_samples = st.buffer_samples;
s.relative_time = st.relative_time; s.relative_time = st.relative_time;
s.capture_packets = st.capture_packets; s.capture_packets = st.capture_packets;
s.multicast = st.multicast; s.multicast = st.multicast;
@@ -347,7 +347,7 @@ std::vector<KnownSource> known_sources() {
// session file per probe run and every one holds `signals = ["", "", "", ""]`. One that // session file per probe run and every one holds `signals = ["", "", "", ""]`. One that
// carries a *setting* is worth a row even before it has ever streamed -- that is how a host is // carries a *setting* is worth a row even before it has ever streamed -- that is how a host is
// set up from the chooser's form, and the settings are the whole point of editing one. // set up from the chooser's form, and the settings are the whole point of editing one.
const bool configured = s.signals > 0 || s.buffer_mb > 0.0 || !st.comment.empty() || const bool configured = s.signals > 0 || s.buffer_samples > 0 || !st.comment.empty() ||
st.relative_time || st.capture_packets > 0; st.relative_time || st.capture_packets > 0;
if (!configured) continue; if (!configured) continue;
struct stat fb {}; struct stat fb {};
@@ -583,9 +583,11 @@ bool load_layout(const std::string& path, LayoutState& out) {
if (SourceConfig::valid_key(k)) st.source_opts.set(k, strip_quote(value)); if (SourceConfig::valid_key(k)) st.source_opts.set(k, strip_quote(value));
} else if (key == "comment") { } else if (key == "comment") {
st.comment = strip_quote(value); st.comment = strip_quote(value);
} else if (key == "buffer_mb") { } else if (key == "buffer_samples") {
try { try {
st.buffer_mb = std::stod(value); // A negative or absurd count is "not stored", not a request: `stoull` wraps a minus sign.
const unsigned long long n = value.find('-') == std::string::npos ? std::stoull(value) : 0;
st.buffer_samples = n >= 128 && n <= 1000000000000ull ? n : 0;
} catch (...) { } catch (...) {
} }
} else if (key == "relative_time") { } else if (key == "relative_time") {
@@ -768,7 +770,7 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "trigger_density_tau = " << state.trigger.density_tau_s << "\n"; o << "trigger_density_tau = " << state.trigger.density_tau_s << "\n";
o << "trigger_density_factor = " << state.trigger.density_factor << "\n"; o << "trigger_density_factor = " << state.trigger.density_factor << "\n";
if (!state.comment.empty()) o << "comment = " << toml_escape(state.comment) << "\n"; if (!state.comment.empty()) o << "comment = " << toml_escape(state.comment) << "\n";
if (state.buffer_mb > 0.0) o << "buffer_mb = " << state.buffer_mb << "\n"; if (state.buffer_samples > 0) o << "buffer_samples = " << state.buffer_samples << "\n";
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n"; o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
if (state.capture_packets > 0) o << "capture_packets = " << state.capture_packets << "\n"; if (state.capture_packets > 0) o << "capture_packets = " << state.capture_packets << "\n";
for (const auto& row : state.rows) { for (const auto& row : state.rows) {
+7 -4
View File
@@ -139,9 +139,12 @@ struct LayoutState {
/// The startup settings the configuration carries, applied when the chooser opens it -- which is /// The startup settings the configuration carries, applied when the chooser opens it -- which is
/// why they live here and not only on the command line: two sources on one machine want different /// why they live here and not only on the command line: two sources on one machine want different
/// buffers and different clocks, and retyping the flags to look at the second one is how a /// buffers and different clocks, and retyping the flags to look at the second one is how a
/// setting gets forgotten. A zero `buffer_mb` and an empty `capture_packets` mean "not stored": /// setting gets forgotten. A zero `buffer_samples` and an empty `capture_packets` mean "not stored":
/// the command line's value stands, so an old file cannot silently shrink a buffer. /// the command line's value stands, so an old file cannot silently shrink a buffer. The budget is
double buffer_mb = 0.0; /// samples per acquired signal (the memory limit is the application's, not a source's). A file from
/// before that unit carries `buffer_mb`, which is not read: it reads as "not stored" and the
/// default stands, rather than a megabyte count being taken for a sample count.
std::uint64_t buffer_samples = 0;
bool relative_time = false; bool relative_time = false;
std::uint32_t capture_packets = 0; std::uint32_t capture_packets = 0;
}; };
@@ -162,7 +165,7 @@ struct KnownSource {
std::string config; std::string config;
std::string comment; ///< the configuration's note, shown beside its name std::string comment; ///< the configuration's note, shown beside its name
/// The startup settings this configuration carries, so the chooser can apply them. /// The startup settings this configuration carries, so the chooser can apply them.
double buffer_mb = 0.0; std::uint64_t buffer_samples = 0;
bool relative_time = false; bool relative_time = false;
std::uint32_t capture_packets = 0; std::uint32_t capture_packets = 0;
}; };
+5 -1
View File
@@ -24,7 +24,11 @@ int main(int argc, char** argv) {
// The preferences fill in what the command line did not decide. `--relative-time` is an explicit // The preferences fill in what the command line did not decide. `--relative-time` is an explicit
// instruction for this run, so a preference can only turn the mode on, never off -- which is also // instruction for this run, so a preference can only turn the mode on, never off -- which is also
// why this is read here, before the buffers are planned: the mode changes how they are filled. // why this is read here, before the buffers are planned: the mode changes how they are filled.
if (udpscope::load_settings().relative_time) cfg.relative_time = true; const udpscope::Settings prefs = udpscope::load_settings();
if (prefs.relative_time) cfg.relative_time = true;
// The memory limit is the same kind of statement, the other way round: the flag is the explicit
// instruction for this run and the preference only fills in when it was absent.
if (!cfg.memory_limit_specified) cfg.memory_limit_mb = static_cast<std::uint64_t>(prefs.memory_limit_mb);
} catch (const std::exception& e) { } catch (const std::exception& e) {
std::fprintf(stderr, "udpscope: %s\n", e.what()); std::fprintf(stderr, "udpscope: %s\n", e.what());
return 2; return 2;
+92 -41
View File
@@ -3,6 +3,8 @@
#include "model.hpp" #include "model.hpp"
#include "status_text.hpp" // `format_samples()` / `format_bytes()`, for the one line a clipped plan prints
#include <algorithm> #include <algorithm>
#include <chrono> #include <chrono>
#include <cmath> #include <cmath>
@@ -40,12 +42,24 @@ constexpr std::uint64_t kMathCostSample = 1024;
} // namespace } // namespace
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes, u64 buffer_ticks(u64 requested_samples) {
double target_span_s, bool with_abs_time) { // 2^40 slots is already 8 TiB of clock alone: a request past it is nonsense and the cap keeps
// `bytes_for()` far from overflowing.
constexpr u64 kMaxTicks = u64(1) << 40;
return std::min(pow2_floor(std::max<u64>(requested_samples, 128)), kMaxTicks);
}
u64 planned_bytes(u64 ticks, std::size_t channels, bool with_abs_time) {
return ticks * sizeof(f64) * (with_abs_time ? 2u : 1u) + ticks * sizeof(f32) * channels;
}
Partition plan_partition(const std::vector<double>& channel_dt, u64 samples_per_signal,
u64 max_bytes, double target_span_s, bool with_abs_time) {
Partition p; Partition p;
const std::size_t n = channel_dt.size(); const std::size_t n = channel_dt.size();
p.stride.assign(n, 1); p.stride.assign(n, 1);
p.value_capacity.assign(n, 0); p.value_capacity.assign(n, 0);
p.requested_samples = samples_per_signal;
if (n == 0) return p; if (n == 0) return p;
// The fastest channel drives the shared time base: every other channel is // The fastest channel drives the shared time base: every other channel is
@@ -69,25 +83,13 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
p.abs_time = with_abs_time; p.abs_time = with_abs_time;
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes)); // The ring is a power of two, so the user's count is a ceiling and the plan takes the largest
// Weighted in *bytes*, not in slots: the shared clock is f64 and the values are f32 // power of two under it. The budget is in *samples* now: it is the memory limit, not the request,
// (see `BufferStore::time()`), so a time slot costs twice a value sample and the two // that is in bytes -- and a time slot (f64) and a value sample (f32) are not the same size, which
// slices are not interchangeable. Counting both as one f32 is what would make // is why the limit below is weighed with `bytes_for()` and not as a count of slots.
// `--buffer-mb` quietly understate the time ring by its own size. //
double weight = static_cast<double>(sizeof(f64)) * (with_abs_time ? 2.0 : 1.0); // the time slice(s) // A channel's slots are rounded *down* on the power of two `RingBuffer` will round them to
for (u64 s : p.stride) weight += static_cast<double>(sizeof(f32)) / static_cast<double>(s); // anyway, so every channel covers at most the time ring's span.
const u64 slots = static_cast<u64>(static_cast<double>(budget_bytes) / weight);
u64 time_capacity = pow2_floor(std::max<u64>(slots, 128));
if (time_capacity == 0) {
throw std::runtime_error("buffer too small for one time-base sample per channel");
}
// The target span sets a floor, not a ceiling: the rest of the budget buys a
// longer ring, which is strictly more history to scroll back through. Growth
// stops before the total would exceed the budget. A channel's slots are rounded
// *up* on the power of two `RingBuffer` will round them to anyway, so every
// channel covers at least the time ring's span.
// Floor, not ceiling: a channel's window must not be *longer* than the time ring // Floor, not ceiling: a channel's window must not be *longer* than the time ring
// it reads its stamps from. Rounding up (with a stride that is not a power of two) // it reads its stamps from. Rounding up (with a stride that is not a power of two)
// gave a channel more samples than the shared timeline covers, and the oldest of // gave a channel more samples than the shared timeline covers, and the oldest of
@@ -103,19 +105,24 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
for (u64 s : p.stride) total += capacity_for(ticks, s) * sizeof(f32); for (u64 s : p.stride) total += capacity_for(ticks, s) * sizeof(f32);
return total; return total;
}; };
// `bytes_for` bounds the doubling on its own: it is at least `2 * ticks * sizeof(f64)`, u64 time_capacity = buffer_ticks(samples_per_signal);
// so a pass means `2 * ticks` is well inside the budget and cannot overflow. const u64 wanted_ticks = time_capacity;
while (bytes_for(time_capacity * 2) <= budget_bytes) { // The memory limit halves the request until the rings fit, never the other way: a bigger plan than
time_capacity *= 2; // asked for is not "up to".
while (time_capacity > 128 && bytes_for(time_capacity) > max_bytes) time_capacity /= 2;
if (bytes_for(time_capacity) > max_bytes) {
throw std::runtime_error("memory limit too small for the sample rings of " +
std::to_string(n) + " channels");
} }
p.memory_limited = time_capacity < wanted_ticks;
p.time_capacity = time_capacity; p.time_capacity = time_capacity;
for (std::size_t i = 0; i < n; ++i) { for (std::size_t i = 0; i < n; ++i) {
p.value_capacity[i] = capacity_for(p.time_capacity, p.stride[i]); p.value_capacity[i] = capacity_for(p.time_capacity, p.stride[i]);
} }
// Only now, with the ring as long as the budget can buy, decimate the shared clock // Only now, with the ring as long as the request and the memory limit allow, decimate the shared clock
// enough for the requested window to fit in it: deciding this earlier would over- // enough for the requested window to fit in it: deciding this earlier would over-
// decimate by whatever factor the growth loop was about to add. A power of two keeps // decimate by whatever the ring's final length would have made unnecessary. A power of two keeps
// the ring capacities (themselves powers of two) in step and lets the producer's group // the ring capacities (themselves powers of two) in step and lets the producer's group
// test be a mask (`Acquisition::acc_mask_`) rather than a modulo -- a modulo on a stride // test be a mask (`Acquisition::acc_mask_`) rather than a modulo -- a modulo on a stride
// this function chose at runtime is a real 64-bit division, which the test *was* paying // this function chose at runtime is a real 64-bit division, which the test *was* paying
@@ -178,6 +185,7 @@ void BufferStore::acquire_all() {
// --- ScopeModel ------------------------------------------------------------- // --- ScopeModel -------------------------------------------------------------
ScopeModel::ScopeModel(const Config& cfg) : cfg_(cfg) { ScopeModel::ScopeModel(const Config& cfg) : cfg_(cfg) {
memory_limit_bytes_.store(cfg.memory_limit_mb * 1024u * 1024u, std::memory_order_relaxed);
trigger_.holdoff_s = cfg.trigger_holdoff_s; trigger_.holdoff_s = cfg.trigger_holdoff_s;
} }
@@ -567,22 +575,16 @@ void ScopeModel::rebuild_locked() {
holes_.clear(); holes_.clear();
++plan_generation_; ++plan_generation_;
std::vector<double> dts; if (channels_.empty()) {
dts.reserve(channels_.size());
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
if (dts.empty()) {
part_ = Partition{}; part_ = Partition{};
store_.reset(); store_.reset();
return; return;
} }
const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0, const Partition want = plan_locked(target_span_.load(std::memory_order_relaxed));
target_span_.load(std::memory_order_relaxed),
cfg_.relative_time);
// A plan that describes the storage already allocated is cleared, not rebuilt. Allocating // A plan that describes the storage already allocated is cleared, not rebuilt. Allocating
// a store is what costs -- its rings are value-initialised, so it touches the whole // a store is what costs -- its rings are value-initialised, so it touches all of the
// `--buffer-mb` -- and most structural changes do not move the plan at all: a calibration // planned rings -- and most structural changes do not move the plan at all: a calibration
// keeps the channel set, and so does leaving an event for the live view. Startup asked for // keeps the channel set, and so does leaving an event for the live view. Startup asked for
// sixteen of these and eight of them planned the same buffer twice. Clearing has the same // sixteen of these and eight of them planned the same buffer twice. Clearing has the same
// meaning (`BufferStore::reset()`): the history is gone either way, which is why // meaning (`BufferStore::reset()`): the history is gone either way, which is why
@@ -601,6 +603,17 @@ void ScopeModel::rebuild_locked() {
// the defect the line exists to name (it was fifteen of them at startup). // the defect the line exists to name (it was fifteen of them at startup).
std::fprintf(stderr, "[udpscope] buffer allocated: %zu channels, %.3g s span, %.0f ms\n", std::fprintf(stderr, "[udpscope] buffer allocated: %zu channels, %.3g s span, %.0f ms\n",
channels_.size(), part_.span_s, double(now_ns() - t0) / 1.0e6); channels_.size(), part_.span_s, double(now_ns() - t0) / 1.0e6);
if (part_.memory_limited) {
// The one line that says the request was not honoured, and why: a smaller ring than the source
// form promised is otherwise explained nowhere.
std::fprintf(stderr,
"[udpscope] buffer limited to %s samples per signal (%s requested) by the %s "
"memory limit; the rings take %s\n",
format_samples(part_.time_capacity).c_str(),
format_samples(part_.requested_samples).c_str(),
format_bytes(memory_limit_bytes()).c_str(),
format_bytes(part_.bytes()).c_str());
}
std::fflush(stderr); std::fflush(stderr);
} }
@@ -608,6 +621,47 @@ void ScopeModel::rebuild_locked() {
prepare_math_locked(); prepare_math_locked();
} }
Partition ScopeModel::plan_locked(double seconds) const {
std::vector<double> dts;
dts.reserve(channels_.size());
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
return plan_partition(dts, cfg_.buffer_samples, memory_limit_bytes(), seconds, cfg_.relative_time);
}
bool ScopeModel::set_memory_limit_bytes(std::uint64_t bytes, bool replan) {
std::lock_guard<std::mutex> lock(mutex_);
const std::uint64_t previous = memory_limit_bytes_.load(std::memory_order_relaxed);
if (bytes == previous) return false;
if (channels_.empty() || !replan) {
memory_limit_bytes_.store(bytes, std::memory_order_relaxed);
return false;
}
memory_limit_bytes_.store(bytes, std::memory_order_relaxed);
Partition want;
try {
want = plan_locked(target_span_.load(std::memory_order_relaxed));
} catch (const std::exception& e) {
memory_limit_bytes_.store(previous, std::memory_order_relaxed);
std::fprintf(stderr, "[udpscope] memory limit %s refused: %s\n", format_bytes(bytes).c_str(),
e.what());
std::fflush(stderr);
return false;
}
if (store_ && part_.same_storage_as(want)) {
// The same rings: only what the plan *says* about itself moved. Written in place, because the
// producer reads `part_` under the lock we hold and nothing here reallocates it.
part_.requested_samples = want.requested_samples;
part_.memory_limited = want.memory_limited;
return false;
}
std::fprintf(stderr, "[udpscope] memory limit %s -> %s: buffer re-planned\n",
format_bytes(previous).c_str(), format_bytes(bytes).c_str());
std::fflush(stderr);
rebuild_locked();
generation_.fetch_add(1, std::memory_order_release);
return true;
}
bool ScopeModel::set_target_span(double seconds) { bool ScopeModel::set_target_span(double seconds) {
if (!(seconds > 0.0)) return false; if (!(seconds > 0.0)) return false;
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
@@ -616,10 +670,7 @@ bool ScopeModel::set_target_span(double seconds) {
// Re-plan only if the factor moves. The planner is cheap, so ask it rather than // Re-plan only if the factor moves. The planner is cheap, so ask it rather than
// reimplementing its arithmetic here -- two expressions for the same decision is how // reimplementing its arithmetic here -- two expressions for the same decision is how
// the buffer and the view end up disagreeing about the stored rate. // the buffer and the view end up disagreeing about the stored rate.
std::vector<double> dts; const Partition want = plan_locked(seconds);
dts.reserve(channels_.size());
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0, seconds, cfg_.relative_time);
if (want.time_stride == part_.time_stride) return false; if (want.time_stride == part_.time_stride) return false;
std::fprintf(stderr, std::fprintf(stderr,
"[udpscope] window %.4f s -> %.4f s: decimation %llu -> %llu, stored rate " "[udpscope] window %.4f s -> %.4f s: decimation %llu -> %llu, stored rate "
+67 -15
View File
@@ -104,9 +104,27 @@ struct Partition {
/// True when a second f64 ring parallel to the clock was planned and paid for in the /// True when a second f64 ring parallel to the clock was planned and paid for in the
/// byte budget. Relative mode only -- in stream mode `time()` already *is* the absolute /// byte budget. Relative mode only -- in stream mode `time()` already *is* the absolute
/// clock and a copy would cost ~22% of the user's `--buffer-mb` for nothing. /// clock and a copy would cost ~22% of the user's sample budget for nothing.
bool abs_time = false; bool abs_time = false;
/// The samples-per-signal the user asked for, as given (not rounded). `time_capacity` is what
/// the plan actually holds: the request rounded down to a power of two, then halved further
/// while the rings did not fit the memory limit. These two are readings, not storage -- they are
/// not in `same_storage_as()` -- so a re-plan that only moves them does not drop history.
u64 requested_samples = 0;
/// True when the memory limit, and not the power-of-two rounding, made `time_capacity` smaller than
/// the request: the one case the UI has to explain.
bool memory_limited = false;
/// The bytes the rings of this plan occupy: the time ring (twice in relative mode) plus every
/// value ring. This is the figure to quote and to budget against; a count of the user's
/// request is not, because the rings round and the limit can clip.
u64 bytes() const noexcept {
u64 total = time_capacity * sizeof(f64) * (abs_time ? 2u : 1u);
for (u64 c : value_capacity) total += c * sizeof(f32);
return total;
}
/// Seconds between two time-base slots (the sample interval of every channel). /// Seconds between two time-base slots (the sample interval of every channel).
double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); } double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); }
/// The rate samples are actually stored at, in samples per second. /// The rate samples are actually stored at, in samples per second.
@@ -137,13 +155,24 @@ struct Partition {
} }
}; };
/// Plans a partition for `channel_dt` cadences within `buffer_bytes`, covering at least /// The ring length a request of `requested_samples` per signal buys: the largest power of two not
/// `target_span_s` seconds of stream -- decimating (with the ingest filter) by whatever /// above it (at least 128). The rings are powers of two, so the user's number is a ceiling.
/// power of two that takes. Throws std::runtime_error when the budget cannot hold even u64 buffer_ticks(u64 requested_samples);
/// one sample per channel. `with_abs_time` adds a second f64 per time slot to the budget
/// and sets `Partition::abs_time`. /// What `ticks` slots cost for `channels` unit-stride channels: the f64 time ring (twice with the
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes, /// absolute clock) plus one f32 ring per channel. The planner's own byte formula, for a caller
double target_span_s, bool with_abs_time = false); /// that has no `Partition` yet (the source form, the probes).
u64 planned_bytes(u64 ticks, std::size_t channels, bool with_abs_time);
/// Plans a partition for `channel_dt` cadences: `samples_per_signal` slots for the time ring and for
/// every channel, rounded down to a power of two, then halved while the rings (`planned_bytes()`)
/// exceed `max_bytes` -- the memory limit. Covers at least `target_span_s` seconds of stream,
/// decimating (with the ingest filter) by whatever power of two that takes. Throws
/// std::runtime_error when even 128 slots do not fit `max_bytes`. `with_abs_time` adds a second f64
/// per time slot to the budget and sets `Partition::abs_time`. `Partition::memory_limited` says
/// whether the limit clipped the request.
Partition plan_partition(const std::vector<double>& channel_dt, u64 samples_per_signal,
u64 max_bytes, double target_span_s, bool with_abs_time = false);
/// One of the two identical sample buffers: a shared time-base ring plus one /// One of the two identical sample buffers: a shared time-base ring plus one
/// value ring and one `ChannelBuffer` per channel. /// value ring and one `ChannelBuffer` per channel.
@@ -187,7 +216,7 @@ class BufferStore {
/// ///
/// Why it exists: constructing a store *is* its cost. A ring is a /// Why it exists: constructing a store *is* its cost. A ring is a
/// `vector<std::atomic<T>>`, which is value-initialised, so building one touches every /// `vector<std::atomic<T>>`, which is value-initialised, so building one touches every
/// byte of the user's `--buffer-mb` -- about 300 ms per GB, nearly all of it first-touch /// byte of the planned buffer (`--buffer-samples`) -- about 300 ms per GB, nearly all of it first-touch
/// page faults. Clearing is a count store per ring. Most structural changes (a /// page faults. Clearing is a count store per ring. Most structural changes (a
/// calibration, a re-enable of what was just disabled, going back to live) re-plan to the /// calibration, a re-enable of what was just disabled, going back to live) re-plan to the
/// *same* partition, and paying a second full allocation for them is what made a large /// *same* partition, and paying a second full allocation for them is what made a large
@@ -546,13 +575,13 @@ class ScopeModel {
std::uint64_t persist_dropped() const noexcept { std::uint64_t persist_dropped() const noexcept {
return persist_dropped_.load(std::memory_order_relaxed); return persist_dropped_.load(std::memory_order_relaxed);
} }
/// The bytes persistence may retain, which is the ring's own byte budget (`--buffer-mb`): the /// The bytes persistence may retain, which is the rings' own planned byte total
/// snapshots are copies *of* what the rings hold, so holding as much again as the buffer does is /// (`Partition::bytes()`): the snapshots are copies *of* what the rings hold, so holding as much
/// the honest ceiling for a display of it -- and it is the bound that cannot be driven into an /// again as the buffer does is the honest ceiling for a display of it -- and it is the bound that
/// OOM by a long window on a fast stream, whatever depth the panel asks for. /// cannot be driven into an OOM by a long window on a fast stream, whatever depth the panel asks
/// for. Zero until a stream has planned a buffer. Read under `mutex()`, like `partition()`.
std::size_t persist_budget_bytes() const noexcept { std::size_t persist_budget_bytes() const noexcept {
const int mb = cfg_.buffer_mb > 0 ? cfg_.buffer_mb : 0; return static_cast<std::size_t>(part_.bytes());
return static_cast<std::size_t>(mb) * 1024u * 1024u;
} }
/// Sets how many acquisitions the persistence display keeps, clamped to `kMaxPersistDepth`, and /// Sets how many acquisitions the persistence display keeps, clamped to `kMaxPersistDepth`, and
@@ -671,6 +700,22 @@ class ScopeModel {
/// `mutex()` itself; do not call it holding the lock. /// `mutex()` itself; do not call it holding the lock.
/// Returns true when the buffer was rebuilt. /// Returns true when the buffer was rebuilt.
bool set_target_span(double seconds); bool set_target_span(double seconds);
/// The memory limit the planner honours, in bytes. Starts as `Config::memory_limit_mb` and is the
/// one planner input that changes while the application runs (the preferences). Atomic so the
/// status and the probes can read it without the lock.
std::uint64_t memory_limit_bytes() const noexcept {
return memory_limit_bytes_.load(std::memory_order_relaxed);
}
/// Changes the memory limit and re-plans. The buffer is rebuilt -- and the history dropped -- only
/// when the new limit changes the *storage* (`Partition::same_storage_as()`): a limit that still
/// leaves room for the plan updates `Partition::memory_limited` in place and costs nothing.
/// Returns true when the buffer was rebuilt. Takes `mutex()` itself; do not call it holding the
/// lock. A limit too small for even 128 slots per channel is refused (returns false, keeps the old
/// limit) and said on stderr.
/// With `replan` false the limit is only stored, for the next plan: a file or a replayed event
/// lives in the buffer, and re-planning it would clear what the user is looking at.
bool set_memory_limit_bytes(std::uint64_t bytes, bool replan = true);
double target_span() const noexcept { return target_span_.load(std::memory_order_relaxed); } double target_span() const noexcept { return target_span_.load(std::memory_order_relaxed); }
/// Trigger state for the UI, published by the acquisition thread. Atomics /// Trigger state for the UI, published by the acquisition thread. Atomics
@@ -734,7 +779,14 @@ class ScopeModel {
/// so a window change that moves the decimation re-prepares the filters exactly as an edit does. /// so a window change that moves the decimation re-prepares the filters exactly as an edit does.
void prepare_math_locked(); void prepare_math_locked();
/// Plans the partition for the current channels at `seconds` of window with the current sample
/// budget and memory limit. The one place the planner's inputs are assembled, so the re-plan on a
/// window change, a structural change and a memory-limit change cannot disagree. Throws what
/// `plan_partition()` throws. Caller holds `mutex()`.
Partition plan_locked(double seconds) const;
Config cfg_; Config cfg_;
std::atomic<std::uint64_t> memory_limit_bytes_{0};
std::vector<SignalInfo> signals_; std::vector<SignalInfo> signals_;
std::vector<ChannelInfo> channels_; std::vector<ChannelInfo> channels_;
// --- math channels -------------------------------------------------------- // --- math channels --------------------------------------------------------
+5
View File
@@ -3,6 +3,7 @@
#include "settings.hpp" #include "settings.hpp"
#include <algorithm>
#include <cstdlib> #include <cstdlib>
#include <cstring> #include <cstring>
#include <fstream> #include <fstream>
@@ -64,6 +65,9 @@ Settings load_settings() {
s.event_cache_max = std::max(0, std::atoi(value.c_str())); s.event_cache_max = std::max(0, std::atoi(value.c_str()));
} else if (key == "relative_time") { } else if (key == "relative_time") {
s.relative_time = parse_bool(value, s.relative_time); s.relative_time = parse_bool(value, s.relative_time);
} else if (key == "memory_limit_mb") {
const long mb = std::strtol(value.c_str(), nullptr, 10);
if (mb >= 16) s.memory_limit_mb = static_cast<int>(std::min<long>(mb, 1000000000L));
} else if (key == "theme") { } else if (key == "theme") {
if (value == "auto" || value == "light" || value == "dark") s.theme = value; if (value == "auto" || value == "light" || value == "dark") s.theme = value;
} else if (key == "contextual_help") { } else if (key == "contextual_help") {
@@ -83,6 +87,7 @@ bool save_settings(const Settings& settings) {
out << "event_cache_dir = \"" << settings.event_cache_dir << "\"\n"; out << "event_cache_dir = \"" << settings.event_cache_dir << "\"\n";
out << "event_cache_max = " << settings.event_cache_max << "\n"; out << "event_cache_max = " << settings.event_cache_max << "\n";
out << "relative_time = " << (settings.relative_time ? "true" : "false") << "\n"; out << "relative_time = " << (settings.relative_time ? "true" : "false") << "\n";
out << "memory_limit_mb = " << settings.memory_limit_mb << "\n";
out << "theme = \"" << settings.theme << "\"\n"; out << "theme = \"" << settings.theme << "\"\n";
// The two help keys are the *app's* memory rather than the dialog's: the contextual-help rows -- // The two help keys are the *app's* memory rather than the dialog's: the contextual-help rows --
// the Help menu's and the source chooser's own -- write the first, and the first-run tour writes the // the Help menu's and the source chooser's own -- write the first, and the first-run tour writes the
+6
View File
@@ -30,6 +30,12 @@ struct Settings {
/// buffers are planned before any stream is known, so it takes effect on the next start (the /// buffers are planned before any stream is known, so it takes effect on the next start (the
/// preferences dialog says so, like the configurations dialog does). /// preferences dialog says so, like the configurations dialog does).
bool relative_time = false; bool relative_time = false;
/// The hard ceiling on the sample rings' memory, in MiB, for the whole application rather than per
/// source (`--memory-limit-mb` overrides it for one run). The planner halves a source's sample
/// budget until the rings fit under it. Unlike the cache directory and relative time it applies as
/// it is changed: a running scope re-plans its buffer (and so drops its history) when the new limit
/// changes the rings.
int memory_limit_mb = 8192;
/// "auto", "light" or "dark": what the interface should look like. Kept as the three names libadwaita /// "auto", "light" or "dark": what the interface should look like. Kept as the three names libadwaita
/// uses, so the file says what the setting is rather than what an enum happens to be numbered. /// uses, so the file says what the setting is rather than what an enum happens to be numbered.
std::string theme = "auto"; std::string theme = "auto";
+73 -27
View File
@@ -7,7 +7,9 @@
#include "help_window.hpp" #include "help_window.hpp"
#include "layout_store.hpp" #include "layout_store.hpp"
#include "model.hpp" // `buffer_ticks()` / `planned_bytes()`: what a request becomes
#include "settings.hpp" #include "settings.hpp"
#include "status_text.hpp" // `format_samples()` and friends
#include "source/registry.hpp" #include "source/registry.hpp"
// The app's shared chrome -- `ui_impl::menu_button()`/`menu_toggle()` and `install_css()` -- so the // The app's shared chrome -- `ui_impl::menu_button()`/`menu_toggle()` and `install_css()` -- so the
// chooser's help menu is the *same* menu the window's Help view is, not a second one that merely // chooser's help menu is the *same* menu the window's Help view is, not a second one that merely
@@ -21,6 +23,7 @@
#include <unistd.h> #include <unistd.h>
#include <algorithm> #include <algorithm>
#include <cmath>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <cstring> #include <cstring>
@@ -47,7 +50,7 @@ void apply_source(Config& cfg, const KnownSource& src) {
// The configuration's own startup settings. They are read *before* the model exists, which is the // The configuration's own startup settings. They are read *before* the model exists, which is the
// only moment a buffer size can be applied at all -- and the reason they are stored per // only moment a buffer size can be applied at all -- and the reason they are stored per
// configuration rather than only passed on the command line. // configuration rather than only passed on the command line.
if (src.buffer_mb > 0.0) cfg.buffer_mb = src.buffer_mb; if (src.buffer_samples > 0) cfg.buffer_samples = src.buffer_samples;
// The list's own copy of the clock, but only where nothing newer has said: with the preferences // The list's own copy of the clock, but only where nothing newer has said: with the preferences
// open from this dialog a user can state relative time a minute before clicking the row, and that // open from this dialog a user can state relative time a minute before clicking the row, and that
// statement must survive the click that follows it. // statement must survive the click that follows it.
@@ -59,8 +62,9 @@ void apply_source(Config& cfg, const KnownSource& src) {
void report_sources(const std::vector<KnownSource>& known) { void report_sources(const std::vector<KnownSource>& known) {
std::fprintf(stderr, "[sources] %zu known:", known.size()); std::fprintf(stderr, "[sources] %zu known:", known.size());
for (const KnownSource& s : known) { for (const KnownSource& s : known) {
std::fprintf(stderr, " %s:%u/%s plots=%d signals=%d buffer=%.6g relative=%d capture=%u", std::fprintf(stderr, " %s:%u/%s plots=%d signals=%d samples=%llu relative=%d capture=%u",
s.host.c_str(), s.port, s.config.c_str(), s.rows, s.signals, s.buffer_mb, s.host.c_str(), s.port, s.config.c_str(), s.rows, s.signals,
static_cast<unsigned long long>(s.buffer_samples),
s.relative_time ? 1 : 0, s.capture_packets); s.relative_time ? 1 : 0, s.capture_packets);
if (!s.comment.empty()) std::fprintf(stderr, " note='%s'", s.comment.c_str()); if (!s.comment.empty()) std::fprintf(stderr, " note='%s'", s.comment.c_str());
if (!s.multicast.empty()) std::fprintf(stderr, " multicast=%s", s.multicast.c_str()); if (!s.multicast.empty()) std::fprintf(stderr, " multicast=%s", s.multicast.c_str());
@@ -405,9 +409,7 @@ void chooser_populate(Chooser& c) {
std::string sub = std::to_string(s.rows) + (s.rows == 1 ? " plot, " : " plots, ") + std::string sub = std::to_string(s.rows) + (s.rows == 1 ? " plot, " : " plots, ") +
std::to_string(s.signals) + (s.signals == 1 ? " signal" : " signals"); std::to_string(s.signals) + (s.signals == 1 ? " signal" : " signals");
if (s.buffer_mb > 0.0) { if (s.buffer_samples > 0) sub += ", buffer " + format_samples(s.buffer_samples) + " samples";
sub += ", buffer " + std::to_string(static_cast<int>(s.buffer_mb)) + " MB";
}
if (s.relative_time) sub += ", relative time"; if (s.relative_time) sub += ", relative time";
if (s.capture_packets > 0) { if (s.capture_packets > 0) {
sub += ", capture " + std::to_string(s.capture_packets) + " packets"; sub += ", capture " + std::to_string(s.capture_packets) + " packets";
@@ -442,6 +444,32 @@ void chooser_populate(Chooser& c) {
} }
} }
/// The form's sample budget as a count: the spin is in millions, and a count is what is stored.
std::uint64_t form_buffer_samples(const Chooser& c) {
const double m = adw_spin_row_get_value(c.form_buffer);
return static_cast<std::uint64_t>(std::max(128.0, std::round(m * 1.0e6)));
}
/// Says under the "Samples per signal" row what the number will become: the rings are powers of two,
/// so the request is a ceiling, and the memory limit can lower it further. The limit is judged for
/// *one* signal here -- the channel count is not known until a stream declares it -- so a clip shown
/// is certain, and the status line says what a real stream got. The cost line gives the two figures
/// the user can multiply by their own signal count.
void update_buffer_note(Chooser& c) {
if (c.form_buffer == nullptr) return;
const std::uint64_t requested = form_buffer_samples(c);
const bool relative = adw_switch_row_get_active(c.form_relative) != FALSE;
const std::uint64_t limit = c.cfg->memory_limit_mb * 1024u * 1024u;
const std::uint64_t wanted = buffer_ticks(requested);
std::uint64_t used = wanted;
while (used > 128 && planned_bytes(used, 1, relative) > limit) used /= 2;
const std::string note =
describe_buffer(requested, used, used < wanted, limit) + ". The rings take " +
format_bytes(used * sizeof(double) * (relative ? 2 : 1)) + " for the clock and " +
format_bytes(used * sizeof(float)) + " per signal.";
adw_action_row_set_subtitle(ADW_ACTION_ROW(c.form_buffer), note.c_str());
}
void form_close(Chooser& c) { void form_close(Chooser& c) {
// The form's own `closed` handler clears the pointer, so this is the only place that has to know how // The form's own `closed` handler clears the pointer, so this is the only place that has to know how
// an `AdwDialog` closes. // an `AdwDialog` closes.
@@ -497,7 +525,7 @@ void on_form_connect(GtkButton*, gpointer user) {
const std::string iface = udps ? scfg.get("interface") : std::string(); const std::string iface = udps ? scfg.get("interface") : std::string();
std::string name = gtk_editable_get_text(GTK_EDITABLE(c.form_name)); std::string name = gtk_editable_get_text(GTK_EDITABLE(c.form_name));
const std::string note = gtk_editable_get_text(GTK_EDITABLE(c.form_note)); const std::string note = gtk_editable_get_text(GTK_EDITABLE(c.form_note));
const double buffer_mb = adw_spin_row_get_value(c.form_buffer); const std::uint64_t buffer_samples = form_buffer_samples(c);
const bool relative = adw_switch_row_get_active(c.form_relative) != FALSE; const bool relative = adw_switch_row_get_active(c.form_relative) != FALSE;
if (name.empty()) name = "default"; if (name.empty()) name = "default";
@@ -513,7 +541,7 @@ void on_form_connect(GtkButton*, gpointer user) {
} }
// The startup settings. Applied here because this runs *before* `ScopeModel` exists, which is the // The startup settings. Applied here because this runs *before* `ScopeModel` exists, which is the
// only moment a buffer size can be applied at all -- and stored, so the next run gets them back. // only moment a buffer size can be applied at all -- and stored, so the next run gets them back.
c.cfg->buffer_mb = buffer_mb; c.cfg->buffer_samples = buffer_samples;
c.cfg->relative_time = relative; c.cfg->relative_time = relative;
LayoutState st; LayoutState st;
@@ -523,7 +551,7 @@ void on_form_connect(GtkButton*, gpointer user) {
load_layout(configuration_path(c.edit_host, c.edit_port, c.edit_config), st); load_layout(configuration_path(c.edit_host, c.edit_port, c.edit_config), st);
} }
st.comment = note; st.comment = note;
st.buffer_mb = buffer_mb; st.buffer_samples = buffer_samples;
st.relative_time = relative; st.relative_time = relative;
st.multicast = group; st.multicast = group;
st.interface_addr = iface; st.interface_addr = iface;
@@ -541,9 +569,9 @@ void on_form_connect(GtkButton*, gpointer user) {
c.saved_config = name; c.saved_config = name;
c.saved_note = note; c.saved_note = note;
std::fprintf(stderr, std::fprintf(stderr,
"[sources] configuration %s: %s:%u/%s note='%s' buffer=%.6g relative=%d\n", "[sources] configuration %s: %s:%u/%s note='%s' samples=%llu relative=%d\n",
editing ? "saved" : "created", host.c_str(), port, name.c_str(), note.c_str(), editing ? "saved" : "created", host.c_str(), port, name.c_str(), note.c_str(),
buffer_mb, relative ? 1 : 0); static_cast<unsigned long long>(buffer_samples), relative ? 1 : 0);
std::fflush(stderr); std::fflush(stderr);
} else { } else {
std::fprintf(stderr, "[sources] configuration write failed: %s:%u/%s\n", host.c_str(), port, std::fprintf(stderr, "[sources] configuration write failed: %s:%u/%s\n", host.c_str(), port,
@@ -624,12 +652,24 @@ void open_form(Chooser& c, int edit_index = -1) {
// the rows' subtitles, so an empty field still says what empty means. // the rows' subtitles, so an empty field still says what empty means.
gtk_editable_set_text(GTK_EDITABLE(c.form_name), src != nullptr ? src->config.c_str() : "default"); gtk_editable_set_text(GTK_EDITABLE(c.form_name), src != nullptr ? src->config.c_str() : "default");
gtk_editable_set_text(GTK_EDITABLE(c.form_note), src != nullptr ? src->comment.c_str() : ""); gtk_editable_set_text(GTK_EDITABLE(c.form_note), src != nullptr ? src->comment.c_str() : "");
adw_spin_row_set_value(c.form_buffer, src != nullptr && src->buffer_mb > 0.0 adw_spin_row_set_value(c.form_buffer, double(src != nullptr && src->buffer_samples > 0
? src->buffer_mb ? src->buffer_samples
: std::max(1.0, c.cfg->buffer_mb)); : c.cfg->buffer_samples) /
1.0e6);
update_buffer_note(c);
g_signal_connect(c.form_buffer, "notify::value", G_CALLBACK(+[](GObject*, GParamSpec*, gpointer user) {
update_buffer_note(*static_cast<Chooser*>(user));
}),
&c);
adw_switch_row_set_active(c.form_relative, adw_switch_row_set_active(c.form_relative,
(src != nullptr ? src->relative_time : c.cfg->relative_time) ? TRUE (src != nullptr ? src->relative_time : c.cfg->relative_time) ? TRUE
: FALSE); : FALSE);
// The relative clock doubles the time ring, so the note under the sample budget follows it.
g_signal_connect(c.form_relative, "notify::active", G_CALLBACK(+[](GObject*, GParamSpec*, gpointer user) {
update_buffer_note(*static_cast<Chooser*>(user));
}),
&c);
update_buffer_note(c);
// The dropdown lists the registered kinds. A new host starts on the kind the command line named // The dropdown lists the registered kinds. A new host starts on the kind the command line named
// (UDPS by default); an edit starts on the configuration's own kind and cannot change it -- // (UDPS by default); an edit starts on the configuration's own kind and cannot change it --
@@ -701,10 +741,11 @@ void report_pick(const Chooser& c) {
// size would look identical here otherwise, and the buffer is the one of them a user notices // size would look identical here otherwise, and the buffer is the one of them a user notices
// only after a long capture has been sized wrong. // only after a long capture has been sized wrong.
std::fprintf(stderr, std::fprintf(stderr,
"[sources] picked %s:%u multicast=%s interface=%s buffer=%.6g relative=%d " "[sources] picked %s:%u multicast=%s interface=%s samples=%llu relative=%d "
"capture=%u config=%s note='%s'\n", "capture=%u config=%s note='%s'\n",
c.cfg->server.c_str(), c.cfg->port, c.cfg->multicast.c_str(), c.cfg->server.c_str(), c.cfg->port, c.cfg->multicast.c_str(),
c.cfg->interface_addr.c_str(), c.cfg->buffer_mb, c.cfg->relative_time ? 1 : 0, c.cfg->interface_addr.c_str(),
static_cast<unsigned long long>(c.cfg->buffer_samples), c.cfg->relative_time ? 1 : 0,
c.cfg->capture_packets, c.cfg->capture_packets,
(c.saved_config.empty() ? (c.known.empty() ? "-" : c.known.front().config) (c.saved_config.empty() ? (c.known.empty() ? "-" : c.known.front().config)
: c.saved_config) : c.saved_config)
@@ -748,6 +789,10 @@ void open_chooser_preferences(Chooser& c) {
// stored configuration, and it works in both directions -- turning it on *and* off. // stored configuration, and it works in both directions -- turning it on *and* off.
c.cfg->relative_time = s.relative_time; c.cfg->relative_time = s.relative_time;
c.cfg->relative_time_specified = true; c.cfg->relative_time_specified = true;
// The memory limit is read by the model when it is built, which is after this returns, so the
// chooser's own change is the newest word on it for the run about to start.
c.cfg->memory_limit_mb = static_cast<std::uint64_t>(s.memory_limit_mb);
c.cfg->memory_limit_specified = true;
if (!written) { if (!written) {
std::fprintf(stderr, "[sources] prefs: could not write %s\n", settings_path().c_str()); std::fprintf(stderr, "[sources] prefs: could not write %s\n", settings_path().c_str());
std::fflush(stderr); std::fflush(stderr);
@@ -1171,7 +1216,7 @@ gboolean pick_probe(gpointer user) {
c.form_cfg.write(sc); c.form_cfg.write(sc);
gtk_editable_set_text(GTK_EDITABLE(c.form_name), "probe-host"); gtk_editable_set_text(GTK_EDITABLE(c.form_name), "probe-host");
gtk_editable_set_text(GTK_EDITABLE(c.form_note), "made by the new-host probe"); gtk_editable_set_text(GTK_EDITABLE(c.form_note), "made by the new-host probe");
adw_spin_row_set_value(c.form_buffer, 32); adw_spin_row_set_value(c.form_buffer, 0.5);
adw_switch_row_set_active(c.form_relative, TRUE); adw_switch_row_set_active(c.form_relative, TRUE);
// 9/9 for UDPS: the form's row titles are mnemonics (`Source _type`, `_Server`, `_Port`, ...). The // 9/9 for UDPS: the form's row titles are mnemonics (`Source _type`, `_Server`, `_Port`, ...). The
// total follows the kind's row count: five rows are the chooser's, the rest the kind's own. // total follows the kind's row count: five rows are the chooser's, the rest the kind's own.
@@ -1183,7 +1228,7 @@ gboolean pick_probe(gpointer user) {
std::fprintf(stderr, "[sources] form mnemonics=%d/%zu\n", underlines, std::fprintf(stderr, "[sources] form mnemonics=%d/%zu\n", underlines,
5 + c.form_cfg.rows.size()); 5 + c.form_cfg.rows.size());
std::fprintf(stderr, std::fprintf(stderr,
"[sources] form: name='%s' note='%s' buffer=%.6g relative=%d " "[sources] form: name='%s' note='%s' samples_m=%.6g relative=%d "
"connect_sensitive=%d\n", "connect_sensitive=%d\n",
gtk_editable_get_text(GTK_EDITABLE(c.form_name)), gtk_editable_get_text(GTK_EDITABLE(c.form_name)),
gtk_editable_get_text(GTK_EDITABLE(c.form_note)), gtk_editable_get_text(GTK_EDITABLE(c.form_note)),
@@ -1216,14 +1261,14 @@ gboolean pick_probe(gpointer user) {
// The reading is the *pre-fill*: an edit form that opens empty is the defect this asserts // The reading is the *pre-fill*: an edit form that opens empty is the defect this asserts
// against, and the button's label says which mode it is in. // against, and the button's label says which mode it is in.
std::fprintf(stderr, std::fprintf(stderr,
"[sources] edit form: name='%s' note='%s' buffer=%.6g relative=%d button='%s'\n", "[sources] edit form: name='%s' note='%s' samples_m=%.6g relative=%d button='%s'\n",
gtk_editable_get_text(GTK_EDITABLE(c.form_name)), gtk_editable_get_text(GTK_EDITABLE(c.form_name)),
gtk_editable_get_text(GTK_EDITABLE(c.form_note)), gtk_editable_get_text(GTK_EDITABLE(c.form_note)),
adw_spin_row_get_value(c.form_buffer), adw_spin_row_get_value(c.form_buffer),
adw_switch_row_get_active(c.form_relative) ? 1 : 0, adw_switch_row_get_active(c.form_relative) ? 1 : 0,
gtk_button_get_label(GTK_BUTTON(c.form_connect))); gtk_button_get_label(GTK_BUTTON(c.form_connect)));
gtk_editable_set_text(GTK_EDITABLE(c.form_note), "edited by the probe"); gtk_editable_set_text(GTK_EDITABLE(c.form_note), "edited by the probe");
adw_spin_row_set_value(c.form_buffer, 16); adw_spin_row_set_value(c.form_buffer, 0.25);
adw_switch_row_set_active(c.form_relative, FALSE); adw_switch_row_set_active(c.form_relative, FALSE);
// `UDPSCOPE_PROBE_EDITNAME=new-name` also renames it, which is a *move*: the old file must not // `UDPSCOPE_PROBE_EDITNAME=new-name` also renames it, which is a *move*: the old file must not
// survive, or the chooser lists the same session twice. // survive, or the chooser lists the same session twice.
@@ -1263,16 +1308,17 @@ void apply_stored_settings(Config& cfg) {
// Only where nothing newer was said: a flag the user typed, or the preferences they just applied, // Only where nothing newer was said: a flag the user typed, or the preferences they just applied,
// wins -- because it is the more recent statement of intent, and the file is not a place to hide a // wins -- because it is the more recent statement of intent, and the file is not a place to hide a
// surprise from whoever just made it. // surprise from whoever just made it.
if (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) cfg.buffer_mb = st.buffer_mb; if (!cfg.buffer_samples_specified && st.buffer_samples > 0) cfg.buffer_samples = st.buffer_samples;
const bool relative = !cfg.relative_time_specified && st.relative_time; const bool relative = !cfg.relative_time_specified && st.relative_time;
if (relative) cfg.relative_time = true; if (relative) cfg.relative_time = true;
if (cfg.capture_packets == 0 && st.capture_packets > 0) cfg.capture_packets = st.capture_packets; if (cfg.capture_packets == 0 && st.capture_packets > 0) cfg.capture_packets = st.capture_packets;
const bool applied = (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) || relative || const bool applied = (!cfg.buffer_samples_specified && st.buffer_samples > 0) || relative ||
st.capture_packets > 0; st.capture_packets > 0;
if (applied) { if (applied) {
std::fprintf(stderr, std::fprintf(stderr,
"[sources] settings from configuration '%s': buffer=%.6g relative=%d capture=%u\n", "[sources] settings from configuration '%s': samples=%llu relative=%d capture=%u\n",
want.c_str(), cfg.buffer_mb, cfg.relative_time ? 1 : 0, cfg.capture_packets); want.c_str(), static_cast<unsigned long long>(cfg.buffer_samples),
cfg.relative_time ? 1 : 0, cfg.capture_packets);
std::fflush(stderr); std::fflush(stderr);
} }
} }
@@ -1330,9 +1376,9 @@ void loading_show(const Config& cfg) {
head = "Connecting to " + cfg.server + ":" + std::to_string(cfg.port); head = "Connecting to " + cfg.server + ":" + std::to_string(cfg.port);
if (!cfg.multicast.empty()) head += " (" + cfg.multicast + ")"; if (!cfg.multicast.empty()) head += " (" + cfg.multicast + ")";
} }
char buf[128]; sub = "planning the buffer: " + format_samples(buffer_ticks(cfg.buffer_samples)) +
std::snprintf(buf, sizeof(buf), "planning the %.0f MB sample buffer", cfg.buffer_mb); " samples per signal, " + format_bytes(cfg.memory_limit_mb * 1024u * 1024u) +
sub = buf; " memory limit";
} else { } else {
const std::size_t slash = cfg.open_waveform.rfind('/'); const std::size_t slash = cfg.open_waveform.rfind('/');
head = "Opening " + (slash == std::string::npos ? cfg.open_waveform head = "Opening " + (slash == std::string::npos ? cfg.open_waveform
+49 -4
View File
@@ -41,6 +41,43 @@ std::string format_rate(double per_s) {
return buf; return buf;
} }
std::string format_samples(std::uint64_t n) {
char buf[32];
const double d = static_cast<double>(n);
if (n >= 1000000000ull) {
std::snprintf(buf, sizeof(buf), "%.3g G", d / 1.0e9);
} else if (n >= 1000000ull) {
std::snprintf(buf, sizeof(buf), "%.3g M", d / 1.0e6);
} else if (n >= 1000ull) {
std::snprintf(buf, sizeof(buf), "%.3g k", d / 1.0e3);
} else {
std::snprintf(buf, sizeof(buf), "%llu", static_cast<unsigned long long>(n));
}
return buf;
}
std::string format_bytes(std::uint64_t bytes) {
char buf[32];
const double d = static_cast<double>(bytes);
if (bytes >= (1ull << 30)) {
std::snprintf(buf, sizeof(buf), "%.3g GiB", d / double(1ull << 30));
} else if (bytes >= (1ull << 20)) {
std::snprintf(buf, sizeof(buf), "%.3g MiB", d / double(1ull << 20));
} else if (bytes >= (1ull << 10)) {
std::snprintf(buf, sizeof(buf), "%.3g KiB", d / double(1ull << 10));
} else {
std::snprintf(buf, sizeof(buf), "%llu B", static_cast<unsigned long long>(bytes));
}
return buf;
}
std::string describe_buffer(std::uint64_t requested, std::uint64_t used, bool memory_limited,
std::uint64_t memory_limit_bytes) {
std::string out = format_samples(requested) + " requested, " + format_samples(used) + " used";
if (memory_limited) out += " (limited by the " + format_bytes(memory_limit_bytes) + " memory limit)";
return out;
}
std::string format_glance(const StatusSnapshot& s) { std::string format_glance(const StatusSnapshot& s) {
std::string out = s.state_word; std::string out = s.state_word;
out += " · "; out += " · ";
@@ -68,17 +105,25 @@ std::string format_detail(const StatusSnapshot& s) {
char buf[1024]; char buf[1024];
std::snprintf(buf, sizeof(buf), std::snprintf(buf, sizeof(buf),
"[%s] %s %s | rate %s | packets %llu frames %llu gaps %llu | %zu signals, " "[%s] %s %s | rate %s | packets %llu frames %llu gaps %llu | %zu signals, "
"%zu channels | plot 1: %d trace(s), %zu samples | buffer %.1f MB, %.2f s " "%zu channels | plot 1: %d trace(s), %zu samples | buffer %s samples/signal "
"span, time x%llu", "(%s), %.2f s span, time x%llu",
s.state_detail.c_str(), s.connected ? "connected" : "waiting", s.state_detail.c_str(), s.connected ? "connected" : "waiting",
s.have_config ? "configured" : "-", s.have_config ? "configured" : "-",
s.rate_text.empty() ? "--" : s.rate_text.c_str(), s.rate_text.empty() ? "--" : s.rate_text.c_str(),
static_cast<unsigned long long>(s.packets), static_cast<unsigned long long>(s.packets),
static_cast<unsigned long long>(s.frames), static_cast<unsigned long long>(s.frames),
static_cast<unsigned long long>(s.gaps), s.signals, s.channels, static_cast<unsigned long long>(s.gaps), s.signals, s.channels,
s.plot1_traces, s.plot1_samples, s.buffer_mb, s.span_s, s.plot1_traces, s.plot1_samples, format_samples(s.buffer_samples).c_str(),
static_cast<unsigned long long>(s.time_stride)); s.buffer_bytes > 0 ? format_bytes(s.buffer_bytes).c_str() : "not planned",
s.span_s, static_cast<unsigned long long>(s.time_stride));
std::string out = buf; std::string out = buf;
// The request is quoted only where it differs from what was planned, and the limit only where it
// is the reason: a clean run says the figure once, and a clipped one says why in the same breath.
if (s.memory_limited) {
out += " | limited to " + format_samples(s.buffer_samples) + " samples by the " +
format_bytes(s.memory_limit_bytes) + " memory limit (" +
format_samples(s.buffer_requested) + " requested)";
}
// Nothing about resequencing is said at all while all six read zero, which is a stream that // Nothing about resequencing is said at all while all six read zero, which is a stream that
// never lost ordering: the field would otherwise be six zeros on every clean run, and this // never lost ordering: the field would otherwise be six zeros on every clean run, and this
// string is read by eye out of the exit dump. // string is read by eye out of the exit dump.
+22 -1
View File
@@ -41,7 +41,15 @@ struct StatusSnapshot {
std::size_t channels = 0; std::size_t channels = 0;
int plot1_traces = 0; int plot1_traces = 0;
std::size_t plot1_samples = 0; std::size_t plot1_samples = 0;
double buffer_mb = 0.0; /// The sample budget as planned: slots per signal the rings hold (the request rounded down to a
/// power of two and clipped by the memory limit), what was asked for, and the bytes those rings
/// occupy -- 0 until a stream has planned a buffer. `memory_limited` says the limit, and not the
/// rounding, made `buffer_samples` smaller than the request.
std::uint64_t buffer_samples = 0;
std::uint64_t buffer_requested = 0;
std::uint64_t buffer_bytes = 0;
std::uint64_t memory_limit_bytes = 0;
bool memory_limited = false;
double span_s = 0.0; double span_s = 0.0;
std::uint64_t time_stride = 1; std::uint64_t time_stride = 1;
bool have_event = false; bool have_event = false;
@@ -82,6 +90,19 @@ std::string format_detail(const StatusSnapshot& s);
/// cannot track the stream. Moved here from ui.cpp, which still calls it. /// cannot track the stream. Moved here from ui.cpp, which still calls it.
std::string format_rate(double hz); std::string format_rate(double hz);
/// A sample count as "67.1 M": `%.3g` of the count in the unit it reads shortest in (k, M, G; decimal,
/// like `--buffer-samples`). Under a thousand it is the bare number.
std::string format_samples(std::uint64_t n);
/// A byte count in binary units, "168 MiB" / "8 GiB", `%.3g` so the width does not track the figure.
std::string format_bytes(std::uint64_t bytes);
/// The sentence the source form, the status popover and the exit dump share for a sample budget:
/// "100 M requested, 67.1 M used", and " (limited by the 1 GiB memory limit)" when the limit clipped it.
/// `requested` is the user's number, `used` the planned slots per signal.
std::string describe_buffer(std::uint64_t requested, std::uint64_t used, bool memory_limited,
std::uint64_t memory_limit_bytes);
/// An elapsed time, stepping to a coarser unit rather than growing digits, so its width /// An elapsed time, stepping to a coarser unit rather than growing digits, so its width
/// cannot track the stream. Moved here from ui.cpp. /// cannot track the stream. Moved here from ui.cpp.
std::string format_elapsed(double seconds); std::string format_elapsed(double seconds);
+50 -3
View File
@@ -4397,7 +4397,16 @@ gboolean tick(gpointer user) {
snap.channels = channel_count; snap.channels = channel_count;
snap.plot1_traces = bound; snap.plot1_traces = bound;
snap.plot1_samples = samples; snap.plot1_samples = samples;
snap.buffer_mb = double(model.config().buffer_mb); {
// The planned buffer when there is one; before the first CONFIG there is no plan, and the
// request (rounded as the ring will round it) is the honest figure, with no bytes to quote.
const Partition& part = model.partition();
snap.buffer_requested = model.config().buffer_samples;
snap.buffer_samples = part.time_capacity > 0 ? part.time_capacity : buffer_ticks(snap.buffer_requested);
snap.buffer_bytes = part.bytes();
snap.memory_limit_bytes = model.memory_limit_bytes();
snap.memory_limited = part.memory_limited;
}
snap.span_s = span_s; snap.span_s = span_s;
snap.time_stride = time_stride; snap.time_stride = time_stride;
snap.have_event = app->last_latch_us != 0; snap.have_event = app->last_latch_us != 0;
@@ -5281,8 +5290,9 @@ void on_activate(GtkApplication* application, gpointer user) {
if (app->waveform_name.empty()) { if (app->waveform_name.empty()) {
const Config& cfg = app->model->config(); const Config& cfg = app->model->config();
char buf[192]; char buf[192];
std::snprintf(buf, sizeof(buf), "connecting to %s:%u -- planning the %.0f MB buffer", std::snprintf(buf, sizeof(buf), "connecting to %s:%u -- planning the buffer: %s samples per signal",
cfg.server.c_str(), unsigned(cfg.port), cfg.buffer_mb); cfg.server.c_str(), unsigned(cfg.port),
format_samples(buffer_ticks(cfg.buffer_samples)).c_str());
set_notice(*app, buf, 6); set_notice(*app, buf, 6);
} }
} }
@@ -6081,6 +6091,43 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
static_cast<unsigned long long>(model.live().values(i).produced()), static_cast<unsigned long long>(model.live().values(i).produced()),
static_cast<unsigned long long>(model.live().channel(i).size())); static_cast<unsigned long long>(model.live().channel(i).size()));
} }
// The sample budget as this run planned it, and the planner on fixed inputs. The first line is
// the live buffer: `used` is the request rounded down to a power of two and clipped by the memory
// limit, `bytes` the rings' real size. The second feeds `plan_partition()` three channels and a
// 100 M request three ways -- room to spare, a 1 GiB limit that must clip, a limit too small for
// even 128 slots -- so a change to the rounding, the halving or the byte formula reads here even
// when the run's own plan happens not to be clipped.
std::printf("[udpscope] buffer: requested=%llu used=%llu bytes=%llu limit=%llu limited=%d\n",
static_cast<unsigned long long>(model.config().buffer_samples),
static_cast<unsigned long long>(part.time_capacity),
static_cast<unsigned long long>(part.bytes()),
static_cast<unsigned long long>(model.memory_limit_bytes()),
part.memory_limited ? 1 : 0);
{
const std::vector<double> dts(3, 1.0e-4);
const auto pow2 = [](unsigned long long v) { return v != 0 && (v & (v - 1)) == 0; };
const Partition open = plan_partition(dts, 100000000ull, 16ull << 30, 0.0);
constexpr unsigned long long kGiB = 1ull << 30;
const Partition clip = plan_partition(dts, 100000000ull, kGiB, 0.0);
const Partition clip_rel = plan_partition(dts, 100000000ull, kGiB, 0.0, true);
bool thrown = false;
try {
(void)plan_partition(dts, 100000000ull, 1000, 0.0);
} catch (const std::exception&) {
thrown = true;
}
std::printf("[udpscope] planner: unclipped got=%llu pow2=%d limited=%d | clipped(1GiB) "
"got=%llu bytes=%llu fits=%d pow2=%d limited=%d | clipped_rel got=%llu "
"bytes=%llu fits=%d | too_small_throws=%d\n",
static_cast<unsigned long long>(open.time_capacity),
pow2(open.time_capacity) ? 1 : 0, open.memory_limited ? 1 : 0,
static_cast<unsigned long long>(clip.time_capacity),
static_cast<unsigned long long>(clip.bytes()), clip.bytes() <= kGiB ? 1 : 0,
pow2(clip.time_capacity) ? 1 : 0, clip.memory_limited ? 1 : 0,
static_cast<unsigned long long>(clip_rel.time_capacity),
static_cast<unsigned long long>(clip_rel.bytes()),
clip_rel.bytes() <= kGiB ? 1 : 0, thrown ? 1 : 0);
}
// `frames_painted` is the area's, read from the snapshot taken before the window was // `frames_painted` is the area's, read from the snapshot taken before the window was
// destroyed (the PlotArea wrapper dies with its widget): one figure, one render per // destroyed (the PlotArea wrapper dies with its widget): one figure, one render per
// frame, so it is the same count for every row. // frame, so it is the same count for every row.
+22 -1
View File
@@ -13,7 +13,7 @@
show-apply-button is off here: the write is a few bytes to one file, so a keystroke is not worth show-apply-button is off here: the write is a few bytes to one file, so a keystroke is not worth
batching). The two settings that cannot take effect immediately — the cache directory and relative batching). The two settings that cannot take effect immediately — the cache directory and relative
time, both of which are planned before any source is known — say so in their subtitles instead of time, both of which are planned before any source is known — say so in their subtitles instead of
being held back by a button. being held back by a button. The memory limit does take effect at once: a running buffer re-plans.
Nothing here carries a value: `ui_prefs.cpp` fills the rows from `Settings` when the dialog opens, Nothing here carries a value: `ui_prefs.cpp` fills the rows from `Settings` when the dialog opens,
and writes it back on every change. This file is the shape, that is the state. and writes it back on every change. This file is the shape, that is the state.
@@ -77,6 +77,27 @@
</child> </child>
</object> </object>
</child> </child>
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="yes">Sample buffer</property>
<child>
<object class="AdwSpinRow" id="memory_row">
<property name="title" translatable="yes">_Memory limit (MB)</property>
<property name="use-underline">True</property>
<property name="subtitle" translatable="yes">Hard ceiling on the memory of the sample rings, all signals together. A source asks for a number of samples per signal; if that does not fit under this limit it is halved until it does, and the status line says so. Applies at once: a running buffer is re-planned, which clears its history.</property>
<property name="adjustment">
<object class="GtkAdjustment">
<property name="lower">16</property>
<property name="upper">1048576</property>
<property name="step-increment">256</property>
<property name="page-increment">1024</property>
<property name="value">8192</property>
</object>
</property>
</object>
</child>
</object>
</child>
</object> </object>
</child> </child>
<child> <child>
+7 -5
View File
@@ -85,15 +85,17 @@
</child> </child>
<child> <child>
<object class="AdwSpinRow" id="buffer_row"> <object class="AdwSpinRow" id="buffer_row">
<property name="title" translatable="yes">_Buffer MB</property> <property name="title" translatable="yes">_Samples per signal (millions)</property>
<property name="use-underline">True</property> <property name="use-underline">True</property>
<property name="subtitle" translatable="yes">sample buffer per source, applied when this source is opened</property> <property name="subtitle" translatable="yes">samples kept for every signal, rounded down to a power of two; applied when this source is opened</property>
<property name="digits">3</property>
<property name="adjustment"> <property name="adjustment">
<object class="GtkAdjustment"> <object class="GtkAdjustment">
<property name="lower">1</property> <property name="lower">0.001</property>
<property name="upper">65536</property> <property name="upper">4096</property>
<property name="step-increment">1</property> <property name="step-increment">1</property>
<property name="value">64</property> <property name="page-increment">10</property>
<property name="value">10</property>
</object> </object>
</property> </property>
</object> </object>
+5 -7
View File
@@ -103,7 +103,7 @@ LayoutState collect_layout(App& app) {
// *startup* parameter -- the rings are allocated before any stream is known -- so a configuration // *startup* parameter -- the rings are allocated before any stream is known -- so a configuration
// is the only place it can be remembered per source, and the chooser applies it on open. // is the only place it can be remembered per source, and the chooser applies it on open.
st.comment = app.config_comment; st.comment = app.config_comment;
st.buffer_mb = app.model->config().buffer_mb; st.buffer_samples = app.model->config().buffer_samples;
st.relative_time = app.model->config().relative_time; st.relative_time = app.model->config().relative_time;
st.capture_packets = app.model->config().capture_packets; st.capture_packets = app.model->config().capture_packets;
return st; return st;
@@ -135,8 +135,8 @@ void on_config_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
// a configuration that appears to half-apply. // a configuration that appears to half-apply.
const Config& cfg = app.model->config(); const Config& cfg = app.model->config();
std::string later; std::string later;
if (st.buffer_mb > 0.0 && st.buffer_mb != cfg.buffer_mb) { if (st.buffer_samples > 0 && st.buffer_samples != cfg.buffer_samples) {
later += "buffer " + std::to_string(static_cast<int>(st.buffer_mb)) + " MB"; later += "buffer " + format_samples(st.buffer_samples) + " samples";
} }
if (st.relative_time != cfg.relative_time) { if (st.relative_time != cfg.relative_time) {
later += later.empty() ? "" : ", "; later += later.empty() ? "" : ", ";
@@ -237,9 +237,7 @@ void config_dialog_refresh(ConfigDialog& d) {
} }
std::string sub = std::to_string(st.rows.size()) + " plots, " + std::to_string(signals) + std::string sub = std::to_string(st.rows.size()) + " plots, " + std::to_string(signals) +
" signals"; " signals";
if (st.buffer_mb > 0.0) { if (st.buffer_samples > 0) sub += ", buffer " + format_samples(st.buffer_samples) + " samples";
sub += ", buffer " + std::to_string(static_cast<int>(st.buffer_mb)) + " MB";
}
if (st.relative_time) sub += ", relative time"; if (st.relative_time) sub += ", relative time";
GtkWidget* counts = gtk_label_new(sub.c_str()); GtkWidget* counts = gtk_label_new(sub.c_str());
gtk_widget_add_css_class(counts, "dim-label"); gtk_widget_add_css_class(counts, "dim-label");
@@ -410,7 +408,7 @@ void apply_layout(App& app, const LayoutState& st) {
// What the session had on, collected and enabled **once**, at the end of this function. // What the session had on, collected and enabled **once**, at the end of this function.
// Each enable re-plans the partition, and a re-plan allocates -- and so touches -- the whole // Each enable re-plans the partition, and a re-plan allocates -- and so touches -- the whole
// of `--buffer-mb`: seven signals one at a time is seven allocations before a single sample // of the planned rings: seven signals one at a time is seven allocations before a single sample
// is kept, which is the ten seconds of blank window a large buffer used to start with. The // is kept, which is the ten seconds of blank window a large buffer used to start with. The
// math channels are not collected here: their enable state is the session's `math` and // math channels are not collected here: their enable state is the session's `math` and
// `set_math_channels()` has already restored it, so listing them again could turn on one // `set_math_channels()` has already restored it, so listing them again could turn on one
+29 -1
View File
@@ -4,6 +4,7 @@
#include "ui_prefs.hpp" #include "ui_prefs.hpp"
#include "event_store.hpp" // `default_events_dir()`, so the row shows the path the cache uses #include "event_store.hpp" // `default_events_dir()`, so the row shows the path the cache uses
#include "status_text.hpp" // `format_bytes()`
#include "ui_app.hpp" // the application's half of the contract, at the bottom of this file #include "ui_app.hpp" // the application's half of the contract, at the bottom of this file
#include <adwaita.h> #include <adwaita.h>
@@ -36,6 +37,7 @@ Settings read_rows(const PrefsForm& f) {
s.event_cache_dir = gtk_editable_get_text(GTK_EDITABLE(f.dir_row)); s.event_cache_dir = gtk_editable_get_text(GTK_EDITABLE(f.dir_row));
s.event_cache_max = static_cast<int>(adw_spin_row_get_value(f.keep_row)); s.event_cache_max = static_cast<int>(adw_spin_row_get_value(f.keep_row));
s.relative_time = adw_switch_row_get_active(f.relative_row) != FALSE; s.relative_time = adw_switch_row_get_active(f.relative_row) != FALSE;
s.memory_limit_mb = static_cast<int>(adw_spin_row_get_value(f.memory_row));
s.theme = theme_name(adw_combo_row_get_selected(f.theme_row)); s.theme = theme_name(adw_combo_row_get_selected(f.theme_row));
// The row shows the *effective* directory, so the default is written out in full rather than // The row shows the *effective* directory, so the default is written out in full rather than
// described. Leaving it at that default is not a choice to pin it: the file keeps "unset", and the // described. Leaving it at that default is not a choice to pin it: the file keeps "unset", and the
@@ -63,6 +65,7 @@ void fill_rows(PrefsForm& f, const Settings& s) {
(s.event_cache_dir.empty() ? default_events_dir() : s.event_cache_dir).c_str()); (s.event_cache_dir.empty() ? default_events_dir() : s.event_cache_dir).c_str());
adw_spin_row_set_value(f.keep_row, s.event_cache_max < 0 ? 0.0 : s.event_cache_max); adw_spin_row_set_value(f.keep_row, s.event_cache_max < 0 ? 0.0 : s.event_cache_max);
adw_switch_row_set_active(f.relative_row, s.relative_time ? TRUE : FALSE); adw_switch_row_set_active(f.relative_row, s.relative_time ? TRUE : FALSE);
adw_spin_row_set_value(f.memory_row, s.memory_limit_mb);
adw_combo_row_set_selected(f.theme_row, s.theme == "light" ? 1 : (s.theme == "dark" ? 2 : 0)); adw_combo_row_set_selected(f.theme_row, s.theme == "light" ? 1 : (s.theme == "dark" ? 2 : 0));
f.filling = false; f.filling = false;
} }
@@ -141,6 +144,7 @@ void on_closed(AdwDialog*, gpointer user) {
f.dir_row = nullptr; f.dir_row = nullptr;
f.keep_row = nullptr; f.keep_row = nullptr;
f.relative_row = nullptr; f.relative_row = nullptr;
f.memory_row = nullptr;
f.theme_row = nullptr; f.theme_row = nullptr;
f.browse = nullptr; f.browse = nullptr;
f.parent = nullptr; f.parent = nullptr;
@@ -155,12 +159,14 @@ void resolve_rows(PrefsForm& f) {
f.keep_row = ADW_SPIN_ROW(gtk_builder_get_object(f.builder, "keep_row")); f.keep_row = ADW_SPIN_ROW(gtk_builder_get_object(f.builder, "keep_row"));
f.relative_row = ADW_SWITCH_ROW(gtk_builder_get_object(f.builder, "relative_row")); f.relative_row = ADW_SWITCH_ROW(gtk_builder_get_object(f.builder, "relative_row"));
f.theme_row = ADW_COMBO_ROW(gtk_builder_get_object(f.builder, "theme_row")); f.theme_row = ADW_COMBO_ROW(gtk_builder_get_object(f.builder, "theme_row"));
f.memory_row = ADW_SPIN_ROW(gtk_builder_get_object(f.builder, "memory_row"));
if (f.dir_row == nullptr || f.keep_row == nullptr || f.relative_row == nullptr || if (f.dir_row == nullptr || f.keep_row == nullptr || f.relative_row == nullptr ||
f.theme_row == nullptr) { f.theme_row == nullptr || f.memory_row == nullptr) {
f.dir_row = nullptr; f.dir_row = nullptr;
f.keep_row = nullptr; f.keep_row = nullptr;
f.relative_row = nullptr; f.relative_row = nullptr;
f.theme_row = nullptr; f.theme_row = nullptr;
f.memory_row = nullptr;
g_clear_object(&f.builder); g_clear_object(&f.builder);
} }
} }
@@ -214,6 +220,7 @@ void prefs_open(PrefsForm& form, GtkWidget* parent, const Settings& current,
g_signal_connect(form.dir_row, "changed", G_CALLBACK(on_entry_changed), &form); g_signal_connect(form.dir_row, "changed", G_CALLBACK(on_entry_changed), &form);
g_signal_connect(form.keep_row, "notify::value", G_CALLBACK(on_row_notify), &form); g_signal_connect(form.keep_row, "notify::value", G_CALLBACK(on_row_notify), &form);
g_signal_connect(form.relative_row, "notify::active", G_CALLBACK(on_row_notify), &form); g_signal_connect(form.relative_row, "notify::active", G_CALLBACK(on_row_notify), &form);
g_signal_connect(form.memory_row, "notify::value", G_CALLBACK(on_row_notify), &form);
g_signal_connect(form.theme_row, "notify::selected", G_CALLBACK(on_row_notify), &form); g_signal_connect(form.theme_row, "notify::selected", G_CALLBACK(on_row_notify), &form);
if (GObject* reset = gtk_builder_get_object(form.builder, "reset_row"); reset != nullptr) { if (GObject* reset = gtk_builder_get_object(form.builder, "reset_row"); reset != nullptr) {
g_signal_connect(reset, "activated", G_CALLBACK(on_reset), &form); g_signal_connect(reset, "activated", G_CALLBACK(on_reset), &form);
@@ -239,9 +246,29 @@ void prefs_probe_change(PrefsForm& form) {
gtk_editable_set_text(GTK_EDITABLE(form.dir_row), "/tmp/udpscope-prefs-events"); gtk_editable_set_text(GTK_EDITABLE(form.dir_row), "/tmp/udpscope-prefs-events");
adw_spin_row_set_value(form.keep_row, 7.0); adw_spin_row_set_value(form.keep_row, 7.0);
adw_switch_row_set_active(form.relative_row, TRUE); adw_switch_row_set_active(form.relative_row, TRUE);
adw_spin_row_set_value(form.memory_row, 4096.0);
adw_combo_row_set_selected(form.theme_row, 2); adw_combo_row_set_selected(form.theme_row, 2);
} }
/// A changed memory limit reaches the running model. Only when it differs from the model's own (every
/// preference arrives through the same callback, and a theme change is not a re-plan), and without
/// re-planning while a file or a replayed event is what the buffer holds -- clearing those would
/// throw away what the user opened, so the limit is stored and the next plan honours it.
static void apply_memory_limit(App& app, int mb) {
if (app.model == nullptr || mb <= 0) return;
const std::uint64_t bytes = static_cast<std::uint64_t>(mb) * 1024u * 1024u;
if (bytes == app.model->memory_limit_bytes()) return;
const bool holds_a_view = !app.waveform_name.empty() || app.replaying;
const bool rebuilt = app.model->set_memory_limit_bytes(bytes, !holds_a_view);
std::fprintf(stderr, "[udpscope] memory limit now %s (rebuilt=%d%s)\n",
format_bytes(bytes).c_str(), rebuilt ? 1 : 0,
holds_a_view ? ", applies to the next plan" : "");
std::fflush(stderr);
if (rebuilt) {
set_notice(app, "memory limit " + format_bytes(bytes) + ": buffer re-planned, history cleared", 5);
}
}
/// The application's entry point: **Preferences…** in the overflow menu. Kept here rather than in /// The application's entry point: **Preferences…** in the overflow menu. Kept here rather than in
/// `ui.cpp` with the rest of the chrome, because it is the application's half of *this* form's /// `ui.cpp` with the rest of the chrome, because it is the application's half of *this* form's
/// contract -- what a preference changing means to a running scope -- and reading it beside the form is /// contract -- what a preference changing means to a running scope -- and reading it beside the form is
@@ -249,6 +276,7 @@ void prefs_probe_change(PrefsForm& form) {
void open_preferences(App& app) { void open_preferences(App& app) {
prefs_open(app.prefs, app.window, app.settings, [&app](const Settings& s, bool written) { prefs_open(app.prefs, app.window, app.settings, [&app](const Settings& s, bool written) {
app.settings = s; app.settings = s;
apply_memory_limit(app, s.memory_limit_mb);
// Only a failure is announced. A change arrives once per keystroke in the directory row, and a // Only a failure is announced. A change arrives once per keystroke in the directory row, and a
// status line that flashed on each one would be the app shouting about its own bookkeeping. // status line that flashed on each one would be the app shouting about its own bookkeeping.
if (!written) set_notice(app, "could not write the preferences file", 4); if (!written) set_notice(app, "could not write the preferences file", 4);
+1
View File
@@ -62,6 +62,7 @@ struct PrefsForm {
AdwEntryRow* dir_row = nullptr; AdwEntryRow* dir_row = nullptr;
AdwSpinRow* keep_row = nullptr; AdwSpinRow* keep_row = nullptr;
AdwSwitchRow* relative_row = nullptr; AdwSwitchRow* relative_row = nullptr;
AdwSpinRow* memory_row = nullptr;
AdwComboRow* theme_row = nullptr; AdwComboRow* theme_row = nullptr;
/// The folder chooser beside the directory row. A text field alone asks a user to know a path and /// The folder chooser beside the directory row. A text field alone asks a user to know a path and
/// type it correctly; the chooser is how they find one, and what it returns goes into that row -- so /// type it correctly; the chooser is how they find one, and what it returns goes into that row -- so
+5 -4
View File
@@ -1182,7 +1182,7 @@ gboolean math_probe(gpointer user) {
// The one state `math_probe` structurally cannot reach: an edit of a graph whose ring has already // 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 // *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 // 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 // both identities. Run it with a small `--buffer-samples` 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. // second line means the run was too short or the buffer too large to have read anything.
int g_mathwrap_index = -1; int g_mathwrap_index = -1;
double g_mathwrap_y_before = 0.0; double g_mathwrap_y_before = 0.0;
@@ -1231,7 +1231,7 @@ gboolean mathwrap_probe_wait(gpointer user) {
g_mathwrap_y_before = a->model->live().channel(idx).y_bounds().max; 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 // Polled rather than timed: the wrap is a function of the rate and of `--buffer-samples`, and a fixed
// delay that fell short would print a passing line having tested nothing. // delay that fell short would print a passing line having tested nothing.
if (base == 0 && ++g_mathwrap_polls < 200) return G_SOURCE_CONTINUE; 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", std::fprintf(stderr, "[mathwrap-probe] wrapped: base=%llu slots=%llu (want base > 0)\n",
@@ -5384,9 +5384,10 @@ gboolean configs_probe(gpointer user) {
const double noise_back = st.measures.empty() ? -1.0 : st.measures.front().options.noise_pct; const double noise_back = st.measures.empty() ? -1.0 : st.measures.front().options.noise_pct;
const double hf_back = st.measures.empty() ? -1.0 : st.measures.front().options.hf_fc; const double hf_back = st.measures.empty() ? -1.0 : st.measures.front().options.hf_fc;
std::fprintf(stderr, std::fprintf(stderr,
"[configs] round trip read=%d comment='%s' buffer_mb=%.6g relative=%d " "[configs] round trip read=%d comment='%s' buffer_samples=%llu relative=%d "
"capture=%u plots=%zu signals=%d measures=%zu math=%zu noise=%.6g hf=%.6g\n", "capture=%u plots=%zu signals=%d measures=%zu math=%zu noise=%.6g hf=%.6g\n",
read ? 1 : 0, st.comment.c_str(), st.buffer_mb, st.relative_time ? 1 : 0, read ? 1 : 0, st.comment.c_str(),
static_cast<unsigned long long>(st.buffer_samples), st.relative_time ? 1 : 0,
st.capture_packets, st.rows.size(), signals, st.measures.size(), st.math.size(), st.capture_packets, st.rows.size(), signals, st.measures.size(), st.math.size(),
noise_back, hf_back); noise_back, hf_back);
+14 -2
View File
@@ -106,9 +106,21 @@ void update_status_popover(App& app, const udpscope::StatusSnapshot& s) {
std::snprintf(buf, sizeof(buf), "%zu signals · %zu channels", s.signals, s.channels); std::snprintf(buf, sizeof(buf), "%zu signals · %zu channels", s.signals, s.channels);
gtk_label_set_text(GTK_LABEL(app.popover_label[6]), buf); gtk_label_set_text(GTK_LABEL(app.popover_label[6]), buf);
std::snprintf(buf, sizeof(buf), "%.1f MB · %.0f s span · time x%llu", s.buffer_mb, s.span_s, std::snprintf(buf, sizeof(buf), "%s samples/signal · %s · %.0f s span · time x%llu",
static_cast<unsigned long long>(s.time_stride)); 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); 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 stored = udpscope::format_rate(s.stored_rate_hz);
const std::string wire = udpscope::format_rate(s.declared_rate_hz); const std::string wire = udpscope::format_rate(s.declared_rate_hz);