Skip to content

Draft: dev-doc: document sharing gui_data between GUI and pipe threads - #21912

Draft
kofa73 wants to merge 64 commits into
darktable-org:masterfrom
kofa73:dev-doc/gui-data-sharing-pr
Draft

Draft: dev-doc: document sharing gui_data between GUI and pipe threads#21912
kofa73 wants to merge 64 commits into
darktable-org:masterfrom
kofa73:dev-doc/gui-data-sharing-pr

Conversation

@kofa73

@kofa73 kofa73 commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

GUI.md section 3 covered only updating the GUI from process(). It now covers both directions, and states the rule that was never written down: a widget callback may write gui_data, but needs the GUI critical section whenever a pipe thread also touches the field.

Adds which callbacks the pipe drives (their static helpers included), the preconditions for touching gui_data from commit_params(), locking rules for dev->proxy accessors, and why a critical section must span the lifetime of the value rather than just the load. IOP_Module_API.md gets a matching note on commit_params() and a fixed anchor.

Draft, needs editing:

  • large amount of text, probably too detailed
  • dependency on some code changes/bugs identified while the material was gathered.

@kofa73
kofa73 marked this pull request as draft August 18, 2026 20:42
@ralfbrown ralfbrown added the scope: usermanual improving the documentation label Aug 19, 2026
@kofa73
kofa73 force-pushed the dev-doc/gui-data-sharing-pr branch 2 times, most recently from 53484a6 to 2f1c4f1 Compare August 23, 2026 07:25
@kofa73
kofa73 marked this pull request as ready for review August 23, 2026 07:29
@kofa73

kofa73 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator Author

The large amount of text added has now been extracted to its own file; the fix for #21891 is now taken into account. Some redundancy eliminated.
I'll run a few more reviews on it.

@kofa73
kofa73 marked this pull request as draft August 23, 2026 07:33
@kofa73
kofa73 force-pushed the dev-doc/gui-data-sharing-pr branch 2 times, most recently from fce1611 to 231928f Compare August 28, 2026 16:38
kofa73 added 20 commits August 28, 2026 18:39
GUI.md section 3 covered only updating the GUI from process(). It now
covers both directions, and states the rule that was never written down:
a widget callback may write gui_data, but needs the GUI critical section
whenever a pipe thread also touches the field.

Adds which callbacks the pipe drives (their static helpers included), the
preconditions for touching gui_data from commit_params(), locking rules
for dev->proxy accessors, and why a critical section must span the
lifetime of the value rather than just the load. IOP_Module_API.md gets a
matching note on commit_params() and a fixed anchor.

Also states the framework-side teardown contract that the queued-callback
advice rests on: no module GUI is torn down while a pipe is running, and
dt_iop_gui_cleanup_module() nulls gui_data. That makes reading gui_data
once in process() safe for the length of the run, and makes draining
queued idle sources in gui_cleanup() sufficient rather than best-effort
- but only if the drain loops, since a source that survives now hits a
NULL dereference instead of freed memory.
GUI.md section 3 had grown to 476 of the file's 868 lines, more than the
UI construction, event and reparenting sections together, and none of it
is what a reader opening GUI.md for widget layout is looking for.

Extract it verbatim to GUI_Threading.md, one topic per file as the rest
of dev-doc already is. Heading levels drop by one; the text is otherwise
unchanged, so the anchors and every cross-reference inside it still
resolve. GUI.md keeps a short section 3 stating the problem and pointing
at the new file, which leaves the section numbering intact.

README.md, IOP_Module_API.md and New_Module_Guide.md described GUI.md as
the thread-safety reference; they now name GUI_Threading.md instead.
The paragraph on draining queued sources claimed the survivor always
dereferences a NULL gui_data. That only holds where the module struct
outlives its GUI, which is the instance-delete and undo/redo path.

Leaving the darkroom (src/views/darkroom.c) and switching image both
free the struct right after dt_iop_gui_cleanup_module(), so the callback
reads self->gui_data through a dangling pointer: it may fault, or return
a stale pointer that still looks usable.
The paragraph said an undrained source dereferences NULL after an
instance delete, but Pattern A tells the reader to put an
if(!self->gui_data) check at the top of the callback, which catches
exactly that case. Stated the two paths in terms of what the check does:
it returns cleanly after a delete, and it is already too late after a
darkroom exit or image switch, where reading self->gui_data is itself
the use-after-free.

Drop the claim that the freed read yields a usable stale pointer. It is
undefined, and the value is unpredictable in both directions: cleanup
nulls gui_data before the struct is freed, so the bytes often still read
NULL until the chunk is reused.
Four corrections from review, all documentation/code-sync defects:

- Image switching does not free every module. darkroom.c keeps each
  module's base instance across the switch: no gui_cleanup(), gui_data
  and gui_lock stay alive, and the instance is reused after
  reload_defaults() and change_image(). Only the extra instances are
  cleaned up and freed. The section described the switch as a
  darkroom-exit-shaped teardown, which made the NULL check look too late
  when for a base instance it is blind instead: it passes, and the
  survivor writes the previous image's value into the new image's
  widgets. Split the teardown into three shapes, and add change_image()
  as the second cancellation point, since gui_cleanup() never runs there.

- default_colorspace() has a direct GTK-thread call site too: the color
  picker constructor calls it with NULL pipe and piece. It was described
  only as something default_blend_colorspace() forwards to.

- overlay violates the no-GTK-from-a-pipe-thread rule in tree:
  _setup_overlay(), reached from process() whenever the overlay buffer
  has to be built, sets a tooltip on a gui_data widget and can queue a
  redraw. Named next to the denoiseprofile call-chain example, since the
  point of that paragraph is that the mistake hides one call down.

- exposure is named as the proxy example, but neither its producer nor
  its accessor takes gui_lock and the accessor does not check gui_data.
  Say so, so the example is not read as a description of what exposure
  does.

Also: only two of the four teardown sites use
dt_dev_pixelpipe_stop_and_lock_all(); darkroom exit and the image switch
take the same three mutexes directly.
GUI_Threading.md now tells authors to cancel queued idle sources in
change_image(), because an image switch keeps each module's base
instance and never runs its gui_cleanup(). The callback itself was
documented nowhere: the API page named reload_defaults() as the
image-switch callback and its lifecycle diagram went straight from it to
gui_update(), and GUI.md's external-change path did not mention it
either. An author following those pages would not know the hook exists.

Add a change_image() section to IOP_Module_API.md covering both of its
jobs — resetting gui_data that describes the old image, and cancelling
work queued for it — and place it in the lifecycle diagram and in
GUI.md's path C, inside the DT_ENTER_GUI_UPDATE() guard where it
actually runs.

Two precision fixes in GUI_Threading.md while here: the darkroom-exit
shape applies to modules that have a GUI (hidden modules never get one),
and it is pipe threads specifically that cannot queue behind the
change_image() drain.

Also fixed: the blank line 442ab77 left at the end of GUI.md, which
git diff --check flags across the range.

Not changed: New_Module_Guide.md's optional-function list. It is a
six-item starter set that already omits most of the API and links to the
full list; change_image() is not a callback a first module needs.
Three refinements to the section added in the previous commit:

- Path C had the parameter load before DT_ENTER_GUI_UPDATE(). Both
  paths it describes enter the guard first: the image switch at
  darkroom.c:1498, before reload_defaults() and change_image(), with the
  history params copied afterwards by dt_dev_pop_history_items(); undo
  and preset loads through dt_dev_pop_history_items() itself, which
  enters the guard and then copies. Reordered, with reload_defaults()
  named alongside change_image() in the image-switch-only step.

- "each module's base instance is kept, GUI and all" — only the visible
  base instance has a GUI to keep; dt_iop_is_hidden() modules never get
  one.

- "gui_cleanup() does not run on this transition" was true of the
  retained instance and false of the transition: the extra instances do
  run it before being freed. Scoped to the retained instance.

Also name rgblevels and rgbcurve, which implement change_image() too.
Found while checking the pages this branch touches. None of these are
about gui_data threading; they are in the same files and are wrong
against the current tree, so they are separated into this commit and can
be dropped without affecting the rest.

- IOP_Module_API.md said the framework hashes piece->data after
  commit_params() returns. dt_iop_commit_params() hashes the op name,
  instance, module->params, and the blend params and mask group when
  blending is on (src/develop/imageop.c). Nothing reads piece->data.
  Described what is hashed, and what the rest of the cache key adds
  (image id, pipe type, detail mask, the four pipe profiles, upstream
  piece hashes, ROI - dt_dev_pixelpipe_cache_hash()), because that is
  what decides which inputs an author has to worry about: pipe type and
  profiles are in the key already; a preference read in commit_params()
  is not. colorout is cited for both halves.

- commit_params() said its job is to translate self->params. It gets the
  parameters as an argument, and the pipe's defaults sync passes
  default_params, not self->params (src/develop/pixelpipe_hb.c:765).

- The lifecycle diagram had darkroom exit as gui_cleanup() first. The
  pipes are cleaned first: dt_dev_pixelpipe_cleanup_nodes() calls
  cleanup_pipe() for every piece (darkroom.c:4229, pixelpipe_hb.c:467),
  and only then does the module loop call gui_cleanup() (darkroom.c:4242).

- Image Open omitted the second dt_iop_reload_defaults(), which darkroom
  setup calls after dt_iop_gui_init() (darkroom.c:3985). Marked
  simplified and both reloads shown.

- gui_changed() was presented as unconditional in four places: GUI.md's
  prose and path A, README.md's flow, and imageop_gui.md's checklist.
  The callback is optional; dt_iop_gui_update() never calls it, and the
  widget dispatcher calls it only if(module->gui_changed)
  (src/develop/imageop.c:2440, :4321). Qualified in all four.
The single sequence claimed the framework opens the GUI-update guard
before it loads the params. That is true of an image switch
(darkroom.c:1498 before the history copy at develop.c:1717) and of undo
and history navigation (dt_dev_pop_history_items() enters the guard,
then copies), but false of a preset applied directly:
dt_gui_presets_apply_preset() copies into module->params first and only
then calls dt_iop_gui_update(), which is what opens the guard
(presets.c:1076, :1116).

Show the image switch in full, since it is the one with the extra steps
this branch documents, and state the other two as differences from it.

Also add the history-params load to the API quick reference's image
switch line, which jumped from change_image() straight to gui_update(),
and match its 'visible base instance' wording.
Two errors in the split added by the previous commit:

- "Undo, redo, history navigation: the same, without reload_defaults()
  and change_image()" is true only of a parameter-only change. Undo or
  redo of a module add or delete creates or destroys instances and goes
  through dt_dev_reload_history_items() (src/libs/history.c:598-635),
  which can gui_init() and reload defaults; GUI_Threading.md already
  treats that route as a GUI teardown site, so path C contradicted it.

- Copy/paste of history was named as an external-change source two
  paragraphs later but had no route here; it uses the same reload
  machinery (src/common/history.c:990), as do styles.

Both are now one case: routes that can change the module list are not a
params load. Also mark change_image() and gui_changed() conditional in
the image-switch sequence - both are optional callbacks - and drop
"params you did not ask for", which is wrong for a preset the user
picked.
Follow-up to the caching correction in 68c71a5, which listed what the
key adds without saying that it is a list of selected fields. Read as an
inventory, it invites the conclusion that anything reachable through
pipe or dev is covered.

Name the ROI-dependent inputs the list omitted (ROI, scharr, picker
sample), then say plainly that the key does not hash the pipe or the
image record wholesale, with the three cases that catch people: image
metadata other than the id (the id identifies the image, it does not
version it - exposure reads exif_exposure_bias and
exif_highlight_preservation), profile contents behind the hashed
profile-info pointers, and process-global state such as a preference.

pixelpipe_architecture.md's abbreviated inventory, which the API page
links to for caching, had the same shape plus two omissions: the export
profile and the ROI-based inputs. Corrected there too, since a reader
sent to it for detail should not find a shorter list.

Also add the note the quick-reference block was missing: its arrows show
optional callbacks where a module that implements them is called.

This commit and 68c71a5 are one droppable unit - both are corrections
to text that predates this branch.
Read collectively the paragraph was right, but 'they can create and
destroy instances' invited reading both halves into each route. Undo and
redo of an add or delete do both (src/libs/history.c:481-536 creates,
:413-445 cleans up); a history paste or style application adds what the
incoming history needs and re-synchronises the rest
(dt_dev_reload_history_items(), develop.c:1618-1678) without that
teardown path. Split into two bullets.
The section used exposure's cached effective_exposure as the case that
motivates the locking rule, but never stated the resolution: a value that
can be recomputed from params and the image does not belong in gui_data at
all, and then there is nothing to lock.

Rewrite the passage around that lesson, with exposure showing both halves —
manual mode derives, deflicker mode genuinely cannot and so locks both
sides. The manual-mode half describes the module after darktable-org#21974, which has
not landed; a visible note and a greppable TODO marker record that.
The new proxy section called exposure's whole deflicker branch a correct
instance of Pattern A. Only the scalar is. gui_update() and gui_changed()
free g->deflicker_histogram and rebuild it on the GTK thread while
_process_common_setup() reads that pointer and its statistics on a pipe
thread, neither side under gui_lock — in gui_update() the free even sits
between two critical sections that guard other fields.

Scope the claim to deflicker_computed_exposure and name the histogram as
what it is: a live in-tree instance of the trap in "Short Is Not The Same
As Correct". Also drop the premise that only the pipe computes the
histogram; the GUI computes and caches it, and the pipe only builds its
own when there is no GUI.

Found by Codex in the 19-30 round.
A proxy accessor takes whatever instance its caller hands it, and one
instance in the tree has gui_data without a dev: _init_module_so() builds a
throw-away instance with dev == NULL at startup and runs dt_iop_gui_init()
on it so the widgets can register their accelerators. Written
self->dev->gui_attached && g, a guard would dereference NULL in its first
operand, ahead of the test that decides the question; written the other way
round the case cannot arise. colorequal and toneequal already order it that
way.

The same instance explains why the dev->proxy function pointers outlive
every live instance — exposure's gui_cleanup() clears proxy.exposure.module
but nothing clears the pointers — and why the in-tree callers resolve a live
instance themselves rather than trusting the pointer.
The doc glossed self->dev->gui_attached as "the darkroom is live" and the
guard block commented it "darkroom active". Both are false. The field is set
once, when the dt_develop_t is created, and darktable.develop is given TRUE
at src/common/darktable.c before the init_gui branch is reached, so it is
TRUE under darktable-cli as well. dt_iop_gui_init() initialises gui_lock
immediately before calling gui_init(), so a non-NULL gui_data already
implies a usable lock, and any dev whose modules were loaded without a GUI
leaves gui_data NULL regardless.

Say that g != NULL is the whole guard and that gui_attached is kept as the
prevailing in-tree form (about forty places under src/iop) and as a
statement of intent. Same correction in IOP_Module_API.md, which repeated
the advice, and point its cross-reference at the section that now explains
it.
The text claimed a non-NULL gui_data already implies a usable lock. That
holds on the dt_iop_gui_init() lifecycle but is not universal: when undo or
redo has to recreate a deleted instance, _create_deleted_modules() in
src/libs/history.c calls module->gui_init() directly on a module that
dt_iop_load_module() has just memset to zero, so gui_lock is never passed to
dt_pthread_mutex_init(). The same path installs an expander, which makes
dt_dev_reload_history_items() skip the dt_iop_gui_init() that would
otherwise repair it, and exposure's gui_init() enters the critical section
during that very call.

Say that it is the normal lifecycle rather than a guarantee, and name the
exception. A module cannot detect or fix this from commit_params(); the note
exists so the implication is not leaned on.

Found by Codex in the 20-12 round.
"commit_params() must never block waiting for the GTK thread" contradicted
the section that follows it, which requires commit_params() to take
gui_lock — a mutex the GTK thread also holds, so acquiring it can block on
exactly that thread.

Say what the rule is actually about: never wait for the GTK main loop to run
something. Brief contention on gui_lock is required and is not the hazard; a
synchronous round-trip through the main loop is, because the GTK thread may
itself be waiting on a pipe.

Found by Codex in the 20-12 round.
The note opened "The manual-mode half of that ...", but the histogram
paragraph now sits between the two bullets and the note, so "that" no longer
has a clear antecedent. Point at the bullet directly, and say which two
pieces of deflicker text describe the tree as it stands.
The data-flow diagram put "auto-callback (from_params) or manual callback"
on one line and then had self->params updated and gui_changed() called for
both. Only the from_params route does that: dt_iop_gui_changed() is reached
from bauhaus only when the widget has a linked field, and it also adds the
history item itself. A manual callback — channelmixer's red_callback() is
typical — writes self->params and calls dt_dev_add_history_item() directly,
and never reaches gui_changed(), so dependent visibility, sensitivity or
labels go stale unless the module calls it.

Split the two routes in the diagram.

Found by Codex in the 20-12 round.
kofa73 added 26 commits August 28, 2026 18:39
262ccac745 corrected the dt_bauhaus_toggle_from_params() section but not
"Key Points to Remember" at the foot of the same file, which still told the
reader that toggles need a manual gui_update() unlike sliders and
comboboxes. So that commit replaced one wrong statement with two
contradictory ones.

State the distinction the corrected section draws: bound _from_params
widgets, toggles included, are synced by dt_bauhaus_update_from_field();
unbound dt_iop_togglebutton_new() buttons are not.

Found by Gemini in the 00-38 round.
GUI.md carried the same stale claim and the same bad cast that 262ccac745
and 91849f5 removed from imageop_gui.md: "you only need to manually sync
toggle buttons", with a sample gui_update() casting a _from_params toggle
through GTK_TOGGLE_BUTTON(). Bound _from_params widgets, toggles included,
are synced by dt_bauhaus_update_from_field() before gui_update() runs, and a
Bauhaus toggle derives from GtkDrawingArea. The example now syncs a
dt_iop_togglebutton_new() button, which really is a GtkToggleButton
(dtgtk/togglebutton.c), so the GTK setter shown is correct for it.

Also two summaries that this PR's own material contradicts. GUI.md said
gui_init() runs once per instance on entering the darkroom, and
IOP_Module_API.md and README.md said gui_data exists only in darkroom mode;
the startup accelerator probe, which GUI_Threading.md now documents as
load-bearing for proxy accessors, calls gui_init() and allocates gui_data
with dev == NULL before any view is loaded.

And scope the word "safe" in the IOP_Module_API.md guard bullet to what it
covers — the dev dereference — since g != NULL is not by itself proof that
gui_lock was initialised.

Findings 1-3 from Codex in the 00-38 round.
Both patterns are presented for copying, and each is missing the guard the
other one shows.

Pattern A's process() fragment queues its update with no guard at all, three
paragraphs after "Always check these conditions before scheduling a GUI update
from process()" and above a Common Mistakes entry that lists the omission as
WRONG ("No pipe type check at all ... every pipe that runs the module queues
its own update"). Since the document closes by recommending Pattern A over
Pattern B, that is the snippet a reader is most likely to copy.

Pattern B's callback lost the "if(g)" check the pre-branch text in GUI.md had,
while Pattern A kept its equivalent. It needs it more, not less: a source keyed
on the message cannot be cancelled by g_idle_remove_by_data(), so for a deleted
instance -- struct parked in dev->alliop, gui_data cleared -- the check is the
only thing before the memcpy dereferences NULL.

The same callback also reached its widget through msg->self->widget. That field
is destroyed and NULLed by dt_iop_gui_cleanup_module() immediately beside the
gui_data free, with an in-source comment saying it is nulled because
asynchronous signals can still carry the module. Routing every dereference
through g instead makes the one NULL check cover the whole callback body rather
than part of it.

Findings 4 and 3 of the 04-52 review.
README.md's "Key Functions to Implement" is the index a module author works
from, and this PR made change_image() load-bearing without adding it: it gained
a section of its own in IOP_Module_API.md, GUI_Threading.md requires draining
queued idle sources there because gui_cleanup() does not run on every
transition that invalidates the payload, and GUI.md's path C names it in the
image-switch flow. The table listed thirteen other callbacks and not that one,
so a reader who works from the index would not learn it exists -- which is
exactly the reader who most needs the cancellation rule.

change_image is declared OPTIONAL in src/iop/iop_api.h, called from
src/views/darkroom.c on the image switch, and implemented by basicadj,
retouch, rgblevels and rgbcurve.

Finding 5 of the 04-52 review.
…file

Recipe 6's gui_update() carried both of the errors this PR has now corrected
elsewhere, and README.md:26 describes this file as the "Copy-paste patterns"
one, so it is the worst place to leave them.

The toggle line is the same defect 262ccac745, 91849f5 and b4ce0df
removed from imageop_gui.md (twice) and GUI.md: a _from_params toggle is bound
to its field and synced by dt_bauhaus_update_from_field() before gui_update()
runs, and DtBauhausWidget derives from GTK_TYPE_DRAWING_AREA, so the
GTK_TOGGLE_BUTTON() cast is a type error rather than redundant work. Switched
to the unbound dt_iop_togglebutton_new() button the GTK setter is actually
right for, with the same comment GUI.md now uses.

And "**Always** call gui_changed()" is the rule this PR made conditional in the
other three places that state it -- GUI.md, imageop_gui.md Key Point 7 and
README.md's data flow diagram -- because gui_changed is OPTIONAL in
src/iop/iop_api.h and dt_iop_gui_changed() guards on it. This file still said
Always, in bold, over an example that calls it unconditionally.

GUI_Recipes.md:88 is not a defect and is left alone: Recipe 3 builds that
widget with dt_iop_togglebutton_new(), and GtkDarktableToggleButton really does
derive from GTK_TYPE_TOGGLE_BUTTON (src/dtgtk/togglebutton.c).

Finding 6 of the 04-52 review. Outside this PR's original diff; pulling it in
rather than deferring it was Kofa's call.
"The Problem" claimed two directions -- pipe to GUI and GUI to pipe -- and the
document then covered only those. There is a third in tree: one pipe publishes
into gui_data and another pipe reads it, and the framework has a primitive for
it, dt_dev_sync_pixelpipe_hash() (src/develop/develop.c). levels, globaltonemap,
hazeremoval and colorreconstruction all use it, each passing &self->gui_lock;
toneequal does the same with a helper of its own. A module that needs a property
of the whole image cannot get it on the full pipe, which sees only the ROI, so it
computes it on the preview pipe and hands it across.

Four things about that idiom do not follow from anything else here:

- g != NULL is not a test for a GUI. Nothing is displayed; the channel lives in
  gui_data and so exists only when a GUI does. The "processing must not depend
  on the GUI cache" rule still holds, and all four modules have the fallback.
- process() legitimately blocks on another pipe. The document forbids waiting
  for the GTK main loop and says nothing about waiting for a pipe, which reads
  as forbidden by the same argument. It is not: the wait is bounded by
  pixelpipe_synchronization_timeout (opencl_synchronization_timeout on a GPU
  pipe), aborts on pipe shutdown, and falls back to computing the value.
- gui_lock must not be held across the call. The primitive takes the lock it is
  handed on every polling iteration, so calling it inside a critical section
  self-deadlocks on the non-recursive mutex. It also takes dev->history_mutex
  underneath, via dt_dev_hash_plus(), but releases gui_lock first, so the two
  are never nested.
- A scalar hands over cleanly and a heap object does not.
  colorreconstruction copies a frozen bilateral grid pointer out under the lock
  and thaws it after releasing, while the preview pipe's next run and
  gui_update() can free it -- named here as a live instance of the trap "Short
  Is Not The Same As Correct" describes, not as a shape to copy. Written up
  separately in new_source_bugs/.

Finding 2 of the 04-52 review.
src/develop/preview_data.{c,h} is an in-tree service whose file comment says it
exists so that modules displaying a per-pixel value under the cursor stop
duplicating buffer management, hashing and locking. It takes the module's own
gui_lock internally in all seven of its data entry points, and its header
guarantees the property "Short Is Not The Same As Correct" derives by hand:
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 document never mentioned it, so a reader who follows the document writes the
duplication the service was added to remove -- and this document's own reference
module, toneequal, is one of its two users; colorequal is the other. Added a
section after the hand-rolled derivation, with a pointer from the rules summary
at the top, and scoped to what it does replace: the plumbing for that one case,
not gui_lock for a module's other shared fields.

The same section also gained the failure mode the tuple rule does not cover: a
buffer refilled *in place* while the flag beside it still advertises the old
contents. Clearing the flag under the lock before the fill and committing hash
plus flag under the lock after is what toneequal does around
compute_luminance_mask(), with a comment saying why (src/iop/toneequal.c). That
is distinct from the pointer/dimension mismatch already described, and no
locking on the reading side catches it.

Finding 1 of the 04-52 review.
Self-review of 299e37d against src/develop/develop.c and the four modules.

The timeout path was described as "on timeout the module computes the value
itself and logs inconsistent output". Neither half is right.
dt_dev_sync_pixelpipe_hash() returns TRUE after a timeout when the history
stack has changed, because a reprocess is already queued; it fails only when it
has not. And on failure levels, globaltonemap and hazeremoval do not recompute
-- each one's fallback is keyed on the value being uninitialised (NAN,
-FLT_MAX, DT_LEVELS_UNINIT), not on the wait having failed, so a timed-out
consumer proceeds with whatever is in gui_data. Say that: the handshake is
best-effort, and a module has to tolerate a stale value as well as a missing
one. Also that a non-positive pixelpipe_synchronization_timeout turns the wait
off entirely, with the primitive reporting success unchecked.

And "cannot get it on the full pipe" is too absolute: hazeremoval takes the
handover only when HQ processing is off, since with it on the full pipe does
see the whole image.

The claim that the other three modules "say so in a comment" is dropped rather
than defended -- three of them do, but it added nothing.
…makes it

Self-review of 2628840 against src/develop/preview_data.h.

The section credited the whole service with "resize, fill and hash commit
happen inside a single critical section". The header attaches that to
dt_preview_data_store() only. The two-step form -- dt_preview_data_resize()
followed by dt_preview_data_set_hash(), which exists precisely for fills too
expensive to hold the lock across -- gives it up, and replaces it with a
resize_cb() the service calls while still holding the lock so the module can
drop its own validity flag atomically with the resize. That is the same
discipline as the refill paragraph above it, so the two now point at each
other.

Also: "all seven of those" was counting entries in a list that named nine
functions, two of which the same sentence had just excluded;
dt_preview_data_invalidate() marks data stale rather than reporting on it; and
the GTK-thread claim for dt_preview_data_get() now cites where colorequal makes
it, in mouse_moved() and scrolled().
Self-review. The opening sentence and the rules list are restatements of the
document, and both still described the two-direction version of it: "between
the GTK main thread and the pixelpipe threads", listing three topics, and "for
every field both sides touch". With a third direction and a framework service
now in the body, "both sides" is a count that no longer holds and the topic
list is short by two. This is the failure mode the last four rounds kept
finding -- a summary keeping the shape of the text it summarised.
Self-review of 93410df. The section named mouse_moved() and scrolled() as
the call sites. dt_preview_data_get() appears once in src/iop/colorequal.c, in
scrolled(); mouse_moved() calls dt_preview_data_is_fresh(), which is a
different entry point. The point being made -- a GTK-thread read while the pipe
writes -- holds on the one real call site.
299e37d said "toneequal does the same thing with a helper of its own",
carried over from the finding that prompted the section without being
re-derived. It does not. toneequal's hash_set_get() (src/iop/toneequal.c) is a
locked copy in either direction, and g->ui_preview_hash is both written and
read inside the same dt_pipe_is_full() branch: the full pipe memoises its own
luminance mask against its own upstream hash. Nothing waits for another pipe.
Its preview branch publishes to the GUI through preview_data, which is the
other new section's subject, not this one's.

Replaced with the distinction that makes the module worth naming at all -- a
hash beside data in gui_data is a freshness marker, and only a handover when
something waits on it -- since a reader who opens toneequal after this section
will otherwise take the two mechanisms for one.
toneequal has two caching branches and only one of them is what the refill rule
is about. The full-pipe branch fills g->full_preview_buf, which the source
comments as not GUI-accessed and deliberately unlocked; the invalidate-then-
recompute-then-commit sequence is on the preview branch, whose buffer the GUI
does read. Say which one.

Also name the concrete instance of dt_preview_data_resize()'s resize callback
while there: toneequal's _toneeq_preview_resized() drops g->luminance_valid and
nothing else, under the lock the service is still holding, which is the whole
of what that callback is for.
Gemini finding 1, accepted. GUI_Recipes.md Recipe 6 led into its example with
"The callback is optional, so a module that has none has nothing to add:" and
then showed a module that does implement one. The clause was a restatement of
what imageop_gui.md Key Point 7 already says, so it is dropped rather than
patched; the lead-in now matches GUI.md's wording for the same rule, which is
the point of having fixed all four copies.

Gemini finding 2, rejected on the facts -- see the reaction file -- but the
sentence it misread is reworded anyway. "The only thing standing between a
deleted instance and a NULL dereference" is correct: deleting an instance parks
the struct in dev->alliop and frees only the GUI (src/develop/imageop.c, "don't
delete the module, a pipe may still need it"), so gui_data is NULL and the
struct is live. Gemini's falsification pass substituted darkroom exit for
instance delete, which is a different shape this document separates two
sections later. Since a careful reader made that substitution, the sentence now
carries its own scope instead of relying on the later section for it.
"the tethering histogram" covered both of the view's histogram paths and only
one of them belongs on that list. _expose_tethered_mode()
(src/views/tethering.c) branches on cam->is_live_viewing: the live-view branch
computes the histogram straight from the camera's live-view buffer, with no
pixelpipe and no dev of its own -- it touches darktable.develop only to resolve
profile info -- while the else branch runs the last captured image through
dt_imageio_export_with_flags(), which does build its own dt_develop_t
(src/imageio/imageio.c). Only the second is an instance of what this list
enumerates.

Found by Gemini in the 05-50 round. Its proposed fix was to drop the entry;
that would have removed a real case, so the entry is qualified instead.
…them

Kofa's call, 2026-08-27: a document that says "module X is broken, here is its
code" goes stale the day X is fixed, and it drags the reader through details
that belong to one module rather than to the pattern. Four passages named live
in-tree defects. Each is now a generic "this would be wrong" snippet, cut down
to the lines that carry the lesson.

- The call-chain warning named denoiseprofile's unlocked variance readout and
  overlay's GTK calls reached from process() through a helper. Now one invented
  helper that does both, plus the part that was the real point and was only
  implied before: a mutex of your own serialises pipe threads against each
  other and orders nothing against the GTK main loop, so it does not make a
  widget call from a pipe thread safe.
- The gui_lock lifecycle caveat named the undo/redo history route and the two
  functions involved. Now stated as what it is -- dt_iop_gui_init() is the
  wrapper that guarantees the pairing, a path that calls a module's gui_init()
  directly does not, and at least one route in tree does that today.
- The inter-pipe section named colorreconstruction's frozen bilateral grid. Now
  a four-line snippet, with the part specific to this section spelled out: the
  hash handshake proves the publisher finished a matching run, not that it will
  not start another one and free the object while you read it.
- The drain rule named exposure's single g_idle_remove_by_data(). Now the
  wrong version on its own. The reference to src/bauhaus/bauhaus.c stays: that
  one is cited for doing it right.

All five defects are filed under new_source_bugs/ instead, including two not
previously written up: the overlay GTK calls and the undo/redo path leaving
gui_lock uninitialised.

The proxy section keeps exposure by name -- separately decided; its divergence
from master is already marked and scheduled by the darktable-org#21974 TODO.
Same call as a97113e, applied to the examples showing correct usage: a
verbatim quote drags in one module's field names, helper names and problem
domain, and goes stale when that module is refactored. Each is now the pattern
with a reference beside it.

- The commit_params() example was toneequal's, with its sigma/interpolation
  fields and a paragraph explaining that one line of it -- update_curve_lut()
  reading self->params rather than the p it was handed -- must not be copied.
  Now a generic version that takes p correctly, so the caveat becomes a rule
  stated once ("pass it p, not self->params") instead of an exception carved
  out of a quote. The reference to toneequal's commit_params() stays: the
  GUI/non-GUI split is that module's structure and is not going anywhere.
- The two inter-pipe snippets were levels', with auto_levels[] and d->levels[].
  Now g->published_value and d->value, which is all the shape needs, with
  src/iop/levels.c named as the module carrying both halves in one helper.
- The invalidate-before-refill rule was prose plus a pointer at toneequal's
  compute_luminance_mask() branch. Now the six lines it actually is.
- Dropped the enumeration of the four modules using dt_dev_sync_pixelpipe_hash()
  and the naming of which handler in colorequal calls dt_preview_data_get() --
  both are lists that go out of date without teaching anything.
…s flaw

The bullet named a module and asserted that nothing invalidates its cache when
the EXIF fields it reads change -- a live-defect claim that goes stale if the
module or the key changes, and that buries the instruction under the accusation.
The gap is a property of the key, so state it that way, keep the citation as
evidence that modules really do read those fields, and end with what a module
author should do about it. The colorout bullet below already had that shape.
45e13fb replaced the toneequal commit_params() quote with a generic
example, leaving the proxy section pointing at "the toneequal quote earlier in
this document", which no longer exists. The claim it makes is unchanged -- the
example still writes self->dev->gui_attached && g -- so only the pointer needed
fixing. The inter-pipe section had the same problem in smaller form, calling
its own now-generic snippet "the quote above".
…document

This list is a restatement of the document, and restatements are where this PR
has repeatedly lost a qualifier. Two entries were out of step after the
de-naming pass.

"GTK+ call directly in process()" implied the indirect case is fine, which is
the opposite of what "Which Thread Am I On?" now spends a snippet on. It also
carries the point from that snippet: a mutex of your own serialises pipe
threads and orders nothing against the GTK main loop.

And the list had no entry for the pointer-load-protected-pointee-not trap,
though "Short Is Not The Same As Correct" is a whole section about it and the
inter-pipe section now refers back to it. Added, with the three remedies named
so the entry does not stop at the diagnosis.

The recursive-lock entry now names dt_dev_sync_pixelpipe_hash() as a framework
helper that takes the lock you hand it, since "a locking helper" otherwise
reads as something only your own code can be.
The refill snippet added in 45e13fb shows the writing side only, and a
reader who takes it at face value can test the flag under the lock, release,
and then read the buffer -- which is the pointer-load-protected-pointee-not
trap from the top of the same section, reached by a different route. The flag
is only worth having if the reader holds it across the use, or copies out under
it. Said explicitly rather than left to the reader to connect.
1. The inter-pipe section said the hash match "is what makes the value the right
   one for this render". It is weaker than that, in three ways that all matter to
   a module author. _dev_wait_hash() (src/develop/develop.c) snapshots the
   publisher's hash under the lock, releases, and only then compares against a
   freshly computed dt_dev_hash_plus() -- so nothing ties the payload the
   consumer subsequently reads to the hash that matched, and the publisher can
   run again in between. The success return also covers a disabled timeout and a
   post-timeout history change ("pretend that everything is fine"). And
   dt_dev_hash_plus() folds selected piece hashes only; it is not the cache key's
   render identity. Stated as evidence rather than proof.

2. "Every one of those seven takes your module's gui_lock internally, so you
   neither take it nor have to know it is there" oversold the service.
   dt_preview_data_is_fresh() (src/develop/preview_data.c) walks the live
   preview_pipe->nodes list inside that lock, and the topology is freed under
   pipe->busy_mutex and rebuilt under dev->history_mutex by a pipe worker
   (src/develop/pixelpipe_hb.c). gui_lock covers the service's own fields and
   stabilises nothing outside them. Scoped, since a reader who believes the
   original sentence will call it from anywhere.

3. The refill rule cited toneequal as doing "this", where "this" was the whole
   producer-plus-consumer protocol. Its producer half is exact; its GUI readers
   test luminance_valid after leaving the critical section and then read
   g->pd.buf, width and height raw (src/iop/toneequal.c). Citation scoped to the
   producer half, which is what it was there to show.

4. dt_preview_data_get_hash() was grouped with dt_preview_data_is_fresh() as
   reporting "whether what is stored still matches the current pipe state". It
   compares nothing; it returns the stored hash so the caller can compare
   (src/develop/preview_data.c). Separated.

Findings 2 and 3 are also source defects, filed under new_source_bugs/ as
preview-data-is-fresh-walks-live-pipe-nodes.md and
toneequal-readers-ignore-luminance-valid-after-unlock.md. Neither is described
as a defect in the document, per the de-naming rule -- 2 is stated as the limit
of what the lock covers, 3 as the scope of a citation.
Follow-up to 22f2c1a. The publishing snippet's comment said payload and hash
go in together "so a consumer can never see a hash its payload does not match",
which is exactly the guarantee the paragraph above it had just withdrawn. What
one critical section actually buys is that no reader catches the pair
half-updated; it says nothing about a consumer that verified a hash, released,
and read the payload after the publisher ran again. Comment scoped to
atomicity.

The fallback bullet then restated "the handshake is best-effort" a third time;
it now points at the limits stated once, earlier.
The citation added in 22f2c1a said toneequal "commits hash and flag
together", which is what the snippet above it does and not what the module
does. src/iop/toneequal.c calls dt_preview_data_set_hash(), which takes and
releases gui_lock on its own (src/develop/preview_data.c), and then opens a
second critical section to set g->luminance_valid. Two sections, not one --
citing it as the model for the one-section rule undercut the rule.

Its version is still correct, and the reason is worth having: it commits the
hash before raising the flag, so a reader landing between them sees a fresh
hash with the flag down and waits. The reverse order would publish a valid flag
over a hash that had not been committed. Said that way the discrepancy teaches
something instead of being an error in a citation.

Found by Gemini in the 07-00 targeted round -- the only finding of that round on
the Gemini side; it passed the other three areas explicitly.
22f2c1a scoped "every one of those seven takes gui_lock internally" to the
service's own fields, which was the right correction and still not quite right.
dt_preview_data_is_fresh() tests pd->buf in its early return before it takes the
lock at all (src/develop/preview_data.c), and dt_preview_data_store() and
dt_preview_data_resize() free and replace that field while holding it. So even
"for the service's own buffer and hash you neither take it nor have to know it
is there" claimed more than the code gives.

Dropped the "nor have to know it is there" half, and added the precheck beside
the topology walk in the paragraph that already states the limits, rather than
starting a second list of caveats.

Found by Codex in the 07-00 targeted round -- its only finding; it dropped five
candidates and passed all four areas otherwise.
cfd6c4d softened this bullet but kept its example: a module deriving its
result from EXIF gets no invalidation when those fields change. Kofa rejected
the premise. Exposure bias and highlight preservation are capture data written
at import, and the darkroom reads them from dev->image_storage, a copy taken in
_dt_dev_load_raw() and refreshed only through dt_dev_reload_image(), which
forces a resync anyway (src/develop/develop.c). No cache line survives a change
to them, so there was never a hazard to warn about and no reason to hash EXIF.

The hazard is on the database-backed side. dt_variables_expand() takes the
rating from the live image-cache record and the colour labels, tags and user
metadata from their own tables, on every call (src/common/variables.c), none of
it from the snapshot. The rating and the labels can be changed from within the
darkroom; the tagging panel is there under a preference.

Kofa then asked whether an IOP API hook exists for folding a value into the key
without persisting it, as colorout does with the export profile. It does not:
dt_iop_commit_params() builds piece->hash after the callback returns and assigns
it unconditionally (src/develop/imageop.c), and iop_api.h declares nothing else.
The remedy paragraph now states that and gives the three routes that remain --
module->params, the fixed pipe fields the framework hashes, and explicit
invalidation -- replacing a one-line remedy whose advice ("fold it into params")
does not work for a tag list.

Reviewed by Gemini and Codex in the 16-01 round, five findings, all accepted:
the two metadata signals carry no image id and one of them fires for
tag-dictionary edits touching no image, so they are a coarse trigger, not a
ready one; rasterfile hashes its own params, not the file it loaded, so it was
cited for something it does not do; the rating is a live cache read rather than
a query; the user-metadata editor is lighttable and tethering only; and "put it
in params" needed to name module->params, since this section tells the author
eight lines earlier to work from the callback argument instead.

Both reviewers re-derived the capture-side conclusion and the negative claim
about the API surface and passed them; Codex drafted a finding against the
capture side and dropped it.
@kofa73
kofa73 force-pushed the dev-doc/gui-data-sharing-pr branch from 231928f to b9ce6ff Compare August 28, 2026 16:39
@kofa73
kofa73 force-pushed the dev-doc/gui-data-sharing-pr branch from 6ff6436 to 210a556 Compare August 28, 2026 20:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: usermanual improving the documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants