@ubercode/dcmtk 0.15.1 → 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.
package/dist/servers.cjs CHANGED
@@ -2862,6 +2862,7 @@ var DEFAULT_CONNECTION_TIMEOUT_MS = 1e4;
2862
2862
  var CONNECTION_RETRY_INTERVAL_MS = 500;
2863
2863
  var MAX_CONNECTION_RETRIES = 200;
2864
2864
  var MAX_OUTPUT_LINES_PER_ASSOCIATION = 500;
2865
+ var ABORT_REAP_GRACE_MS = 5e3;
2865
2866
  var DicomReceiverOptionsSchema = zod.z.object({
2866
2867
  port: zod.z.number().int().min(0).max(65535),
2867
2868
  storageDir: zod.z.string().min(1).refine(isSafePath, { message: "path traversal detected in storageDir" }),
@@ -2899,6 +2900,9 @@ var Worker = class {
2899
2900
  __publicField(this, "_instancePending", /* @__PURE__ */ new Set());
2900
2901
  __publicField(this, "_instancesReceived", 0);
2901
2902
  __publicField(this, "_instanceErrors", 0);
2903
+ __publicField(this, "_finalized", false);
2904
+ __publicField(this, "_associationReceivedEmitted", false);
2905
+ __publicField(this, "_reapScheduled", false);
2902
2906
  __publicField(this, "_files", []);
2903
2907
  __publicField(this, "_fileSizes", []);
2904
2908
  __publicField(this, "_outputLines", []);
@@ -2939,6 +2943,62 @@ var Worker = class {
2939
2943
  this._instancePending.clear();
2940
2944
  this._instancesReceived = 0;
2941
2945
  this._instanceErrors = 0;
2946
+ this._finalized = false;
2947
+ this._associationReceivedEmitted = false;
2948
+ this._reapScheduled = false;
2949
+ }
2950
+ /**
2951
+ * Claims the single abort-reap timer slot for this association. The socket
2952
+ * `cleanup` handler runs on up to four socket events (remote/worker ×
2953
+ * error/close, plus an explicit setup-race call), so without this guard each
2954
+ * teardown would arm its own redundant grace-period timer. Returns true only
2955
+ * for the first caller; subsequent calls return false and arm nothing.
2956
+ */
2957
+ claimReapSchedule() {
2958
+ if (this._reapScheduled) return false;
2959
+ this._reapScheduled = true;
2960
+ return true;
2961
+ }
2962
+ /** True once per association: the first caller wins the finalize, others get false. */
2963
+ beginFinalizeOnce() {
2964
+ if (this._finalized) return false;
2965
+ this._finalized = true;
2966
+ return true;
2967
+ }
2968
+ /** Whether finalize handling has already been claimed for the current association. */
2969
+ get finalized() {
2970
+ return this._finalized;
2971
+ }
2972
+ /** Records that ASSOCIATION_RECEIVED was emitted to consumers for this association. */
2973
+ markAssociationReceived() {
2974
+ this._associationReceivedEmitted = true;
2975
+ }
2976
+ /**
2977
+ * Whether ASSOCIATION_RECEIVED was emitted for the current association —
2978
+ * i.e. the association was handed off to consumers. When false (a connection
2979
+ * that aborted during/before DICOM negotiation), no consumer ever engaged
2980
+ * with the association directory, so the pool owns its cleanup.
2981
+ */
2982
+ get associationReceived() {
2983
+ return this._associationReceivedEmitted;
2984
+ }
2985
+ /**
2986
+ * Atomically reserves a just-selected idle worker so concurrent connection
2987
+ * routing cannot hand the same worker to two associations. Must be called
2988
+ * synchronously the instant an idle worker is picked — before any `await` —
2989
+ * because `handleConnection` yields (e.g. on `ensureDirectory`) between
2990
+ * selecting a worker and calling {@link beginAssociation}. Without this,
2991
+ * two concurrent connections both see the worker as idle, both proceed, and
2992
+ * the second `beginAssociation` overwrites the first's context — orphaning
2993
+ * the first association (no terminal event ever fires for it). Released via
2994
+ * {@link release} if association setup fails before `beginAssociation`.
2995
+ */
2996
+ reserve() {
2997
+ this._state = "busy";
2998
+ }
2999
+ /** Returns a reserved-but-not-yet-begun worker to the idle pool (setup failed). */
3000
+ release() {
3001
+ this._state = "idle";
2942
3002
  }
2943
3003
  /** Marks the worker as finalizing — still has valid context but cannot accept new connections. */
2944
3004
  markFinalizing() {
@@ -3324,6 +3384,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
3324
3384
  const associationDir = path__namespace.join(this.options.storageDir, associationId);
3325
3385
  const mkdirResult = await ensureDirectory(associationDir);
3326
3386
  if (!mkdirResult.ok) {
3387
+ worker.release();
3327
3388
  remoteSocket.destroy();
3328
3389
  this.emit("error", { error: mkdirResult.error });
3329
3390
  return;
@@ -3342,12 +3403,18 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
3342
3403
  /** Finds an idle worker, retrying up to connectionTimeoutMs. */
3343
3404
  async findIdleWorker() {
3344
3405
  const idle = this.getIdleWorker();
3345
- if (idle !== void 0) return idle;
3406
+ if (idle !== void 0) {
3407
+ idle.reserve();
3408
+ return idle;
3409
+ }
3346
3410
  const maxRetries = Math.min(Math.ceil(this.connectionTimeoutMs / CONNECTION_RETRY_INTERVAL_MS), MAX_CONNECTION_RETRIES);
3347
3411
  for (let i = 0; i < maxRetries; i++) {
3348
3412
  await promises.setTimeout(CONNECTION_RETRY_INTERVAL_MS);
3349
3413
  const found = this.getIdleWorker();
3350
- if (found !== void 0) return found;
3414
+ if (found !== void 0) {
3415
+ found.reserve();
3416
+ return found;
3417
+ }
3351
3418
  if (this.stopping) return void 0;
3352
3419
  }
3353
3420
  return void 0;
@@ -3363,11 +3430,13 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
3363
3430
  pipeConnection(worker, remoteSocket) {
3364
3431
  const workerSocket = net__namespace.createConnection({ port: worker.port, host: "127.0.0.1" });
3365
3432
  worker.setSockets(remoteSocket, workerSocket);
3433
+ const ctx = worker.context;
3366
3434
  const cleanup = () => {
3367
3435
  remoteSocket.unpipe(workerSocket);
3368
3436
  workerSocket.unpipe(remoteSocket);
3369
3437
  if (!remoteSocket.destroyed) remoteSocket.destroy();
3370
3438
  if (!workerSocket.destroyed) workerSocket.destroy();
3439
+ this.scheduleAbortReap(worker, ctx);
3371
3440
  };
3372
3441
  workerSocket.on("connect", () => {
3373
3442
  remoteSocket.pipe(workerSocket);
@@ -3378,6 +3447,42 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
3378
3447
  workerSocket.on("error", cleanup);
3379
3448
  remoteSocket.on("close", cleanup);
3380
3449
  workerSocket.on("close", cleanup);
3450
+ if (remoteSocket.destroyed) {
3451
+ cleanup();
3452
+ }
3453
+ }
3454
+ /**
3455
+ * After a connection's sockets tear down, reaps the association as an
3456
+ * aborted one *only if* dcmrecv never reported completion within
3457
+ * {@link ABORT_REAP_GRACE_MS}. Without this, a peer abort destroys the
3458
+ * sockets but dcmrecv may never emit ASSOCIATION_COMPLETE, so the worker
3459
+ * stays busy forever, ASSOCIATION_FINALIZED never fires (consumers can't
3460
+ * release per-association state), and the association directory is never
3461
+ * removed. The grace delay ensures a normal A-RELEASE — whose socket also
3462
+ * closes — is finalized by its own completion report and never reaped here.
3463
+ */
3464
+ scheduleAbortReap(worker, ctx) {
3465
+ if (ctx === void 0 || worker.context !== ctx || worker.state !== "busy") return;
3466
+ if (!worker.claimReapSchedule()) return;
3467
+ const timer = setTimeout(() => {
3468
+ if (worker.context !== ctx || worker.state !== "busy" || worker.finalized) return;
3469
+ worker.markFinalizing();
3470
+ const removeDir = !worker.associationReceived;
3471
+ void this.finalizeAssociation(
3472
+ worker,
3473
+ {
3474
+ associationId: ctx.associationId,
3475
+ callingAE: "",
3476
+ calledAE: "",
3477
+ source: "",
3478
+ files: [],
3479
+ durationMs: Date.now() - ctx.startAt,
3480
+ endReason: "abort"
3481
+ },
3482
+ removeDir
3483
+ );
3484
+ }, ABORT_REAP_GRACE_MS);
3485
+ timer.unref();
3381
3486
  }
3382
3487
  // -----------------------------------------------------------------------
3383
3488
  // Worker pool management
@@ -3576,19 +3681,38 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
3576
3681
  /** Returns worker to idle pool on association complete, emits summary. */
3577
3682
  wireAssociationComplete(worker) {
3578
3683
  worker.dcmrecv.onAssociationComplete((data) => {
3684
+ if (worker.finalized) return;
3579
3685
  worker.markFinalizing();
3686
+ const removeDir = !worker.associationReceived;
3580
3687
  setImmediate(() => {
3581
- void this.finalizeAssociation(worker, data);
3688
+ void this.finalizeAssociation(worker, data, removeDir);
3582
3689
  });
3583
3690
  });
3584
3691
  }
3585
- /** Awaits file ops, emits ASSOCIATION_COMPLETE, awaits parsing, emits ASSOCIATION_FINALIZED. */
3586
- async finalizeAssociation(worker, data) {
3692
+ /**
3693
+ * Awaits file ops, emits ASSOCIATION_COMPLETE, awaits parsing, emits
3694
+ * ASSOCIATION_FINALIZED, returns the worker to idle. Runs at most once per
3695
+ * association via {@link Worker.beginFinalizeOnce}.
3696
+ *
3697
+ * @param removeDir - When true, removes the association directory after
3698
+ * finalizing. Set when no consumer ever engaged with the association
3699
+ * (no ASSOCIATION_RECEIVED was emitted) — e.g. a connection that aborted
3700
+ * during negotiation — so the pool reclaims the directory it created.
3701
+ * When a consumer did engage this stays false: the consumer owns
3702
+ * directory cleanup once it has processed the received files (removing it
3703
+ * here could race in-flight consumer reads of those files).
3704
+ */
3705
+ async finalizeAssociation(worker, data, removeDir = false) {
3706
+ if (!worker.beginFinalizeOnce()) return;
3587
3707
  await worker.drainPendingFiles();
3588
3708
  this.emitAssociationComplete(worker, data);
3589
3709
  await worker.drainInstancePending();
3590
3710
  this.emitAssociationFinalized(worker, data);
3711
+ const associationDir = worker.context?.associationDir;
3591
3712
  worker.endAssociation();
3713
+ if (removeDir && associationDir !== void 0) {
3714
+ await removeDirSafe(associationDir);
3715
+ }
3592
3716
  void this.scaleDown();
3593
3717
  }
3594
3718
  /** Emits the ASSOCIATION_COMPLETE event with transfer stats. */
@@ -3630,12 +3754,14 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
3630
3754
  source: data.source,
3631
3755
  files: [...worker.files],
3632
3756
  instancesReceived: worker.instancesReceived,
3633
- instanceErrors: worker.instanceErrors
3757
+ instanceErrors: worker.instanceErrors,
3758
+ endReason: data.endReason
3634
3759
  });
3635
3760
  }
3636
3761
  /** Bubbles ASSOCIATION_RECEIVED from dcmrecv worker. */
3637
3762
  wireAssociationReceived(worker) {
3638
3763
  worker.dcmrecv.onEvent("ASSOCIATION_RECEIVED", (data) => {
3764
+ worker.markAssociationReceived();
3639
3765
  this.emit("ASSOCIATION_RECEIVED", {
3640
3766
  associationId: worker.context?.associationId ?? "",
3641
3767
  callingAE: data.callingAE,