Pinta quit unexpectedly (no error dialog)
Description Pinta quit unexpectedly (no error dialog). It happened once while I was interacting with the top-left menus (I think I was closing the Open File dialog). Console showed:
A callback was made on a garbage collected delegate of type 'GLib-2.0!GLib.Internal.SourceFunc::Invoke'
To Reproduce Not reliably reproducible. It occurred once during normal use. I’ll update this issue if I find concrete steps.
Additional Info
Truncated stack trace:
Process terminated. A callback was made on a garbage collected delegate of type 'GLib-2.0!GLib.Internal.SourceFunc::Invoke'.
Repeat 2 times:
--------------------------------
at Gio.Internal.Application.Run(IntPtr, Int32, System.String[])
--------------------------------
at Gio.Application.Run(Int32, System.String[])
at Gio.Application.RunWithSynchronizationContext(System.String[])
at Pinta.MainClass.OpenMainWindow(Int32, System.Collections.Generic.IEnumerable`1<System.String>, Boolean)
at Pinta.MainClass+<>c.<Main>b__0_1(Int32, System.String[], Boolean)
at System.CommandLine.Handler+<>c__DisplayClass4_0`3[[System.Int32, System.Private.CoreLib, Version=8.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e],[System.__Canon, System.Private.CoreLib, Version=8.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e],[System.Boolean, System.Private.CoreLib, Version=8.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]].<SetHandler>b__0(System.CommandLine.Invocation.InvocationContext)
...
at System.CommandLine.CommandExtensions.Invoke(System.CommandLine.Command, System.String[], System.CommandLine.IConsole)
at Pinta.MainClass.Main(System.String[])
Aborted (core dumped)
After the crash, I tried to find places where GLib might be used with SourceFunc callbacks. I looked for timers/idles like TimeoutAdd(...), IdleAdd(...), or UI helpers that likely use the same mechanism. I also tried to better understand the memory management model used by GLib. The relevant section I found was:
There are two options for memory management of the user data passed to a GSource to be passed to its callback on invocation. This data is provided in calls to g_timeout_add(), g_timeout_add_full(), g_idle_add(), etc. and more generally, using g_source_set_callback(). This data is typically an object which ‘owns’ the timeout or idle callback, such as a widget or a network protocol implementation. In many cases, it is an error for the callback to be invoked after this owning object has been destroyed, as that results in use of freed memory.
The first, and preferred, option is to store the source ID returned by functions such as g_timeout_add() or g_source_attach(), and explicitly remove that source from the main context using g_source_remove() when the owning object is finalized. This ensures that the callback can only be invoked while the object is still alive.
My rough understanding:
-
Always keep the delegate/lambda in a field while it’s active GLib.Timeout.Add(...) / GLib.Idle.Add(...) pass a function pointer to GLib. If you don’t keep a managed reference (e.g., store GLib.SourceFunc in a field or a small “pending callbacks” list), the GC can collect it before GLib calls it.
-
Keep the ID (or GLib.Source) when you might cancel before it fires.
-
Repeating sources (return true): must keep the ID (or the GLib.Source object) so you can stop it on teardown.
-
One-shot sources (return false): If there’s any chance you’ll close/teardown before it runs, should keep the ID/Source so you can cancel. If it’s truly fire-and-forget and guaranteed to run very soon while the owner stays alive, you can skip the ID—but you still must root the delegate.
I did some searching around and found some examples that might be relevant (not an exhaustive list):
-
LivePreviewManager —
TimeoutAdd(..., () => { ... return true; })(I think even though the disposable removes the timer resource it might be possible for the same kind of issue due to the above) -
SimpleEffectDialog.DelayedUpdate — one-shot
TimeoutAdd(..., () => { ... return false; }) -
Main.cs —
TimeoutAdd(100, ...)(debug GC loop) and shortTimeoutAdd(10, ...) -
StatusProgressBar —
IdleAdd(..., () => { ... return false; })
It seems like not everywhere stores the lambda/delegate/id associated with these functions and calls GLib.Source.Remove as part of the lifecycle of GC managed objects, though some places do seem to do this like SimpleEffectDialog.cs.
I'm not sure what exactly caused my crash, but I think the above may be related.
Version
- OS: NixOS 24.05 (GNOME, Wayland)
- Pinta: 3.0
This could actually be a bug in GirCore as it should care itself about not loosing any relevant objects.
Callbacks can have different scopes which describe the lifetime of a callback: notified, forever, call.
This is the handler for notified for example: https://github.com/gircore/gir.core/blob/main/src/Generation/Generator/Renderer/Internal/Callback/CallbackNotifiedHandler.cs
There is a command line option to run the GC more frequently in Pinta (--debug) to be able to better diagnose such problems.
Thanks for the notes on Gir.Core and the --debug flag. I tried enabling this during some testing but still no luck yet.
I’m not 100% sure on this, but here’s my current understanding after skimming Pinta and Gir.Core’s GLib bindings:
- When we call
GLib.Timeout.Add(...)/GLib.Idle.Add(...), Gir.Core marshals the C# delegate to a native function pointer and hands that to GLib. - From what I can tell, Gir.Core doesn’t keep a managed reference to that delegate; it mirrors the C API and returns the source ID.
- That means .NET can collect the delegate if we don’t keep a reference on the managed side. If GLib later invokes the pointer and the delegate was GC’d, you get the fail-fast (“garbage collected delegate”).
So, per call site, the safe pattern seems to be:
- Root the callback: keep the
GLib.SourceFuncin a field while it’s active. - Own the source if you might cancel/tear down: keep the source ID and remove/destroy it on close.
Minimal example with a little more context
Here’s SimpleEffectDialog.DelayedUpdate as it exists today (key parts):
// fields already present
const uint EVENT_DELAY_MILLIS = 100;
uint event_delay_timeout_id;
TimeoutHandler? timeout_func;
private void DelayedUpdate(TimeoutHandler handler)
{
if (event_delay_timeout_id != 0) {
GLib.Source.Remove(event_delay_timeout_id);
if (handler != timeout_func)
timeout_func?.Invoke();
}
timeout_func = handler;
// schedules a one-shot timeout using an inline lambda
event_delay_timeout_id = GLib.Functions.TimeoutAdd(
0,
EVENT_DELAY_MILLIS,
() => {
event_delay_timeout_id = 0;
timeout_func.Invoke();
timeout_func = null;
return false; // one-shot
}
);
}
What we add is just a field to hold the SourceFunc, and we pass that field to TimeoutAdd so the delegate can’t be GC’d before it runs:
// new field to root the delegate while active
private GLib.SourceFunc? _eventDelayTick;
private void DelayedUpdate(TimeoutHandler handler)
{
if (event_delay_timeout_id != 0) {
GLib.Source.Remove(event_delay_timeout_id);
if (handler != timeout_func)
timeout_func?.Invoke();
}
timeout_func = handler;
// cache the delegate so it stays alive until it fires or we cancel
_eventDelayTick ??= () => {
event_delay_timeout_id = 0;
timeout_func?.Invoke();
timeout_func = null;
return false; // one-shot
};
event_delay_timeout_id = GLib.Functions.TimeoutAdd(
0,
EVENT_DELAY_MILLIS,
_eventDelayTick
);
}
// on close/teardown (already present, with two tiny tweaks)
private void HandleClose()
{
if (event_delay_timeout_id != 0) {
GLib.Source.Remove(event_delay_timeout_id);
event_delay_timeout_id = 0; // ensure we don't double-remove later
}
timeout_func?.Invoke();
timeout_func = null;
_eventDelayTick = null; // allow GC after we’ve detached
}
I don't think the behavior changes, but should remove the small window where the delegate could be GC’d before GLib calls it (not just applicable to SimpleEffectDialog).
I'll perform some small testing of this patch to see if any regressions or new crashes occur (moving sliders around quickly for artistic image effects on a very large image, closing/opening dialogs, dragging selection tools around quickly):
- https://github.com/mmontalbo/Pinta/commit/922fae04dd563112a386075b2563f02044d02387
- https://github.com/mmontalbo/Pinta/tree/root-timeout-simple-effect
You should not need to store the GLib.SourceFunc as a user of GirCore, like I tried to explain in my previous comment. Let's try again in more detail:
Glib.Functions.TimeoutAdd is implemented like this:
public static uint TimeoutAdd(int priority, uint interval, GLib.SourceFunc function)
{
var functionHandler = new GLib.Internal.SourceFuncNotifiedHandler(function);
var resultTimeoutAdd = GLib.Internal.Functions.TimeoutAdd(priority, interval, functionHandler.NativeCallback, IntPtr.Zero, functionHandler.DestroyNotify);
return resultTimeoutAdd;
}
Please note the SourceFuncNotifiedHandler which is implemented like this:
public class SourceFuncNotifiedHandler
{
public GLib.Internal.SourceFunc? NativeCallback;
public GLib.Internal.DestroyNotify? DestroyNotify;
private GLib.SourceFunc? managedCallback;
private GCHandle gch;
public SourceFuncNotifiedHandler(GLib.SourceFunc? managed)
{
DestroyNotify = DestroyCallback;
managedCallback = managed;
if (managedCallback is null)
{
NativeCallback = null;
DestroyNotify = null;
}
else
{
gch = GCHandle.Alloc(this);
NativeCallback = (IntPtr userData) => {
bool managedCallbackResult = default;
managedCallbackResult = managedCallback();
return managedCallbackResult;
};
}
}
private void DestroyCallback(IntPtr userData)
{
gch.Free();
}
}
The SourceFuncNotfiedHandler already stores the GLib.SourceFunc as an instance member. The handler keeps itself alive via GCHandle.Alloc(this).
The handler itself is only allowed to be garbage collected after GObject triggered the DestroyCallback. So everything should be fine.
Perhaps there is some scenario in which the given code does not work in which case GirCore should be fixed instead of forcing all apps storing their callbacks manually.
Thank you for the detailed response. I tried to track down the behavior you described earlier, but missed this GCHandle.Alloc(this); thank you. I do believe you are correct that GirCore appears as though it should handle owning the callback.
I will try to find other paths where this "safe helper" is potentially bypassed.
I switched to looking at the implications of not storing/removing the resource ID returned by TimeoutAdd / IdleAdd, since GirCore should handle owning and cleaning up the callback. As an example I was looking at StatusProgressBar.
Today StatusProgressBar posts one-shot idles and doesn’t keep the returned source IDs:
public void SetMessage (string msg)
{
GLib.Functions.IdleAdd (0, () => {
progress_bar.Text = msg;
return false; // one-shot
});
}
public void SetProgress (double progress)
{
GLib.Functions.IdleAdd (0, () => {
progress_bar.Fraction = progress;
return false; // one-shot
});
}
Is it possible the widget/dialog gets closed or disposed while a queued idle is still pending? GLib then fires the idle later, the trampoline jumps back into managed code, and that code touches progress_bar after it’s gone. Would we see “callback on a garbage collected delegate”, even if the root cause is “late callback into disposed UI” rather than the callback itself being collected? Here is a diagram to illustrate:
Actors:
[SB] StatusProgressBar instance (owns UI widgets)
[GIR] gir.core SourceFunc handler (pins delegate via GCHandle)
[GLib] GLib main loop / idle GSource
[RT] .NET/GTK runtime (managed<->native trampoline / app lifecycle)
Legend: ---> call; x object torn down; ❌ crash point
1) Queue work
SB GIR GLib RT
| IdleAdd(lambda) ---->| create handler+pin ---> | attach idle source ------->|
| | (GCHandle.Alloc) | |
2) Begin teardown (window close / app exit)
SB GIR GLib RT
| dispose() | | |
| x (widgets, state) | (handler still pinned) | |
| (no Source.Remove) | | |
3) Late idle fires after teardown
SB GIR GLib RT
| | | invoke callback ---------->|
| | | | ❌ unmanaged->managed call
| | | | hits half-torn runtime/UI
| | | | → “callback was made on a
| | | | garbage collected delegate”
4) (Would-be cleanup)
SB GIR GLib RT
| | destroy-notify -------->| free pin (GCHandle.Free) |
| | (may not reach here if we crashed) |
If that is the cause, maybe it make sense to consistently manage the resource IDs returned by IdleAdd/TimeoutAdd and remove them when the owning object is going away (dispose, close, overlay hidden, etc).
High-level shape:
- Keep the
uintIDs for any scheduled idle/timeout on the owning object. - On teardown, call
GLib.Source.Remove(id)and clear them. - Optionally, callbacks can early-out if the widget is already disposed, but the main guard is removing the source.
This could apply anywhere we “fire-and-forget” an idle/timeout.
After reading https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/lambda-expressions#capture-of-outer-variables-and-variable-scope-in-lambda-expressions I believe the handler being pinned by GirCore should capture a strong reference to a managed object whose fields are referenced (i.e., the managed object StatusProgressBar and its field progress_bar). That means the managed objected referenced (StatusProgressBar instance) by the GirCore pinned handler should not be collected and the scenario I describe above should not happen. The exception might be if a weak reference is explicitly captured by the lambda, but I don't see an example of that anywhere.
I will continue to investigate other potential avenues.