darktable-org / darktable-org/darktable
rgblevels writes its auto-levels request from the GTK thread without the lock
@kofa73 is already working on this.
Since Aug 19, 2026.
- Dominant language
- C
- Stars
- 13.1k
- Forks
- 1.4k
- Avg merge
- 22h 14m
- Merged PRs (30d)
- 198
Description
Is there an existing issue for this?
- I checked and did not find my issue in the already reported ones
Describe the bug
Found by Claude + Codex via code analysis.
Description
rgblevels can compute its levels automatically, over the whole image or over a
rectangle the user drags on the image. The work is done by the pixelpipe, and the GUI asks
for it through two fields in the shared GUI data: a state flag, call_auto_levels, and
the rectangle, box_cood. The flag is a small state machine — 0 idle, 1 requested,
-1 being computed, 2 result ready — and every transition on the pipe side is made
inside the critical section. On the GTK side only some writers do the same: the one that
turns the region picker off takes the lock and only asks for a new run when the state is
0, but button_released(), which is how a dragged rectangle is submitted, writes all
four rectangle values and then sets the flag to 1 with no lock at all. The result is
lost requests and dropped results — an auto-levels request made while a previous one is
still being computed can overwrite the -1 or 2 state and be silently discarded, or
discard the finished result before the GUI has stored it. The rectangle itself can also be
read half-updated by the pipe, giving levels computed over an area the user never
selected. Separately, g->channel, which says which of the R, G and B tabs is open, is
written by the GTK thread and read by the pipe with no lock on either side, so changing
tab while a run is in progress can apply the computed levels to a different channel than
the user was looking at. Nothing here is memory-unsafe: the channel value comes from the
notebook page number and stays within the array.
Evidence
All code is in src/iop/rgblevels.c.
button_released() runs on the GTK thread and writes both fields unlocked:
g->box_cood[0] = g->posx_from;
g->box_cood[1] = g->posy_from;
g->box_cood[2] = g->posx_to;
g->box_cood[3] = g->posy_to;
dt_dev_distort_backtransform(darktable.develop, g->box_cood, 2);
g->box_cood[0] /= darktable.develop->preview_pipe->iwidth;
...
g->button_down = 0;
g->call_auto_levels = 1;
dt_dev_reprocess_all(self->dev);
The other GTK-side request, in _turn_selregion_picker_off(), shows the intended shape —
lock held, and a new run only started from the idle state:
dt_iop_gui_enter_critical_section(self);
if(g->call_auto_levels == 0)
{
g->box_cood[0] = g->box_cood[1] = g->box_cood[2] = g->box_cood[3] = 0.f;
g->call_auto_levels = 1;
}
dt_iop_gui_leave_critical_section(self);
The pipe side, in process(), tests and advances the flag under the lock, but reads the
rectangle and the channel after releasing it:
dt_iop_gui_enter_critical_section(self);
if(g->call_auto_levels == 1 && !DT_IN_GUI_UPDATE())
{
g->call_auto_levels = -1;
dt_iop_gui_leave_critical_section(self);
memcpy(&g->params, p, sizeof(dt_iop_rgblevels_params_t));
int box[4] = { 0 };
_get_selected_area(self, piece, g, roi_in, box); // reads g->box_cood, no lock
_auto_levels((const float *const)ivoid, roi_in->width, roi_in->height, box,
&(g->params), g->channel, work_profile); // reads g->channel, no lock
dt_iop_gui_enter_critical_section(self);
g->call_auto_levels = 2;
dt_iop_gui_leave_critical_section(self);
}
process_cl() has the same shape. _get_selected_area() copies the four values out
without any lock:
dt_boundingbox_t box_cood = { g->box_cood[0], g->box_cood[1],
g->box_cood[2], g->box_cood[3] };
The result is picked up on the GTK thread when the preview pipe finishes, again under the
lock, which is what the 2 state is for:
dt_iop_gui_enter_critical_section(self);
if(g->call_auto_levels == 2)
{
g->call_auto_levels = -1;
dt_iop_gui_leave_critical_section(self);
memcpy(p, &g->params, sizeof(dt_iop_rgblevels_params_t));
An unlocked g->call_auto_levels = 1 from button_released() can land on any of these
states, including the -1 that means "a thread is working on this right now".
g->channel is written on the GTK thread in _tab_switch_callback() and in
gui_changed(), both without the lock:
g->channel = (dt_iop_rgblevels_channel_t)page_num;
Suggested fix
Make button_released() follow the shape the module already uses elsewhere: take the
critical section, only start a new run when call_auto_levels is 0, and write the
rectangle and the flag inside it.
dt_iop_gui_enter_critical_section(self);
if(g->call_auto_levels == 0)
{
g->box_cood[0] = ...;
...
g->call_auto_levels = 1;
}
dt_iop_gui_leave_critical_section(self);
Note that dt_dev_distort_backtransform() should stay outside the section — compute the
four values into locals first, then copy the locals in under the lock, so that no real work
is done while the lock is held.
On the pipe side, copy box_cood and channel into locals inside the section that is
already there, in the same place where the state is set to -1, and pass the locals to
_get_selected_area() and _auto_levels(). The GTK-side writes of g->channel in
_tab_switch_callback() and gui_changed() need the lock as well.
Steps to reproduce
No known problem reported before. Derived from reading the code, not carried out. The lost-request case has the widest
window, because the -1 and 2 states last as long as the computation and the redraw:
- Open an image in the darkroom and enable
rgb levels. Use a large image, and turn
OpenCL off, so that the automatic computation takes a noticeable time. - Press the "auto region" button, then drag a rectangle inside the image and release the
mouse. This submits the first request. - Immediately drag and release a second rectangle, before the image finishes updating.
- Watch for the levels not changing at all, or changing to a result that matches neither
of the two rectangles. - For the channel case, switch the R/G/B tab immediately after releasing the mouse, and
watch which channel's levels change.
A build with ThreadSanitizer reports the unsynchronised accesses directly and does not
depend on the timing producing a visible result. A slow pipeline (HQ, large file, no OpenCL) may help reproducing.
Expected behavior
No response
Logfile | Screenshot | Screencast
No response
Commit
No response
Where did you obtain darktable from?
self compiled
darktable version
6783f7c4
What OS are you using?
Linux
What is the version of your OS?
Ubuntu 26.04
Describe your system
No response
Are you using OpenCL GPU in darktable?
None
If yes, what is the GPU card and driver?
No response
Please provide additional context if applicable. You can attach files too, but might need to rename to .txt or .zip
No response
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.