@ubercode/dcmtk 0.15.1 → 0.16.1

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
@@ -36491,6 +36491,7 @@ var DEFAULT_CONNECTION_TIMEOUT_MS = 1e4;
36491
36491
  var CONNECTION_RETRY_INTERVAL_MS = 500;
36492
36492
  var MAX_CONNECTION_RETRIES = 200;
36493
36493
  var MAX_OUTPUT_LINES_PER_ASSOCIATION = 500;
36494
+ var ABORT_REAP_GRACE_MS = 5e3;
36494
36495
  var DicomReceiverOptionsSchema = zod.z.object({
36495
36496
  port: zod.z.number().int().min(0).max(65535),
36496
36497
  storageDir: zod.z.string().min(1).refine(isSafePath, { message: "path traversal detected in storageDir" }),
@@ -36528,6 +36529,9 @@ var Worker = class {
36528
36529
  __publicField(this, "_instancePending", /* @__PURE__ */ new Set());
36529
36530
  __publicField(this, "_instancesReceived", 0);
36530
36531
  __publicField(this, "_instanceErrors", 0);
36532
+ __publicField(this, "_finalized", false);
36533
+ __publicField(this, "_associationReceivedEmitted", false);
36534
+ __publicField(this, "_reapScheduled", false);
36531
36535
  __publicField(this, "_files", []);
36532
36536
  __publicField(this, "_fileSizes", []);
36533
36537
  __publicField(this, "_outputLines", []);
@@ -36568,6 +36572,62 @@ var Worker = class {
36568
36572
  this._instancePending.clear();
36569
36573
  this._instancesReceived = 0;
36570
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";
36571
36631
  }
36572
36632
  /** Marks the worker as finalizing — still has valid context but cannot accept new connections. */
36573
36633
  markFinalizing() {
@@ -36953,6 +37013,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36953
37013
  const associationDir = path__namespace.join(this.options.storageDir, associationId);
36954
37014
  const mkdirResult = await ensureDirectory(associationDir);
36955
37015
  if (!mkdirResult.ok) {
37016
+ worker.release();
36956
37017
  remoteSocket.destroy();
36957
37018
  this.emit("error", { error: mkdirResult.error });
36958
37019
  return;
@@ -36971,12 +37032,18 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36971
37032
  /** Finds an idle worker, retrying up to connectionTimeoutMs. */
36972
37033
  async findIdleWorker() {
36973
37034
  const idle = this.getIdleWorker();
36974
- if (idle !== void 0) return idle;
37035
+ if (idle !== void 0) {
37036
+ idle.reserve();
37037
+ return idle;
37038
+ }
36975
37039
  const maxRetries = Math.min(Math.ceil(this.connectionTimeoutMs / CONNECTION_RETRY_INTERVAL_MS), MAX_CONNECTION_RETRIES);
36976
37040
  for (let i = 0; i < maxRetries; i++) {
36977
37041
  await promises.setTimeout(CONNECTION_RETRY_INTERVAL_MS);
36978
37042
  const found = this.getIdleWorker();
36979
- if (found !== void 0) return found;
37043
+ if (found !== void 0) {
37044
+ found.reserve();
37045
+ return found;
37046
+ }
36980
37047
  if (this.stopping) return void 0;
36981
37048
  }
36982
37049
  return void 0;
@@ -36992,11 +37059,13 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36992
37059
  pipeConnection(worker, remoteSocket) {
36993
37060
  const workerSocket = net__namespace.createConnection({ port: worker.port, host: "127.0.0.1" });
36994
37061
  worker.setSockets(remoteSocket, workerSocket);
37062
+ const ctx = worker.context;
36995
37063
  const cleanup = () => {
36996
37064
  remoteSocket.unpipe(workerSocket);
36997
37065
  workerSocket.unpipe(remoteSocket);
36998
37066
  if (!remoteSocket.destroyed) remoteSocket.destroy();
36999
37067
  if (!workerSocket.destroyed) workerSocket.destroy();
37068
+ this.scheduleAbortReap(worker, ctx);
37000
37069
  };
37001
37070
  workerSocket.on("connect", () => {
37002
37071
  remoteSocket.pipe(workerSocket);
@@ -37007,6 +37076,42 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
37007
37076
  workerSocket.on("error", cleanup);
37008
37077
  remoteSocket.on("close", cleanup);
37009
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();
37010
37115
  }
37011
37116
  // -----------------------------------------------------------------------
37012
37117
  // Worker pool management
@@ -37205,19 +37310,38 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
37205
37310
  /** Returns worker to idle pool on association complete, emits summary. */
37206
37311
  wireAssociationComplete(worker) {
37207
37312
  worker.dcmrecv.onAssociationComplete((data) => {
37313
+ if (worker.finalized) return;
37208
37314
  worker.markFinalizing();
37315
+ const removeDir = !worker.associationReceived;
37209
37316
  setImmediate(() => {
37210
- void this.finalizeAssociation(worker, data);
37317
+ void this.finalizeAssociation(worker, data, removeDir);
37211
37318
  });
37212
37319
  });
37213
37320
  }
37214
- /** Awaits file ops, emits ASSOCIATION_COMPLETE, awaits parsing, emits ASSOCIATION_FINALIZED. */
37215
- 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;
37216
37336
  await worker.drainPendingFiles();
37217
37337
  this.emitAssociationComplete(worker, data);
37218
37338
  await worker.drainInstancePending();
37219
37339
  this.emitAssociationFinalized(worker, data);
37340
+ const associationDir = worker.context?.associationDir;
37220
37341
  worker.endAssociation();
37342
+ if (removeDir && associationDir !== void 0) {
37343
+ await removeDirSafe(associationDir);
37344
+ }
37221
37345
  void this.scaleDown();
37222
37346
  }
37223
37347
  /** Emits the ASSOCIATION_COMPLETE event with transfer stats. */
@@ -37259,12 +37383,15 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
37259
37383
  source: data.source,
37260
37384
  files: [...worker.files],
37261
37385
  instancesReceived: worker.instancesReceived,
37262
- instanceErrors: worker.instanceErrors
37386
+ instanceErrors: worker.instanceErrors,
37387
+ endReason: data.endReason
37263
37388
  });
37264
37389
  }
37265
37390
  /** Bubbles ASSOCIATION_RECEIVED from dcmrecv worker. */
37266
37391
  wireAssociationReceived(worker) {
37267
37392
  worker.dcmrecv.onEvent("ASSOCIATION_RECEIVED", (data) => {
37393
+ if (worker.finalized) return;
37394
+ worker.markAssociationReceived();
37268
37395
  this.emit("ASSOCIATION_RECEIVED", {
37269
37396
  associationId: worker.context?.associationId ?? "",
37270
37397
  callingAE: data.callingAE,
@@ -37575,7 +37702,12 @@ var SenderEngine = class {
37575
37702
  this.activeAssociations++;
37576
37703
  const startMs = Date.now();
37577
37704
  const maxAttempts = entry.maxRetries + 1;
37578
- const failure = await this.attemptSend(entry, maxAttempts, startMs);
37705
+ let failure;
37706
+ try {
37707
+ failure = await this.attemptSend(entry, maxAttempts, startMs);
37708
+ } catch (e) {
37709
+ failure = { error: e instanceof Error ? e : new Error(String(e)), output: { stdout: "", stderr: "" } };
37710
+ }
37579
37711
  if (failure === void 0) return;
37580
37712
  this.activeAssociations--;
37581
37713
  this.recordFailure();