Skip to content

fix(#2341): fill every curve-set channel in the V5 device-link writer - #2352

Merged
xsscx merged 2 commits into
masterfrom
fix/issue-2341-devlink-curveset
Sep 1, 2026
Merged

fix(#2341): fill every curve-set channel in the V5 device-link writer#2352
xsscx merged 2 commits into
masterfrom
fix/issue-2341-devlink-curveset

Conversation

@colourbill-ctrl

@colourbill-ctrl colourbill-ctrl commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes #2341

Root cause

CDevLinkWriter::begin() attaches a CIccMpeCurveSet when the input range is not the default [0,1], sharing one CIccSingleSampledCurve across every input channel. The loop that filled slots 1..n-1 ignored its own counter:

pCurves->SetCurve(0, pCurve0);

for (icUInt16Number i = 1; i < nSrcSamples; i++) {
  pCurves->SetCurve(1, pCurve0);   // always index 1, never i
}

so slot 1 was rewritten nSrcSamples-1 times and slots 2..n-1 stayed NULL.

CIccMpeCurveSet::Write() skips a NULL slot rather than failing, so those channels kept the calloc'd {offset 0, size 0} position-table entry and Write() still returned true. The tool printed LUT successfully written and exited 0 while emitting a link every reader rejects; CIccMpeCurveSet::Begin() refuses the same file, so it could not be applied either.

Visible in the 716-byte RGB link, at the cvst element's position table:

00000128: 6376 7374 0000 0000 0003 0003 0000 0024  cvst...........$
00000138: 0000 0020 0000 0024 0000 0020 0000 0000  ... ...$... ....
00000148: 0000 0000 736e 6766                      ....sngf

ch0 {0x24,0x20}, ch1 {0x24,0x20}, ch2 {0,0} — the third entry is the defect.

A 1-channel source never enters the loop, and a 2-channel source has slot 1 as its only iteration, so both were already correct. That is why this survived from 889db62b (2023-11-03) until now: it needs a source space with at least three channels.

Fix

The one-line correction the issue proposes, SetCurve(1, …)SetCurve(i, …).

Sharing one curve across all channels is deliberate and is kept — the class is built for it. SetCurve() only deletes an outgoing pointer that no other slot still aliases; ~CIccMpeCurveSet()SetSize(0) dedupes through icCurveMap before deleting; and Write() emits the curve once with every position entry pointing at it. Ownership is unchanged: one allocation, one delete, before and after. The fixed RGB link is byte-for-byte the same size as the broken one (716 bytes) — only the two zero-filled position entries become real.

Reproduction

From the issue, no fuzz artifact needed:

iccApplyToLink foo.bar 0 3 1 repro 0.001 0.999 1 0 \
  Testing/sRGB_v4_ICC_preference.icc 0 Testing/sRGB_v4_ICC_preference.icc 0
iccDumpProfile -v foo.bar

On master the writer reports success and exits 0, then:

Unable to parse 'foo.bar' as ICC profile!
Profile has Critical Error(s) that violate ICC specification
Error! -  - AToB0Tag - Tag has invalid structure!

With the fix: Profile is valid for version 5.00.

Verification

The regression coverage asserts the read-back file, not the writer. On the unfixed tool both registered cases exit 0 and print LUT successfully written, so an exit-status or message assertion — the shape every other iccApplyToLink test in the suite uses — is vacuous here by construction.

It also does not stop at the validation verdict. Profile is valid is satisfied by a link that simply omits the structure under test, so each case is pinned by mutation instead. All four measured:

Mutation Test that goes red
original bug — slot 1 rewritten rgb-channels
curve set dropped entirely (if (false)) rgb-channels
right curve count, wrong per-channel domain rgb-channels
curve set attached unconditionally (if (true)) default-range

An earlier verdict-only version of these tests stayed green through the second and fourth of those. EXPECT_DUMP_COUNT=3 now pins one curve carrying the requested domain per input channel — that count is what carries the general claim, so a wider source is not needed to state it — and the default-range case asserts the curve set is absent, which is what gives that control a job.

CMYK (4-channel) and Gray (1-channel) cases were written and withdrawn. They passed locally and turned all three Windows legs red with Unable to add ... to transform chain (status 3: Invalid profile) for Testing/CMYK-3DLUTs/CMYK-3DLUTs.icc and Testing/Display/GrayGSDF.icc. Both validate clean on Linux, and CreateAllProfiles.bat reports "Profile parsed and saved correctly" for both, so the divergence is in reading the profiles back rather than in this change — reported separately rather than gated to non-Windows, which would have buried it. Every fixture that ships here is tracked in git rather than generated, which is what makes the pair portable.

RunDeviceLinkRoundTripTest.cmake is new because the existing single-tool drivers cannot express write-then-read: RunBenchApplyCliTest asserts an exit status and a message from one process, which is exactly what this defect agrees with. Registered from both call lists, since the if(WIN32) branch ends in return() and the two lists are mutually exclusive (#2237).

Pre-flight

  • Strict clang 21.1.3 — strict warnings ENABLED, 0 warnings.
  • GCC 15.2.0 + LTO in ghcr.io/internationalcolorconsortium/iccdev:lateststrict warnings ENABLED, 0 warnings.
  • ASAN+UBSAN with detect_leaks=1 (CI runs detect_leaks=0) clean on every case measured, including a 4-channel CMYK source — relevant here because the fix aliases one pointer into N slots, so the dedupe in SetCurve()/SetSize(0) is what keeps it to one delete. LeakSanitizer was confirmed to fire in that same configuration, so the clean result is not a dead detector.
  • Full suite 231/232. The single failure is iccdev.spectral-tiff-preview, which dies on a missing local imagecodecs Python module — pre-existing and unrelated.

Out of scope

CIccMpeCurveSet::SetCurve bounds-checks nIndex > m_nInputChannels where >= is required, so index == m_nInputChannels would write one past the calloc'd array (IccMpeBasic.cpp:3305). Not touched here: it is unreachable from every caller in the tree, and this change does not widen its reachability — the old code passed indices {0,1} and the new code passes 0..n-1, both strictly inside. Noted for a maintainer ruling rather than fixed in a bug-fix PR.

CI evidence

Pre-PR dispatch, ci_scope=source: run 33558430421. Green on GCC 15.2 Strict Release LTO, Windows MSVC, Windows ClangCL, MinGW UCRT64, macOS Clang Debug and Release, Tool Smoke Tests (ASAN+UBSAN), and the Workflow Security Audit. Both new tests are confirmed to have run on Windows MSVC — Test #127 and #128 of 137, 0.04s each — rather than being absent from that lane.

Docker Clang 22 Verification is red for a reason outside this branch, and PR Summary is only the rollup reporting it — its own inputs are DOCKER_RESULT: failure with every other result success or skipped, so this is one condition, not two.

The job fails in step 7 Pull trusted base image with manifest unknown on ghcr.io/…/iccdev:sha-$BASE_SHA. Two things combine:

  1. ci-docker, which publishes those sha- base images, has been cancelled on every master run since 2026-08-30f8c48f1b, 045003f9, 0e6b0ede, 7c872606; last success faa6b311. So no recent master commit has a published image.
  2. detect-src nevertheless reports one as available. trusted_base_image_available is set from a branch-name test, not an image probe — if [ "$base_ref" = "master" ] (ci-pr-action.yml:496-498) — so for any PR targeting master build_pr_image (:1160) collapses to image_definition_changed. A PR that does not touch the image definition therefore takes the pull path (ci-docker-pr.yml:236) and asks for a tag that was never pushed, instead of falling back to building it as the trusted_base_image_available != 'true' clause intends.

That is why this is not visible on every PR: #2344 passed the same leg at the same time because it changed .github/ci/requirements/docker-spectral-preview.txt, which matches the image_definition_changed pattern at :566 and sent it down the build path. This PR touches only Build/ and Tools/, so it pulls.

Flagging rather than patching, since ci-pr-action.yml is shared CI and the standing guidance is not to modify it in a bug-fix PR.

@github-actions github-actions Bot added Tools Command-line tool or GUI tool changes Testing CTest, regression, or test coverage Source C or C++ source code changes Configuration Repository, CMake, YAML, JSON, or tool configuration Build Build system, CMake, compiler, or packaging failed One or more CI checks failed labels Sep 1, 2026
@colourbill-ctrl

Copy link
Copy Markdown
Contributor Author

The Docker leg is not this branch; PR Summary is the rollup and DOCKER_RESULT is its only failing input. Everything else is green, Windows included.

  1. ci-docker has been cancelled on every master run since 08-30 (last success faa6b311), so no recent master commit has a published sha- base image.
  2. trusted_base_image_available comes from a branch-name test, not an image probe — if [ "$base_ref" = "master" ], ci-pr-action.yml:496-498. build_pr_image (:1160) therefore collapses to image_definition_changed and the build fallback never fires, so the job pulls a tag that was never pushed.

#2344 passed the same leg concurrently: it touched .github/ci/requirements/ and took the build path.

Flagging rather than patching — shared CI. Detail in the description.

CDevLinkWriter::begin() attaches a CIccMpeCurveSet when the input range is
not the default [0,1], sharing one CIccSingleSampledCurve across every input
channel. The loop that filled slots 1..n-1 ignored its own counter and wrote
index 1 on every pass, so slots 2..n-1 were left NULL.

CIccMpeCurveSet::Write() skips a NULL slot rather than failing, so those
channels kept the calloc'd {offset 0, size 0} position-table entry and the
writer still returned true. iccApplyToLink therefore printed "LUT
successfully written" and exited 0 while emitting a device link every reader
rejects -- iccDumpProfile reports "AToB0Tag - Tag has invalid structure!" --
and CIccMpeCurveSet::Begin() refuses the same file, so it could not be
applied either.

Sharing one curve across all channels is what the class is built for and is
kept: SetCurve() only deletes an outgoing pointer that no other slot still
aliases, ~CIccMpeCurveSet() dedupes before deleting, and Write() emits the
curve once with every position entry pointing at it. The fixed RGB link is
byte-for-byte the same size as the broken one (716 bytes); only the two
zero-filled position entries become real.

A 1-channel source never enters the loop and a 2-channel source has slot 1
as its only iteration, so both were already correct -- which is why this
survived from 889db62 (2023-11-03).

The regression coverage asserts the read-back file, not the writer: on the
unfixed tool all four registered cases exit 0 and report a successful write,
so an assertion on the writer is vacuous here by construction. It also does
not stop at the validation verdict, because "Profile is valid" is satisfied
by a link that simply omits the structure under test. Each case is pinned by
mutation instead, all four measured:

  original bug (slot 1 rewritten)      -> RGB red
  curve set dropped entirely           -> RGB red
  right count, wrong per-channel domain-> RGB red
  curve set attached unconditionally   -> default-range red

so EXPECT_DUMP_COUNT pins one curve carrying the requested domain per input
channel, and the default-range case asserts the curve set is absent. An
earlier verdict-only version of these tests stayed green through the second
and fourth of those, which is what motivated the structural assertions.

CMYK and Gray cases were written and then withdrawn: they passed locally and
turned all three Windows legs red with "Unable to add ... to transform chain
(status 3: Invalid profile)" for the two generated profiles they used. Both
validate clean on Linux and both are written by CreateAllProfiles.bat
reporting success, so that divergence is in reading the profiles back rather
than in this change, and it is reported separately. The two cases that ship
use only git-tracked fixtures, and are jointly sufficient for every mutation
above.

The existing single-tool drivers cannot express write-then-read, so
RunDeviceLinkRoundTripTest.cmake is added alongside them. The tests are
registered from both call lists, since the if(WIN32) branch ends in return()
and the two lists are mutually exclusive (#2237).
@xsscx
xsscx force-pushed the fix/issue-2341-devlink-curveset branch from 078e6f3 to a74cf44 Compare September 1, 2026 22:11
@xsscx xsscx self-assigned this Sep 1, 2026
@github-actions github-actions Bot added pending CI checks still running and removed failed One or more CI checks failed labels Sep 1, 2026
@xsscx xsscx removed Tools Command-line tool or GUI tool changes Testing CTest, regression, or test coverage Source C or C++ source code changes Configuration Repository, CMake, YAML, JSON, or tool configuration Build Build system, CMake, compiler, or packaging pending CI checks still running labels Sep 1, 2026
@xsscx xsscx added this to the v2.3.2.4 milestone Sep 1, 2026
@xsscx xsscx added the Resolving Merge Conflict Maintainer indicates in process Resolution of Merge Conflict. HOLD CHANGES label Sep 1, 2026

@xsscx xsscx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

2026-09-01 22:13:04 UTC

@xsscx

xsscx commented Sep 1, 2026

Copy link
Copy Markdown
Member

PR Status

2026-09-01 22:13:56 UTC

  1. Publishing the Image now

@github-actions github-actions Bot added Tools Command-line tool or GUI tool changes Testing CTest, regression, or test coverage Source C or C++ source code changes Configuration Repository, CMake, YAML, JSON, or tool configuration labels Sep 1, 2026
@github-actions github-actions Bot added Build Build system, CMake, compiler, or packaging pending CI checks still running labels Sep 1, 2026
@xsscx xsscx removed the Resolving Merge Conflict Maintainer indicates in process Resolution of Merge Conflict. HOLD CHANGES label Sep 1, 2026
@xsscx
xsscx enabled auto-merge (squash) September 1, 2026 22:19
@github-actions github-actions Bot added failed One or more CI checks failed and removed pending CI checks still running labels Sep 1, 2026
@xsscx
xsscx merged commit cc28759 into master Sep 1, 2026
37 of 39 checks passed
@colourbill-ctrl
colourbill-ctrl deleted the fix/issue-2341-devlink-curveset branch September 2, 2026 01:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Build Build system, CMake, compiler, or packaging Configuration Repository, CMake, YAML, JSON, or tool configuration failed One or more CI checks failed Source C or C++ source code changes Testing CTest, regression, or test coverage Tools Command-line tool or GUI tool changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bisect 889db62b CDevLinkWriter::begin()

2 participants