Skip to content

Develop T4b (part 4): each pipe plans from the viewport it latched - #1144

Merged
aurelienpierre merged 3 commits into
masterfrom
refactor/develop-t4e
Aug 14, 2026
Merged

Develop T4b (part 4): each pipe plans from the viewport it latched#1144
aurelienpierre merged 3 commits into
masterfrom
refactor/develop-t4e

Conversation

@aurelienpierre

Copy link
Copy Markdown
Collaborator

Fourth step of the dev->roi division, on top of #1140, #1141 and #1143.

The darkroom worker now latches dt_dev_roi_request_get() once per loop iteration and copies it into every pipe, before any resync or planning. Everything downstream on the pipeline thread works from that one snapshot for the whole frame: _update_darkroom_roi() plans from it, dt_dev_pixelpipe_has_preview_output() compares against it, and finalscale's commit_params() decides piece->enabled from it.

Why the record alone wasn't enough

C3 made a single read coherent. It could not stop a frame being planned from two different reads — and the GUI thread republishes exactly when the user zooms, pans or resizes, which is precisely while a frame is being computed.

The planner alone made four separate reads: natural_scale, then scaling, then the processed size, then the box and the centre. A zoom landing between the first and the last produced a frame whose geometry never existed at any instant — internally consistent, correctly hashed, and therefore undetectable downstream. That is the same class as the torn x/y pair #1141 fixed, one level up: not a torn field but a torn sequence of reads.

The neutral seed is load-bearing

A pipe nobody latches — export, thumbnail, snapshot — keeps what dt_dev_pixelpipe_init_cached() seeds, and that seed is deliberately not zeroed: scaling = 1 and natural_scale = -1 are what those pipes read off a headless dev before this record existed, and finalscale multiplies exactly those two to decide whether it enables itself. Zeros there would flip piece->enabled on every export.

Verification

Release, Debug (-Werror), nofeatures build. cycles 0, layering_violations 187, all gates pass. Export A/B against the pre-tranche baseline: 0 differing pixels on both pages — though for this commit that only proves the neutral seed is right, since export pipes never latch.

The behaviour this commit exists for is not reachable from a headless export at all. Worth exercising in the GUI: zooming and panning during a slow recompute (a large raw, or a heavy module like diffuse), which is the case the latch is for.

Next

C5 folds the generation into the FULL pipe hash. That is the one with real risk in this tranche: a wrong change-gate silently invalidates the pipeline cache chain on every history commit, and neither the export A/B nor any static gate can see it, because export pipes hash zero there. I'll measure how often the generation actually advances during an editing session rather than relying on the usual checks.

🤖 Generated with Claude Code

… live one

The darkroom worker latches dt_dev_roi_request_get() once per loop iteration and
copies it into every pipe, before any resync or planning. Everything downstream
on the pipeline thread then works from that one snapshot for the whole frame:
_update_darkroom_roi() plans from it, dt_dev_pixelpipe_has_preview_output()
compares against it, and iop/finalscale.c's commit_params() decides
piece->enabled from it.

Reading the record live at each consumer was the remaining half of the problem
C3 set up the record to solve. The record made a single read coherent; it could
not stop a frame being planned from two DIFFERENT reads, because the GUI thread
republishes whenever the user zooms, pans or resizes -- which is precisely while
a frame is being computed. The planner alone made four separate reads
(natural_scale, scaling, processed size, box, centre), so a zoom landing between
the first and the last produced a frame whose geometry never existed at any
instant, internally consistent and correctly hashed.

A pipe nobody latches -- export, thumbnail, snapshot -- keeps the neutral seed
dt_dev_pixelpipe_init_cached() gives it, and the seed is deliberately NOT zeroed:
scaling 1 and natural_scale -1 are what those pipes read off a headless dev
before this record existed, and finalscale multiplies exactly those two. Zeros
there would flip piece->enabled on every export.

The generation is still consumed by nobody; folding it into the FULL pipe hash is
the next commit, and the one with real risk.

Verified: Release, Debug (-Werror), nofeatures build; cycles 0, layering 187;
export A/B against the pre-tranche baseline 0 differing pixels on both pages --
which for this commit only proves the neutral seed is right, since export pipes
never latch. Worth exercising in the GUI: zooming and panning DURING a slow
recompute, which is the case the latch exists for.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Comment thread src/develop/develop.c Outdated
Comment on lines +407 to +408
const dt_dev_roi_request_t request = pipe->roi_request;
if(IS_NULL_PTR(dev) || IS_NULL_PTR(pipe) || !dev->gui_attached || !request.valid) return FALSE;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The function dt_dev_pixelpipe_has_preview_output accesses pipe->roi_request before checking if pipe is NULL, leading to a crash during headless exports.
Severity: HIGH

Suggested Fix

Move the NULL check for the pipe parameter in dt_dev_pixelpipe_has_preview_output to be the first operation in the function, before pipe->roi_request is accessed.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/develop/develop.c#L407-L408

Potential issue: The function `dt_dev_pixelpipe_has_preview_output` dereferences the
`pipe` parameter to access `pipe->roi_request` before checking if `pipe` is `NULL`.
During headless operations like exports, `dev->preview_pipe` is not initialized and
remains `NULL`. When the `denoiseprofile` module calls this function with the `NULL`
`preview_pipe`, the application crashes due to the NULL pointer dereference.

Did we get this right? 👍 / 👎 to inform future reviews.

Comment thread src/develop/develop.c
|| (abs(width - dt_dev_roi_request_preview_height(dev)) <= tol && abs(height - dt_dev_roi_request_preview_width(dev)) <= tol);
= (abs(width - request.preview_width) <= tol && abs(height - request.preview_height) <= tol)
|| (abs(width - request.preview_height) <= tol && abs(height - request.preview_width) <= tol);
if(!dims_match) return FALSE;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: A data race on pipe->roi_request occurs due to unsynchronized access between the GUI thread (read) and a worker thread (write), which was previously seqlock-protected.
Severity: MEDIUM

Suggested Fix

Reintroduce a synchronization mechanism, such as the previous seqlock via dt_dev_roi_request_get(), to protect reads and writes to pipe->roi_request from different threads. Avoid direct, unsynchronized access to this shared data.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/develop/develop.c#L448

Potential issue: A data race exists on the `pipe->roi_request` field. The GUI thread
reads this field via `_update_darkroom_roi` without any synchronization, while a worker
thread concurrently writes to it. The previous implementation used a seqlock via
`dt_dev_roi_request_get()` to protect against this, but the new code accesses the field
directly. This concurrent, unsynchronized access is undefined behavior and could lead to
visual glitches.

Did we get this right? 👍 / 👎 to inform future reviews.

The tranche plan ended with a step folding dt_dev_roi_request_t.generation into
the FULL pipe's per-piece hash, so a cacheline planned from generation N could
not satisfy a request planned from N+1. That step is not being taken, and the
reasoning belongs next to the code rather than in a plan document nobody reads
next to it.

The guarantee already exists, by content instead of by version: piece->roi_in and
piece->roi_out are folded two lines below, and every field of the record reaches
them in _update_darkroom_roi(). Zoom and the fit scale become roi.scale as their
product, the centre becomes roi.x/roi.y, the box and the processed size become
roi.width/height. finalscale's piece->enabled is the one consumer that reads the
record rather than the ROI, and it is a function of that same product -- and a
disabled piece is skipped before the hash is computed, so a toggle changes the
chain regardless.

Folding the generation on top would add no discriminating power and could only
over-invalidate: a republication advancing the generation while producing
identical ROIs -- a sub-pixel pan rounding to the same roi.x, a box change the
fminf clamp absorbs -- would rekey the entire FULL-pipe cache chain for pixels
that are bit-identical. That is a silent performance regression no export A/B and
no static gate can see, which is exactly what made this the risky step; the
resolution is that it should not be taken at all.

The contrast with mask_preview_settings_revision, hashed thirty lines below, is
the useful part for a future reader: that one IS a revision fold, precisely
because the state it stands for reaches no ROI.

Comment only, no code change.
…everything else

Two defects in C4, both found in review, neither reachable by the export A/B.

FIRST, self-inflicted: dt_dev_pixelpipe_has_preview_output() read
pipe->roi_request BEFORE its own IS_NULL_PTR(pipe) guard, because C4 hoisted the
read to the top of the function. dev->preview_pipe is allocated only for a
gui_attached dev, so any caller passing it on a headless dev dereferences NULL.
The guard now comes first, as it did before C4 touched it.

The path the report cited -- iop/denoiseprofile.c:401 -- turns out to be inside
`#ifdef DEBUG_SCALES', dead in a normal build, and it names an undeclared `self',
so it would not compile if that macro were defined. The reported mechanism was
right and the cited caller is not live; the ordering is restored regardless,
because a function's NULL guard exists to be the first thing it does.

SECOND, and live: the latch was a plain struct assignment written by the darkroom
worker and read by the GUI thread. dt_dev_pipelines_share_preview_output() reads
it from the darkroom expose path (views/darkroom.c:859) through
_update_darkroom_roi(), and every dt_dev_pixelpipe_has_preview_output() call from
an IOP GUI callback does the same. A 56-byte struct copied across threads without
synchronisation is a torn read -- the exact failure this tranche exists to remove,
reintroduced one level down at the moment the record reached the pipe.

The pipe now holds a dt_dev_roi_request_store_t and publishes through
dt_dev_roi_request_latch(), read back through dt_dev_roi_request_of_pipe(): the
same seqlock as the dev-level record, the geometry record and the viewport. A
reader on any thread gets either the previous latch or the new one, never half of
each, and the pipeline thread keeps seeing one frozen record for a whole run
because only the worker publishes and only once per iteration.

Verified: Release, Debug (-Werror), nofeatures build; cycles 0, layering 187;
export A/B 0 differing pixels -- which, to be explicit, would not have caught
either of these: one is behind a dead #ifdef and the other is a GUI-thread race.
@aurelienpierre

Copy link
Copy Markdown
Collaborator Author

Both findings fixed in 769ec1a6cb, with one correction to the first report.

The NULL-deref (HIGH) is real and was my regression: C4 hoisted the pipe->roi_request read above the function's own IS_NULL_PTR(pipe) guard. Guard restored to first position.

The cited caller, however, is not live: iop/denoiseprofile.c:401 sits inside #ifdef DEBUG_SCALES, which no normal build defines — and that block also references an undeclared self inside a function that has no such parameter, so it would fail to compile if the macro were ever set. Worth a separate cleanup; flagging rather than touching it here. Every other caller passes its own live pipe. So: right mechanism, dead path, fix applied anyway, because a NULL guard that isn't the first thing the function does isn't a guard.

The data race (MEDIUM) is live and the more serious of the two. dt_dev_pipelines_share_preview_output() is called from the darkroom expose path (views/darkroom.c:859), on the GUI thread, and reaches the latch through _update_darkroom_roi(); every dt_dev_pixelpipe_has_preview_output() call from an IOP GUI callback does the same. A 56-byte struct assigned by the worker and copied by the GUI thread with no synchronisation is a torn read — the exact failure this tranche removes at the dev level, reintroduced one level down at the point the record reaches the pipe.

The pipe now holds a dt_dev_roi_request_store_t and publishes through dt_dev_roi_request_latch(), read back via dt_dev_roi_request_of_pipe() — the same seqlock as the dev record, the geometry record and the viewport. A reader on any thread gets the previous latch or the new one, never half of each, and the pipeline thread still sees one frozen record per run because only the worker publishes, once per iteration.

Neither defect was reachable by the export A/B (one behind a dead #ifdef, one a GUI-thread race), which is the pattern across this tranche: the pixel harness has stayed at 0 differing pixels through every real defect found in it.

@sonarqubecloud

Copy link
Copy Markdown

@aurelienpierre
aurelienpierre merged commit a745623 into master Aug 14, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

1 participant