How an IOP module shares gui_data between the GTK main thread and the pixelpipe
worker threads, and between two pipe worker threads: which callback runs where, when the
GUI critical section is required, how to send an update to the GUI without outliving the
module, and which parts of this the framework already does for you.
See also:
- GUI.md — GUI architecture: UI construction, events and callbacks, reparenting
- IOP_Module_API.md — Module API reference,
commit_params(), lifecycle - pixelpipe_architecture.md — Pixelpipe data flow, caching, ROI, ordering
process() is not a GTK callback and has no guaranteed thread affinity. GTK is not thread-safe. You cannot call GTK functions directly from process().
The same split applies to data: gui_data is not owned by the GTK thread alone. Three
directions need care:
- the pipe writing values for the GUI to display,
- the GUI writing values the pipe will read,
- and one pipe writing values another pipe reads,
because
gui_datais where that channel lives too.
The rules, in short:
- Know which thread you are on.
commit_params()andprocess()are pipe callbacks, not GUI ones. - Take the GUI critical section for every field more than one thread touches — and only when a GUI exists.
- Hold it as long as the value must stay valid — not just for the load.
- Never call GTK from a pipe worker thread. Hand the update to the main loop (Pattern A), and cancel it before the module or the image goes away.
- Check whether the framework has already done it before hand-rolling buffer, hash and lock plumbing for a cursor readout.
The sections, in order:
Where your code runs
Locking rules
- Using
gui_datafromcommit_params() - Writing
gui_datafrom a Widget Callback - Publishing
gui_dataThrough a Proxy - Passing Values Between Pipes Through
gui_data - The Lock Is Recursive
- Hold the Lock as Long as the Value Must Stay Valid
What the framework already does
Sending updates to the GUI
- Guards Before Sending GUI Updates
- Pattern A: Critical Section +
g_idle_add - Pattern B: Message Passing
- The Callback Must Not Outlive the Module or the Image
Reference
| Always the GTK main thread | Pipeline callbacks — no thread guarantee |
|---|---|
gui_init(), gui_cleanup() |
commit_params() |
gui_update(), gui_changed() |
process(), process_cl() |
| widget callbacks (sliders, buttons, combos) | process_tiling(), process_tiling_cl() |
draw / expose callbacks, gui_post_expose() |
modify_roi_in(), modify_roi_out(), tiling_callback() |
| mouse and scroll handlers | init_pipe(), cleanup_pipe() |
color_picker_apply() |
output_format(), the four colorspace callbacks |
distort_transform(), distort_backtransform(), distort_mask() |
The right column is the per-instance src/iop/iop_api.h callbacks the pipe drives that
can reach gui_data. It is not the whole API surface — the pipe also calls metadata
callbacks such as flags() and operation_tags(), but their signatures give them no
module instance, so they cannot touch gui_data. (input_format() is declared but has
no caller in the tree, so it is not listed.)
color_picker_apply() is on the left even though the pipe measured the values it reads,
because the pipe does not call it. Once the preview pipe has sampled the picker area, it
raises DT_SIGNAL_CONTROL_PICKERDATA_READY. That signal is asynchronous, so its handler
runs later on the GTK main loop, and that handler is the callback's only caller
(src/gui/color_picker_proxy.c, src/control/signal.c). A picker callback may call GTK,
and needs the lock only for the gui_data fields a pipe callback also touches, as in
a widget callback.
In tree today: the picked values themselves are not handed over under any lock. The preview pipe writes
self->picked_color,picked_color_minandpicked_color_max, and thepicked_output_*arrays beside them, with no lock held, just before it raises the signal (src/develop/pixelpipe_hb.c). Nothing orders the callback's later read against the preview pipe's next run, which rewrites the same arrays. A module cannot close that window: the pipe takes no lock that the callback could take too.
Each column covers the static helpers called from it too. A process() that hands
gui_data to a helper does not make the access GTK-thread-safe, and that is where
mistakes hide: the offending line is two or three frames down, in a function whose name
says nothing about which thread reaches it. When you audit a field, follow the call
chain, not just the callback name.
This would be wrong:
static void _rebuild_cache(dt_iop_module_t *self, ...)
{
dt_iop_mymodule_gui_data_t *g = self->gui_data;
...
g->readout = value; // no critical section
gtk_widget_set_tooltip_text(g->area, text); // GTK, on whatever thread got here
gtk_widget_queue_draw(g->area);
}
void process(...)
{
if(_cache_is_stale(...)) _rebuild_cache(self, ...); // a pipe worker thread
...
}A mutex of your own does not rescue that. Holding one across _rebuild_cache()
serializes your pipe worker threads against each other, which is worth doing for the
cache; the GTK main loop never takes that mutex and is not ordered by it, so the two
widget calls are exactly as unsynchronized as they were. Only getting them onto the main
loop fixes them — Pattern A, or one of the
thread-safe redraw helpers below.
These marshal the redraw onto the GTK main context internally and are safe to call from any thread:
dt_control_queue_redraw_widget(widget)— redraw a specific widgetdt_control_queue_redraw_center()— redraw the center view
The right column has no fixed thread, but for the instance that owns your gui_data
the picture is narrower:
- Your darkroom instance —
process()runs on one of the three screen pipes' worker threads (full, preview, preview2), never on the GTK main thread.commit_params()usually does too, as part of synchronizing a pipe, but it has a route onto the GTK thread as well: rebuilding a pipe's nodes calls every module'sinit_pipe(), andbasecurve's callscommit_params()straight back (src/iop/basecurve.c) — while the image switch rebuilds the screen pipes from an idle callback (src/views/darkroom.c). So not even this instance givescommit_params()an affinity you can rely on. - Every instance in another develop context — export, thumbnail generation,
snapshots, the duplicate manager, the tethering view's histogram of the last captured
image, and the
overlaymodule rendering its second image, among others — belongs to a separatedt_develop_twith its own module instances and no GUI (gui_data == NULL). Most of those develop contexts are built for one render and thrown away, but not all: the pinned second-window preview keeps its own develop context, history, modules and pipes until it is unpinned (src/develop/develop.c). What they have in common is the absentgui_data, not a short life. Several of them run the pipe synchronously from a GTK draw handler, so there the very same callbacks do run on the GTK main thread.
Hence the two-sided rule: commit_params() must never touch GTK, and must never wait
for the GTK main loop to run anything. Contending for gui_lock is not that — it is a
short mutex both sides release quickly, and taking it is the whole point of the next
section. It stays short only while whoever holds it blocks on none of the locks the pipe
already holds when it takes gui_lock; that is
gui_lock Is the Innermost Lock. A synchronous
round-trip through the main loop deadlocks even when the locking is right, because the
GTK thread may itself be waiting on a pipe.
Four entries in the right column are also called directly from GTK-thread code, so they
run on either thread and need the same care for a different reason:
distort_transform() and distort_backtransform() (mask handling and darkroom zoom
both call them), blend_colorspace() (the blend GUI calls it) and
default_colorspace() (the color picker calls it while building a picker button,
src/gui/color_picker_proxy.c). distort_mask() does not have that property despite
the similar name — it is invoked only from pipeline code.
Those two colorspace callbacks are called with NULL for both pipe and piece from
the GTK side, so each has to tolerate that. default_colorspace() is also reached
indirectly: if you leave blend_colorspace() at its default,
default_blend_colorspace() forwards its two arguments straight through.
commit_params() is the one that surprises people. It reads like module setup code
that belongs to the GUI, but it is called while a pipe is being synchronized. The
three screen pipes each run on their own worker thread, yet synchronization holds
dev->history_mutex across the commit, so commit_params() does not run concurrently
with itself for one module instance. It can still overlap with process() on another
pipe and with any GTK-thread callback — that is what the lock is for.
Two preconditions, both mandatory:
- The GUI may not exist.
gui_dataisNULLin export and batch mode, and the GUI critical section —dt_iop_gui_enter_critical_section(), i.e. the module'sgui_lock— is only initialized bydt_iop_gui_init(). Entering it without a GUI locks a mutex that was never set up. - Processing must not depend on the GUI cache. The same
commit_params()runs during export, where there is nothing to read. Whatever the cache saves for the darkroom, the non-GUI branch has to compute independently.
The shape:
void commit_params(dt_iop_module_t *self, dt_iop_params_t *params, ...)
{
dt_iop_mymodule_params_t *p = (dt_iop_mymodule_params_t *)params;
dt_iop_mymodule_gui_data_t *g = self->gui_data;
...
if(self->dev->gui_attached && g)
{
// darkroom: refresh and reuse the GUI-side cache
dt_iop_gui_enter_critical_section(self);
if(g->smoothing != p->smoothing) g->interpolation_valid = FALSE;
g->smoothing = p->smoothing;
dt_iop_gui_leave_critical_section(self);
_rebuild_lut(self, p); // takes the lock itself, so call it outside the section
...
}
else
{
// export or headless: solve from scratch, with no cache to lean on
_solve_from_scratch(p, ...);
...
}
}Two things to copy and one to watch. Copy the guard and the critical section around the
fields both sides touch. Copy the else branch too — the export path has to reach the
same processing result without the cache, and a module that quietly depends on the cache
exports differently from how it previewed. What to watch is the helper: pass it p, the
argument this commit was handed, and not self->params. Those are the same object in
the normal case and different on the pipe's defaults sync, which passes default_params
(see
IOP_Module_API.md).
And keep it outside the critical section
if it takes the lock itself — see
The Lock Is Recursive.
src/iop/toneequal.c's commit_params() is the in-tree instance of this split.
g != NULL is the whole guard, because a dev whose modules were loaded without a GUI
leaves gui_data NULL, and on the normal lifecycle the lock is ready by then:
dt_iop_gui_init() initializes gui_lock immediately before it calls gui_init().
Treat that as the lifecycle, not as an invariant you can lean on.
In tree today:
dt_iop_gui_init()is the wrapper that creates the lock, and it is the wrapper that guarantees the pairing. A framework path that calls a module'sgui_init()directly instead leavesgui_dataallocated andgui_lockas it found it — and at least one history route in tree does exactly that today. Nothing in a module can detect it, and nothing you write incommit_params()fixes it. It is noted here only so that "gui_data != NULLmeans the lock is ready" is read as what normally happens rather than as something the framework promises.
g is the test that holds; self->dev->gui_attached is the in-tree idiom. Write
both, as the examples in this document do: it is what the tree writes, at about forty
places under src/iop, and it records the intent. But read only g as a test.
gui_attached adds nothing to g in current code. It is fixed while the dt_develop_t
is being built and never toggled afterwards. Most non-GUI contexts pass FALSE straight
to dt_dev_init() — export, thumbnailing, style application, mask objects. Exactly one
passes TRUE and overwrites it: the throw-away develop context dt_dev_image() renders
into, which clears the field before it loads a single module (src/develop/develop.c).
darktable.develop is given TRUE before darktable decides whether to start a GUI at all
(src/common/darktable.c) — so it is TRUE under darktable-cli too, and on its own it
is not even a test for "a GUI exists".
Dereferencing self->dev to reach it is safe here, because a module only ever reaches
a pipe through dev->iop and so always has a dev by the time commit_params() or
process() runs. That is not true everywhere — one instance in the tree has
gui_data without a dev; see
Publishing gui_data Through a Proxy.
This is allowed and common. The question is only whether you need the lock:
- Field touched by the GTK thread only — no lock. Mouse position, which node is selected, a cached gradient used solely for drawing the widget.
- Field also read or written by
commit_params()orprocess()— take the lock. Typically these are cached results and their validity flags.
static void _slider_callback(GtkWidget *slider, dt_iop_module_t *self)
{
dt_iop_mymodule_params_t *p = self->params;
dt_iop_mymodule_gui_data_t *g = self->gui_data;
p->xyz = dt_bauhaus_slider_get(slider);
// g->cached_xyz is also written by commit_params() on a pipe worker thread
dt_iop_gui_enter_critical_section(self);
if(g->cached_xyz != p->xyz) g->cache_valid = FALSE;
g->cached_xyz = p->xyz;
dt_iop_gui_leave_critical_section(self);
dt_dev_add_history_item(darktable.develop, self, TRUE);
}If you are unsure whether a field crosses threads, search for every read and write of it and check which callback each one sits in, using the table above.
Asking for a reprocess is not synchronization. A widget callback that writes a
field and then calls dt_dev_reprocess_center(), dt_dev_reprocess_all() or
dt_dev_add_history_item() still needs the lock. Those calls set pipe->changed,
invalidate buffers and queue a redraw; none of them waits for a running pipe or issues
a barrier, so a pipe already inside process() is not ordered against your write at
all — it may see the old value, the new one, or change its mind mid-frame if the
compiler re-loads the field.
Some modules publish a value to the rest of darktable through dev->proxy — exposure
publishes its effective exposure that way, and agx reads it from a GTK-thread helper
(src/iop/agx.c). The caller sits in another module and has no way to take your
gui_lock, so the accessor has to make its own access safe.
Derive, don't cache. Before asking how to lock the field, ask whether it has to be a
field at all. A value that is a function of params and of the image can be recomputed
inside the accessor, on the caller's thread, from data that thread already owns. Then
there is no shared field, no lock, and no window in which the pipe and the GUI disagree.
Having a pipe worker thread compute the same value into gui_data so that the GUI can
read it back buys nothing and costs a data race.
exposure shows both halves of the answer, because its two modes are genuinely
different:
- Manual mode — the effective exposure is the exposure parameter plus the two
optional compensations, and both compensations come from the image's EXIF data. All of
that is available to the accessor, so the accessor derives the value and does not read
gui_dataat all. Nothing is shared, so nothing needs locking, provided the accessor runs on the GTK thread, which ownsparams. The thread contract ondt_dev_proxy_exposure_trequires that of every caller (src/develop/develop.h). - Deflicker mode — the correction is computed from a histogram of the raw file,
which is nowhere in
params, so there is nothing to derive from and the value has to be published. The scalar that carries it follows the locking half of Pattern A:_process_common_setup()writesg->deflicker_computed_exposureinside a critical section on the pipe worker thread, and both of its GTK-thread readers, the_show_computed()idle callback and the proxy accessor, read it inside one (src/iop/exposure.c). The idle callback is not a model for the other half: it locks and readsgui_datawithout testing it at all,gui_cleanup()removes only one queued source, and the module has nochange_image(), all short of The Callback Must Not Outlive the Module or the Image (#22007).
The histogram behind that scalar is a second piece of shared state, and it is not covered. Publishing a value safely does not make the state it was computed from safe; each shared field needs its own answer.
In tree today:
gui_update()andgui_changed()freeg->deflicker_histogramand rebuild it on the GTK thread, while_process_common_setup()reads that pointer and its statistics on a pipe worker thread — neither side takesgui_lock, and ingui_update()the free sits between two critical sections that guard other fields. That is the trap from Hold the Lock as Long as the Value Must Stay Valid, live in the tree: a heap buffer ingui_datathat one thread can free while the other is walking it.
So which shape a proxy accessor needs depends on which of the two cases it is. A
derived value makes the accessor a plain function of params. A value that genuinely
has to come from the pipe makes it this:
static float _mymodule_proxy_get_value(dt_iop_module_t *self)
{
dt_iop_mymodule_gui_data_t *g = self->gui_data;
if(!g) return 0.0f;
dt_iop_gui_enter_critical_section(self);
const float v = g->computed_value;
dt_iop_gui_leave_critical_section(self);
return v;
}The example tests g and stops there, and stopping there is the point. A proxy accessor
takes whatever instance its caller hands it, and self->dev is not guaranteed: on the
throw-away instance darktable builds at startup so that widgets can register their
accelerators, gui_data is allocated and dev is NULL (see
GUI.md).
Reordering the guard does not help, and it is worth being explicit about why,
because the opposite is easy to assume. On that instance gui_data is allocated, so g
is non-NULL: if(!g || !self->dev->gui_attached) gets straight past !g and
dereferences the NULL dev, exactly as if(self->dev->gui_attached && g) would.
Short-circuit evaluation only protects the operand it skips, and here it skips neither.
What protects you is not reaching for self->dev at all — as the example above does —
or testing it on its own if you need something from it:
if(!g || !self->dev) return 0.0f;Inside process() and commit_params() none of this arises, for the reason given in
Using gui_data from commit_params(): a module
reaches a pipe only through dev->iop, so it always has a dev there and either order
is safe. That is why the commit_params() example earlier in this document can write
self->dev->gui_attached && g without qualification.
That startup instance is also why the dev->proxy function pointers outlive every live
instance. It is torn down again straight away, and exposure's gui_cleanup() clears
proxy.exposure.module, but nothing clears the function pointers it registered
(src/iop/exposure.c) — so a non-NULL accessor pointer does not mean there is an
instance behind it. In tree the callers carry that check:
dt_dev_exposure_get_effective_exposure() resolves a live, enabled instance itself and
passes that one, and the other two getters go through a helper that requires
proxy.exposure.module to be set (src/develop/develop.c).
The producing side — usually commit_params() or process() — must take the same lock.
Publishing a raw pointer into gui_data through a proxy is worse still: the reader then
has no lock to take at all, and no way to know the GUI is being torn down.
gui_data is also the channel one pipe uses to hand a value to another. A module that
needs a property of the whole image usually cannot get it on the full pipe, which
normally sees only the region of interest at the current zoom. The preview pipe does see
the whole image, at reduced size, so the module computes the value there, publishes it
into gui_data, and lets the full pipe pick it up.
The framework primitive for the handover is dt_dev_sync_pixelpipe_hash()
(src/develop/develop.c). The publisher stores the value together with the cumulative
hash of its own upstream pipe state. The consumer hands the primitive that hash cell and
its gui_lock, and the primitive waits until the stored hash matches what the
consumer's own upstream state hashes to.
Read that as evidence, not as proof. The primitive compares a hash it snapshotted
against one it computes afterwards, and returns as soon as the two agree; it does not
snapshot the payload, and it does not stop the publisher running again and replacing
payload and hash before the consumer gets round to reading them. The hash it compares is
also a hash of selected upstream piece values, not a full render identity — the cache
key is a different and larger thing. So a match makes a leftover from the previous
history state unlikely; it does not make it impossible.
A handful of modules use it, each passing &self->gui_lock as the lock argument so
that the primitive can read the publisher's hash safely while it waits.
Not every hash in gui_data means this is happening. A module may keep one purely to
memoize its own work, comparing it only against a hash it wrote itself on the same pipe,
and never waiting for anybody. A hash beside a payload is a freshness marker; it is a
handover only when one pipe waits on a hash another pipe wrote.
The consuming side:
// on the consuming pipe
if(g && dt_pipe_is_full(piece->pipe))
{
dt_iop_gui_enter_critical_section(self);
const dt_hash_t hash = g->hash; // has anything been published yet?
dt_iop_gui_leave_critical_section(self);
if(hash != DT_INVALID_HASH
&& !dt_dev_sync_pixelpipe_hash(self->dev, piece->pipe, self->iop_order,
DT_DEV_TRANSFORM_DIR_BACK_INCL,
&self->gui_lock, &g->hash))
dt_control_log(_("inconsistent output"));
dt_iop_gui_enter_critical_section(self);
d->value = g->published_value; // the payload, whatever it is
dt_iop_gui_leave_critical_section(self);
}and the publishing side, usually in the same function:
// on the producing pipe
if(g && dt_pipe_is_preview(piece->pipe))
{
const dt_hash_t hash = dt_dev_hash_plus(self->dev, piece->pipe, self->iop_order,
DT_DEV_TRANSFORM_DIR_BACK_INCL);
dt_iop_gui_enter_critical_section(self);
g->published_value = d->value; // both go in under one lock, so no reader can
g->hash = hash; // catch the pair half-updated
dt_iop_gui_leave_critical_section(self);
}src/iop/levels.c carries both halves in one helper, commit_params_late(), called
from process() and process_cl().
Four things about that shape do not follow from the rest of this document.
g != NULL is not testing for a GUI here. Nothing is being displayed; the test is
there because the channel itself lives in gui_data and so exists only when a GUI does.
The rule from Using gui_data from commit_params()
applies unchanged — processing must not depend on the cache — and these modules obey it.
The in-tree consumers each recompute from scratch when they are on the producing pipe,
or when the published value is still at its uninitialized sentinel. Note what that
fallback does not cover: it triggers on a value that was never published, not on one
that arrived late. After a timed-out wait the consumer uses whatever is in gui_data.
Between that and the limits above, a module here has to be able to live with a stale
value as well as with none.
process() may block here, and that is the supported idiom. The wait is bounded by
the pixelpipe_synchronization_timeout preference — or by
darktable.opencl->opencl_synchronization_timeout on a GPU pipe — and it gives up early
once the pipe is flagged for shutdown. Setting that preference to zero or less switches
the wait off, and the primitive then reports success without checking anything. After a
real timeout it still reports success if the history stack has changed underneath, since
a reprocess is already on its way; it fails only when neither holds, and that failure is
the inconsistent output in the snippet above. This is not the wait ruled out in
Which Thread Am I On?. What that section rules out is a
synchronous round-trip through the GTK main loop, because the GTK thread may itself be
waiting on a pipe. Waiting on another pipe is a different thing, and the framework
supplies the primitive for it.
Do not hold gui_lock across the call. The primitive takes the lock you hand it on
every polling iteration, so calling it from inside a critical section self-deadlocks on
the recursive mutex. Snapshot what you need, release,
then call — as the consuming snippet above does. Underneath, the probe also takes
dev->history_mutex, because hashing the upstream state walks the pipe. The primitive
releases your gui_lock before it does that, so the two are never nested; keep it that
way on your side, for the reason in
gui_lock Is the Innermost Lock.
A scalar hands over cleanly; an allocation does not. When the payload is a plain number, the span in which it must stay valid ends at the load and the short critical section above is exactly right. When it is a heap object, copying the pointer out under the lock buys nothing: only the pointer load is protected, and the publisher's next run may have freed the object before you are done with it.
The hash handshake does not close that window. It establishes that the publisher has finished a run matching your upstream state; it does not stop the next slider move from starting another one that frees and replaces the object while you are still reading it. A widget callback that resets the published state can free it too. This is Hold the Lock as Long as the Value Must Stay Valid wearing an inter-pipe costume: hold the lock through the last use, copy the data rather than the pointer, or transfer ownership so the publisher cannot free it. Pick one of the three before you publish an allocation.
dt_iop_gui_enter_critical_section() takes a plain, non-recursive mutex. Taking it
twice on the same thread deadlocks — and the second acquisition is usually invisible,
hidden inside a helper you call.
// WRONG — self-deadlock if _rebuild_cache() takes the lock itself
dt_iop_gui_enter_critical_section(self);
g->cached_xyz = p->xyz;
_rebuild_cache(self);
dt_iop_gui_leave_critical_section(self);
// RIGHT — keep the section around the writes only
dt_iop_gui_enter_critical_section(self);
g->cached_xyz = p->xyz;
dt_iop_gui_leave_critical_section(self);
_rebuild_cache(self); // takes the lock itselfSo before calling anything from inside a critical section, check whether the callee
locks. Framework helpers count — dt_dev_sync_pixelpipe_hash() in particular, which
takes the lock you hand it on every polling iteration. Keep critical sections short and
free of function calls where you can — that avoids the problem instead of reasoning about
it.
Taking gui_lock twice is one way a helper inside a critical section hangs you. The
other has nothing to do with recursion: holding gui_lock while you block on a lock
that the pipe already holds when it takes gui_lock.
Every pipe path that takes gui_lock takes it last. dt_dev_pixelpipe_change() takes
dev->history_mutex and keeps it across the whole node sync. The sync takes the pipe's
busy_mutex and, inside both, calls commit_params(): only for the top history item's
module when that item is all that changed (dt_dev_pixelpipe_synch_top()), for every
module on a full sync (dt_dev_pixelpipe_synch_all()). process() runs with
busy_mutex held for the whole pipe run (src/develop/pixelpipe_hb.c). A module that
uses gui_data from either callback therefore takes gui_lock inside those:
dev->history_mutex -> pipe->busy_mutex -> gui_lock commit_params(), node sync
pipe->busy_mutex -> gui_lock process(), pipe run
The two lines are two paths, not one order between history_mutex and busy_mutex.
process() may take history_mutex under busy_mutex too: in the publishing snippet
in Passing Values Between Pipes Through gui_data,
dt_dev_hash_plus() takes and releases it before the snippet enters the section, and
src/iop/levels.c does the same. What every path agrees on is where gui_lock goes.
So nothing may block on them the other way round. Holding gui_lock across a call that
blocks on history_mutex or on a pipe's busy_mutex is an AB-BA inversion: your thread
holds gui_lock and waits for the mutex, a pipe worker holds the mutex and waits for
gui_lock, and neither ever gets what it is waiting for. When your thread is the GTK
one, the darkroom is frozen for good. No round trip through the main loop is involved,
and nothing in the module's own file looks wrong. It is not only a GTK-side rule,
either: gui_lock belongs to the module instance, which all three screen pipes share,
so process() on one pipe holding it across such a call deadlocks against a node sync
on another, and the next GTK callback that enters the section hangs behind them.
That dev->history_mutex is recursive does not help: recursion lets one thread take the
same mutex again, and does nothing about two threads taking two mutexes in opposite
orders.
As with recursion, the second lock is usually hidden inside a call. The common ones that
take history_mutex (all in src/develop/develop.c):
dt_dev_distort_transform_plus()anddt_dev_distort_backtransform_plus(), the usual way to map a cursor position onto a buffer pixel;dt_dev_hash_plus(), and through itdt_dev_sync_pixelpipe_hash(), whose own note is in Passing Values Between Pipes Throughgui_data;dt_dev_add_history_item()anddt_dev_add_masks_history_item().
// WRONG — gui_lock held across a call that takes history_mutex
dt_iop_gui_enter_critical_section(self);
g->cursor_exposure = log2f(_luminance_at_cursor(self)); // backtransforms inside
dt_iop_gui_leave_critical_section(self);
// RIGHT — map the cursor first, then lock for the buffer read and the write
float pt[2] = { x, y };
dt_dev_distort_backtransform_plus(self->dev, self->dev->preview_pipe, self->iop_order,
DT_DEV_TRANSFORM_DIR_FORW_EXCL, pt, 1);
dt_iop_gui_enter_critical_section(self);
g->cursor_exposure = log2f(_luminance_at(g, pt[0], pt[1])); // reads g's buffer only
dt_iop_gui_leave_critical_section(self);The RIGHT form moves only the transform out of the section. Moving the whole helper out would fix the inversion and open the other trap: the buffer read then happens with no lock held, which Hold the Lock as Long as the Value Must Stay Valid rules out.
toneequal's scrolled() had the WRONG form until 890c7f5fdc
(darktable-org#22133, found by reading the code rather than from a reported
freeze). That commit moved the whole helper out of the section, so it is not an example
of the RIGHT form: the buffer read in scrolled() still happens with no lock held, and
darktable-org#22068, still open, tracks it.
The framework keeps to the same order where it cannot avoid nesting:
dt_preview_data_is_fresh() already holds gui_lock when it needs the preview pipe's
busy_mutex, so it only tries to take it and treats a busy pipe as "not fresh"
(src/develop/preview_data.c). That reverse nesting is safe only
because a trylock cannot block: turning it into a plain lock brings the inversion back.
Shortening a critical section past the point where the value is still needed is its own bug, and a nastier one, because the module now looks locked.
// WRONG — the pointer load is protected, the pointee is not
dt_iop_gui_enter_critical_section(self);
my_cache_t *c = g->cache;
dt_iop_gui_leave_critical_section(self);
use_cache(c); // another thread may have freed g->cache by nowThe lock has to cover the whole span in which the value must stay valid, not just the load. For a plain scalar that span ends at the load, and a snapshot is exactly right. For anything the other thread can free or resize — a heap pointer, a buffer plus the width and height that describe it — it ends when you stop using the value, and you have to pick one of:
- Hold the lock through the last use. The simplest thing that works — but only if
every path that frees or replaces the object takes the same lock.
gui_cleanup()does not, and does not have to — see The Callback Must Not Outlive the Module or the Image. Every other path that frees or replaces the object — a widget callback, a reset, a reload — still has to take it. It also needs that use to be short: holding the mutex across an allocation, a device transfer or a full-buffer copy blocks the other thread for as long as it takes, and when the blocked thread is the GTK one the user sees it. - Copy the data, not the pointer, inside the section, then work on your copy.
- Reference-count the object, or hand ownership over explicitly, so the other thread cannot free something you still hold.
The same trap catches tuples. If a buffer and its dimensions are written together under the lock, read them together under the lock too. Reading the dimensions again after releasing can pair a new size with an old allocation.
That rule covers a buffer being replaced. It does not cover one being refilled in place while the flag beside it still says the old contents are good. Clear the flag inside a critical section before the fill starts, and commit the new contents' hash and the flag inside another one once the data is there; between the two the reader sees "not valid" and stays away:
dt_iop_gui_enter_critical_section(self);
g->buffer_valid = FALSE; // reader now knows to stay away
dt_iop_gui_leave_critical_section(self);
_fill_buffer(g->buffer, ...); // long, and deliberately outside the lock
dt_iop_gui_enter_critical_section(self);
g->hash = new_hash; // hash and flag committed together
g->buffer_valid = TRUE;
dt_iop_gui_leave_critical_section(self);Without the first section there is a window in which a valid-flagged buffer holds half of one image and half of another — a different failure from the size mismatch above, and one that no amount of locking around the read will catch.
src/iop/toneequal.c is worth reading for the producer half of this, with one
difference. It clears the flag and fills outside the lock as above, but it commits the
hash and raises the flag in two separate critical sections rather than one. That is
still safe, and only because of the order: a reader that lands in the gap sees a fresh
hash with the flag still down and waits, never the reverse. Committing both in one
section, as shown here, is the version that does not need that argument made for it.
The flag only pays off if the reader honors it for as long as it uses the buffer. Testing it under the lock, releasing, and then reading puts you back in the first trap in this section: the writer can clear the flag and start refilling in between. Test and use inside one critical section, or copy out what you need while you hold it.
Everything above is the hand-rolled version. For the most common instance of it — a
per-pixel value produced on the preview pipe and read on the GTK thread under the mouse
cursor — the framework already has it: src/develop/preview_data.h. Its file comment
says what it is for, and names the modules that used to duplicate it.
The service owns the buffer, the hash and the locking:
dt_preview_data_alloc()ingui_init()anddt_preview_data_free()ingui_cleanup()are the bookends. These are the two entry points that do not take the lock —_free()leans on the same teardown guaranteegui_cleanup()does, scoped as in The Callback Must Not Outlive the Module or the Image.dt_preview_data_store()is the write side: it sizes the buffer, fills it through a callback of yours and commits the hash.dt_preview_data_resize()anddt_preview_data_set_hash()are the two-step form of that, for a fill too expensive to hold the lock across.dt_preview_data_get()reads one component of one pixel, from the GTK thread, while the pipe may be writing.dt_preview_data_is_fresh()compares the stored hash against the module's piece in the current preview pipe and answers yes or no. It also answers no, without comparing, whenever it cannot take the preview pipe'sbusy_mutex, which the pipe holds for a whole run and while its nodes are synchronized or rebuilt. A no therefore does not tell stale data from a busy pipe, and the data may well be current: of the pipe's pieces, only yours and those before it enter the hash (its other inputs are listed in Hash-based Caching), so editing a later module usually re-runs the preview pipe without changing the hash. If you request a reprocess on a no, ascolorequaldoes, request it once and not again until a yes, which is what itsreprocess_pendingflag is for.dt_preview_data_get_hash()does not compare anything — it hands back the stored hash so you can do the comparison yourself.dt_preview_data_invalidate()marks the data stale without dropping the buffer.
The other seven take your module's gui_lock internally for the fields they own, so for
the service's own buffer and hash you do not have to take it yourself. What is awkward to
build by hand is the guarantee the header attaches to dt_preview_data_store(): resize,
fill and hash commit happen inside a single critical section, so the GUI can never
observe a resized but not-yet-filled buffer.
The two-step form gives up that single-section guarantee, and hands you the piece you
need to replace it: if dt_preview_data_resize() has to resize, it calls a callback of
yours while still holding the lock, so you can drop your own validity flag atomically
with the resize — the refill-in-place discipline from
Hold the Lock as Long as the Value Must Stay Valid,
with the framework opening the critical section for you. In src/iop/toneequal.c that
callback is four lines long and clears one flag.
The internal locking that spares you taking gui_lock also means you must not be holding
it when you call the seven. The lock is recursive, so
calling one of them from inside your own critical section deadlocks the thread on a lock
it already holds. Your fill and resize callbacks are already inside one: the service calls
them from its own section, so they may neither call the seven nor enter the section
themselves. dt_preview_data_get() is the likeliest to end up in the wrong place. It
returns one value and reads like an array access, so it is easy to drop into a section
that has just tested your validity flag.
That section is exactly where the read belongs, though: the end of
Hold the Lock as Long as the Value Must Stay Valid
has the reader test the flag and use the buffer inside one critical section, so a reader
that honors a flag cannot go through the accessor. Inside that section, read pd.buf,
pd.width and pd.height directly, as toneequal's update_histogram() does. That is
safe because dt_preview_data_store() and dt_preview_data_resize() replace those fields
only while holding the same lock (src/develop/preview_data.c).
With dt_preview_data_store() there is no flag to consult, since the fill happens inside
the lock, but the accessor on its own is still enough only for one component at a buffer
pixel you already have. Mapping the cursor to that pixel needs pd.width and
pd.height, and reading several components of one pixel needs them all from the same
fill. Both need a single hold of the lock, so do them by hand inside one section as well.
colorequal's mouse_moved() maps the cursor that way, for a single component. For
several, index each one as pd.buf[(y * pd.width + x) * pd.components + c] inside the
same section, which is the index the accessor computes. dt_preview_data_get()
re-checks its coordinates against the current size under its own lock, so dimensions
read earlier cannot make it read out of range, but after a resize they make it read the
wrong pixel.
Nor does the accessor tell you whether the value is current. It does not look at the
hash, and when dt_preview_data_store() fails to allocate a new size it keeps the old
buffer and only invalidates the hash. dt_preview_data_is_fresh() answers that question,
called outside any section of yours like the rest of the seven, but only for the stored
data at the moment it runs, in a section of its own, and only when it answers yes: a no
may just mean the preview pipe was busy. A dt_preview_data_store() can
replace buffer and hash between your read and the check, so a TRUE does not vouch for a
value read before or after it. colorequal uses it as a gate, deciding whether to show
its cursor and whether to request a reprocess, and reads the buffer again on the next
mouse move. If a value must be tied to its hash, read pd.hash in the same section as
the value: dt_preview_data_store() commits the two together.
In tree today: not all of
dt_preview_data_is_fresh()runs in its section. It tests the buffer pointer in an early return, before it takes the lock at all, against a field thatdt_preview_data_store()anddt_preview_data_resize()free and replace while holding it. The pointer is only compared with NULL and never dereferenced, so racing the first allocation can make the function answer FALSE, but not a wrong TRUE.
toneequal and colorequal use the service. What stays yours is what the header says
is module-specific: computing the value, drawing it, and mapping the cursor position to
a buffer pixel — that last one depends on which geometry modules sit after yours in the
pipe, so the service cannot do it for you.
This replaces the buffer, hash and lock bookkeeping for that one case. It does not
replace gui_lock for your module's other shared fields, and it is not a general
gui_data mutex — everything else in this document still applies to them.
The guard block below belongs before you schedule a GUI update from process(). It has
three lines, but only two of them are tests, and only the first is unconditional.
dt_iop_mymodule_gui_data_t *g = self->gui_data;
if(g != NULL // GUI exists (not export) — the test that holds
&& self->dev->gui_attached // the in-tree idiom, not a second test
&& dt_pipe_is_full(piece->pipe)) // the pipe this value belongs to
{
// Schedule GUI update...
}The pipe test is the one to think about rather than copy. dt_pipe_is_full() is right
when the value only means something for the full-resolution render, and it keeps the two
preview pipes from queueing an update apiece. When the value is produced elsewhere, test
for that pipe instead: exposure computes its deflicker readout on the preview pipe and
publishes it from there (dt_pipe_is_preview(), src/iop/exposure.c). What is never
right is no pipe test at all — then every pipe running your module queues its own
update.
The middle line is the in-tree idiom, not a working guard — see
Using gui_data from commit_params().
Reading g once is enough for the length of the run: on the four darkroom teardown
paths the framework holds the screen-pipe mutexes across module GUI teardown, so a
non-NULL g cannot be freed while your process() or commit_params() is executing.
What the guard does not cover is work you hand to the main loop, and the scope of that
teardown guarantee — both are in
The Callback Must Not Outlive the Module or the Image.
Store computed values in gui_data under mutex, then schedule a GTK-thread callback.
This is the pattern to reach for first; Pattern B below is for the payload that cannot
live in gui_data.
g is self->gui_data, tested as in
Guards Before Sending GUI Updates — that guard
block is required context for both patterns.
// In process():
if(g != NULL && self->dev->gui_attached // see "Guards Before Sending GUI Updates"
&& dt_pipe_is_full(piece->pipe)) // — and pick the pipe test to match
{ // where your value is produced
dt_iop_gui_enter_critical_section(self);
g->computed_exposure = exposure;
dt_iop_gui_leave_critical_section(self);
g_idle_add(_show_computed, self);
}
// Callback (GTK main thread):
static gboolean _show_computed(gpointer user_data)
{
dt_iop_module_t *self = user_data;
dt_iop_mymodule_gui_data_t *g = self->gui_data;
// fallback check only — see "The Callback Must Not Outlive the Module or the Image"
if(!g) return G_SOURCE_REMOVE;
dt_iop_gui_enter_critical_section(self);
float val = g->computed_exposure;
dt_iop_gui_leave_critical_section(self);
gchar *str = g_strdup_printf(_("%.2f EV"), val);
gtk_label_set_text(g->label, str);
g_free(str);
return G_SOURCE_REMOVE; // Run once, then remove
}
// In gui_cleanup(): drop callbacks still queued for this module
while(g_idle_remove_by_data(self)) ;
// And in change_image(): an image switch keeps the base instance and its gui_data,
// so a source queued for the old image would otherwise run against the new oneAllocate a message struct that the callback owns and frees. The payload never lives in
gui_data, so no critical section is needed for it — but cancelling the callback is
harder, and the bookkeeping that fixes it is more than the critical section this pattern
set out to avoid. Prefer Pattern A unless the payload genuinely cannot live in
gui_data; the reasoning is at the end of
The Callback Must Not Outlive the Module or the Image.
The snippet below shows the dispatch and the callback only. It is not complete: the source-id bookkeeping that makes it cancellable is described in that section, and has to be added.
typedef struct
{
dt_iop_module_t *self;
float values[3];
} mymodule_gui_msg_t;
// Callback (GTK main thread):
static gboolean _update_gui(gpointer data)
{
mymodule_gui_msg_t *msg = data;
dt_iop_mymodule_gui_data_t *g = msg->self->gui_data;
// fallback check only — see "The Callback Must Not Outlive the Module or the Image"
if(!g)
{
g_free(msg);
return G_SOURCE_REMOVE;
}
memcpy(g->display_values, msg->values, sizeof(g->display_values));
gtk_widget_queue_draw(g->area); // reach the widget through g, not through msg->self
g_free(msg); // Callback owns the message
return G_SOURCE_REMOVE;
}
// At end of process():
if(g != NULL && self->dev->gui_attached // see "Guards Before Sending GUI Updates"
&& dt_pipe_is_full(piece->pipe))
{
mymodule_gui_msg_t *msg = g_malloc(sizeof(*msg));
msg->self = self;
memcpy(msg->values, local_values, sizeof(msg->values));
// keyed on msg, so it cannot be cancelled by data — see the next section
g_idle_add(_update_gui, msg);
}Two details in that callback are not decoration. The NULL check earns more here than it
does in Pattern A: a source keyed on the message cannot be cancelled with
g_idle_remove_by_data(), so until you add the source-id bookkeeping described in the
next section it is the only thing standing between a deleted instance — the one teardown
shape that keeps the module struct and frees only its GUI — and a NULL dereference. On
the shapes that free the struct as well it is already too late, which is the whole of
why it is a fallback check and not a guard.
And every field the callback touches is reached through g, so that one check covers
the whole body — including the widget. Taking the widget from msg->self->widget
instead would leave a dereference the check does not guard:
dt_iop_gui_cleanup_module() destroys that widget and sets the field to NULL, right
beside where it frees gui_data, with the in-source note that it does so because
asynchronous work can still be carrying the module (src/develop/imageop.c). Keep your
widget pointers in gui_data and the guard stays honest.
The framework guarantees one thing here: on all four darkroom teardown paths, no module
GUI is torn down while a pipe is running. Those four sites — leaving the darkroom,
switching image, deleting an instance, and undoing or redoing a module add or delete —
hold all three screen-pipe mutexes across dt_iop_gui_cleanup_module(). Deleting an
instance and undo/redo take them through the dt_dev_pixelpipe_stop_and_lock_all() /
dt_dev_pixelpipe_unlock_all() pair (src/develop/develop.c), which also flags the
pipes for shutdown; darkroom exit and the image switch lock the same three mutexes
directly (src/views/darkroom.c), the image switch with trylock and a re-queue for
as long as a pipe is busy. One consequence for module authors: on those four paths
gui_cleanup() runs with all three screen-pipe mutexes held, so it must not call
anything that waits on a pipe. Those four are not the only callers, though — the startup accelerator
probe tears its throw-away instance down with no pipe lock held at all
(src/develop/imageop.c; on that startup instance dev is NULL as well, see
GUI.md). So take the mutexes as a constraint on what
gui_cleanup() may do, never as a guarantee it can lean on.
That closes the window against pipe worker threads. It does not close the one you open yourself by handing work to the main loop.
g_idle_add() hands the main loop a raw dt_iop_module_t *. What teardown does to
that pointer depends on the path, and there are three shapes:
- Leaving the darkroom — for every module that has a GUI:
gui_cleanup()runs,gui_lockis destroyed,gui_datais freed, and then the module itself is freed. (Hidden modules never get a GUI, so all three teardown shapes skip them.) - Deleting an instance, or undo/redo of an add or delete — the same GUI teardown
runs, but the module struct survives: it is parked in
dev->alliopfor pipes that may still reference it, and freed when the darkroom is left or the image is switched. - Switching image — this one splits (
src/views/darkroom.c). Each module's base instance — the one with the lowestmulti_priority— is kept: nogui_cleanup(),gui_lockandgui_datastay alive, and the instance is re-used for the new image afterdt_iop_reload_defaults()and, if it implements one,change_image(). Its extra instances are torn down like a darkroom exit —gui_cleanup(), then the struct is freed — and the ones the new image's history needs are rebuilt with a freshgui_init().
In the first two shapes your source is still sitting in the main loop and fires against freed GUI state. In the third, for a base instance, nothing is freed and nothing cancels the source: it fires on a live module and writes a value computed from the previous image into the new image's widgets.
The if(!self->gui_data) return G_SOURCE_REMOVE; at the top of both pattern callbacks
does not cover all three; what it does on each is worked through
below. Cancel the source instead.
Cancel in gui_cleanup(). If the source data is self, that is one line:
void gui_cleanup(dt_iop_module_t *self)
{
...
// g_idle_remove_by_data() drops one source per call, so drain
while(g_idle_remove_by_data(self)) ;
}gui_cleanup() is not the only cancellation point, because it does not run on every
transition that invalidates your payload. A base instance survives an image switch, so
a source queued while the old image was loaded fires against the new one. Drain in
change_image() too — that is the callback the framework gives a retained instance for
exactly this kind of reset, and it runs while the three screen-pipe mutexes are still
held, so no pipe worker thread can queue a source behind the drain. The modules that
implement change_image() use it to clear GUI state; they are listed in
IOP_Module_API.md:
void change_image(dt_iop_module_t *self)
{
while(g_idle_remove_by_data(self)) ;
... // reset the rest of gui_data for the new image
}If the queued value can simply be recomputed, cancelling is the whole fix: the new image's first pipe run queues a fresh update.
The loop is easy to drop, because a single call looks like it cancels. This would be wrong:
void gui_cleanup(dt_iop_module_t *self)
{
...
g_idle_remove_by_data(self); // removes ONE source, and returns whether it did
}process() can queue a source on every qualifying run, so several can be outstanding
when the GUI is torn down, and one call is not guaranteed to remove them all.
src/bauhaus/bauhaus.c drains in a while loop and is the model for that line.
Why the loop is not a detail. Every one of these transitions holds the screen-pipe
mutexes before it touches a module GUI, so nothing can queue a fresh source once
gui_cleanup() — or change_image() — has started. Draining there is therefore
sufficient, as long as it actually drains.
If the source data is a heap message (Pattern B), g_idle_remove_by_data() cannot find
it — the source is keyed on the message, not on the module. You then have to track the
source ids yourself: keep the id in gui_data, and queue with g_idle_add_full()
passing g_free as the GDestroyNotify. Drop the g_free(msg) from the callback if
you do — the notification runs after a normal dispatch as well as on cancellation, so
keeping both frees the message twice. A second update also has to supersede the first
rather than overwrite its id unremoved. That bookkeeping is why Pattern B's opening
preference for Pattern A applies.
dt_iop_gui_cleanup_module() sets module->gui_data to NULL after freeing it, and
teardown and idle dispatch both run on the GTK main thread, so they cannot interleave.
That is enough to make if(!self->gui_data) return G_SOURCE_REMOVE; meaningful on one
of the three shapes and not on the other two:
- For a deleted instance it is a real guard. The module struct is still there,
parked in
dev->alliop, andgui_datahas been cleared, so the check sees NULL and returns; without it the callback dereferences NULL and the process stops there. - On darkroom exit, and for an extra instance on an image switch, the struct
itself is freed right after its GUI, so reading
self->gui_datais the use-after-free. The check comes too late whatever it returns — and what it returns is undefined: the freed bytes may still hold the NULL that cleanup wrote, or anything the allocator has since put there. - For a base instance on an image switch the check is blind rather than late.
Nothing is freed,
gui_datais still allocated, so the test passes and the callback runs to completion against the wrong image.
Where the check does work it has to come first, ahead of
dt_iop_gui_enter_critical_section(), which would otherwise lock a destroyed mutex.
Treat it as a fallback check, never as a substitute for cancelling: only the drain covers
all three shapes.
Each row is one WRONG line and the section that explains it.
| Mistake | WRONG | Explained in |
|---|---|---|
GTK call from process(), directly or through a helper |
gtk_label_set_text(g->label, "value"); |
Which Thread Am I On? |
Assuming commit_params() runs on the GTK thread |
void commit_params(...) { gtk_widget_queue_draw(g->area); } |
Which Thread Am I On? |
No critical section when the pipe writes gui_data — queueing the GUI read for later does not synchronize the write |
g->computed_value = result; in process(), then g_idle_add(_update_gui, self); |
Using gui_data from commit_params() |
| Entering the critical section without checking that the GUI exists | dt_iop_gui_enter_critical_section(self); in commit_params(), unguarded |
Using gui_data from commit_params() |
| No critical section in a widget callback either, when the pipe reads the field | g->cache_valid = FALSE; in a slider callback |
Writing gui_data from a Widget Callback |
| Treating a reprocess request as a barrier | dt_dev_reprocess_center(self->dev, self->iop_order); after writing a shared field |
Writing gui_data from a Widget Callback |
| Calling a locking helper from inside a critical section | _update_cache(self); between enter and leave |
The Lock Is Recursive |
Holding gui_lock across a call that takes history_mutex or a pipe's busy_mutex |
g->exposure = _value_at_cursor(self); between enter and leave, where the helper backtransforms the cursor |
gui_lock Is the Innermost Lock |
| Locking the pointer load and not the pointee | my_cache_t *c = g->cache; under the lock, use_cache(c); after it |
Hold the Lock as Long as the Value Must Stay Valid |
Calling a dt_preview_data_* accessor from inside a critical section, or from the fill or resize callback |
dt_preview_data_get(&g->pd, x, y, 0, &v); between enter and leave, after testing a validity flag |
The Framework Service for Per-Pixel Readouts |
| No pipe test at all, so every pipe queues its own update | if(g != NULL) g_idle_add(...); |
Guards Before Sending GUI Updates |
| Forgetting to free the Pattern B message — or freeing it twice | return G_SOURCE_REMOVE; with no g_free(data), or one alongside a g_free GDestroyNotify |
The Callback Must Not Outlive the Module or the Image |
| Queued callback with no cancellation | g_idle_add(_update_gui, self); with no drain in gui_cleanup() or change_image() |
The Callback Must Not Outlive the Module or the Image |