@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.
@@ -1,4 +1,4 @@
1
- export { A as AssociationAcknowledgedData, a as AssociationCompleteData, b as AssociationContext, c as AssociationReceivedData, d as AssociationSummary, e as AssociationTracker, C as CStoreRequestData, f as CannotStartListenerData, g as ConfigErrorData, D as DCMPRSCP_FATAL_EVENTS, h as DCMPRSCP_PATTERNS, i as DCMPSRCV_FATAL_EVENTS, j as DCMPSRCV_PATTERNS, k as DCMQRSCP_FATAL_EVENTS, l as DCMQRSCP_PATTERNS, m as DCMRECV_FATAL_EVENTS, n as DCMRECV_PATTERNS, o as DatabaseReadyData, p as DcmQRSCP, q as DcmQRSCPEventMap, r as DcmQRSCPOptions, s as DcmprsCP, t as DcmprsCPEventMap, u as DcmprsCPOptions, v as DcmprscpEvent, w as DcmprscpEventValue, x as Dcmpsrcv, y as DcmpsrcvEvent, z as DcmpsrcvEventMap, B as DcmpsrcvEventValue, E as DcmpsrcvOptions, F as DcmqrscpEvent, G as DcmqrscpEventValue, H as Dcmrecv, I as DcmrecvEvent, J as DcmrecvEventMap, K as DcmrecvEventValue, L as DcmrecvOptions, M as DcmtkProcess, N as DcmtkProcessConfig, O as DcmtkProcessEventMap, P as DicomReceiver, Q as DicomReceiverEventMap, R as DicomReceiverOptions, S as EventPattern, T as FileDeletedData, U as FileReceivedData, V as FilenameMode, W as FilenameModeValue, X as LineParser, Y as LineParserEventMap, Z as MultiLineConfig, a2 as PoolStatus, a3 as PreferredTransferSyntax, a4 as PreferredTransferSyntaxValue, a5 as PrintAssociationAcknowledgedData, a6 as PrintAssociationReceivedData, a7 as PrintCannotStartListenerData, a8 as ProcessState, a9 as ProcessStateValue, aa as QRAssociationAcknowledgedData, ab as QRAssociationReceivedData, ac as QRCFindRequestData, ad as QRCGetRequestData, ae as QRCMoveRequestData, af as QRCStoreRequestData, ag as QRCannotStartListenerData, ah as QRListeningData, ai as ReceiverAssociationAcknowledgedData, aj as ReceiverAssociationData, ak as ReceiverAssociationFinalizedData, al as ReceiverAssociationReceivedData, am as ReceiverCStoreRequestData, an as ReceiverCannotStartListenerData, ao as ReceiverConfigErrorData, ap as ReceiverDatabaseReadyData, aq as ReceiverEchoRequestData, ar as ReceiverErrorData, as as ReceiverFileData, at as ReceiverFileReceivedData, au as ReceiverFileStoredData, av as ReceiverInstanceData, aw as ReceiverInstanceErrorData, ax as ReceiverListeningData, ay as RefusingAssociationData, az as STORESCP_FATAL_EVENTS, aA as STORESCP_PATTERNS, aB as StorageMode, aC as StorageModeValue, aD as StoreSCP, aE as StoreSCPEventMap, aF as StoreSCPOptions, aG as StoreSCPPreset, aH as StoreSCPPresetName, aI as StoredFileData, aJ as StorescpEvent, aK as StorescpEventValue, aL as StoringFileData, aM as SubdirectoryCreatedData, aN as SubdirectoryMode, aO as SubdirectoryModeValue, aP as TrackedFile, aQ as WLMSCPFS_FATAL_EVENTS, aR as WLMSCPFS_PATTERNS, aS as WlmAssociationAcknowledgedData, aT as WlmAssociationReceivedData, aU as WlmCFindRequestData, aV as WlmCannotStartListenerData, aW as WlmListeningData, aX as Wlmscpfs, aY as WlmscpfsEvent, aZ as WlmscpfsEventMap, a_ as WlmscpfsEventValue, a$ as WlmscpfsOptions } from './index-CtqMUrjP.cjs';
1
+ export { A as AssociationAcknowledgedData, a as AssociationCompleteData, b as AssociationContext, c as AssociationReceivedData, d as AssociationSummary, e as AssociationTracker, C as CStoreRequestData, f as CannotStartListenerData, g as ConfigErrorData, D as DCMPRSCP_FATAL_EVENTS, h as DCMPRSCP_PATTERNS, i as DCMPSRCV_FATAL_EVENTS, j as DCMPSRCV_PATTERNS, k as DCMQRSCP_FATAL_EVENTS, l as DCMQRSCP_PATTERNS, m as DCMRECV_FATAL_EVENTS, n as DCMRECV_PATTERNS, o as DatabaseReadyData, p as DcmQRSCP, q as DcmQRSCPEventMap, r as DcmQRSCPOptions, s as DcmprsCP, t as DcmprsCPEventMap, u as DcmprsCPOptions, v as DcmprscpEvent, w as DcmprscpEventValue, x as Dcmpsrcv, y as DcmpsrcvEvent, z as DcmpsrcvEventMap, B as DcmpsrcvEventValue, E as DcmpsrcvOptions, F as DcmqrscpEvent, G as DcmqrscpEventValue, H as Dcmrecv, I as DcmrecvEvent, J as DcmrecvEventMap, K as DcmrecvEventValue, L as DcmrecvOptions, M as DcmtkProcess, N as DcmtkProcessConfig, O as DcmtkProcessEventMap, P as DicomReceiver, Q as DicomReceiverEventMap, R as DicomReceiverOptions, S as EventPattern, T as FileDeletedData, U as FileReceivedData, V as FilenameMode, W as FilenameModeValue, X as LineParser, Y as LineParserEventMap, Z as MultiLineConfig, a2 as PoolStatus, a3 as PreferredTransferSyntax, a4 as PreferredTransferSyntaxValue, a5 as PrintAssociationAcknowledgedData, a6 as PrintAssociationReceivedData, a7 as PrintCannotStartListenerData, a8 as ProcessState, a9 as ProcessStateValue, aa as QRAssociationAcknowledgedData, ab as QRAssociationReceivedData, ac as QRCFindRequestData, ad as QRCGetRequestData, ae as QRCMoveRequestData, af as QRCStoreRequestData, ag as QRCannotStartListenerData, ah as QRListeningData, ai as ReceiverAssociationAcknowledgedData, aj as ReceiverAssociationData, ak as ReceiverAssociationFinalizedData, al as ReceiverAssociationReceivedData, am as ReceiverCStoreRequestData, an as ReceiverCannotStartListenerData, ao as ReceiverConfigErrorData, ap as ReceiverDatabaseReadyData, aq as ReceiverEchoRequestData, ar as ReceiverErrorData, as as ReceiverFileData, at as ReceiverFileReceivedData, au as ReceiverFileStoredData, av as ReceiverInstanceData, aw as ReceiverInstanceErrorData, ax as ReceiverListeningData, ay as RefusingAssociationData, az as STORESCP_FATAL_EVENTS, aA as STORESCP_PATTERNS, aB as StorageMode, aC as StorageModeValue, aD as StoreSCP, aE as StoreSCPEventMap, aF as StoreSCPOptions, aG as StoreSCPPreset, aH as StoreSCPPresetName, aI as StoredFileData, aJ as StorescpEvent, aK as StorescpEventValue, aL as StoringFileData, aM as SubdirectoryCreatedData, aN as SubdirectoryMode, aO as SubdirectoryModeValue, aP as TrackedFile, aQ as WLMSCPFS_FATAL_EVENTS, aR as WLMSCPFS_PATTERNS, aS as WlmAssociationAcknowledgedData, aT as WlmAssociationReceivedData, aU as WlmCFindRequestData, aV as WlmCannotStartListenerData, aW as WlmListeningData, aX as Wlmscpfs, aY as WlmscpfsEvent, aZ as WlmscpfsEventMap, a_ as WlmscpfsEventValue, a$ as WlmscpfsOptions } from './index-D1msvaAt.cjs';
2
2
  import './types-Cgumy1N4.cjs';
3
3
  import 'node:events';
4
4
  import 'node:net';
package/dist/servers.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { A as AssociationAcknowledgedData, a as AssociationCompleteData, b as AssociationContext, c as AssociationReceivedData, d as AssociationSummary, e as AssociationTracker, C as CStoreRequestData, f as CannotStartListenerData, g as ConfigErrorData, D as DCMPRSCP_FATAL_EVENTS, h as DCMPRSCP_PATTERNS, i as DCMPSRCV_FATAL_EVENTS, j as DCMPSRCV_PATTERNS, k as DCMQRSCP_FATAL_EVENTS, l as DCMQRSCP_PATTERNS, m as DCMRECV_FATAL_EVENTS, n as DCMRECV_PATTERNS, o as DatabaseReadyData, p as DcmQRSCP, q as DcmQRSCPEventMap, r as DcmQRSCPOptions, s as DcmprsCP, t as DcmprsCPEventMap, u as DcmprsCPOptions, v as DcmprscpEvent, w as DcmprscpEventValue, x as Dcmpsrcv, y as DcmpsrcvEvent, z as DcmpsrcvEventMap, B as DcmpsrcvEventValue, E as DcmpsrcvOptions, F as DcmqrscpEvent, G as DcmqrscpEventValue, H as Dcmrecv, I as DcmrecvEvent, J as DcmrecvEventMap, K as DcmrecvEventValue, L as DcmrecvOptions, M as DcmtkProcess, N as DcmtkProcessConfig, O as DcmtkProcessEventMap, P as DicomReceiver, Q as DicomReceiverEventMap, R as DicomReceiverOptions, S as EventPattern, T as FileDeletedData, U as FileReceivedData, V as FilenameMode, W as FilenameModeValue, X as LineParser, Y as LineParserEventMap, Z as MultiLineConfig, a2 as PoolStatus, a3 as PreferredTransferSyntax, a4 as PreferredTransferSyntaxValue, a5 as PrintAssociationAcknowledgedData, a6 as PrintAssociationReceivedData, a7 as PrintCannotStartListenerData, a8 as ProcessState, a9 as ProcessStateValue, aa as QRAssociationAcknowledgedData, ab as QRAssociationReceivedData, ac as QRCFindRequestData, ad as QRCGetRequestData, ae as QRCMoveRequestData, af as QRCStoreRequestData, ag as QRCannotStartListenerData, ah as QRListeningData, ai as ReceiverAssociationAcknowledgedData, aj as ReceiverAssociationData, ak as ReceiverAssociationFinalizedData, al as ReceiverAssociationReceivedData, am as ReceiverCStoreRequestData, an as ReceiverCannotStartListenerData, ao as ReceiverConfigErrorData, ap as ReceiverDatabaseReadyData, aq as ReceiverEchoRequestData, ar as ReceiverErrorData, as as ReceiverFileData, at as ReceiverFileReceivedData, au as ReceiverFileStoredData, av as ReceiverInstanceData, aw as ReceiverInstanceErrorData, ax as ReceiverListeningData, ay as RefusingAssociationData, az as STORESCP_FATAL_EVENTS, aA as STORESCP_PATTERNS, aB as StorageMode, aC as StorageModeValue, aD as StoreSCP, aE as StoreSCPEventMap, aF as StoreSCPOptions, aG as StoreSCPPreset, aH as StoreSCPPresetName, aI as StoredFileData, aJ as StorescpEvent, aK as StorescpEventValue, aL as StoringFileData, aM as SubdirectoryCreatedData, aN as SubdirectoryMode, aO as SubdirectoryModeValue, aP as TrackedFile, aQ as WLMSCPFS_FATAL_EVENTS, aR as WLMSCPFS_PATTERNS, aS as WlmAssociationAcknowledgedData, aT as WlmAssociationReceivedData, aU as WlmCFindRequestData, aV as WlmCannotStartListenerData, aW as WlmListeningData, aX as Wlmscpfs, aY as WlmscpfsEvent, aZ as WlmscpfsEventMap, a_ as WlmscpfsEventValue, a$ as WlmscpfsOptions } from './index-D7nnTY1R.js';
1
+ export { A as AssociationAcknowledgedData, a as AssociationCompleteData, b as AssociationContext, c as AssociationReceivedData, d as AssociationSummary, e as AssociationTracker, C as CStoreRequestData, f as CannotStartListenerData, g as ConfigErrorData, D as DCMPRSCP_FATAL_EVENTS, h as DCMPRSCP_PATTERNS, i as DCMPSRCV_FATAL_EVENTS, j as DCMPSRCV_PATTERNS, k as DCMQRSCP_FATAL_EVENTS, l as DCMQRSCP_PATTERNS, m as DCMRECV_FATAL_EVENTS, n as DCMRECV_PATTERNS, o as DatabaseReadyData, p as DcmQRSCP, q as DcmQRSCPEventMap, r as DcmQRSCPOptions, s as DcmprsCP, t as DcmprsCPEventMap, u as DcmprsCPOptions, v as DcmprscpEvent, w as DcmprscpEventValue, x as Dcmpsrcv, y as DcmpsrcvEvent, z as DcmpsrcvEventMap, B as DcmpsrcvEventValue, E as DcmpsrcvOptions, F as DcmqrscpEvent, G as DcmqrscpEventValue, H as Dcmrecv, I as DcmrecvEvent, J as DcmrecvEventMap, K as DcmrecvEventValue, L as DcmrecvOptions, M as DcmtkProcess, N as DcmtkProcessConfig, O as DcmtkProcessEventMap, P as DicomReceiver, Q as DicomReceiverEventMap, R as DicomReceiverOptions, S as EventPattern, T as FileDeletedData, U as FileReceivedData, V as FilenameMode, W as FilenameModeValue, X as LineParser, Y as LineParserEventMap, Z as MultiLineConfig, a2 as PoolStatus, a3 as PreferredTransferSyntax, a4 as PreferredTransferSyntaxValue, a5 as PrintAssociationAcknowledgedData, a6 as PrintAssociationReceivedData, a7 as PrintCannotStartListenerData, a8 as ProcessState, a9 as ProcessStateValue, aa as QRAssociationAcknowledgedData, ab as QRAssociationReceivedData, ac as QRCFindRequestData, ad as QRCGetRequestData, ae as QRCMoveRequestData, af as QRCStoreRequestData, ag as QRCannotStartListenerData, ah as QRListeningData, ai as ReceiverAssociationAcknowledgedData, aj as ReceiverAssociationData, ak as ReceiverAssociationFinalizedData, al as ReceiverAssociationReceivedData, am as ReceiverCStoreRequestData, an as ReceiverCannotStartListenerData, ao as ReceiverConfigErrorData, ap as ReceiverDatabaseReadyData, aq as ReceiverEchoRequestData, ar as ReceiverErrorData, as as ReceiverFileData, at as ReceiverFileReceivedData, au as ReceiverFileStoredData, av as ReceiverInstanceData, aw as ReceiverInstanceErrorData, ax as ReceiverListeningData, ay as RefusingAssociationData, az as STORESCP_FATAL_EVENTS, aA as STORESCP_PATTERNS, aB as StorageMode, aC as StorageModeValue, aD as StoreSCP, aE as StoreSCPEventMap, aF as StoreSCPOptions, aG as StoreSCPPreset, aH as StoreSCPPresetName, aI as StoredFileData, aJ as StorescpEvent, aK as StorescpEventValue, aL as StoringFileData, aM as SubdirectoryCreatedData, aN as SubdirectoryMode, aO as SubdirectoryModeValue, aP as TrackedFile, aQ as WLMSCPFS_FATAL_EVENTS, aR as WLMSCPFS_PATTERNS, aS as WlmAssociationAcknowledgedData, aT as WlmAssociationReceivedData, aU as WlmCFindRequestData, aV as WlmCannotStartListenerData, aW as WlmListeningData, aX as Wlmscpfs, aY as WlmscpfsEvent, aZ as WlmscpfsEventMap, a_ as WlmscpfsEventValue, a$ as WlmscpfsOptions } from './index-C6dWOhHS.js';
2
2
  import './types-Cgumy1N4.js';
3
3
  import 'node:events';
4
4
  import 'node:net';
package/dist/servers.js CHANGED
@@ -2837,6 +2837,7 @@ var DEFAULT_CONNECTION_TIMEOUT_MS = 1e4;
2837
2837
  var CONNECTION_RETRY_INTERVAL_MS = 500;
2838
2838
  var MAX_CONNECTION_RETRIES = 200;
2839
2839
  var MAX_OUTPUT_LINES_PER_ASSOCIATION = 500;
2840
+ var ABORT_REAP_GRACE_MS = 5e3;
2840
2841
  var DicomReceiverOptionsSchema = z.object({
2841
2842
  port: z.number().int().min(0).max(65535),
2842
2843
  storageDir: z.string().min(1).refine(isSafePath, { message: "path traversal detected in storageDir" }),
@@ -2874,6 +2875,9 @@ var Worker = class {
2874
2875
  __publicField(this, "_instancePending", /* @__PURE__ */ new Set());
2875
2876
  __publicField(this, "_instancesReceived", 0);
2876
2877
  __publicField(this, "_instanceErrors", 0);
2878
+ __publicField(this, "_finalized", false);
2879
+ __publicField(this, "_associationReceivedEmitted", false);
2880
+ __publicField(this, "_reapScheduled", false);
2877
2881
  __publicField(this, "_files", []);
2878
2882
  __publicField(this, "_fileSizes", []);
2879
2883
  __publicField(this, "_outputLines", []);
@@ -2914,6 +2918,62 @@ var Worker = class {
2914
2918
  this._instancePending.clear();
2915
2919
  this._instancesReceived = 0;
2916
2920
  this._instanceErrors = 0;
2921
+ this._finalized = false;
2922
+ this._associationReceivedEmitted = false;
2923
+ this._reapScheduled = false;
2924
+ }
2925
+ /**
2926
+ * Claims the single abort-reap timer slot for this association. The socket
2927
+ * `cleanup` handler runs on up to four socket events (remote/worker ×
2928
+ * error/close, plus an explicit setup-race call), so without this guard each
2929
+ * teardown would arm its own redundant grace-period timer. Returns true only
2930
+ * for the first caller; subsequent calls return false and arm nothing.
2931
+ */
2932
+ claimReapSchedule() {
2933
+ if (this._reapScheduled) return false;
2934
+ this._reapScheduled = true;
2935
+ return true;
2936
+ }
2937
+ /** True once per association: the first caller wins the finalize, others get false. */
2938
+ beginFinalizeOnce() {
2939
+ if (this._finalized) return false;
2940
+ this._finalized = true;
2941
+ return true;
2942
+ }
2943
+ /** Whether finalize handling has already been claimed for the current association. */
2944
+ get finalized() {
2945
+ return this._finalized;
2946
+ }
2947
+ /** Records that ASSOCIATION_RECEIVED was emitted to consumers for this association. */
2948
+ markAssociationReceived() {
2949
+ this._associationReceivedEmitted = true;
2950
+ }
2951
+ /**
2952
+ * Whether ASSOCIATION_RECEIVED was emitted for the current association —
2953
+ * i.e. the association was handed off to consumers. When false (a connection
2954
+ * that aborted during/before DICOM negotiation), no consumer ever engaged
2955
+ * with the association directory, so the pool owns its cleanup.
2956
+ */
2957
+ get associationReceived() {
2958
+ return this._associationReceivedEmitted;
2959
+ }
2960
+ /**
2961
+ * Atomically reserves a just-selected idle worker so concurrent connection
2962
+ * routing cannot hand the same worker to two associations. Must be called
2963
+ * synchronously the instant an idle worker is picked — before any `await` —
2964
+ * because `handleConnection` yields (e.g. on `ensureDirectory`) between
2965
+ * selecting a worker and calling {@link beginAssociation}. Without this,
2966
+ * two concurrent connections both see the worker as idle, both proceed, and
2967
+ * the second `beginAssociation` overwrites the first's context — orphaning
2968
+ * the first association (no terminal event ever fires for it). Released via
2969
+ * {@link release} if association setup fails before `beginAssociation`.
2970
+ */
2971
+ reserve() {
2972
+ this._state = "busy";
2973
+ }
2974
+ /** Returns a reserved-but-not-yet-begun worker to the idle pool (setup failed). */
2975
+ release() {
2976
+ this._state = "idle";
2917
2977
  }
2918
2978
  /** Marks the worker as finalizing — still has valid context but cannot accept new connections. */
2919
2979
  markFinalizing() {
@@ -3299,6 +3359,7 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3299
3359
  const associationDir = path.join(this.options.storageDir, associationId);
3300
3360
  const mkdirResult = await ensureDirectory(associationDir);
3301
3361
  if (!mkdirResult.ok) {
3362
+ worker.release();
3302
3363
  remoteSocket.destroy();
3303
3364
  this.emit("error", { error: mkdirResult.error });
3304
3365
  return;
@@ -3317,12 +3378,18 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3317
3378
  /** Finds an idle worker, retrying up to connectionTimeoutMs. */
3318
3379
  async findIdleWorker() {
3319
3380
  const idle = this.getIdleWorker();
3320
- if (idle !== void 0) return idle;
3381
+ if (idle !== void 0) {
3382
+ idle.reserve();
3383
+ return idle;
3384
+ }
3321
3385
  const maxRetries = Math.min(Math.ceil(this.connectionTimeoutMs / CONNECTION_RETRY_INTERVAL_MS), MAX_CONNECTION_RETRIES);
3322
3386
  for (let i = 0; i < maxRetries; i++) {
3323
3387
  await setTimeout$1(CONNECTION_RETRY_INTERVAL_MS);
3324
3388
  const found = this.getIdleWorker();
3325
- if (found !== void 0) return found;
3389
+ if (found !== void 0) {
3390
+ found.reserve();
3391
+ return found;
3392
+ }
3326
3393
  if (this.stopping) return void 0;
3327
3394
  }
3328
3395
  return void 0;
@@ -3338,11 +3405,13 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3338
3405
  pipeConnection(worker, remoteSocket) {
3339
3406
  const workerSocket = net.createConnection({ port: worker.port, host: "127.0.0.1" });
3340
3407
  worker.setSockets(remoteSocket, workerSocket);
3408
+ const ctx = worker.context;
3341
3409
  const cleanup = () => {
3342
3410
  remoteSocket.unpipe(workerSocket);
3343
3411
  workerSocket.unpipe(remoteSocket);
3344
3412
  if (!remoteSocket.destroyed) remoteSocket.destroy();
3345
3413
  if (!workerSocket.destroyed) workerSocket.destroy();
3414
+ this.scheduleAbortReap(worker, ctx);
3346
3415
  };
3347
3416
  workerSocket.on("connect", () => {
3348
3417
  remoteSocket.pipe(workerSocket);
@@ -3353,6 +3422,42 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3353
3422
  workerSocket.on("error", cleanup);
3354
3423
  remoteSocket.on("close", cleanup);
3355
3424
  workerSocket.on("close", cleanup);
3425
+ if (remoteSocket.destroyed) {
3426
+ cleanup();
3427
+ }
3428
+ }
3429
+ /**
3430
+ * After a connection's sockets tear down, reaps the association as an
3431
+ * aborted one *only if* dcmrecv never reported completion within
3432
+ * {@link ABORT_REAP_GRACE_MS}. Without this, a peer abort destroys the
3433
+ * sockets but dcmrecv may never emit ASSOCIATION_COMPLETE, so the worker
3434
+ * stays busy forever, ASSOCIATION_FINALIZED never fires (consumers can't
3435
+ * release per-association state), and the association directory is never
3436
+ * removed. The grace delay ensures a normal A-RELEASE — whose socket also
3437
+ * closes — is finalized by its own completion report and never reaped here.
3438
+ */
3439
+ scheduleAbortReap(worker, ctx) {
3440
+ if (ctx === void 0 || worker.context !== ctx || worker.state !== "busy") return;
3441
+ if (!worker.claimReapSchedule()) return;
3442
+ const timer = setTimeout(() => {
3443
+ if (worker.context !== ctx || worker.state !== "busy" || worker.finalized) return;
3444
+ worker.markFinalizing();
3445
+ const removeDir = !worker.associationReceived;
3446
+ void this.finalizeAssociation(
3447
+ worker,
3448
+ {
3449
+ associationId: ctx.associationId,
3450
+ callingAE: "",
3451
+ calledAE: "",
3452
+ source: "",
3453
+ files: [],
3454
+ durationMs: Date.now() - ctx.startAt,
3455
+ endReason: "abort"
3456
+ },
3457
+ removeDir
3458
+ );
3459
+ }, ABORT_REAP_GRACE_MS);
3460
+ timer.unref();
3356
3461
  }
3357
3462
  // -----------------------------------------------------------------------
3358
3463
  // Worker pool management
@@ -3551,19 +3656,38 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3551
3656
  /** Returns worker to idle pool on association complete, emits summary. */
3552
3657
  wireAssociationComplete(worker) {
3553
3658
  worker.dcmrecv.onAssociationComplete((data) => {
3659
+ if (worker.finalized) return;
3554
3660
  worker.markFinalizing();
3661
+ const removeDir = !worker.associationReceived;
3555
3662
  setImmediate(() => {
3556
- void this.finalizeAssociation(worker, data);
3663
+ void this.finalizeAssociation(worker, data, removeDir);
3557
3664
  });
3558
3665
  });
3559
3666
  }
3560
- /** Awaits file ops, emits ASSOCIATION_COMPLETE, awaits parsing, emits ASSOCIATION_FINALIZED. */
3561
- async finalizeAssociation(worker, data) {
3667
+ /**
3668
+ * Awaits file ops, emits ASSOCIATION_COMPLETE, awaits parsing, emits
3669
+ * ASSOCIATION_FINALIZED, returns the worker to idle. Runs at most once per
3670
+ * association via {@link Worker.beginFinalizeOnce}.
3671
+ *
3672
+ * @param removeDir - When true, removes the association directory after
3673
+ * finalizing. Set when no consumer ever engaged with the association
3674
+ * (no ASSOCIATION_RECEIVED was emitted) — e.g. a connection that aborted
3675
+ * during negotiation — so the pool reclaims the directory it created.
3676
+ * When a consumer did engage this stays false: the consumer owns
3677
+ * directory cleanup once it has processed the received files (removing it
3678
+ * here could race in-flight consumer reads of those files).
3679
+ */
3680
+ async finalizeAssociation(worker, data, removeDir = false) {
3681
+ if (!worker.beginFinalizeOnce()) return;
3562
3682
  await worker.drainPendingFiles();
3563
3683
  this.emitAssociationComplete(worker, data);
3564
3684
  await worker.drainInstancePending();
3565
3685
  this.emitAssociationFinalized(worker, data);
3686
+ const associationDir = worker.context?.associationDir;
3566
3687
  worker.endAssociation();
3688
+ if (removeDir && associationDir !== void 0) {
3689
+ await removeDirSafe(associationDir);
3690
+ }
3567
3691
  void this.scaleDown();
3568
3692
  }
3569
3693
  /** Emits the ASSOCIATION_COMPLETE event with transfer stats. */
@@ -3605,12 +3729,15 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3605
3729
  source: data.source,
3606
3730
  files: [...worker.files],
3607
3731
  instancesReceived: worker.instancesReceived,
3608
- instanceErrors: worker.instanceErrors
3732
+ instanceErrors: worker.instanceErrors,
3733
+ endReason: data.endReason
3609
3734
  });
3610
3735
  }
3611
3736
  /** Bubbles ASSOCIATION_RECEIVED from dcmrecv worker. */
3612
3737
  wireAssociationReceived(worker) {
3613
3738
  worker.dcmrecv.onEvent("ASSOCIATION_RECEIVED", (data) => {
3739
+ if (worker.finalized) return;
3740
+ worker.markAssociationReceived();
3614
3741
  this.emit("ASSOCIATION_RECEIVED", {
3615
3742
  associationId: worker.context?.associationId ?? "",
3616
3743
  callingAE: data.callingAE,