@ubercode/dcmtk 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1567,6 +1567,14 @@ interface ReceiverAssociationFinalizedData {
1567
1567
  readonly files: readonly string[];
1568
1568
  readonly instancesReceived: number;
1569
1569
  readonly instanceErrors: number;
1570
+ /**
1571
+ * How the association ended: `'release'` for a normal A-RELEASE, `'abort'`
1572
+ * for a peer abort (including associations synthesized by the grace-period
1573
+ * reaper when dcmrecv never reported completion). Lets consumers that drive
1574
+ * terminal state off this event distinguish a reaped abort from a clean
1575
+ * finish without also listening to ASSOCIATION_COMPLETE.
1576
+ */
1577
+ readonly endReason: 'release' | 'abort';
1570
1578
  }
1571
1579
  /** Data emitted with error events. */
1572
1580
  interface ReceiverErrorData {
@@ -1787,6 +1795,17 @@ declare class DicomReceiver extends EventEmitter<DicomReceiverEventMap> {
1787
1795
  private getIdleWorker;
1788
1796
  /** Pipes remote socket bidirectionally to the worker's port. */
1789
1797
  private pipeConnection;
1798
+ /**
1799
+ * After a connection's sockets tear down, reaps the association as an
1800
+ * aborted one *only if* dcmrecv never reported completion within
1801
+ * {@link ABORT_REAP_GRACE_MS}. Without this, a peer abort destroys the
1802
+ * sockets but dcmrecv may never emit ASSOCIATION_COMPLETE, so the worker
1803
+ * stays busy forever, ASSOCIATION_FINALIZED never fires (consumers can't
1804
+ * release per-association state), and the association directory is never
1805
+ * removed. The grace delay ensures a normal A-RELEASE — whose socket also
1806
+ * closes — is finalized by its own completion report and never reaped here.
1807
+ */
1808
+ private scheduleAbortReap;
1790
1809
  /** Spawns `count` new workers and adds them to the pool. */
1791
1810
  private spawnWorkers;
1792
1811
  /** Creates a Dcmrecv instance with the pool's shared options. */
@@ -1811,7 +1830,19 @@ declare class DicomReceiver extends EventEmitter<DicomReceiverEventMap> {
1811
1830
  private parseAndEmitInstance;
1812
1831
  /** Returns worker to idle pool on association complete, emits summary. */
1813
1832
  private wireAssociationComplete;
1814
- /** Awaits file ops, emits ASSOCIATION_COMPLETE, awaits parsing, emits ASSOCIATION_FINALIZED. */
1833
+ /**
1834
+ * Awaits file ops, emits ASSOCIATION_COMPLETE, awaits parsing, emits
1835
+ * ASSOCIATION_FINALIZED, returns the worker to idle. Runs at most once per
1836
+ * association via {@link Worker.beginFinalizeOnce}.
1837
+ *
1838
+ * @param removeDir - When true, removes the association directory after
1839
+ * finalizing. Set when no consumer ever engaged with the association
1840
+ * (no ASSOCIATION_RECEIVED was emitted) — e.g. a connection that aborted
1841
+ * during negotiation — so the pool reclaims the directory it created.
1842
+ * When a consumer did engage this stays false: the consumer owns
1843
+ * directory cleanup once it has processed the received files (removing it
1844
+ * here could race in-flight consumer reads of those files).
1845
+ */
1815
1846
  private finalizeAssociation;
1816
1847
  /** Emits the ASSOCIATION_COMPLETE event with transfer stats. */
1817
1848
  private emitAssociationComplete;
@@ -1567,6 +1567,14 @@ interface ReceiverAssociationFinalizedData {
1567
1567
  readonly files: readonly string[];
1568
1568
  readonly instancesReceived: number;
1569
1569
  readonly instanceErrors: number;
1570
+ /**
1571
+ * How the association ended: `'release'` for a normal A-RELEASE, `'abort'`
1572
+ * for a peer abort (including associations synthesized by the grace-period
1573
+ * reaper when dcmrecv never reported completion). Lets consumers that drive
1574
+ * terminal state off this event distinguish a reaped abort from a clean
1575
+ * finish without also listening to ASSOCIATION_COMPLETE.
1576
+ */
1577
+ readonly endReason: 'release' | 'abort';
1570
1578
  }
1571
1579
  /** Data emitted with error events. */
1572
1580
  interface ReceiverErrorData {
@@ -1787,6 +1795,17 @@ declare class DicomReceiver extends EventEmitter<DicomReceiverEventMap> {
1787
1795
  private getIdleWorker;
1788
1796
  /** Pipes remote socket bidirectionally to the worker's port. */
1789
1797
  private pipeConnection;
1798
+ /**
1799
+ * After a connection's sockets tear down, reaps the association as an
1800
+ * aborted one *only if* dcmrecv never reported completion within
1801
+ * {@link ABORT_REAP_GRACE_MS}. Without this, a peer abort destroys the
1802
+ * sockets but dcmrecv may never emit ASSOCIATION_COMPLETE, so the worker
1803
+ * stays busy forever, ASSOCIATION_FINALIZED never fires (consumers can't
1804
+ * release per-association state), and the association directory is never
1805
+ * removed. The grace delay ensures a normal A-RELEASE — whose socket also
1806
+ * closes — is finalized by its own completion report and never reaped here.
1807
+ */
1808
+ private scheduleAbortReap;
1790
1809
  /** Spawns `count` new workers and adds them to the pool. */
1791
1810
  private spawnWorkers;
1792
1811
  /** Creates a Dcmrecv instance with the pool's shared options. */
@@ -1811,7 +1830,19 @@ declare class DicomReceiver extends EventEmitter<DicomReceiverEventMap> {
1811
1830
  private parseAndEmitInstance;
1812
1831
  /** Returns worker to idle pool on association complete, emits summary. */
1813
1832
  private wireAssociationComplete;
1814
- /** Awaits file ops, emits ASSOCIATION_COMPLETE, awaits parsing, emits ASSOCIATION_FINALIZED. */
1833
+ /**
1834
+ * Awaits file ops, emits ASSOCIATION_COMPLETE, awaits parsing, emits
1835
+ * ASSOCIATION_FINALIZED, returns the worker to idle. Runs at most once per
1836
+ * association via {@link Worker.beginFinalizeOnce}.
1837
+ *
1838
+ * @param removeDir - When true, removes the association directory after
1839
+ * finalizing. Set when no consumer ever engaged with the association
1840
+ * (no ASSOCIATION_RECEIVED was emitted) — e.g. a connection that aborted
1841
+ * during negotiation — so the pool reclaims the directory it created.
1842
+ * When a consumer did engage this stays false: the consumer owns
1843
+ * directory cleanup once it has processed the received files (removing it
1844
+ * here could race in-flight consumer reads of those files).
1845
+ */
1815
1846
  private finalizeAssociation;
1816
1847
  /** Emits the ASSOCIATION_COMPLETE event with transfer stats. */
1817
1848
  private emitAssociationComplete;
package/dist/index.cjs CHANGED
@@ -31290,8 +31290,14 @@ var KNOWN_VR_CODES = /* @__PURE__ */ new Set([
31290
31290
  "UT",
31291
31291
  "UV"
31292
31292
  ]);
31293
+ function decodeXmlEntities(value) {
31294
+ return value.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
31295
+ }
31296
+ function decodeIfString(value) {
31297
+ return typeof value === "string" ? decodeXmlEntities(value) : value;
31298
+ }
31293
31299
  function buildPnString(comp) {
31294
- const parts = [comp.FamilyName ?? "", comp.GivenName ?? "", comp.MiddleName ?? "", comp.NamePrefix ?? "", comp.NameSuffix ?? ""];
31300
+ const parts = [comp.FamilyName ?? "", comp.GivenName ?? "", comp.MiddleName ?? "", comp.NamePrefix ?? "", comp.NameSuffix ?? ""].map(decodeXmlEntities);
31295
31301
  let last = parts.length - 1;
31296
31302
  for (; last >= 0; last--) {
31297
31303
  if (parts[last] !== "") break;
@@ -31330,9 +31336,9 @@ function convertBulkDataURI(attr, element) {
31330
31336
  const bulkArray = toArray(attr.BulkDataURI);
31331
31337
  const firstBulk = bulkArray[0];
31332
31338
  if (typeof firstBulk === "object" && firstBulk !== null && "@_uri" in firstBulk) {
31333
- element.BulkDataURI = safeString(firstBulk["@_uri"]);
31339
+ element.BulkDataURI = decodeXmlEntities(safeString(firstBulk["@_uri"]));
31334
31340
  } else {
31335
- element.BulkDataURI = safeString(firstBulk);
31341
+ element.BulkDataURI = decodeXmlEntities(safeString(firstBulk));
31336
31342
  }
31337
31343
  }
31338
31344
  function convertPNValue(attr, element) {
@@ -31376,7 +31382,7 @@ function convertRegularValue(attr, element, vr) {
31376
31382
  const values = [];
31377
31383
  const isNumeric = NUMERIC_JSON_VRS.has(vr);
31378
31384
  for (const v of valArray) {
31379
- const unwrapped = unwrapValue(v);
31385
+ const unwrapped = decodeIfString(unwrapValue(v));
31380
31386
  values.push(isNumeric ? coerceNumeric(unwrapped) : unwrapped);
31381
31387
  }
31382
31388
  if (values.length > 0) element.Value = values;
@@ -36485,6 +36491,7 @@ var DEFAULT_CONNECTION_TIMEOUT_MS = 1e4;
36485
36491
  var CONNECTION_RETRY_INTERVAL_MS = 500;
36486
36492
  var MAX_CONNECTION_RETRIES = 200;
36487
36493
  var MAX_OUTPUT_LINES_PER_ASSOCIATION = 500;
36494
+ var ABORT_REAP_GRACE_MS = 5e3;
36488
36495
  var DicomReceiverOptionsSchema = zod.z.object({
36489
36496
  port: zod.z.number().int().min(0).max(65535),
36490
36497
  storageDir: zod.z.string().min(1).refine(isSafePath, { message: "path traversal detected in storageDir" }),
@@ -36522,6 +36529,9 @@ var Worker = class {
36522
36529
  __publicField(this, "_instancePending", /* @__PURE__ */ new Set());
36523
36530
  __publicField(this, "_instancesReceived", 0);
36524
36531
  __publicField(this, "_instanceErrors", 0);
36532
+ __publicField(this, "_finalized", false);
36533
+ __publicField(this, "_associationReceivedEmitted", false);
36534
+ __publicField(this, "_reapScheduled", false);
36525
36535
  __publicField(this, "_files", []);
36526
36536
  __publicField(this, "_fileSizes", []);
36527
36537
  __publicField(this, "_outputLines", []);
@@ -36562,6 +36572,62 @@ var Worker = class {
36562
36572
  this._instancePending.clear();
36563
36573
  this._instancesReceived = 0;
36564
36574
  this._instanceErrors = 0;
36575
+ this._finalized = false;
36576
+ this._associationReceivedEmitted = false;
36577
+ this._reapScheduled = false;
36578
+ }
36579
+ /**
36580
+ * Claims the single abort-reap timer slot for this association. The socket
36581
+ * `cleanup` handler runs on up to four socket events (remote/worker ×
36582
+ * error/close, plus an explicit setup-race call), so without this guard each
36583
+ * teardown would arm its own redundant grace-period timer. Returns true only
36584
+ * for the first caller; subsequent calls return false and arm nothing.
36585
+ */
36586
+ claimReapSchedule() {
36587
+ if (this._reapScheduled) return false;
36588
+ this._reapScheduled = true;
36589
+ return true;
36590
+ }
36591
+ /** True once per association: the first caller wins the finalize, others get false. */
36592
+ beginFinalizeOnce() {
36593
+ if (this._finalized) return false;
36594
+ this._finalized = true;
36595
+ return true;
36596
+ }
36597
+ /** Whether finalize handling has already been claimed for the current association. */
36598
+ get finalized() {
36599
+ return this._finalized;
36600
+ }
36601
+ /** Records that ASSOCIATION_RECEIVED was emitted to consumers for this association. */
36602
+ markAssociationReceived() {
36603
+ this._associationReceivedEmitted = true;
36604
+ }
36605
+ /**
36606
+ * Whether ASSOCIATION_RECEIVED was emitted for the current association —
36607
+ * i.e. the association was handed off to consumers. When false (a connection
36608
+ * that aborted during/before DICOM negotiation), no consumer ever engaged
36609
+ * with the association directory, so the pool owns its cleanup.
36610
+ */
36611
+ get associationReceived() {
36612
+ return this._associationReceivedEmitted;
36613
+ }
36614
+ /**
36615
+ * Atomically reserves a just-selected idle worker so concurrent connection
36616
+ * routing cannot hand the same worker to two associations. Must be called
36617
+ * synchronously the instant an idle worker is picked — before any `await` —
36618
+ * because `handleConnection` yields (e.g. on `ensureDirectory`) between
36619
+ * selecting a worker and calling {@link beginAssociation}. Without this,
36620
+ * two concurrent connections both see the worker as idle, both proceed, and
36621
+ * the second `beginAssociation` overwrites the first's context — orphaning
36622
+ * the first association (no terminal event ever fires for it). Released via
36623
+ * {@link release} if association setup fails before `beginAssociation`.
36624
+ */
36625
+ reserve() {
36626
+ this._state = "busy";
36627
+ }
36628
+ /** Returns a reserved-but-not-yet-begun worker to the idle pool (setup failed). */
36629
+ release() {
36630
+ this._state = "idle";
36565
36631
  }
36566
36632
  /** Marks the worker as finalizing — still has valid context but cannot accept new connections. */
36567
36633
  markFinalizing() {
@@ -36947,6 +37013,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36947
37013
  const associationDir = path__namespace.join(this.options.storageDir, associationId);
36948
37014
  const mkdirResult = await ensureDirectory(associationDir);
36949
37015
  if (!mkdirResult.ok) {
37016
+ worker.release();
36950
37017
  remoteSocket.destroy();
36951
37018
  this.emit("error", { error: mkdirResult.error });
36952
37019
  return;
@@ -36965,12 +37032,18 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36965
37032
  /** Finds an idle worker, retrying up to connectionTimeoutMs. */
36966
37033
  async findIdleWorker() {
36967
37034
  const idle = this.getIdleWorker();
36968
- if (idle !== void 0) return idle;
37035
+ if (idle !== void 0) {
37036
+ idle.reserve();
37037
+ return idle;
37038
+ }
36969
37039
  const maxRetries = Math.min(Math.ceil(this.connectionTimeoutMs / CONNECTION_RETRY_INTERVAL_MS), MAX_CONNECTION_RETRIES);
36970
37040
  for (let i = 0; i < maxRetries; i++) {
36971
37041
  await promises.setTimeout(CONNECTION_RETRY_INTERVAL_MS);
36972
37042
  const found = this.getIdleWorker();
36973
- if (found !== void 0) return found;
37043
+ if (found !== void 0) {
37044
+ found.reserve();
37045
+ return found;
37046
+ }
36974
37047
  if (this.stopping) return void 0;
36975
37048
  }
36976
37049
  return void 0;
@@ -36986,11 +37059,13 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36986
37059
  pipeConnection(worker, remoteSocket) {
36987
37060
  const workerSocket = net__namespace.createConnection({ port: worker.port, host: "127.0.0.1" });
36988
37061
  worker.setSockets(remoteSocket, workerSocket);
37062
+ const ctx = worker.context;
36989
37063
  const cleanup = () => {
36990
37064
  remoteSocket.unpipe(workerSocket);
36991
37065
  workerSocket.unpipe(remoteSocket);
36992
37066
  if (!remoteSocket.destroyed) remoteSocket.destroy();
36993
37067
  if (!workerSocket.destroyed) workerSocket.destroy();
37068
+ this.scheduleAbortReap(worker, ctx);
36994
37069
  };
36995
37070
  workerSocket.on("connect", () => {
36996
37071
  remoteSocket.pipe(workerSocket);
@@ -37001,6 +37076,42 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
37001
37076
  workerSocket.on("error", cleanup);
37002
37077
  remoteSocket.on("close", cleanup);
37003
37078
  workerSocket.on("close", cleanup);
37079
+ if (remoteSocket.destroyed) {
37080
+ cleanup();
37081
+ }
37082
+ }
37083
+ /**
37084
+ * After a connection's sockets tear down, reaps the association as an
37085
+ * aborted one *only if* dcmrecv never reported completion within
37086
+ * {@link ABORT_REAP_GRACE_MS}. Without this, a peer abort destroys the
37087
+ * sockets but dcmrecv may never emit ASSOCIATION_COMPLETE, so the worker
37088
+ * stays busy forever, ASSOCIATION_FINALIZED never fires (consumers can't
37089
+ * release per-association state), and the association directory is never
37090
+ * removed. The grace delay ensures a normal A-RELEASE — whose socket also
37091
+ * closes — is finalized by its own completion report and never reaped here.
37092
+ */
37093
+ scheduleAbortReap(worker, ctx) {
37094
+ if (ctx === void 0 || worker.context !== ctx || worker.state !== "busy") return;
37095
+ if (!worker.claimReapSchedule()) return;
37096
+ const timer = setTimeout(() => {
37097
+ if (worker.context !== ctx || worker.state !== "busy" || worker.finalized) return;
37098
+ worker.markFinalizing();
37099
+ const removeDir = !worker.associationReceived;
37100
+ void this.finalizeAssociation(
37101
+ worker,
37102
+ {
37103
+ associationId: ctx.associationId,
37104
+ callingAE: "",
37105
+ calledAE: "",
37106
+ source: "",
37107
+ files: [],
37108
+ durationMs: Date.now() - ctx.startAt,
37109
+ endReason: "abort"
37110
+ },
37111
+ removeDir
37112
+ );
37113
+ }, ABORT_REAP_GRACE_MS);
37114
+ timer.unref();
37004
37115
  }
37005
37116
  // -----------------------------------------------------------------------
37006
37117
  // Worker pool management
@@ -37199,19 +37310,38 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
37199
37310
  /** Returns worker to idle pool on association complete, emits summary. */
37200
37311
  wireAssociationComplete(worker) {
37201
37312
  worker.dcmrecv.onAssociationComplete((data) => {
37313
+ if (worker.finalized) return;
37202
37314
  worker.markFinalizing();
37315
+ const removeDir = !worker.associationReceived;
37203
37316
  setImmediate(() => {
37204
- void this.finalizeAssociation(worker, data);
37317
+ void this.finalizeAssociation(worker, data, removeDir);
37205
37318
  });
37206
37319
  });
37207
37320
  }
37208
- /** Awaits file ops, emits ASSOCIATION_COMPLETE, awaits parsing, emits ASSOCIATION_FINALIZED. */
37209
- async finalizeAssociation(worker, data) {
37321
+ /**
37322
+ * Awaits file ops, emits ASSOCIATION_COMPLETE, awaits parsing, emits
37323
+ * ASSOCIATION_FINALIZED, returns the worker to idle. Runs at most once per
37324
+ * association via {@link Worker.beginFinalizeOnce}.
37325
+ *
37326
+ * @param removeDir - When true, removes the association directory after
37327
+ * finalizing. Set when no consumer ever engaged with the association
37328
+ * (no ASSOCIATION_RECEIVED was emitted) — e.g. a connection that aborted
37329
+ * during negotiation — so the pool reclaims the directory it created.
37330
+ * When a consumer did engage this stays false: the consumer owns
37331
+ * directory cleanup once it has processed the received files (removing it
37332
+ * here could race in-flight consumer reads of those files).
37333
+ */
37334
+ async finalizeAssociation(worker, data, removeDir = false) {
37335
+ if (!worker.beginFinalizeOnce()) return;
37210
37336
  await worker.drainPendingFiles();
37211
37337
  this.emitAssociationComplete(worker, data);
37212
37338
  await worker.drainInstancePending();
37213
37339
  this.emitAssociationFinalized(worker, data);
37340
+ const associationDir = worker.context?.associationDir;
37214
37341
  worker.endAssociation();
37342
+ if (removeDir && associationDir !== void 0) {
37343
+ await removeDirSafe(associationDir);
37344
+ }
37215
37345
  void this.scaleDown();
37216
37346
  }
37217
37347
  /** Emits the ASSOCIATION_COMPLETE event with transfer stats. */
@@ -37253,12 +37383,14 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
37253
37383
  source: data.source,
37254
37384
  files: [...worker.files],
37255
37385
  instancesReceived: worker.instancesReceived,
37256
- instanceErrors: worker.instanceErrors
37386
+ instanceErrors: worker.instanceErrors,
37387
+ endReason: data.endReason
37257
37388
  });
37258
37389
  }
37259
37390
  /** Bubbles ASSOCIATION_RECEIVED from dcmrecv worker. */
37260
37391
  wireAssociationReceived(worker) {
37261
37392
  worker.dcmrecv.onEvent("ASSOCIATION_RECEIVED", (data) => {
37393
+ worker.markAssociationReceived();
37262
37394
  this.emit("ASSOCIATION_RECEIVED", {
37263
37395
  associationId: worker.context?.associationId ?? "",
37264
37396
  callingAE: data.callingAE,
@@ -37569,7 +37701,12 @@ var SenderEngine = class {
37569
37701
  this.activeAssociations++;
37570
37702
  const startMs = Date.now();
37571
37703
  const maxAttempts = entry.maxRetries + 1;
37572
- const failure = await this.attemptSend(entry, maxAttempts, startMs);
37704
+ let failure;
37705
+ try {
37706
+ failure = await this.attemptSend(entry, maxAttempts, startMs);
37707
+ } catch (e) {
37708
+ failure = { error: e instanceof Error ? e : new Error(String(e)), output: { stdout: "", stderr: "" } };
37709
+ }
37573
37710
  if (failure === void 0) return;
37574
37711
  this.activeAssociations--;
37575
37712
  this.recordFailure();