Skip to content

fixes: 12-bit wiring, buffer sizing, error-path cleanup, and test gating - #71

Closed
sedghi wants to merge 10 commits into
mainfrom
fixes
Closed

fixes: 12-bit wiring, buffer sizing, error-path cleanup, and test gating#71
sedghi wants to merge 10 commits into
mainfrom
fixes

Conversation

@sedghi

@sedghi sedghi commented Jul 7, 2026

Copy link
Copy Markdown
Member

Summary

A batch of correctness, robustness, and test-infrastructure fixes across the codec packages. Each change is an independent commit.

Changes

  • libjpeg-turbo-12bit — implement the codec end to end. The 12-bit decoder produced a single-component 16-bit frame but was decoding into a mismatched color space and buffer size; this corrects the output format and buffer sizing so the decoded dimensions and sample count are right. The dicom-codec wrapper for transfer syntax 1.2.840.10008.1.2.4.51 (previously an unimplemented stub that always threw) is now wired to the real decoder, and the package main/exports are corrected to the filenames the build actually emits. Adds a decode test against a real 12-bit fixture.

  • Overflow-safe buffer sizing (openjpeg, openjph, libjpeg-8bit/12bit). The decoded-buffer size is width * height * components * bytesPerPixel, all taken from the encoded header. On the 32-bit wasm target that product can wrap around; the size math is now computed with a checked 64-bit multiply and a sane upper bound, so a malformed header can't produce an undersized allocation.

  • openjpeg decoder — reject inputs it can't handle. Bail out cleanly on unsupported component counts and on buffers too small to contain a header, instead of reading/writing past the intended bounds.

  • openjpeg BufferStream — bounds-check the callbacks. The skip/write/seek stream callbacks now clamp to the buffer extents rather than advancing past them.

  • dicom-codec — free wasm instances on the error path. decode/encode now release the decoder/encoder instance in a finally, so a failed decode no longer leaks the instance and its heap buffers.

  • Test infrastructure. The libjpeg-turbo-8bit suite is gated on a built dist/ so a build-less yarn test skips cleanly instead of failing with a module-not-found error; a root test script is added.

Verification

yarn test passes locally (13 passed | 75 skipped); the wasm-backed suites skip cleanly because the WebAssembly artifacts aren't built in a plain checkout. The wasm packages are compiled and their decode tests run in CI (the emsdk build matrix), which is the authoritative gate for the C++ changes here.

Summary by CodeRabbit

  • New Features

    • Added support for decoding 12-bit JPEG images, including build and package updates to expose the codec.
  • Bug Fixes

    • Improved cleanup during codec processing so resources are released even when decoding fails.
    • Added safer size checks and bounds handling to reduce crashes or invalid allocations when handling large or malformed images.
  • Tests

    • Added coverage for 12-bit JPEG decoding, error handling, and resource cleanup.
    • Updated test scripts to run with Vitest.

@cursor

cursor Bot commented Jul 7, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@sedghi sedghi changed the title Codec decode fixes: 12-bit wiring, buffer sizing, error-path cleanup, and test gating fixes: 12-bit wiring, buffer sizing, error-path cleanup, and test gating Jul 7, 2026
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@sedghi, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4ef2a382-b475-4b92-8b0f-0456ca490344

📥 Commits

Reviewing files that changed from the base of the PR and between 0f609bd and 3179998.

📒 Files selected for processing (4)
  • .github/workflows/pr-checks.yml
  • packages/libjpeg-turbo-12bit/src/JPEGDecoder.hpp
  • packages/libjpeg-turbo-12bit/test/decode.test.js
  • packages/openjpeg/src/J2KDecoder.hpp
📝 Walkthrough

Walkthrough

This PR enables the libjpeg-turbo 12-bit JPEG codec in the dispatcher, switches its decoder to grayscale 12-bit output, and ensures codecFactory always cleans up encoder/decoder instances via try/finally. It also adds overflow-checked buffer size computations across libjpeg-turbo 8-bit, openjpeg, and openjphjs decoders, plus new tests and Vitest configs.

Changes

12-bit JPEG codec enablement

Layer / File(s) Summary
codecFactory cleanup guarantees
packages/dicom-codec/src/codecs/codecFactory.js, packages/dicom-codec/test/dispatch.test.js
encode()/decode() move delete() calls into finally blocks so instances are cleaned up even on error; a new test verifies cleanup on decode failure.
Dispatcher wiring for 12-bit codec
packages/dicom-codec/src/codecs/libjpegTurbo12bit.js
Wires real decode via codecFactory.runProcess/decode, delegates getPixelData, keeps encode unsupported.
Grayscale 12-bit decode path
packages/libjpeg-turbo-12bit/src/JPEGDecoder.hpp
Switches decode to JCS_GRAYSCALE, updates frame info, adds overflow-checked/bounded buffer sizing, removes dead code.
Package config, integration and unit tests
package.json, packages/libjpeg-turbo-12bit/package.json, packages/libjpeg-turbo-12bit/vitest.config.mjs, packages/dicom-codec/test/integration.test.js, packages/libjpeg-turbo-12bit/test/decode.test.js
Adds Vitest scripts/config, updates package exports/main, gates integration test on 12-bit build artifact and adds a 12-bit decode test case, and adds a dedicated decoder test suite.

Decoder buffer-size overflow hardening

Layer / File(s) Summary
libjpeg-turbo 8-bit checked sizing
packages/libjpeg-turbo-8bit/src/JPEGDecoder.hpp, packages/libjpeg-turbo-8bit/test/decode.test.js
Adds checkedDecodedSize helper used in decode(); tests gate on build artifact existence.
OpenJPEG bounds checks and hardening
packages/openjpeg/src/BufferStream.hpp, packages/openjpeg/src/J2KDecoder.hpp, packages/openjpeg/src/J2KEncoder.hpp, packages/openjpeg/test/decode.test.js
Adds write/skip/seek bounds checks, decoded-size validation, component-count checks, encoder preallocation headroom, and robustness tests.
OpenJPH checked sizing
packages/openjphjs/src/HTJ2KDecoder.hpp
Adds checkedDecodedSize helper applied before resizing the decoded buffer.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Dispatcher
  participant codecFactory
  participant JPEGDecoder
  Dispatcher->>codecFactory: decode(imageFrame, imageInfo)
  codecFactory->>JPEGDecoder: decode() (via runProcess)
  JPEGDecoder->>JPEGDecoder: checkedDecodedSize / grayscale 12-bit decode
  alt decode succeeds
    JPEGDecoder-->>codecFactory: decoded buffer
    codecFactory->>JPEGDecoder: delete() (finally)
    codecFactory-->>Dispatcher: imageFrame, imageInfo, duration
  else decode throws
    JPEGDecoder-->>codecFactory: error
    codecFactory->>JPEGDecoder: delete() (finally)
    codecFactory-->>Dispatcher: rethrow error
  end
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: 12-bit codec wiring, safer buffer sizing, cleanup on error, and test gating.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fixes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codspeed-hq

codspeed-hq Bot commented Jul 7, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
❌ 1 regressed benchmark
✅ 29 untouched benchmarks
⏩ 19 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
decode jpeg400jfif.jpg (600x800x8bit) — warm 10 ms 10.9 ms -8.44%
encode CT1.RAW (HTJ2K lossless) — cold 36.7 ms 34.5 ms +6.21%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing fixes (3179998) with main (04c3e87)

Open in CodSpeed

Footnotes

  1. 19 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@cursor

cursor Bot commented Jul 7, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
packages/libjpeg-turbo-8bit/src/JPEGDecoder.hpp (1)

118-141: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

tjInstance leaks if checkedDecodedSize throws.

checkedDecodedSize(...) at line 131 can throw std::runtime_error for an oversized/zero-sized frame, but tjInstance (created at line 120) is only destroyed on the tjDecompress2 failure path and at normal completion — not on this new throw path. Since this validation is exercised precisely on attacker-controlled/malformed image dimensions, this introduces a leak of the native decompressor handle on every such input.

🐛 Proposed fix
     int pixelFormat = (frameInfo_.componentCount == 1) ? TJPF_GRAY : TJPF_RGB;
 
-    const size_t destinationSize = checkedDecodedSize(frameInfo_.width, frameInfo_.height, 1, tjPixelSize[pixelFormat]);
+    size_t destinationSize;
+    try {
+      destinationSize = checkedDecodedSize(frameInfo_.width, frameInfo_.height, 1, tjPixelSize[pixelFormat]);
+    } catch (...) {
+      tjDestroy(tjInstance);
+      throw;
+    }
     decoded_.resize(destinationSize);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/libjpeg-turbo-8bit/src/JPEGDecoder.hpp` around lines 118 - 141,
`decode()` leaks the native `tjInstance` if `checkedDecodedSize(...)` throws
before the explicit `tjDestroy` calls. Update `JPEGDecoder::decode` to ensure
the decompressor handle is always released on every exit path, including
exceptions from `checkedDecodedSize`, by using an RAII guard or equivalent
cleanup tied to `tjInitDecompress()`/`tjDestroy()`. Keep the existing error
handling in `readHeader_i` and `tjDecompress2`, but make the cleanup automatic
so malformed image dimensions cannot leak the handle.
packages/openjpeg/src/J2KDecoder.hpp (1)

862-867: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

checkedDecodedSize throw leaks l_stream, l_codec, and image.

If the computed destination size is zero or exceeds the 512 MiB cap (attacker-influenced via decomposition-level/dimension fields), the exception unwinds past l_stream/l_codec/image without any of the three being released, unlike the other error paths in this function.

🐛 Proposed fix
       const size_t bytesPerPixel = (frameInfo_.bitsPerSample + 8 - 1) / 8;
-      const size_t destinationSize = checkedDecodedSize(sizeAtDecompositionLevel.width, sizeAtDecompositionLevel.height, frameInfo_.componentCount, bytesPerPixel);
+      size_t destinationSize;
+      try {
+        destinationSize = checkedDecodedSize(sizeAtDecompositionLevel.width, sizeAtDecompositionLevel.height, frameInfo_.componentCount, bytesPerPixel);
+      } catch (...) {
+        opj_stream_destroy(l_stream);
+        opj_destroy_codec(l_codec);
+        opj_image_destroy(image);
+        throw;
+      }
       decoded_.resize(destinationSize);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/openjpeg/src/J2KDecoder.hpp` around lines 862 - 867, The
destination-size computation in J2KDecoder’s decode path can throw from
checkedDecodedSize before l_stream, l_codec, and image are released, causing a
leak on invalid or oversized dimensions. Update the cleanup flow around the
sizeAtDecompositionLevel/decoded_.resize block so any exception from
checkedDecodedSize or later allocation still frees those resources, matching the
other error paths in this function; use the existing cleanup logic for l_stream,
l_codec, and image rather than letting the exception escape first.
packages/libjpeg-turbo-12bit/src/JPEGDecoder.hpp (1)

115-172: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Install a non-terminating libjpeg error handler
jpeg_std_error keeps libjpeg’s default error_exit, which aborts the runtime on fatal JPEG errors. Add a custom error manager with setjmp/longjmp so bad input can unwind as an exception instead of terminating the whole process/WASM instance.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/libjpeg-turbo-12bit/src/JPEGDecoder.hpp` around lines 115 - 172, The
JPEGDecoder::decode path still uses jpeg_std_error with libjpeg’s default fatal
error_exit, so malformed input can terminate the process instead of unwinding
safely. Add a custom error manager for JPEGDecoder that installs a
non-terminating handler and uses setjmp/longjmp around jpeg_create_decompress,
jpeg_read_header, and jpeg_start_decompress so libjpeg errors are converted into
a C++ exception. Keep the existing cleanup via jpeg_destroy_decompress in the
error path and preserve the current buffer-size checks and grayscale decoding
setup.
🧹 Nitpick comments (3)
packages/dicom-codec/test/dispatch.test.js (1)

40-79: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding a symmetric encoder cleanup test.

This test covers decode() cleanup on throw, but encode() got the same try/finally treatment in codecFactory.js and has no equivalent coverage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/dicom-codec/test/dispatch.test.js` around lines 40 - 79, Add a
matching cleanup test for the encode path in codecFactory by extending the
existing codecFactory instance cleanup coverage: create a FakeEncoder with
encode() throwing and delete() setting a flag, then call codecFactory.encode
with a similar context/codecConfig setup and assert the thrown error is
preserved and delete() still runs. Place the new test alongside the current
decode() cleanup test so both try/finally paths in codecFactory are covered
symmetrically.
packages/libjpeg-turbo-12bit/test/decode.test.js (2)

9-11: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fixture read isn't gated, so a missing fixture crashes the whole file instead of skipping.

ct12bit is read at module scope for both build variants, unlike the isBuilt dist-artifact gating. If the fixture file is ever absent, every test (including build-variant-skipped ones) fails hard rather than skipping gracefully, which runs counter to this PR's stated test-infra goal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/libjpeg-turbo-12bit/test/decode.test.js` around lines 9 - 11, The
fixture load for ct12bit is happening at module scope, so a missing JPEG file
crashes the entire test file before any build-variant gating can skip tests.
Move the readFileSync/resolve lookup into the test setup or guard it with the
same conditional used by isBuilt so decode.test.js can skip gracefully when the
fixture is absent. Keep the change localized around ct12bit and the existing
built-vs-skipped test paths.

34-55: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Wrap decoder in try/finally to avoid leaking the WASM instance on assertion failure.

decoder.delete() only runs if every preceding expect() passes. A failing assertion leaves the WASM decoder instance undeleted for the rest of the test run.

♻️ Proposed fix
   it.skipIf(!isBuilt)(
     "decodes the CT-512x512 12-bit fixture and reports correct dimensions/format",
     () => {
       const decoder = new codec.JPEGDecoder()
-      const encodedBuffer = decoder.getEncodedBuffer(ct12bit.length)
-      encodedBuffer.set(ct12bit)
-
-      decoder.decode()
-
-      const frameInfo = decoder.getFrameInfo()
-      expect(frameInfo.width).toBe(512)
-      expect(frameInfo.height).toBe(512)
-      expect(frameInfo.bitsPerSample).toBe(12)
-      expect(frameInfo.componentCount).toBe(1)
-
-      const decoded = decoder.getDecodedBuffer()
-      // One 16-bit-wide sample per pixel (grayscale, 1 component/pixel).
-      expect(decoded.length).toBe(512 * 512)
-
-      decoder.delete()
+      try {
+        const encodedBuffer = decoder.getEncodedBuffer(ct12bit.length)
+        encodedBuffer.set(ct12bit)
+
+        decoder.decode()
+
+        const frameInfo = decoder.getFrameInfo()
+        expect(frameInfo.width).toBe(512)
+        expect(frameInfo.height).toBe(512)
+        expect(frameInfo.bitsPerSample).toBe(12)
+        expect(frameInfo.componentCount).toBe(1)
+
+        const decoded = decoder.getDecodedBuffer()
+        expect(decoded.length).toBe(512 * 512)
+      } finally {
+        decoder.delete()
+      }
     }
   )

Same pattern applies to the truncated-input test at Lines 68-77.

Also applies to: 68-77

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/libjpeg-turbo-12bit/test/decode.test.js` around lines 34 - 55, The
JPEGDecoder test cases in decode.test.js are leaking the WASM instance because
decoder.delete() is only reached after all expectations pass. Update the test
bodies that use codec.JPEGDecoder to wrap decode/getFrameInfo/getDecodedBuffer
assertions in a try/finally block so decoder.delete() always runs, including the
CT-512x512 case and the truncated-input test mentioned in the comment.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/libjpeg-turbo-12bit/src/JPEGDecoder.hpp`:
- Around line 168-172: The decoded buffer in getDecodedBuffer() is being exposed
through Uint8ClampedArray.new_(), which clamps away 12-bit sample values before
codecFactory.js can reconstruct them. Update the getDecodedBuffer() path in
JPEGDecoder.hpp to return the raw typed_memory_view(...) or another 16-bit typed
array so the original sample data is preserved; use the existing decoded_ buffer
and stride/output_size logic as the place to adjust the return type.

In `@packages/openjpeg/src/J2KDecoder.hpp`:
- Around line 835-841: The component-count validation in decode_i leaks native
OpenJPEG resources because the new unsupported count throw path only destroys
image and skips releasing l_codec and l_stream. Update this branch to mirror the
other failure paths in J2KDecoder::decode_i by cleaning up all allocated handles
before throwing, using the same teardown pattern already present for earlier
error cases.

---

Outside diff comments:
In `@packages/libjpeg-turbo-12bit/src/JPEGDecoder.hpp`:
- Around line 115-172: The JPEGDecoder::decode path still uses jpeg_std_error
with libjpeg’s default fatal error_exit, so malformed input can terminate the
process instead of unwinding safely. Add a custom error manager for JPEGDecoder
that installs a non-terminating handler and uses setjmp/longjmp around
jpeg_create_decompress, jpeg_read_header, and jpeg_start_decompress so libjpeg
errors are converted into a C++ exception. Keep the existing cleanup via
jpeg_destroy_decompress in the error path and preserve the current buffer-size
checks and grayscale decoding setup.

In `@packages/libjpeg-turbo-8bit/src/JPEGDecoder.hpp`:
- Around line 118-141: `decode()` leaks the native `tjInstance` if
`checkedDecodedSize(...)` throws before the explicit `tjDestroy` calls. Update
`JPEGDecoder::decode` to ensure the decompressor handle is always released on
every exit path, including exceptions from `checkedDecodedSize`, by using an
RAII guard or equivalent cleanup tied to `tjInitDecompress()`/`tjDestroy()`.
Keep the existing error handling in `readHeader_i` and `tjDecompress2`, but make
the cleanup automatic so malformed image dimensions cannot leak the handle.

In `@packages/openjpeg/src/J2KDecoder.hpp`:
- Around line 862-867: The destination-size computation in J2KDecoder’s decode
path can throw from checkedDecodedSize before l_stream, l_codec, and image are
released, causing a leak on invalid or oversized dimensions. Update the cleanup
flow around the sizeAtDecompositionLevel/decoded_.resize block so any exception
from checkedDecodedSize or later allocation still frees those resources,
matching the other error paths in this function; use the existing cleanup logic
for l_stream, l_codec, and image rather than letting the exception escape first.

---

Nitpick comments:
In `@packages/dicom-codec/test/dispatch.test.js`:
- Around line 40-79: Add a matching cleanup test for the encode path in
codecFactory by extending the existing codecFactory instance cleanup coverage:
create a FakeEncoder with encode() throwing and delete() setting a flag, then
call codecFactory.encode with a similar context/codecConfig setup and assert the
thrown error is preserved and delete() still runs. Place the new test alongside
the current decode() cleanup test so both try/finally paths in codecFactory are
covered symmetrically.

In `@packages/libjpeg-turbo-12bit/test/decode.test.js`:
- Around line 9-11: The fixture load for ct12bit is happening at module scope,
so a missing JPEG file crashes the entire test file before any build-variant
gating can skip tests. Move the readFileSync/resolve lookup into the test setup
or guard it with the same conditional used by isBuilt so decode.test.js can skip
gracefully when the fixture is absent. Keep the change localized around ct12bit
and the existing built-vs-skipped test paths.
- Around line 34-55: The JPEGDecoder test cases in decode.test.js are leaking
the WASM instance because decoder.delete() is only reached after all
expectations pass. Update the test bodies that use codec.JPEGDecoder to wrap
decode/getFrameInfo/getDecodedBuffer assertions in a try/finally block so
decoder.delete() always runs, including the CT-512x512 case and the
truncated-input test mentioned in the comment.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a85cc5ec-e4ee-4c59-9e98-dbf54b33e9af

📥 Commits

Reviewing files that changed from the base of the PR and between 04c3e87 and 0f609bd.

📒 Files selected for processing (16)
  • package.json
  • packages/dicom-codec/src/codecs/codecFactory.js
  • packages/dicom-codec/src/codecs/libjpegTurbo12bit.js
  • packages/dicom-codec/test/dispatch.test.js
  • packages/dicom-codec/test/integration.test.js
  • packages/libjpeg-turbo-12bit/package.json
  • packages/libjpeg-turbo-12bit/src/JPEGDecoder.hpp
  • packages/libjpeg-turbo-12bit/test/decode.test.js
  • packages/libjpeg-turbo-12bit/vitest.config.mjs
  • packages/libjpeg-turbo-8bit/src/JPEGDecoder.hpp
  • packages/libjpeg-turbo-8bit/test/decode.test.js
  • packages/openjpeg/src/BufferStream.hpp
  • packages/openjpeg/src/J2KDecoder.hpp
  • packages/openjpeg/src/J2KEncoder.hpp
  • packages/openjpeg/test/decode.test.js
  • packages/openjphjs/src/HTJ2KDecoder.hpp

Comment thread packages/libjpeg-turbo-12bit/src/JPEGDecoder.hpp
Comment thread packages/openjpeg/src/J2KDecoder.hpp
@cursor

cursor Bot commented Jul 7, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@cursor

cursor Bot commented Jul 7, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@cursor

cursor Bot commented Jul 7, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

sedghi added a commit that referenced this pull request Jul 8, 2026
…es), not main

The previous split commit reverted sources to main, but this PR is
stacked on the fixes branch (#71) which already carries the first round
of codec fixes — so the diff showed reversions of #71's work
(codecFactory, 12-bit decoder, openjpeg guards, dispatcher wiring).

Sources are now pinned to the fixes tree (zero src delta in this PR)
and the classification was re-run empirically against wasm rebuilt from
the fixes branch's C++: 17 tests depend on fixes this branch itself
introduced and move to the follow-up PR; everything that #71's fixes
already satisfy stays here, including the 12-bit decode suite, the
dispatcher 12-bit integration test, the codecFactory cleanup test, the
openjpeg small-buffer guard tests and the 12-bit browser-smoke variants.

Moved out (fail without this branch's own fixes):
- big-endian 1-bit/32-bit tests, little-endian 32-bit typing/realign
- dicom-codec planar RLE and both J2K encode round-trips
- openjpeg encoder-failure throw and encode/delete heap stability
- openjphjs 12-bit encoder round-trip
- libjpeg-turbo-12bit multi-component rejection + bench

Full workspace green with CI=1 (177/177); dist-size and browser smoke
pass against dists built from the fixes branch's C++.
sedghi added a commit that referenced this pull request Jul 8, 2026
Second-round fixes found by the pixel-correctness suite (#72), split out
so that PR stays a pure test/CI change. First-round fixes (12-bit decode
path, codecFactory instance cleanup, openjpeg decoder guards and
BufferStream bounds) are already in #71 — this PR carries only what the
test branch itself introduced, each fix together with the tests that
fail without it (verified against wasm rebuilt from the fixes branch's
C++ with CI's emsdk 3.1.74 image):

- openjpeg: J2KEncoder throws on setup/compress failure instead of
  silently returning, with the encoded buffer zeroed (callers previously
  read back a garbage pre-sized allocation as a successful encode);
  frees codec/stream/image on every exit path (repeated encodes grew the
  wasm heap monotonically); sizes the output buffer with headroom so
  clamped writes surface as errors instead of truncation. Pinned by the
  encoder-failure-throw tests, the encode/delete heap-stability test,
  and both dicom-codec J2K round-trips (encode .90 and transcode
  .80->.90), which fail byte-exactness without this rework.
- openjphjs: HTJ2KEncoder rounds bytesPerPixel UP; bitsPerSample/8
  truncated to 1 for 9..15-bit samples, halving the row stride so every
  row after the first was read from the wrong offset (12-bit encodes
  corrupted). Pinned by the 12-bit encoder round-trip.
- libjpeg-turbo-12bit: fail closed on multi-component input — forcing
  JCS_GRAYSCALE on a color 12-bit JPEG silently discards chroma and
  reports componentCount=1. Pinned by the multi-component rejection
  test; also adds the CodSpeed bench and its package script.
- dicom-codec: adaptImageInfo preserves planarConfiguration
  (decode8Planar was unreachable; PlanarConfiguration=1 RLE silently
  produced interleaved output). Pinned by the planar RLE test.
- little-endian/big-endian: 32-bit pixel data decodes to
  Uint32Array/Int32Array per pixelRepresentation with Float32Array only
  as the no-pixelRepresentation fallback (review feedback from
  wayfarer3130, matching cornerstone3D's decodeLittleEndian); 32-bit
  views realign to 4-byte boundaries (the old offset % 2 check threw a
  RangeError at offset % 4 == 2); big-endian gains 1-bit passthrough
  and byte-swapped 32-bit support. Same typing fix applied to
  dicom-codec's littleEndian getPixelData.
sedghi added a commit that referenced this pull request Jul 8, 2026
Reverts the fixes-branch-relative classification: #71 is being closed in
favor of a single consolidated fixes PR stacked on this one, and this PR
retargets to main. With no fix PR below it, this branch must hold only
tests that pass against plain main sources — which is exactly the state
this restores (verified earlier against wasm rebuilt from main's C++
with CI's emsdk 3.1.74 image: 164/164 with CI=1, dist-size gate and
browser smoke green).
sedghi added a commit that referenced this pull request Jul 8, 2026
All source fixes for the codec packages in one PR, stacked on the
pixel-correctness test PR (#72): the first round formerly on the fixes
branch (#71, closed in favor of this) plus the second round found by
the new test suite. Each fix travels with the tests that fail without
it — classification was empirical, running the full workspace against
wasm rebuilt from unfixed C++ with CI's emsdk 3.1.74 image.

First round (formerly #71):
- libjpeg-turbo-12bit: decode as single-component grayscale into 16-bit
  output. Forcing JCS_EXT_RGBA sized the buffer for 1 sample/pixel
  while libjpeg wrote 4 (heap overflow), and Uint8ClampedArray
  flattened 12-bit samples to 255. Fix the package entry points
  (dist/libjpegturbo12js.js) so the package is requireable at all, and
  wire the decoder into dicom-codec's dispatcher (.51), which
  previously threw 'Decoder not found'.
- openjpeg: decoder rejects <4-byte input and unsupported component
  counts, frees handles on the rejection path; BufferStream
  write/skip/seek callbacks are bounds-checked.
- dicom-codec: codecFactory reads results before delete() and frees
  decoder/encoder instances in finally, so failures no longer leak
  wasm instances.
- overflow-checked decoded-buffer sizing on wasm32 (openjpeg,
  openjphjs, libjpeg-turbo-8bit).

Second round (found by the pixel-correctness suite):
- openjpeg: J2KEncoder throws on setup/compress failure instead of
  silently returning a garbage pre-sized buffer, frees
  codec/stream/image on every exit path (repeated encodes grew the
  wasm heap monotonically), and sizes the output buffer with headroom.
- openjphjs: HTJ2KEncoder rounds bytesPerPixel UP; bitsPerSample/8
  truncated to 1 for 9..15-bit samples, halving the row stride and
  corrupting every row after the first in 12-bit encodes.
- libjpeg-turbo-12bit: fail closed on multi-component input instead of
  silently discarding chroma; add the CodSpeed bench.
- dicom-codec: adaptImageInfo preserves planarConfiguration
  (decode8Planar was unreachable; PlanarConfiguration=1 RLE silently
  produced interleaved output).
- little-endian/big-endian: 32-bit pixel data decodes to
  Uint32Array/Int32Array per pixelRepresentation with Float32Array
  only as the no-pixelRepresentation fallback (review feedback from
  wayfarer3130, matching cornerstone3D's decodeLittleEndian); 32-bit
  views realign to 4-byte boundaries; big-endian gains 1-bit
  passthrough and byte-swapped 32-bit support. Same typing fix applied
  to dicom-codec's littleEndian getPixelData.
@sedghi

sedghi commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

Closing in favor of #73: every fix on this branch (12-bit decode path and package entry points, dispatcher wiring, codecFactory instance cleanup, openjpeg decoder guards and BufferStream bounds, wasm32 size-overflow checks) now lives there, consolidated with the second-round fixes found by the pixel-correctness suite, each paired with the tests that fail without it. Merge order: #72 (tests only, targets main) first, then #73.

@sedghi sedghi closed this Jul 8, 2026
wayfarer3130 added a commit that referenced this pull request Sep 2, 2026
* test: gate libjpeg-turbo-8bit suite on built dist so build-less runs skip

* fix(dicom-codec): free wasm decoder/encoder instances on error path

* fix(openjpeg): bounds-check BufferStream skip/write/seek callbacks

* fix(openjpeg): reject unsupported component counts, short buffers, and overflow-sized frames

* fix(codecs): overflow-check decoded-buffer sizing on wasm32 (openjph, libjpeg-8bit)

* fix(libjpeg-turbo-12bit): correct decode buffer sizing, wire dispatcher, add tests

* test(libjpeg-turbo-12bit): accept graceful recovery on truncated input

* ci: pin codspeed bench runner and action version for stable baseline comparison

* fix(libjpeg-turbo-12bit): return 12-bit samples as Uint16Array, not clamped bytes

* fix(openjpeg): free codec and stream handles on component-count rejection path

* test: verify exact pixel output for every codec + fail CI on silently-skipped suites

Pixel correctness:
- libjpeg-turbo-8bit: byte-compare decode against the RAW reference
  (previously only length was checked) and bound encode round-trip error
  (measured maxAbsDiff 6 / meanAbsDiff 0.26; asserted <= 10 / <= 1)
- libjpeg-turbo-12bit: add RAW reference (verified bit-identical against
  DCMTK dcmdjpeg) and byte-compare decode against it
- charls: add CT1.RAW (cross-validated bit-for-bit against openjpeg and
  openjph decodes of the same slice) and a pinned golden for the
  near-lossless .81 path; byte-compare both
- openjpeg: pin the lossy .91 decode as a golden and byte-compare
- openjphjs: corpus test pinning SHA-256 of decoded pixels for all 13
  j2c fixtures (MG/MR/NM/RG/SC/XA modalities)
- dicom-codec: every transfer syntax now byte-compares decoded pixels
  against cross-validated references. RLE and JPEG Lossless Process 14
  outputs were verified identical to each other and to DCMTK.
- documents a real upstream bug found by these tests:
  jpeg-lossless-decoder-js decodes the final pixel of the SV1 fixture as
  0 instead of -2000 (DCMTK confirms the fixture is correct). Pinned via
  an all-but-last-pixel compare plus an it.fails() sentinel that flips
  when upstream fixes it.

Endian packages:
- big-endian: handle 32-bit (swap32 -> Float32Array) and 1-bit frames,
  mirroring little-endian; previously 32-bit fell through and left
  pixelData undefined
- little-endian: fix Float32Array alignment check (offset % 4, not % 2)
- exact-value tests for both, including unaligned byteOffset cases

CI:
- tests now fail loudly in CI when a dist is missing instead of
  silently skipping entire suites (it.runIf(process.env.CI) guards)
- any package change builds all packages: dicom-codec integration
  decodes through every sibling dist, so partial builds would trip the
  guards; docs-only changes still skip everything
- single test job for the whole vitest workspace (per-package test jobs
  were ~90% runner setup)
- CodSpeed benches only the packages the PR touched (main still
  re-benches everything for full baselines)
- node_modules cached keyed on yarn.lock; shallow checkout for builds

* tools: from-scratch decoders that verify all RAW pixel references

Independent implementations of JPEG-LS (T.87 regular+run mode, NEAR>=0),
JPEG Lossless (T.81 SOF3, predictors 1-7), DICOM RLE (PS3.5 Annex G) and
sequential DCT JPEG (SOF0/SOF1 with libjpeg's exact islow IDCT constants),
sharing no code with the codecs under test. run-all.js binary-compares
their output against every committed RAW reference: 12/12 byte-exact.

Also re-confirms the jpeg-lossless-decoder-js SV1 last-pixel bug from a
fourth independent decoder: the from-scratch SOF3 decoder produces -2000
for the final sample, matching DCMTK/RLE/Process-14 and the committed
reference.

* fix: fail closed on multi-component 12-bit JPEGs and failed J2K encodes

Review findings on the 12-bit/openjpeg changes:

- libjpeg-turbo-12bit: decode() forced JCS_GRAYSCALE unconditionally, so
  a color (multi-component) 12-bit JPEG would silently drop its chroma
  channels and report componentCount=1. Now rejects num_components != 1
  after jpeg_read_header with cleanup + throw. Test splices the grayscale
  fixture into a syntactically valid 3-component JPEG and asserts the
  decoder throws.

- openjpeg: J2KEncoder::encode() swallowed opj_setup_encoder /
  opj_start_compress / opj_encode / opj_end_compress failures with bare
  returns. With the bounded BufferStream, an undersized output estimate
  surfaces through exactly those return values — and the JS caller would
  read back the full pre-sized allocation as a successful encode. All
  failure paths now free the codec/stream/image, empty encoded_, and
  throw. Also frees those handles on the success path, which previously
  leaked them on every encode. Test forces opj_setup_encoder failure
  (41 resolutions > OpenJPEG's max 33) and asserts encode() throws and
  the encoded buffer is empty.

* ci: single run per PR commit + CPU logging to tame CodSpeed env warnings

'Different runtime environments detected' on PR comparisons: every
controllable axis already matched between BASE and HEAD (runner image
20260628.225.1, CodSpeedHQ/action v4.18.2, node 22.23.1 — verified from
the job logs), so the mismatch is GitHub's runner CPU lottery: standard
runners land randomly on Intel Xeon 8370C or AMD EPYC 7763, whose cache
sizes and ISA extensions make glibc execute different code paths,
shifting instruction counts even in simulation mode. See
https://codspeed.io/blog/unrelated-benchmark-regression

Mitigations:
- push trigger now fires on main only. PR branches were running the
  whole workflow twice per commit (push + pull_request) and uploading
  two CodSpeed measurements per commit, each on random hardware —
  doubling both CI usage and the odds of cross-CPU comparisons. main
  pushes still seed the baseline; PRs keep their pull_request runs.
- codspeed-bench logs lscpu before benching so any future environment
  warning is diagnosable from the job log.

The residual case (PR run and baseline run landing on different CPU
models) is inherent to shared runners; the durable fix would be CodSpeed
macro runners or GitHub larger runners.

* ci: add walltime instrument on CodSpeed macro runners alongside simulation

Dual-instrument setup: simulation stays the blocking regression gate
(deterministic <1% drift, catches small algorithmic slips); a new
advisory codspeed-walltime job measures real wall-clock on CodSpeed's
ARM64 bare-metal macro runners, covering simulation's blind spots
(real cache/branch behavior, and the pure-JS endian packages where the
no-JIT simulation model diverges most from production V8).

- upgrade vitest 2.1.9 -> 3.2.7, @vitest/coverage-v8 -> 3.2.4 and
  @codspeed/vitest-plugin 4.0.1 -> 5.7.1 (walltime requires plugin >= 5,
  which requires vitest >= 3.2); all 123 tests pass on the new stack
- walltime benches run sequentially (--concurrency 1): parallel
  processes contend for cores and add wall-clock noise, unlike
  instruction counting
- walltime job is continue-on-error while the macro-runner setup beds
  in, with timeout-minutes: 30 in case runner pickup stalls
- cache keys now include runner.os/arch: macro runners are ARM64 and
  sharing keys with x64 jobs would restore broken native binaries
- BENCHMARKING.md documents the two-instrument model

* ci: provision node 22 in the emsdk build container

vitest 3 pulls vite 7, whose engines check (>=20.19) rejects the node
20.18.0 bundled in emscripten/emsdk:3.1.74, failing yarn install in
every build job. setup-node puts node 22 on PATH for yarn/webpack;
emcc keeps using the node binary pinned in its own emsdk config.

* ci: serialize simulation benches; gate walltime job behind repo variable

- vitest 3 enforces a 60s worker RPC timeout; under valgrind with
  lerna --parallel, 8 bench processes on 4 cores starved the openjpeg
  and dicom-codec suites past it. Sequential benching changes only wall
  time — instruction counts are contention-immune.
- runs-on: codspeed-macro queues up to 24h when macro runners aren't
  provisioned for the org (job timeouts don't cover queue time), so the
  walltime job now requires CODSPEED_MACRO_ENABLED=true. Set it after
  enabling macro runners for the org on app.codspeed.io.

* ci: dist-size regression gate against committed baseline

New dist-size job compares every shipped artifact (js/wasm/mem, both raw
and gzip level-9 sizes) against tools/dist-size/baseline.json and fails
when anything grows beyond max(1%, 1 KiB), so a PR cannot increase codec
binary size unintentionally. New or missing artifacts also fail, keeping
additions deliberate.

The baseline was generated from CI-built artifacts (gh run download of
run 28905144867), not local builds — the Debug-built wasm embeds source
paths, so only CI output is a stable ground truth. Intentional size
changes rerun 'node tools/dist-size/check.js --update' against CI
artifacts and commit the diff, making growth visible in review.

* ci: suppress vitest RPC-timeout exit noise in simulation benches

Under valgrind the entire vitest process (main and forked worker) runs
~60x slow while vitest 3's hard-coded 60s birpc timer counts real
seconds, so large suites structurally hit 'Timeout calling onTaskUpdate'
AFTER their benches complete — serializing lerna didn't help because the
timeout is intra-process. The benches finish and upload correctly; only
the exit code was polluted. Forward --dangerouslyIgnoreUnhandledErrors
to vitest for the simulation job only (walltime runs native speed and
stays strict) and restore --parallel, which was never the culprit.

* ci: move simulation RPC-noise suppression into vitest configs

yarn 1 mangles '--'-forwarded flags (lerna received a bare
--dangerouslyIgnoreUnhandledErrors and rejected it), so set the option
in each package's vitest config instead, gated on
CODSPEED_RUNNER_MODE === 'simulation' — the env var the CodSpeed runner
itself sets. Walltime and regular test runs stay strict.

* ci: match legacy 'instrumentation' runner-mode string for RPC-noise suppression

The CodSpeed runner exports CODSPEED_RUNNER_MODE as 'instrumentation' on
some versions and 'simulation' on newer ones (@codspeed/core accepts
both), so gate on CODSPEED_ENV being set and mode != walltime instead of
an exact 'simulation' match. Verified all three behaviors locally with a
synthetic unhandled error: suppressed under instrumentation/simulation,
strict under walltime, strict without CodSpeed.

* test: color and bit-depth decode matrix across all codecs (plans 034/035)

Coverage added:
- openjpeg: corpus test activates 15 shipped-but-unused fixtures with
  committed RAW references — 8/10/12/15/16-bit, signed and unsigned,
  3-component color (US1, VL1, VL4, VL6) — all byte-exact, plus the
  0-decomposition CT1 variant.
- charls: 3-component interleaved color (ILV=sample), 8-bit and 16-bit
  unsigned grayscale, and the shipped 12-bit SC1 fixture (SHA-pinned).
- openjphjs: color with and without the reversible color transform (the
  RCT path was flagged untested in the source), 8-bit and 12-bit gray.
- libjpeg-turbo-8bit: color 4:2:0 YCbCr decode (DCMTK-verified golden)
  and a progressive SOF2 decode (Pillow-verified golden).
- dicom-codec: color JPEG .50, color RLE in both planar configurations,
  8-bit JPEG-LS .80.

Fixtures are generated deterministically from committed sources by
tools/fixture-verification/gen/ (US1.RAW RGB frame, CT2.RAW transforms);
lossless tests re-derive their references, so no golden files are needed
except the two lossy JPEG raws (DCMTK/Pillow verified).

Two real bugs found and fixed by these tests:
- HTJ2KEncoder.hpp: row stride computed as bitsPerSample/8, truncating
  to 1 byte for 9..15-bit samples — every row after the first was read
  from the wrong offset when encoding 12-bit data (openjphjs rebuilt).
- dicom-codec adaptImageInfo dropped planarConfiguration, which made the
  RLE plane-sequential decode path (decode8Planar) unreachable through
  the public decode() API.

* ci: toolchain-only changes trigger the full pipeline (plan 033)

An emsdk bump edits only this workflow; a vitest/plugin bump edits only
the root manifest and lockfile. Neither matched any packages/<pkg>/ path,
so detect-changes skipped the entire pipeline — the exact PRs that change
every compiled byte or every measurement ran zero CI. Toolchain paths now
force packages=ALL and bench=ALL (a full before/after sweep is precisely
what a toolchain bump needs). Docs-only changes still skip. The workflow
header documents the expected dist-size baseline / lossy-golden
regeneration procedure for such bumps.

* test: exercise the untested wasm and dispatcher API surface (plan 036)

- openjpeg: header/coding-parameter getters pinned after decode()
  (5 decomps, LRCP, 1 layer, 64x64 blocks, grayscale for CT1);
  decodeSubResolution levels 1-2 pinned — the level-1 output is
  cross-validated byte-identical with openjphjs' decodeSubResolution of
  the same slice (shared 5/3 LL band); setProgressionOrder and lossy
  setQuality/setCompressionRatio proven observable with a PSNR floor.
- openjphjs: readHeader-only introspection (populates frameInfo without
  decoding) and decodeSubResolution(1) pinned to the cross-validated hash.
- dicom-codec: encode() round-trips for .90/.80, transcode() .80->.90
  preserves pixels exactly, getPixelData typed-array contract pinned.

Three known defects documented as it.fails sentinels / comments that flip
when fixed:
- openjpeg readHeader() populates nothing — getters return zeros or
  uninitialized memory until decode() runs
- openjpeg getIsReversible() reports true for the irreversible 9-7 stream
- openjpeg encoder setters blockDimensions/tileSize/tileOffset/precincts/
  downSample are stored but never applied to opj_cparameters

* test: encoder quality pinning + bench coverage gaps (plan 037, absorbs 018)

- Size bounds on every lossless encode round-trip (floor 0.5x catches
  silent truncation, ceiling 1.10x catches compression-ratio
  regressions), with measured constants dated in comments: charls CT2
  115504, openjpeg CT1 174404, openjphjs CT1 185183, 8bit jpeg400 63975.
- PSNR floor (48 dB, measured 53.6) on the 8-bit JPEG round-trip.
- charls near-lossless: exact T.87 spec bound maxAbsDiff <= NEAR asserted
  for NEAR 1..3, plus proof it is actually lossy.
- libjpeg-turbo-12bit gets its first benches (cold/warm decode) and a
  bench script — previously invisible to CodSpeed, so a toolchain bump's
  full sweep measured nothing for it.
- dicom-codec: dispatcher-level encode (.80/.90) and transcode
  (.80->.90) benches.

* test: wasm heap-stability assertions per codec (plan 039)

Repeated decode/delete, encode/delete and failing-decode cycles must
leave HEAP8 capacity byte-identical after a settling warmup. Emscripten
arenas grow but never shrink, so any native leak (the class fixed in
plan 002, commit 3179998 and the PR #72 encoder cleanup) becomes
monotonic capacity growth. Loops are sized against the 50 MiB
INITIAL_MEMORY slack — verified to fail when a delete() call is removed
(100 leaked charls decoders grow the heap ~22 MiB). Error-path loops use
garbage headers (fast rejection) rather than truncated streams (seconds
of recovery each) so they can iterate enough to exceed the slack.

* test: browser smoke-decode for every wasm build variant (plan 038)

tools/browser-smoke/run.js serves the repo over local HTTP (correct
application/wasm MIME so streaming compilation runs), loads each of the
11 build variants in headless Chromium, decodes the reference fixture in
the page, and compares the decoded pixels' SHA-256 against the committed
RAW. Catches the emscripten glue regressions node tests cannot see (wasm
URL resolution, fetch loading, MIME fallbacks) — the first thing emsdk
bumps break. New advisory browser-smoke CI job (continue-on-error while
it beds in) with a cached Chromium install. All 11 variants pass
locally.

* test: silence wasm stdout/stderr in decoder benches to improve measurement accuracy

* ci(dist-size): print every tracked artifact, marking unchanged files as identical

Previously only files whose bytes drifted from baseline produced output,
so a fully-green run printed nothing per package and it looked like only
the drifting package had been checked. Every file now gets an ok line,
with byte-identical files labeled 'identical' to distinguish them from
sub-0.005% drift that rounds to +0.00%.

Claude-Session: https://claude.ai/code/session_017xiEYAJH7wzPnwNpnoGqet

* bench: batch microsecond-scale bench bodies to the millisecond range

A single instantiate+destroy (~60us) or typed-array-view decode (<1us)
is smaller than the fixed per-bench harness overhead (wrapper frames,
task attribution) plus the simulation cache model's CPU-to-CPU
variation, so those benches flagged spurious regressions on every
harness upgrade or runner-hardware change while the ms-scale decode
benches stayed clean through both. Loop the fragile bodies (x50 for
wasm lifecycle benches, x100 for the little-endian view benches and the
big-endian 8-bit passthrough) so the measured work dominates the noise
floor. The big-endian 16-bit benches do real per-pixel swap work on a
shared buffer (batching would just re-swap it) and stay unbatched.

Bench names carry the batch factor (x50/x100), so CodSpeed will report
these as new benchmarks and retire the old names - a one-time baseline
reset for the six fragile benches.

Claude-Session: https://claude.ai/code/session_017xiEYAJH7wzPnwNpnoGqet

* fix: decode 32-bit pixel data as int per pixelRepresentation, float only as fallback

Review feedback from wayfarer3130: BitsAllocated=32 PixelData is integer
data (signed per PixelRepresentation); float applies only to float pixel
data elements. Decode now returns Uint32Array/Int32Array accordingly and
falls back to Float32Array when pixelRepresentation is absent, matching
cornerstone3D's decodeLittleEndian. Applied to big-endian, little-endian,
and dicom-codec's littleEndian getPixelData.

Also documents that 1-bit data must be frame-extracted by the caller and
why 32-bit views may need realignment to a 4-byte boundary.

* chore: remove accidentally committed profiling artifacts

The little-endian package picked up V8 JIT dump files (12 MB) and local
CodSpeed run outputs that were never meant to be tracked.

* test: keep this PR to tests that pass against unmodified sources

Review follow-up: this PR mixed pixel-correctness tests with source
fixes the tests uncovered. To keep it reviewable as a pure test/CI
change, every source fix (JS decoders, dicom-codec factory/dispatch,
C++ decoder/encoder hardening) moves to a follow-up PR together with
the tests that require it.

Classification is empirical: the four wasm packages were rebuilt from
main's C++ (same emsdk 3.1.74 image as CI) and the whole workspace run
against unmodified sources; the 27 failing tests moved out with their
fixes, the 146 that pass stay (164 including CI-only guards, all green,
dist-size gate and browser smoke verified against those builds):

- big-endian: 1-bit passthrough and all 32-bit decode tests
- little-endian: 32-bit integer typing and 4-byte realignment tests
- dicom-codec: planar RLE output, codecFactory cleanup-on-throw, 12-bit
  dispatch, and both J2K encode round-trips (encode reads the result
  after Wasm memory is freed without the codecFactory fix)
- libjpeg-turbo-12bit: whole suite + bench + vitest config (decoder is
  unusable on main: RGBA heap overflow and a broken package entry point)
- openjpeg: tiny-buffer throw, encoder-failure throw, encoder heap leak
- openjphjs: 12-bit encoder round-trip (row-stride fix)
- browser-smoke: 12-bit variants (hash-compare needs the fixed decoder)

The 12-bit RAW reference stays: tools/fixture-verification/run-all.js
validates it independently of the wasm codecs.

* test: measure the tests-only split against this PR's actual base (fixes), not main

The previous split commit reverted sources to main, but this PR is
stacked on the fixes branch (#71) which already carries the first round
of codec fixes — so the diff showed reversions of #71's work
(codecFactory, 12-bit decoder, openjpeg guards, dispatcher wiring).

Sources are now pinned to the fixes tree (zero src delta in this PR)
and the classification was re-run empirically against wasm rebuilt from
the fixes branch's C++: 17 tests depend on fixes this branch itself
introduced and move to the follow-up PR; everything that #71's fixes
already satisfy stays here, including the 12-bit decode suite, the
dispatcher 12-bit integration test, the codecFactory cleanup test, the
openjpeg small-buffer guard tests and the 12-bit browser-smoke variants.

Moved out (fail without this branch's own fixes):
- big-endian 1-bit/32-bit tests, little-endian 32-bit typing/realign
- dicom-codec planar RLE and both J2K encode round-trips
- openjpeg encoder-failure throw and encode/delete heap stability
- openjphjs 12-bit encoder round-trip
- libjpeg-turbo-12bit multi-component rejection + bench

Full workspace green with CI=1 (177/177); dist-size and browser smoke
pass against dists built from the fixes branch's C++.

* test: classify against main — all fix PRs consolidate into one follow-up

Reverts the fixes-branch-relative classification: #71 is being closed in
favor of a single consolidated fixes PR stacked on this one, and this PR
retargets to main. With no fix PR below it, this branch must hold only
tests that pass against plain main sources — which is exactly the state
this restores (verified earlier against wasm rebuilt from main's C++
with CI's emsdk 3.1.74 image: 164/164 with CI=1, dist-size gate and
browser smoke green).

* fix: consolidated codec correctness fixes (supersedes #71)

All source fixes for the codec packages in one PR, stacked on the
pixel-correctness test PR (#72): the first round formerly on the fixes
branch (#71, closed in favor of this) plus the second round found by
the new test suite. Each fix travels with the tests that fail without
it — classification was empirical, running the full workspace against
wasm rebuilt from unfixed C++ with CI's emsdk 3.1.74 image.

First round (formerly #71):
- libjpeg-turbo-12bit: decode as single-component grayscale into 16-bit
  output. Forcing JCS_EXT_RGBA sized the buffer for 1 sample/pixel
  while libjpeg wrote 4 (heap overflow), and Uint8ClampedArray
  flattened 12-bit samples to 255. Fix the package entry points
  (dist/libjpegturbo12js.js) so the package is requireable at all, and
  wire the decoder into dicom-codec's dispatcher (.51), which
  previously threw 'Decoder not found'.
- openjpeg: decoder rejects <4-byte input and unsupported component
  counts, frees handles on the rejection path; BufferStream
  write/skip/seek callbacks are bounds-checked.
- dicom-codec: codecFactory reads results before delete() and frees
  decoder/encoder instances in finally, so failures no longer leak
  wasm instances.
- overflow-checked decoded-buffer sizing on wasm32 (openjpeg,
  openjphjs, libjpeg-turbo-8bit).

Second round (found by the pixel-correctness suite):
- openjpeg: J2KEncoder throws on setup/compress failure instead of
  silently returning a garbage pre-sized buffer, frees
  codec/stream/image on every exit path (repeated encodes grew the
  wasm heap monotonically), and sizes the output buffer with headroom.
- openjphjs: HTJ2KEncoder rounds bytesPerPixel UP; bitsPerSample/8
  truncated to 1 for 9..15-bit samples, halving the row stride and
  corrupting every row after the first in 12-bit encodes.
- libjpeg-turbo-12bit: fail closed on multi-component input instead of
  silently discarding chroma; add the CodSpeed bench.
- dicom-codec: adaptImageInfo preserves planarConfiguration
  (decode8Planar was unreachable; PlanarConfiguration=1 RLE silently
  produced interleaved output).
- little-endian/big-endian: 32-bit pixel data decodes to
  Uint32Array/Int32Array per pixelRepresentation with Float32Array
  only as the no-pixelRepresentation fallback (review feedback from
  wayfarer3130, matching cornerstone3D's decodeLittleEndian); 32-bit
  views realign to 4-byte boundaries; big-endian gains 1-bit
  passthrough and byte-swapped 32-bit support. Same typing fix applied
  to dicom-codec's littleEndian getPixelData.

* fix(dicom-codec): free each wasm instance exactly once after the main merge

  Merging main brought in the HTJ2K decoder-reuse work alongside this
  branch's finally-based cleanup. Both add a delete(), in different places,
  so git merged them cleanly and the result freed every instance twice:

    - single-use codecs deleted inline on the success path and then again
      in the finally, so "instance already deleted" threw on the first
      decode
    - HTJ2K, which reuses its decoder, skipped the inline delete but had
      the finally destroy the instance that was meant to survive, so the
      next decode got a dead handle

  That failed 50 tests across five dicom-codec suites. Keeping the finally
  (it is what stops a throwing decode from leaking) and moving the
  !reuseDecoder guard into it satisfies both designs: cleanup still covers
  every throw path, and a reused decoder still lives until release().

  Also in this commit:

  - Declare @cornerstonejs/codec-libjpeg-turbo-12bit ^0.4.4.
    codecs/libjpegTurbo12bit.js has required it since this branch wired
    12-bit into .51 dispatch, but it was never in the manifest, so a clean
    install could not resolve it.

  - Regenerate tools/dist-size/baseline.json. libjpeg-turbo-12bit is the
    only artifact past tolerance (+283,789 B wasm, +14.5%): the old decoder
    forced JCS_EXT_RGBA while sizing for one sample per pixel, driving a
    single narrow libjpeg path that let the linker drop the rest, and
    decoding grayscale into 16-bit output plus failing closed on
    multi-component input pulls that code back in. No build flags changed.
    openjpeg and libjpeg-turbo-8bit move 0.05-0.25% from the added overflow
    and bounds checks. The endian packages grow ~100 B each, which reads as
    a large percentage only because those files are about 1 KB. charls and
    libjxl are byte-identical, which confirms the build matched CI.

  Verified with pnpm install --frozen-lockfile from an empty node_modules,
  all six wasm codecs rebuilt in the CI toolchain image, and 272/272 tests
  passing under CI=1 with no skips.

* fix(dicom-codec): fill in the big-endian getPixelData depth gaps

getPixelData handled only 8 and 16 bit, so it returned `undefined` for
bitsAllocated 1 and 32 — depths the big-endian package's own decode()
accepts, and that littleEndian.js gained paths for in this branch. A
consumer reading 1-bit or 32-bit Explicit VR Big Endian PixelData got no
error, just nothing.

1-bit is a byte stream here and stays bit-packed. 32-bit needs the swap
the little-endian side does not, so swap32 comes across from the
big-endian package along with its 4-byte realignment.

No word swap is applied to 1-bit data, and the comment now says why
rather than leaving it looking overlooked: PS3.5 2016b A.3.2 requires OW
only above 8 bits, so at 1 bit both OB (no swap) and OW (each byte pair
of pixels transposed) are conformant and bitsAllocated cannot tell them
apart. Passing the bytes through is right for OB; a caller that knows its
dataset used OW has to swap the words itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(openjphjs): pin the encoder stride fix at 1 and 4 bits

The row-stride fix in HTJ2KEncoder.hpp shipped with no test below 12 bit,
which left its worst case unpinned. `bitsPerSample / 8` is 0 for a 1-bit
sample, so the encode loop read every row from offset 0 and the
codestream held row 0 repeated `height` times — verified against a
pre-fix build, which returns exactly that. getDecodedBuffer already used
the round-up form, so the buffer the caller filled was the right size all
along; only the reader of it disagreed.

The round-trip asserts byte equality, and a second assertion names the
failure mode explicitly so a regression reads as "rows collapsed" rather
than a bare buffer mismatch. 4 bit comes along as the cheap check that
the fix is the whole round-up and not a 1-bit special case — 2..7 all
truncated to a stride of 0 the same way.

bilevelFromCT2 thresholds the existing gray8 derivation rather than
generating noise, so the source is a CT silhouette: long runs broken by
an irregular boundary, which is what makes a stride or bit-order mistake
visible. packBitsLsbFirst is the DICOM packing (PS3.5 8.1.1) the
dicom-codec tests need. No new binary fixtures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(dicom-codec): unpack bit-packed 1-bit PixelData before encoding

BitsAllocated=1 is the one depth where the caller's frame and the
encoder's input buffer are not the same shape. DICOM packs 1-bit
PixelData eight samples to a byte (PS3.5 8.1.1); every wasm encoder sizes
its input at (bitsPerSample + 7) / 8 == 1 byte per sample and reads one
sample per byte. So `decodedTypedArray.set(imageFrame)` filled an eighth
of the buffer with bytes that each carry eight unrelated pixels and left
the remaining seven eighths zero — and the encode SUCCEEDED, which is
what made it worth a test rather than a throw somewhere.

toEncoderLayout expands the frame when it arrives packed and passes it
through when it is already one byte per sample, which is what the
decoders emit for 1-bit. The two cannot be confused: packed is ceil(n/8)
elements against unpacked's n, equal only for a single-sample image. That
keeps transcode working in both directions. A frame handed over as a
wider view is read as bytes, since packed data is a byte stream whatever
view wraps it, and a frame too short for its sample count throws instead
of silently zero-filling.

Covered end to end through the dispatcher for HTJ2K (.201) and JPEG 2000
(.90) — both round-trip a 512x512 bi-level CT silhouette byte-exact, and
all three end-to-end cases fail with this line reverted. JPEG-LS is left
out on purpose: CharLS rejects bit depths below 2.

Also re-indents encode()'s and decode()'s try bodies, which the earlier
try/finally refactor left at the old column with the closing brace pulled
to column 2. Whitespace and comment re-wrapping only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style(openjphjs): restore indentation in the decoder try blocks

readHeader(), decode() and decodeSubResolution() all had their try block
bodies left at the outer column with the closing brace pulled to column
2, so the `catch` below looked like it belonged to nothing. Accidental
damage from the partial-decode change; no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf: bound the realignment copy to the frame, not the rest of the file

ArrayBuffer.prototype.slice(offset) copies through the end of the BACKING
buffer, and PixelData is normally one frame's view into a whole
multi-frame P10 buffer. So realigning a misaligned frame allocated and
copied the entire rest of the file: measured at 67 MB for a 1 MB 32-bit
frame sitting at offset 2 of a 64 MB buffer. The pixels were right either
way -- the returned view's length is correct -- which is why it went
unnoticed.

Bounding the slice to offset + byteLength fixes all eight sites, not the
three CodeRabbit flagged: every 16-bit branch has the same call, and
dicom-codec's bigEndian.js has two more that were added in this branch.
byteLength rather than the `length` the review suggested, because these
functions already assume a byte view when they compute length / 4, and
byteLength is right whatever element width the caller hands over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: release native handles on the codec throw paths

Every hand-written wasm wrapper freed its native handles by repeating the
destroy calls at each early return. That covers the paths that RETURN and
misses the ones that THROW -- which is most of what this PR added. Each
of these is a leak per failed operation, in a long-lived wasm module that
never gives memory back:

  J2KDecoder::decode_i  checkedDecodedSize's range check and the
                        component-count rejection both throw with the
                        codec, stream and image all live, and
                        decoded_.resize() can raise std::bad_alloc in the
                        same state. Worse, cstr_info from
                        opj_get_cstr_info was never freed on ANY path,
                        successful decodes included.
  J2KEncoder::encode    encoded_.resize() throwing std::bad_alloc skips
                        the cleanup() the explicit paths call.
  JPEGDecoder (8bit)    checkedDecodedSize throws, and decoded_.resize()
                        can, with tjInstance live.
  JPEGEncoder (8bit)    the tjCompress2 failure threw without destroying
                        the compressor at all -- pre-existing.
  JPEGDecoder (12bit)   decoded_.resize() throwing skips every explicit
                        jpeg_destroy_decompress.

Replaced with a scope guard per function. A destructor is what these
needed rather than a tidier cleanup() call: resize()'s throw has no call
site to attach cleanup to, and a guard cannot be forgotten when the next
early exit is added. Members are references to the existing locals, so
no renaming. buffer_info moves above the guard in both openjpeg files
because the stream points at it and locals are destroyed in reverse
declaration order.

Also from the same review round, and adjacent enough to do here:
  - opj_image_create and opj_create_compress results were dereferenced
    unchecked; in wasm a null deref traps the whole module instead of
    raising a JS exception.
  - cstr_info and its tccp_info are now null-checked before use.
    opj_get_cstr_info does return NULL, and opj_destroy_cstr_info tests
    its argument pointer but then dereferences *cstr_info unguarded, so
    the guard must not hand it a pointer to NULL.
  - openjpeg's FrameInfo fields and J2KDecoder's scalar members had no
    initialisers, so every getter was an uninitialised read until a
    decode succeeded -- and because the wasm allocator reuses freed
    blocks, a fresh decoder routinely reported the PREVIOUS frame's
    geometry. This surfaced as api.test.js's it.fails("readHeader() alone
    populates frameInfo") starting to PASS, reading 512 from the block a
    just-deleted decoder had released.

No upstream/submodule changes: extern/ was read to confirm the destroy
functions' null-handling and opj_get_cstr_info's contract, nothing more.

heap-stability.test.js records what is NOT covered and why -- the obvious
looping test for these paths was written, measured and removed because it
tripped an unrelated crash. See the comment there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Joe Boccanfuso <joe.boccanfuso@radicalimaging.com>
Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant