The lifetime fix in ffec797 removes the UDPSCOPE_PROBE_PICK_MS timeout after
the nested loop returns, so a row picked before the delay elapsed cannot leave
an armed source pointing at a gone stack frame. But the probe returns
G_SOURCE_REMOVE on every path, so on the ordinary run -- the probe fires, picks
row 0 and the loop quits -- the id was already stale and the removal printed
"Source ID N was not found when attempting to remove it" on every probe run.
The id moves onto the Chooser and pick_probe clears it on entry, so the
cancellation applies only to a probe that never fired.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
421 lines
18 KiB
C++
421 lines
18 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#include "source_dialog.hpp"
|
|
|
|
#include "layout_store.hpp"
|
|
|
|
#include <gtk/gtk.h>
|
|
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
namespace udpscope {
|
|
namespace {
|
|
|
|
/// A chooser row's source becomes the connection to open. The interface comes along with the
|
|
/// group: it is the local address the join happens on, so storing one without the other would
|
|
/// reconnect to the right group on the wrong path.
|
|
void apply_source(Config& cfg, const KnownSource& src) {
|
|
cfg.server = src.host;
|
|
cfg.port = src.port;
|
|
cfg.multicast = src.multicast;
|
|
cfg.interface_addr = src.interface_addr;
|
|
}
|
|
|
|
/// One line per known source on stderr, in the style of the probes in `ui.cpp`.
|
|
void report_sources(const std::vector<KnownSource>& known) {
|
|
std::fprintf(stderr, "[sources] %zu known:", known.size());
|
|
for (const KnownSource& s : known) {
|
|
std::fprintf(stderr, " %s:%u plots=%d signals=%d", s.host.c_str(), s.port, s.rows, s.signals);
|
|
if (!s.multicast.empty()) std::fprintf(stderr, " multicast=%s", s.multicast.c_str());
|
|
if (!s.interface_addr.empty()) {
|
|
std::fprintf(stderr, " interface=%s", s.interface_addr.c_str());
|
|
}
|
|
std::fprintf(stderr, " |");
|
|
}
|
|
std::fprintf(stderr, "\n");
|
|
std::fflush(stderr);
|
|
}
|
|
|
|
/// The chooser's own state for the life of its nested loop. A plain local in `choose_source()`:
|
|
/// the loop runs inside that call, so nothing here outlives the stack frame and there is nothing
|
|
/// to free on a handler path.
|
|
struct Chooser {
|
|
Config* cfg = nullptr;
|
|
GMainLoop* loop = nullptr;
|
|
bool chosen = false;
|
|
GtkWidget* window = nullptr;
|
|
GtkWidget* list = nullptr;
|
|
std::vector<KnownSource> known;
|
|
/// The connection form, created on demand. Members of the chooser rather than a heap struct:
|
|
/// the chooser outlives the form by construction, so there is nothing to free on a close path
|
|
/// -- and a dialog whose context is freed by a handler is how this project earned a use-after
|
|
/// -free in the math editor.
|
|
GtkWidget* form = nullptr;
|
|
GtkWidget* form_server = nullptr;
|
|
GtkWidget* form_port = nullptr;
|
|
GtkWidget* form_group = nullptr;
|
|
GtkWidget* form_iface = nullptr;
|
|
GtkWidget* form_connect = nullptr;
|
|
/// `UDPSCOPE_PROBE_PICK_MS`'s timeout, kept so a pick made before it fires can cancel it. The
|
|
/// probe clears it when it runs: `g_source_remove()` on an id whose source already returned
|
|
/// `G_SOURCE_REMOVE` is a GLib critical, not a no-op.
|
|
guint pick_source = 0;
|
|
};
|
|
|
|
void chooser_finish(Chooser& c, bool chosen) {
|
|
c.chosen = chosen;
|
|
// Destroy the form first: any path that reaches here while the form is up -- a row click, the
|
|
// Escape key -- would otherwise leave a window whose four handlers are bound to a dead stack
|
|
// frame. `gtk_window_destroy()` rather than `gtk_window_close()`: the pointer is already
|
|
// cleared, so the `close-request` handler is not needed here, and `close()` on an unrealized
|
|
// window returns having done nothing.
|
|
if (c.form != nullptr) {
|
|
GtkWidget* w = c.form;
|
|
c.form = nullptr; // before the destroy, so the `close-request` handler cannot re-enter
|
|
gtk_window_destroy(GTK_WINDOW(w));
|
|
}
|
|
if (c.window != nullptr) {
|
|
GtkWidget* w = c.window;
|
|
c.window = nullptr; // before the destroy: `close-request` must not re-enter this
|
|
gtk_window_destroy(GTK_WINDOW(w));
|
|
}
|
|
if (c.loop != nullptr) g_main_loop_quit(c.loop);
|
|
}
|
|
|
|
void chooser_populate(Chooser& c);
|
|
|
|
void on_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
|
|
Chooser& c = *static_cast<Chooser*>(user);
|
|
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "source-index"));
|
|
if (idx < 0 || static_cast<std::size_t>(idx) >= c.known.size()) return;
|
|
apply_source(*c.cfg, c.known[static_cast<std::size_t>(idx)]);
|
|
chooser_finish(c, true);
|
|
}
|
|
|
|
void on_forget_clicked(GtkButton* button, gpointer user) {
|
|
Chooser& c = *static_cast<Chooser*>(user);
|
|
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(button), "source-index"));
|
|
if (idx < 0 || static_cast<std::size_t>(idx) >= c.known.size()) return;
|
|
forget_source(c.known[static_cast<std::size_t>(idx)]);
|
|
// Repopulate rather than remove one row: the rows carry their index into `known`, and dropping
|
|
// an entry renumbers every later one. Re-reading is also the honest answer to "did it go".
|
|
chooser_populate(c);
|
|
}
|
|
|
|
/// Fills the list box from `known_sources()`. Called again after a forget.
|
|
void chooser_populate(Chooser& c) {
|
|
while (GtkWidget* child = gtk_widget_get_first_child(c.list)) {
|
|
gtk_list_box_remove(GTK_LIST_BOX(c.list), child);
|
|
}
|
|
c.known = known_sources();
|
|
|
|
if (c.known.empty()) {
|
|
GtkWidget* empty = gtk_label_new("No saved sources yet.");
|
|
gtk_widget_add_css_class(empty, "dim-label");
|
|
gtk_widget_set_margin_top(empty, 24);
|
|
gtk_widget_set_margin_bottom(empty, 24);
|
|
// GTK4 auto-wraps any non-row widget appended to a `GtkListBox` in a row, so this is
|
|
// activatable -- but the wrapped row carries no `"source-index"` data, `g_object_get_data()`
|
|
// returns null, the index reads 0, and `on_row_activated`'s bounds check refuses it.
|
|
gtk_list_box_append(GTK_LIST_BOX(c.list), empty);
|
|
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.list), GTK_SELECTION_NONE);
|
|
return;
|
|
}
|
|
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.list), GTK_SELECTION_SINGLE);
|
|
|
|
for (std::size_t i = 0; i < c.known.size(); ++i) {
|
|
const KnownSource& s = c.known[i];
|
|
GtkWidget* row = gtk_list_box_row_new();
|
|
g_object_set_data(G_OBJECT(row), "source-index", GINT_TO_POINTER(static_cast<int>(i)));
|
|
|
|
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12);
|
|
gtk_widget_set_margin_start(line, 12);
|
|
gtk_widget_set_margin_end(line, 6);
|
|
gtk_widget_set_margin_top(line, 6);
|
|
gtk_widget_set_margin_bottom(line, 6);
|
|
|
|
GtkWidget* text = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
|
|
gtk_widget_set_hexpand(text, TRUE);
|
|
|
|
// The *server*, even for a multicast session: that is the host the file is named after and
|
|
// the one CONFIG is fetched from. Titling the row by the group would name it after something
|
|
// that is not its identity.
|
|
const std::string title = s.host + ":" + std::to_string(s.port);
|
|
GtkWidget* title_label = gtk_label_new(title.c_str());
|
|
gtk_widget_set_halign(title_label, GTK_ALIGN_START);
|
|
gtk_box_append(GTK_BOX(text), title_label);
|
|
|
|
std::string sub = std::to_string(s.rows) + (s.rows == 1 ? " plot, " : " plots, ") +
|
|
std::to_string(s.signals) + (s.signals == 1 ? " signal" : " signals");
|
|
if (!s.multicast.empty()) sub += ", multicast " + s.multicast;
|
|
GtkWidget* sub_label = gtk_label_new(sub.c_str());
|
|
gtk_widget_add_css_class(sub_label, "dim-label");
|
|
gtk_widget_set_halign(sub_label, GTK_ALIGN_START);
|
|
gtk_box_append(GTK_BOX(text), sub_label);
|
|
gtk_box_append(GTK_BOX(line), text);
|
|
|
|
GtkWidget* forget = gtk_button_new_from_icon_name("window-close-symbolic");
|
|
gtk_widget_set_tooltip_text(forget, "Forget this source (deletes its saved session)");
|
|
gtk_widget_add_css_class(forget, "flat");
|
|
gtk_widget_set_valign(forget, GTK_ALIGN_CENTER);
|
|
g_object_set_data(G_OBJECT(forget), "source-index", GINT_TO_POINTER(static_cast<int>(i)));
|
|
g_signal_connect(forget, "clicked", G_CALLBACK(on_forget_clicked), &c);
|
|
gtk_box_append(GTK_BOX(line), forget);
|
|
|
|
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line);
|
|
gtk_list_box_append(GTK_LIST_BOX(c.list), row);
|
|
}
|
|
}
|
|
|
|
void form_close(Chooser& c) {
|
|
if (c.form == nullptr) return;
|
|
GtkWidget* w = c.form;
|
|
c.form = nullptr; // before the close, so the handler cannot re-enter
|
|
gtk_window_close(GTK_WINDOW(w));
|
|
}
|
|
|
|
void on_form_server_changed(GtkEditable* entry, gpointer user) {
|
|
Chooser& c = *static_cast<Chooser*>(user);
|
|
if (c.form_connect == nullptr) return;
|
|
// The only validation here. A *malformed* address is the acquisition layer's to report -- it
|
|
// already does, and the starvation notice covers a source that takes the socket and says
|
|
// nothing -- so checking it twice would be two expressions of one rule.
|
|
gtk_widget_set_sensitive(c.form_connect, gtk_entry_buffer_get_length(
|
|
gtk_entry_get_buffer(GTK_ENTRY(entry))) > 0);
|
|
}
|
|
|
|
void on_form_connect(GtkButton*, gpointer user) {
|
|
Chooser& c = *static_cast<Chooser*>(user);
|
|
const char* server = gtk_editable_get_text(GTK_EDITABLE(c.form_server));
|
|
if (server == nullptr || server[0] == '\0') return;
|
|
c.cfg->server = server;
|
|
c.cfg->port = static_cast<std::uint16_t>(
|
|
gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(c.form_port)));
|
|
c.cfg->multicast = gtk_editable_get_text(GTK_EDITABLE(c.form_group));
|
|
c.cfg->interface_addr = gtk_editable_get_text(GTK_EDITABLE(c.form_iface));
|
|
form_close(c);
|
|
chooser_finish(c, true);
|
|
}
|
|
|
|
/// A labelled row for the form's grid.
|
|
GtkWidget* form_field(GtkWidget* grid, int row, const char* label, GtkWidget* widget) {
|
|
GtkWidget* l = gtk_label_new(label);
|
|
gtk_widget_set_halign(l, GTK_ALIGN_END);
|
|
gtk_grid_attach(GTK_GRID(grid), l, 0, row, 1, 1);
|
|
gtk_widget_set_hexpand(widget, TRUE);
|
|
gtk_grid_attach(GTK_GRID(grid), widget, 1, row, 1, 1);
|
|
return widget;
|
|
}
|
|
|
|
void open_form(Chooser& c) {
|
|
if (c.form != nullptr) {
|
|
gtk_window_present(GTK_WINDOW(c.form));
|
|
return;
|
|
}
|
|
c.form = gtk_window_new();
|
|
gtk_window_set_title(GTK_WINDOW(c.form), "New host");
|
|
gtk_window_set_transient_for(GTK_WINDOW(c.form), GTK_WINDOW(c.window));
|
|
gtk_window_set_modal(GTK_WINDOW(c.form), TRUE);
|
|
|
|
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
|
|
gtk_widget_set_margin_start(box, 12);
|
|
gtk_widget_set_margin_end(box, 12);
|
|
gtk_widget_set_margin_top(box, 12);
|
|
gtk_widget_set_margin_bottom(box, 12);
|
|
gtk_window_set_child(GTK_WINDOW(c.form), box);
|
|
|
|
GtkWidget* grid = gtk_grid_new();
|
|
gtk_grid_set_row_spacing(GTK_GRID(grid), 6);
|
|
gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
|
|
gtk_box_append(GTK_BOX(box), grid);
|
|
|
|
c.form_server = form_field(grid, 0, "Server", gtk_entry_new());
|
|
gtk_editable_set_text(GTK_EDITABLE(c.form_server), "127.0.0.1");
|
|
// A spin button holds no decimals by default, which is right for a port and wrong for every
|
|
// other numeric field in this project -- see the math dialog's fields.
|
|
c.form_port = form_field(grid, 1, "Port", gtk_spin_button_new_with_range(1, 65535, 1));
|
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), 8000);
|
|
c.form_group = form_field(grid, 2, "Multicast group", gtk_entry_new());
|
|
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_group), "empty = unicast");
|
|
c.form_iface = form_field(grid, 3, "Interface", gtk_entry_new());
|
|
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_iface), "empty = default route");
|
|
|
|
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
|
gtk_widget_set_halign(actions, GTK_ALIGN_END);
|
|
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
|
|
g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
|
form_close(*static_cast<Chooser*>(user));
|
|
}),
|
|
&c);
|
|
c.form_connect = gtk_button_new_with_label("Connect");
|
|
g_signal_connect(c.form_connect, "clicked", G_CALLBACK(on_form_connect), &c);
|
|
gtk_box_append(GTK_BOX(actions), cancel);
|
|
gtk_box_append(GTK_BOX(actions), c.form_connect);
|
|
gtk_box_append(GTK_BOX(box), actions);
|
|
|
|
g_signal_connect(c.form_server, "changed", G_CALLBACK(on_form_server_changed), &c);
|
|
// `close-request` clears the pointer, and only a *close* emits it: `gtk_window_destroy()` does
|
|
// not, which is how the math dialog once kept presenting a finalized window.
|
|
g_signal_connect(c.form, "close-request", G_CALLBACK(+[](GtkWindow*, gpointer user) {
|
|
static_cast<Chooser*>(user)->form = nullptr;
|
|
return FALSE;
|
|
}),
|
|
&c);
|
|
|
|
gtk_window_present(GTK_WINDOW(c.form));
|
|
gtk_widget_grab_focus(c.form_server);
|
|
}
|
|
|
|
/// What the pick probes report: the connection the chooser came back with. One helper rather than
|
|
/// the same four lines in each branch, since both probes assert against this exact line.
|
|
void report_pick(const Chooser& c) {
|
|
std::fprintf(stderr, "[sources] picked %s:%u multicast=%s interface=%s\n", c.cfg->server.c_str(),
|
|
c.cfg->port, c.cfg->multicast.c_str(), c.cfg->interface_addr.c_str());
|
|
std::fflush(stderr);
|
|
}
|
|
|
|
gboolean on_chooser_close(GtkWindow*, gpointer user) {
|
|
Chooser& c = *static_cast<Chooser*>(user);
|
|
c.window = nullptr; // GTK destroys it for us on the default handler
|
|
chooser_finish(c, false);
|
|
return FALSE;
|
|
}
|
|
|
|
gboolean on_chooser_key(GtkEventControllerKey*, guint keyval, guint, GdkModifierType,
|
|
gpointer user) {
|
|
if (keyval != GDK_KEY_Escape) return FALSE;
|
|
chooser_finish(*static_cast<Chooser*>(user), false);
|
|
return TRUE;
|
|
}
|
|
|
|
/// `UDPSCOPE_PROBE_PICK_MS`: activate the first row, the way a click does.
|
|
/// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row. The crash surface of a
|
|
/// dialog built, filled and destroyed is as much the point as the values, the same way
|
|
/// `UDPSCOPE_PROBE_FIELDS_MS` is in `ui.cpp`.
|
|
gboolean pick_probe(gpointer user) {
|
|
Chooser& c = *static_cast<Chooser*>(user);
|
|
// Every path below returns `G_SOURCE_REMOVE`, so the source is gone the moment this returns and
|
|
// its id is stale. Clear it here rather than on each path: `choose_source()`'s cancel-the-pending
|
|
// -probe call would otherwise be a GLib critical on every run that let the probe fire.
|
|
c.pick_source = 0;
|
|
|
|
// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row.
|
|
if (const char* spec = g_getenv("UDPSCOPE_PROBE_NEWHOST")) {
|
|
open_form(c);
|
|
const std::string s = spec;
|
|
const std::size_t colon = s.rfind(':');
|
|
gtk_editable_set_text(GTK_EDITABLE(c.form_server),
|
|
colon == std::string::npos ? s.c_str() : s.substr(0, colon).c_str());
|
|
if (colon != std::string::npos) {
|
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), std::atof(s.c_str() + colon + 1));
|
|
}
|
|
std::fprintf(stderr, "[sources] form open, connect_sensitive=%d\n",
|
|
gtk_widget_get_sensitive(c.form_connect) ? 1 : 0);
|
|
g_signal_emit_by_name(c.form_connect, "clicked");
|
|
report_pick(c);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
// Check the source list rather than the row count: `gtk_list_box_append` auto-wraps any
|
|
// non-row widget in a `GtkListBoxRow`, so `gtk_list_box_get_row_at_index` returns non-null
|
|
// even when the only list child is the empty-state placeholder label.
|
|
if (c.known.empty()) {
|
|
std::fprintf(stderr, "[sources] picked nothing: the list is empty\n");
|
|
std::fflush(stderr);
|
|
chooser_finish(c, false);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
GtkListBoxRow* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(c.list), 0);
|
|
// `row-activated`, not `gtk_widget_activate()`: this project has already learned that
|
|
// activating a widget takes a path a real click does not (a `GtkButton` holds for its 250 ms
|
|
// ACTIVATE_TIMEOUT and then emits into a widget a rebuild has destroyed).
|
|
g_signal_emit_by_name(c.list, "row-activated", row);
|
|
report_pick(c);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool choose_source(Config& cfg) {
|
|
const std::vector<KnownSource> known = known_sources();
|
|
|
|
// The discovery half, checkable with no display at all: this returns before `gtk_init()`, so
|
|
// it asserts what the list found without needing an X server or a GL context. False means the
|
|
// caller exits 0, which is what "list and exit" wants.
|
|
if (g_getenv("UDPSCOPE_PROBE_SOURCES") != nullptr) {
|
|
report_sources(known);
|
|
return false;
|
|
}
|
|
|
|
gtk_init();
|
|
|
|
Chooser c;
|
|
c.cfg = &cfg;
|
|
c.window = gtk_window_new();
|
|
gtk_window_set_title(GTK_WINDOW(c.window), "udpscope \xe2\x80\x94 choose a source");
|
|
gtk_window_set_default_size(GTK_WINDOW(c.window), 420, 320);
|
|
|
|
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
|
gtk_window_set_child(GTK_WINDOW(c.window), box);
|
|
|
|
GtkWidget* scroller = gtk_scrolled_window_new();
|
|
gtk_widget_set_vexpand(scroller, TRUE);
|
|
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER,
|
|
GTK_POLICY_AUTOMATIC);
|
|
c.list = gtk_list_box_new();
|
|
gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(c.list), TRUE);
|
|
g_signal_connect(c.list, "row-activated", G_CALLBACK(on_row_activated), &c);
|
|
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), c.list);
|
|
gtk_box_append(GTK_BOX(box), scroller);
|
|
|
|
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
|
gtk_widget_set_margin_start(actions, 12);
|
|
gtk_widget_set_margin_end(actions, 12);
|
|
gtk_widget_set_margin_top(actions, 12);
|
|
gtk_widget_set_margin_bottom(actions, 12);
|
|
gtk_widget_set_halign(actions, GTK_ALIGN_END);
|
|
GtkWidget* new_host = gtk_button_new_with_label("New host\xe2\x80\xa6");
|
|
g_signal_connect(new_host, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
|
open_form(*static_cast<Chooser*>(user));
|
|
}),
|
|
&c);
|
|
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
|
|
g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
|
chooser_finish(*static_cast<Chooser*>(user), false);
|
|
}),
|
|
&c);
|
|
gtk_box_append(GTK_BOX(actions), new_host);
|
|
gtk_box_append(GTK_BOX(actions), cancel);
|
|
gtk_box_append(GTK_BOX(box), actions);
|
|
|
|
chooser_populate(c);
|
|
if (c.known.empty()) gtk_widget_grab_focus(new_host);
|
|
|
|
g_signal_connect(c.window, "close-request", G_CALLBACK(on_chooser_close), &c);
|
|
GtkEventController* keys = gtk_event_controller_key_new();
|
|
g_signal_connect(keys, "key-pressed", G_CALLBACK(on_chooser_key), &c);
|
|
gtk_widget_add_controller(c.window, keys);
|
|
|
|
gtk_window_present(GTK_WINDOW(c.window));
|
|
|
|
// Keep the timeout id so it can be cancelled if the chooser finishes before it fires. A row
|
|
// clicked before UDPSCOPE_PROBE_PICK_MS elapses would otherwise leave an armed source on the
|
|
// default main context pointing at the now-gone stack frame.
|
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_PICK_MS")) {
|
|
c.pick_source = g_timeout_add(static_cast<guint>(std::atoi(env)), pick_probe, &c);
|
|
}
|
|
|
|
c.loop = g_main_loop_new(nullptr, FALSE);
|
|
g_main_loop_run(c.loop);
|
|
g_main_loop_unref(c.loop);
|
|
c.loop = nullptr;
|
|
if (c.pick_source != 0) g_source_remove(c.pick_source);
|
|
return c.chosen;
|
|
}
|
|
|
|
} // namespace udpscope
|