Skip to content

fix: Download of binary DICOM and study index writes wrong files (rebase of #127 onto current master) - #131

Merged
wayfarer3130 merged 9 commits into
masterfrom
rebase/127-over-129
Oct 13, 2026
Merged

wayfarer3130 merged 9 commits into
masterfrom
rebase/127-over-129

Conversation

@awatson1978

@awatson1978 awatson1978 commented Aug 17, 2026 •

Copy link
Copy Markdown
Collaborator

This PR is #127, rebased onto the current master. The branch now also merges origin/master (v1.7.8) and has fixes from a review.

User-facing changes

These changes alter what a user or a client sees. Read them before you merge this PR.

  • Instance retrieval (WADO-RS, Part 10): the webserver now serves the stored Part 10 file instances/<sop>/index.mht.gz when the file exists. The webserver generates Part 10 data from metadata and bulkdata only when no usable stored file exists.
    • The Content-Type of a multipart/related response now holds the boundary of the stored file.
    • A client that accepts gzip gets the stored file with Content-Encoding: gzip. Another client gets the decompressed multipart body.
    • application/dicom and application/zip responses get the payload from the stored file.
  • STOW and ingest: create-dicomweb writes instances/<sop>/index.mht.gz for SEG, SR, and Parametric Map instances. Create-dicomweb generates this file from the stored dataset after the commit of the instance. A failure of this write logs a warning, and the instance stays stored.
  • S3 retrieve (deploydicomweb): files get the local names that the other tools read.
    • Multipart objects get .mht or .mht.gz.
    • A key that has children in the listing maps to <key>/index.json.gz, or to index.mht.gz for a multipart object.
    • Retrieve renames an old multipart file at a guessed <key>.gz name to the correct name. Retrieve never renames bulkdata, because the BulkDataURI sets the bulkdata name.
    • Retrieve now downloads a file again when the size or the ETag is different. Before, retrieve kept every existing file.
  • S3 upload content types: frames that are raw codestreams (.jxl, .jll, .jls, .jhc) now get the image type of the transfer syntax, not multipart/related.
  • CLI: deploydicomweb studies <studyUIDs...> now requires one or more study UIDs, or *. The command fails with an error when you give no UID. Before, the command did nothing.

Implementation changes

  • S3Ops.findExistingLocal is the one place that finds an existing local file and renames an old multipart file. DeployGroup calls it.
  • DeployGroup now awaits shouldSkip. Before, a Promise that was not awaited made every existing file skip.
  • multipartFile.mjs (static-wado-util) checks the content of a file with async I/O.
  • readBulkData now finds the closing multipart boundary. Before, readBulkData removed 2 bytes from files that static-wado-creator writes, because those files have no \r\n after the closing boundary.
  • readBulkData now adds the byteOffset of the Node Buffer. Before, files smaller than 4 KB could return bytes from the shared buffer pool.
  • part10Controller streams the stored file. A read error before the first byte does not close the response, so generation can still send the data.
  • writeRawPart10 now reports a failed close of the stream.
  • STOW parsing uses clearBuffers: true, so the parser frees the bytes that it has read.

Known limits

  • If a SEG or SR is stored again and the new Part 10 file fails, the old index.mht.gz stays. The webserver then serves the old file.
  • Create-dicomweb holds one full copy of the instance in memory while it writes the Part 10 file. This applies only to SEG, SR, and Parametric Map instances.
  • No tests cover assignFileNames (the directory-index names) or the change to the studies CLI arguments.

Tests

  • static-wado-util: 22 tests pass.
  • s3-deploy: 12 tests pass.
  • static-wado-deploy: 2 tests pass.
  • static-wado-webserver: 12 tests pass, and 1 test is skipped.
  • create-dicomweb: 69 tests pass.
  • static-wado-creator: 4 tests pass.

🤖 Generated with Claude Code

wayfarer3130 and others added 9 commits August 16, 2026 18:40
…er bun

- Declare <studyUIDs...> as a required positional on the studies command so
  commander reports a missing-argument error instead of exiting silently
  (previously the variadic -d option could swallow the study UID, leaving
  the command with no arguments and studiesMain returned without a message).
- Import uids via its subpath in static-wado-creator index.mjs; mixing an
  ESM import of the static-wado-util package root with the CJS require()
  calls elsewhere in the package made bun treat the root as an async module
  and fail with "require() async module is unsupported".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On upload, fileToKey strips "/index.json.gz" so the study singleton index
is stored at the S3 key "studies/<uid>". On retrieve, contentItemToFileName
had no reverse mapping for that key shape and fell through to
"studies/<uid>.gz", so the study index never landed at
studies/<uid>/index.json.gz and mkdicomweb index could not add the study
to the overall studies index.

dir() now detects directory-style index objects generically: any listed
key that also has child keys maps back to <key>/index.json.gz. This covers
the study singleton, the overall studies index, and instance-level query
indexes, without hardcoding path names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fileToKey strips the .mht extension on upload, so multipart objects live
at extension-less keys (frames/1, bulkdata/xx/yy/zzzz). The directory
listing can only guess "<key>.gz" for those, which stored retrieved
frames as 1.gz instead of 1.mht / 1.mht.gz.

- S3Ops.retrieve now corrects the destination name from the object's
  ContentType/ContentEncoding: multipart/related objects are written as
  <key>.mht, or <key>.mht.gz when gzip encoded.
- S3Ops.localCandidates lists the possible local names for a listed
  object so DeployGroup.retrieve can recognize already-retrieved .mht
  files and skip re-downloading them.
- DeployGroup renames files stored under the generic ".gz" name by older
  retrieves to their correct multipart name, sniffing the leading bytes
  (multipart boundary, or gzip whose content starts with a boundary) so
  gzipped JSON like metadata.gz is left alone. Skipped on --dry-run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tream

writeRawPart10 re-read the received bytes from the parse stream, which only
worked because the STOW upload stream never released its buffers - so it
silently skipped (or threw on an undefined buffer) on the file-stream path,
and kept a whole instance resident per concurrent upload on the STOW path.

Regenerate the rendition from the parsed dataset via generatePart10ForInstance
instead, re-reading the frame and bulkdata files just written, and turn on
clearBuffers for the STOW stream now that nothing needs the received bytes.
The rendition write moves after commitPendingMoves, since the files it reads
back are at temp paths until then, and is written in 1 MiB chunks so it
honours the stream highWaterMark.

Also fix readBulkData ignoring byteOffset: Node pools Buffers under 4k, so
data.buffer.slice(start, end) read unrelated pool memory for most frame and
bulkdata files. This corrupted the part10 controller generation fallback and
the part10Main CLI export as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
part10Controller: a multipart/related body cannot be parsed without its boundary,
but neither stored-rendition branch sent one. Read it from the stored wrapper's
delimiter line and advertise it, and stop dicomMap replacing a fuller
multipart/related type with its boundary-less default. That same read tells us
whether the file is a usable wrapper at all, so an unusable one falls through to
generation rather than being served broken.

part10Controller: stream application/dicom out of the wrapper (file -> gunzip ->
payload transform -> res) rather than readFileSync + gunzipSync of the whole
rendition, which blocked the event loop and held three copies of a large SEG. The
transform withholds a 1k tail so the closing boundary is never emitted. zip still
buffers - adm-zip needs the whole member - but reads asynchronously.

DeployGroup: await ops.shouldSkip. The un-awaited Promise was always truthy, so
every existing local file was skipped regardless of size or ETag and a truncated
file could never be repaired.

Multipart sniffing: require a boundary delimiter line followed by a MIME header
instead of a bare leading "--". Bulkdata is stored under extension-less keys and
is itself multipart, so the discrimination has to come from the content, not the
path. Moved to static-wado-util/multipartFile.mjs so s3-deploy shares it.

S3Ops: check every localCandidates name before skipping a retrieve, and correct a
multipart file left at the guessed name instead of stranding it there. Derive
retrieveFileName and localCandidates from one multipartBase helper so they cannot
disagree - localCandidates split on "/" only, so it produced no candidates for
Windows paths.

S3Ops: only extension-less frame files are multipart wrappers. Raw codestreams
take their content type from the transfer syntax table's extensions, instead of
being labelled multipart/related whenever mime.lookup fails.

Tests for all of the above, including the first suite in s3-deploy - its .gitignore
excluded the whole test directory, now narrowed to the compiled output it meant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	packages/static-wado-creator/lib/index.mjs
- Do not rename bulkdata by content: the BulkDataURI fixes its name.
- Force the download when a local copy fails shouldSkip.
- Keep the response open when a stored rendition fails before any byte
  is sent, so generation can still answer.
- Send the decompressed wrapper to a client that does not accept gzip.
- Report a failed close of the Part 10 rendition stream.
- Put the find-and-rename logic in S3Ops.findExistingLocal only, read
  the zip payload with readBulkData, and sniff files with async I/O.
- Move long inline comments into the doc comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
static-wado-creator ends the closing delimiter without a final CRLF, so
the fixed-length end cut 2 bytes from its payloads, including the zip
download of a stored Part 10 rendition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wayfarer3130
wayfarer3130 merged commit 735373b into master Oct 13, 2026
5 checks passed
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.

2 participants