Vala: D-Bus и плагины
Третья страница раздела о памяти. Здесь о том, что живёт дольше самого объекта: подписки на шину D-Bus и плагины libpeas, которые Niti и Tuner загружают на ходу. Предыдущие страницы: память и замыкания и GTK4 и сигналы.
Подписка на сигнал D-Bus#
Подписка на шину держится соединением, а не вашим объектом. Соединение живёт всё время работы программы, поэтому подписка без отмены удерживает подписчика вечно. Всегда сохраняйте идентификатор и отменяйте в dispose:
public class NetworkMonitor : GLib.Object {
private DBusConnection conn;
private uint signal_id = 0;
public NetworkMonitor (DBusConnection conn) {
this.conn = conn;
connect_signals ();
}
private void connect_signals () {
disconnect_signals ();
signal_id = conn.signal_subscribe (
"org.freedesktop.NetworkManager", "org.freedesktop.NetworkManager",
"StateChanged", "/org/freedesktop/NetworkManager", null,
DBusSignalFlags.NONE, on_state_changed);
}
private void on_state_changed (DBusConnection connection, string sender_name,
string object_path, string interface_name, string signal_name,
GLib.Variant parameters) {
message ("State changed");
}
private void disconnect_signals () {
if (signal_id != 0) {
conn.signal_unsubscribe (signal_id);
signal_id = 0;
}
}
public override void dispose () {
disconnect_signals ();
base.dispose ();
}
}То же верно для остальных вызовов, которые заводят запись на шине:
| Вызов | Чем отменять |
|---|---|
signal_subscribe | signal_unsubscribe |
Bus.watch_name | Bus.unwatch_name |
Bus.own_name | Bus.unown_name |
register_object | unregister_object |
Для register_object особенно важно: шина держит зарегистрированный объект, и простое обнуление вашей ссылки его не уничтожит, он продолжит отвечать на запросы.
Прокси и сигналы#
Цепочка такая: соединение держит прокси, прокси держит обработчик сигнала, обработчик держит ваш виджет. Даже убранный из окна виджет остаётся в памяти и продолжает реагировать на сигналы:
public class AudioVolumeWidget : Gtk.Box {
private DBusProxy? proxy = null;
private ulong signal_handler_id = 0;
public async void init_dbus () {
proxy = yield new DBusProxy.for_bus (BusType.SESSION, DBusProxyFlags.NONE, null,
"org.pulseaudio.Server", "/org/pulseaudio/server", "org.pulseaudio.Server");
signal_handler_id = proxy.g_signal.connect_after ((sender, signal_name, parameters) => {
this.on_audio_signal (signal_name, parameters);
});
}
public override void dispose () {
if (proxy != null && signal_handler_id != 0) {
GLib.SignalHandler.disconnect (proxy, signal_handler_id);
signal_handler_id = 0;
}
proxy = null;
base.dispose ();
}
private void on_audio_signal (string name, GLib.Variant parameters) { }
}Асинхронные вызовы и отмена#
Если пользователь закрыл окно, а D-Bus-вызов завис, стандартный таймаут составляет 25 секунд. Все это время объект остаётся живым («зомби») и держит графические ресурсы. Вручную переданный в обратный вызов указатель на this без счётчика приводит к обращению к освобождённой памяти. Используйте GLib.Cancellable и отменяйте его в dispose:
public class StatusFetcher : GLib.Object {
private DBusProxy? proxy = null;
private GLib.Cancellable cancellable = new GLib.Cancellable ();
public async void load_data () {
if (proxy == null) return;
try {
var result = yield proxy.call ("GetStatus", null, DBusCallFlags.NONE, -1, cancellable);
if (cancellable.is_cancelled ()) return;
this.process_result (result);
} catch (GLib.IOError.CANCELLED e) {
debug ("cancelled correctly");
} catch (Error e) {
warning ("Failed: %s", e.message);
}
}
public override void dispose () {
cancellable.cancel ();
proxy = null;
base.dispose ();
}
private void process_result (GLib.Variant result) { }
}После каждого yield проверяйте, не отменили ли операцию и не уничтожен ли объект.
Массив в параметре async-метода#
string[] в параметрах async-метода корутина не копирует: массив принадлежит вызывающему и освобождается после его возврата, а корутина живёт дольше. Результат: вместо реальных значений в ответ уходит мусор. В реальной ошибке агент секретов NetworkManager отправлял вместо psk мусорные ключи. Лечение: в самом начале метода, до первого yield, сделайте копию:
public async void on_secrets (string[] hints) {
string[] own = hints; // копия до первого yield
yield do_something ();
use (own);
}Плагины libpeas#
Niti и Tuner загружают плагины как расширения libpeas. Плагин это общая библиотека, и при выгрузке dlclose () её код исчезает из памяти.
Опасность выгрузки#
Если хоть один экземпляр класса плагина остался живым, любой вызов его виртуального метода (в том числе finalize) ведёт по недействительному адресу и обрушивает всю панель. Поэтому, пока библиотека ещё загружена, нужно:
- отключить подписки плагина на сигналы хоста;
- убрать его виджеты из дерева (
unparent ()); - снять таймеры
TimeoutиIdle; - вызвать
run_dispose ()у расширения.
private void on_extension_removed (Peas.PluginInfo info, GLib.Object ext) {
Gtk.Widget widget;
if (active_widgets.steal (info, out widget)) {
if (widget.get_parent () != null) widget.unparent ();
}
active_extensions.remove (info);
ext.run_dispose ();
}В GTK4 нет gtk_container_remove, виджет убирается только через unparent ().
Типовые утечки при добавлении и удалении расширений#
- подписка на сигналы плагина в
extension-addedбез отписки вextension-removed; - виджет плагина добавлен в контейнер, но не убран при удалении;
- расширения хранятся в коллекциях хоста как
owned, а неunowned.
Делегаты через границу хост и плагин#
Самый опасный сценарий: плагин создаёт лямбду и передаёт её хосту, а хост её хранит. После выгрузки плагина и вызов, и очистка делегата идут в уже выгруженный код.
| Способ передачи | Риск | Рекомендация |
|---|---|---|
| Делегат с target (лямбда) | критический | не передавать из плагина в хост |
[CCode (has_target = false)] | средний | безопаснее, но вызов после выгрузки всё равно падает |
| Интерфейс GObject | минимальный | лучший способ |
Лучший способ: слушатель оформляется как интерфейс, хост держит список слушателей и убирает их при отключении плагина:
public interface PluginEventListener : GLib.Object {
public abstract void on_host_event (string msg);
}
public class HostSystem : GLib.Object {
private HashTable<unowned PluginEventListener, unowned PluginEventListener> listeners;
public void add_listener (PluginEventListener listener) {
listeners.insert (listener, listener);
}
public void remove_listener (PluginEventListener listener) {
listeners.remove (listener);
}
public void notify_all (string msg) {
var iter = HashTableIter<unowned PluginEventListener, unowned PluginEventListener> (listeners);
unowned PluginEventListener listener;
while (iter.next (out listener, null)) listener.on_host_event (msg);
}
}Как перезагружать плагины безопасно#
Тип GObject регистрируется в глобальном реестре и не может быть дерегистрирован, повторная регистрация того же имени завершается ошибкой. Поэтому для горячей замены рекомендуется «мягкая перезагрузка»: deactivate (), отписка от всех сигналов, unparent () виджетов, отмена таймеров, но без dlclose (). Новая версия плагина загружается под новым именем. Самая надёжная изоляция для недоверенных плагинов это отдельный процесс, но он заметно сложнее.
Чек-лист для плагина#
- [ ] В
deactivate ()отписаны все сигналы. - [ ] Все виджеты убраны через
unparent (). - [ ] Из плагина в хост не передаются лямбды и делегаты с target, только интерфейсы.
- [ ] Таймеры
TimeoutиIdleотменены. - [ ] Нет статических и глобальных полей, удерживающих объекты плагина.
- [ ] Внешние ресурсы (D-Bus, файлы) закрыты.
Как собрать и опубликовать плагин, читайте в руководствах виджет Niti на Vala и плагин Tuner на Vala.