@ubercode/dcmtk 0.9.4 → 0.10.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.
package/dist/index.cjs CHANGED
@@ -2,15 +2,16 @@
2
2
 
3
3
  var path = require('path');
4
4
  var zod = require('zod');
5
- var fs$1 = require('fs');
5
+ var fs = require('fs');
6
6
  var child_process = require('child_process');
7
7
  var kill = require('tree-kill');
8
8
  var events = require('events');
9
9
  var stderrLib = require('stderr-lib');
10
- var fs = require('fs/promises');
10
+ var promises$1 = require('fs/promises');
11
11
  var os = require('os');
12
12
  var fastXmlParser = require('fast-xml-parser');
13
13
  var net = require('net');
14
+ var promises = require('timers/promises');
14
15
  var crypto = require('crypto');
15
16
 
16
17
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
@@ -35,7 +36,6 @@ function _interopNamespace(e) {
35
36
 
36
37
  var path__namespace = /*#__PURE__*/_interopNamespace(path);
37
38
  var kill__default = /*#__PURE__*/_interopDefault(kill);
38
- var fs__namespace = /*#__PURE__*/_interopNamespace(fs);
39
39
  var os__namespace = /*#__PURE__*/_interopNamespace(os);
40
40
  var net__namespace = /*#__PURE__*/_interopNamespace(net);
41
41
 
@@ -224,7 +224,7 @@ function binaryName(name) {
224
224
  }
225
225
  function hasRequiredBinaries(dir) {
226
226
  for (const bin of REQUIRED_BINARIES) {
227
- if (!fs$1.existsSync(path.join(dir, binaryName(bin)))) {
227
+ if (!fs.existsSync(path.join(dir, binaryName(bin)))) {
228
228
  return false;
229
229
  }
230
230
  }
@@ -318,13 +318,14 @@ async function execCommand(binary, args, options) {
318
318
  windowsHide: true,
319
319
  signal: options?.signal
320
320
  });
321
- wireSpawnListeners(child, timeoutMs, resolve);
321
+ wireSpawnListeners(binary, child, timeoutMs, resolve);
322
322
  });
323
323
  }
324
- function wireSpawnListeners(child, timeoutMs, resolve) {
324
+ function wireSpawnListeners(binary, child, timeoutMs, resolve) {
325
325
  let stdout = "";
326
326
  let stderr8 = "";
327
327
  let settled = false;
328
+ const name = binary.split("/").pop() ?? binary;
328
329
  const settle = (result) => {
329
330
  if (settled) return;
330
331
  settled = true;
@@ -333,25 +334,25 @@ function wireSpawnListeners(child, timeoutMs, resolve) {
333
334
  };
334
335
  const timer = setTimeout(() => {
335
336
  if (child.pid !== void 0 && child.pid !== null) killTree(child.pid);
336
- settle(err(new Error(`Process timed out after ${timeoutMs}ms`)));
337
+ settle(err(new Error(`${name} timed out after ${timeoutMs}ms (pid: ${String(child.pid ?? "unknown")})`)));
337
338
  }, timeoutMs);
338
339
  child.stdout?.on("data", (chunk) => {
339
340
  stdout += String(chunk);
340
341
  if (stdout.length + stderr8.length > MAX_OUTPUT_BYTES) {
341
342
  if (child.pid !== void 0 && child.pid !== null) killTree(child.pid);
342
- settle(err(new Error(`Process output exceeded ${MAX_OUTPUT_BYTES} bytes`)));
343
+ settle(err(new Error(`${name} output exceeded ${MAX_OUTPUT_BYTES} bytes`)));
343
344
  }
344
345
  });
345
346
  child.stderr?.on("data", (chunk) => {
346
347
  stderr8 += String(chunk);
347
348
  if (stdout.length + stderr8.length > MAX_OUTPUT_BYTES) {
348
349
  if (child.pid !== void 0 && child.pid !== null) killTree(child.pid);
349
- settle(err(new Error(`Process output exceeded ${MAX_OUTPUT_BYTES} bytes`)));
350
+ settle(err(new Error(`${name} output exceeded ${MAX_OUTPUT_BYTES} bytes`)));
350
351
  }
351
352
  });
352
353
  child.on("error", (error) => {
353
354
  if (child.pid !== void 0 && child.pid !== null) killTree(child.pid);
354
- settle(err(new Error(`Process error: ${error.message}`)));
355
+ settle(err(new Error(`${name} error: ${error.message}`)));
355
356
  });
356
357
  child.on("close", (code) => {
357
358
  settle(ok({ stdout, stderr: stderr8, exitCode: code ?? 1 }));
@@ -366,7 +367,7 @@ async function spawnCommand(binary, args, options) {
366
367
  windowsHide: true,
367
368
  signal: options?.signal
368
369
  });
369
- wireSpawnListeners(child, timeoutMs, resolve);
370
+ wireSpawnListeners(binary, child, timeoutMs, resolve);
370
371
  });
371
372
  }
372
373
  var ProcessState = {
@@ -31508,12 +31509,12 @@ async function tryDirectPath(inputPath, timeoutMs, signal, verbosity) {
31508
31509
  const execOpts = signal !== void 0 ? { timeoutMs, signal } : { timeoutMs };
31509
31510
  return await execAndParse(jsonBinary.value, directArgs, inputPath, execOpts);
31510
31511
  } finally {
31511
- fs.rm(bulkDir, { recursive: true, force: true }).catch(() => {
31512
+ promises$1.rm(bulkDir, { recursive: true, force: true }).catch(() => {
31512
31513
  });
31513
31514
  }
31514
31515
  }
31515
31516
  async function createBulkTempDir() {
31516
- return fs.mkdtemp(path.join(os.tmpdir(), "dcm2json-bulk-"));
31517
+ return promises$1.mkdtemp(path.join(os.tmpdir(), "dcm2json-bulk-"));
31517
31518
  }
31518
31519
  async function execAndParse(binary, args, inputPath, execOpts) {
31519
31520
  const result = await execCommand(binary, args, execOpts);
@@ -34341,19 +34342,19 @@ async function applyModifications(filePath, changeset, options) {
34341
34342
  }
34342
34343
  async function copyFileSafe(source, dest) {
34343
34344
  return stderrLib.tryCatch(
34344
- () => fs.copyFile(source, dest),
34345
+ () => promises$1.copyFile(source, dest),
34345
34346
  (e) => new Error(`Failed to copy file: ${e.message}`)
34346
34347
  );
34347
34348
  }
34348
34349
  async function statFileSize(path2) {
34349
34350
  return stderrLib.tryCatch(
34350
- async () => (await fs.stat(path2)).size,
34351
+ async () => (await promises$1.stat(path2)).size,
34351
34352
  (e) => new Error(`Failed to stat file: ${e.message}`)
34352
34353
  );
34353
34354
  }
34354
34355
  async function unlinkFile(path2) {
34355
34356
  return stderrLib.tryCatch(
34356
- () => fs.unlink(path2),
34357
+ () => promises$1.unlink(path2),
34357
34358
  (e) => new Error(`Failed to delete file: ${e.message}`)
34358
34359
  );
34359
34360
  }
@@ -36272,6 +36273,184 @@ var Wlmscpfs = class _Wlmscpfs extends DcmtkProcess {
36272
36273
  signal.addEventListener("abort", this.abortHandler, { once: true });
36273
36274
  }
36274
36275
  };
36276
+ var MIN_CONCURRENCY = 1;
36277
+ var MAX_CONCURRENCY = 64;
36278
+ var DEFAULT_CONCURRENCY = 4;
36279
+ function clampConcurrency(value) {
36280
+ if (value === void 0) return DEFAULT_CONCURRENCY;
36281
+ return Math.max(MIN_CONCURRENCY, Math.min(MAX_CONCURRENCY, Math.floor(value)));
36282
+ }
36283
+ function createBatchState(total) {
36284
+ const results = [];
36285
+ for (let i = 0; i < total; i += 1) {
36286
+ results.push(void 0);
36287
+ }
36288
+ return { results, succeeded: 0, failed: 0, completed: 0 };
36289
+ }
36290
+ async function processItem(item, index, operation, state) {
36291
+ let result;
36292
+ try {
36293
+ result = await operation(item);
36294
+ } catch (thrown) {
36295
+ result = err(stderrLib.stderr(thrown));
36296
+ }
36297
+ state.results[index] = result;
36298
+ state.completed += 1;
36299
+ if (result.ok) {
36300
+ state.succeeded += 1;
36301
+ } else {
36302
+ state.failed += 1;
36303
+ }
36304
+ }
36305
+ async function batch(items, operation, options) {
36306
+ const concurrency = clampConcurrency(options?.concurrency);
36307
+ const signal = options?.signal;
36308
+ const onProgress = options?.onProgress;
36309
+ const total = items.length;
36310
+ if (total === 0) {
36311
+ return { results: [], succeeded: 0, failed: 0 };
36312
+ }
36313
+ const state = createBatchState(total);
36314
+ const inFlight = /* @__PURE__ */ new Set();
36315
+ let nextIndex = 0;
36316
+ while (nextIndex < total) {
36317
+ if (signal?.aborted) break;
36318
+ const itemIndex = nextIndex;
36319
+ nextIndex += 1;
36320
+ const promise = processItem(items[itemIndex], itemIndex, operation, state).then(() => {
36321
+ if (onProgress) {
36322
+ const result = state.results[itemIndex];
36323
+ onProgress(state.completed, total, result);
36324
+ }
36325
+ });
36326
+ inFlight.add(promise);
36327
+ promise.then(
36328
+ () => inFlight.delete(promise),
36329
+ () => inFlight.delete(promise)
36330
+ );
36331
+ if (inFlight.size >= concurrency) {
36332
+ await Promise.race(inFlight);
36333
+ }
36334
+ }
36335
+ if (inFlight.size > 0) {
36336
+ await Promise.all(inFlight);
36337
+ }
36338
+ return { results: state.results, succeeded: state.succeeded, failed: state.failed };
36339
+ }
36340
+
36341
+ // src/utils/retry.ts
36342
+ var DEFAULT_MAX_ATTEMPTS = 3;
36343
+ var DEFAULT_INITIAL_DELAY_MS = 1e3;
36344
+ var DEFAULT_MAX_DELAY_MS = 3e4;
36345
+ var DEFAULT_BACKOFF_MULTIPLIER = 2;
36346
+ var JITTER_FRACTION = 0.1;
36347
+ var DEFAULT_CONFIG = {
36348
+ maxAttempts: DEFAULT_MAX_ATTEMPTS,
36349
+ initialDelayMs: DEFAULT_INITIAL_DELAY_MS,
36350
+ maxDelayMs: DEFAULT_MAX_DELAY_MS,
36351
+ backoffMultiplier: DEFAULT_BACKOFF_MULTIPLIER,
36352
+ shouldRetry: void 0,
36353
+ signal: void 0,
36354
+ onRetry: void 0
36355
+ };
36356
+ function resolveConfig(opts) {
36357
+ if (!opts) return DEFAULT_CONFIG;
36358
+ return {
36359
+ ...DEFAULT_CONFIG,
36360
+ ...Object.fromEntries(Object.entries(opts).filter(([, v]) => v !== void 0)),
36361
+ maxAttempts: Math.max(1, Math.floor(opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS)),
36362
+ initialDelayMs: Math.max(0, opts.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS),
36363
+ maxDelayMs: Math.max(0, opts.maxDelayMs ?? DEFAULT_MAX_DELAY_MS),
36364
+ backoffMultiplier: Math.max(1, opts.backoffMultiplier ?? DEFAULT_BACKOFF_MULTIPLIER)
36365
+ };
36366
+ }
36367
+ function computeDelay(attempt, config) {
36368
+ const baseDelay = Math.min(config.initialDelayMs * Math.pow(config.backoffMultiplier, attempt), config.maxDelayMs);
36369
+ const jitter = baseDelay * JITTER_FRACTION * (2 * Math.random() - 1);
36370
+ return Math.max(0, Math.round(baseDelay + jitter));
36371
+ }
36372
+ async function waitWithAbort(delayMs, signal) {
36373
+ if (signal?.aborted) {
36374
+ return false;
36375
+ }
36376
+ return new Promise((resolve) => {
36377
+ const timer = setTimeout(() => {
36378
+ cleanup();
36379
+ resolve(true);
36380
+ }, delayMs);
36381
+ const onAbort = () => {
36382
+ clearTimeout(timer);
36383
+ cleanup();
36384
+ resolve(false);
36385
+ };
36386
+ const cleanup = () => {
36387
+ signal?.removeEventListener("abort", onAbort);
36388
+ };
36389
+ if (signal) {
36390
+ signal.addEventListener("abort", onAbort, { once: true });
36391
+ }
36392
+ });
36393
+ }
36394
+ function shouldBreakAfterFailure(attempt, lastError, config) {
36395
+ if (attempt === config.maxAttempts - 1) return true;
36396
+ if (config.shouldRetry && !config.shouldRetry(lastError, attempt + 1)) return true;
36397
+ if (config.signal?.aborted) return true;
36398
+ return false;
36399
+ }
36400
+ async function retry(operation, options) {
36401
+ const config = resolveConfig(options);
36402
+ let lastResult = err(new Error("No attempts executed"));
36403
+ for (let attempt = 0; attempt < config.maxAttempts; attempt += 1) {
36404
+ lastResult = await operation();
36405
+ if (lastResult.ok) return lastResult;
36406
+ if (shouldBreakAfterFailure(attempt, lastResult.error, config)) break;
36407
+ const delayMs = computeDelay(attempt, config);
36408
+ if (config.onRetry) config.onRetry(lastResult.error, attempt + 1, delayMs);
36409
+ const waitCompleted = await waitWithAbort(delayMs, config.signal);
36410
+ if (!waitCompleted) break;
36411
+ }
36412
+ return lastResult;
36413
+ }
36414
+ async function ensureDirectory(dirPath) {
36415
+ try {
36416
+ await promises$1.mkdir(dirPath, { recursive: true });
36417
+ return ok(void 0);
36418
+ } catch (e) {
36419
+ const msg = e instanceof Error ? e.message : String(e);
36420
+ return err(new Error(`Failed to create directory ${dirPath}: ${msg}`));
36421
+ }
36422
+ }
36423
+ async function moveFile(src, dest) {
36424
+ try {
36425
+ await promises$1.rename(src, dest);
36426
+ return ok(void 0);
36427
+ } catch {
36428
+ try {
36429
+ await promises$1.copyFile(src, dest);
36430
+ await promises$1.unlink(src);
36431
+ return ok(void 0);
36432
+ } catch (e) {
36433
+ const msg = e instanceof Error ? e.message : String(e);
36434
+ return err(new Error(`Failed to move file ${src} \u2192 ${dest}: ${msg}`));
36435
+ }
36436
+ }
36437
+ }
36438
+ async function statFileSafe(filePath) {
36439
+ try {
36440
+ const s = await promises$1.stat(filePath);
36441
+ return s.size;
36442
+ } catch {
36443
+ return 0;
36444
+ }
36445
+ }
36446
+ async function removeDirSafe(dirPath) {
36447
+ try {
36448
+ await promises$1.rm(dirPath, { recursive: true, force: true });
36449
+ } catch {
36450
+ }
36451
+ }
36452
+
36453
+ // src/servers/DicomReceiver.ts
36275
36454
  var DEFAULT_MIN_POOL_SIZE = 2;
36276
36455
  var DEFAULT_MAX_POOL_SIZE = 10;
36277
36456
  var DEFAULT_CONNECTION_TIMEOUT_MS = 1e4;
@@ -36297,6 +36476,102 @@ var DicomReceiverOptionsSchema = zod.z.object({
36297
36476
  }).strict().refine((data) => (data.minPoolSize ?? DEFAULT_MIN_POOL_SIZE) <= (data.maxPoolSize ?? DEFAULT_MAX_POOL_SIZE), {
36298
36477
  message: "minPoolSize must be <= maxPoolSize"
36299
36478
  });
36479
+ var Worker = class {
36480
+ constructor(dcmrecv, port, tempDir) {
36481
+ __publicField(this, "dcmrecv");
36482
+ __publicField(this, "port");
36483
+ __publicField(this, "tempDir");
36484
+ __publicField(this, "_state", "idle");
36485
+ __publicField(this, "_context");
36486
+ __publicField(this, "_pending", /* @__PURE__ */ new Set());
36487
+ __publicField(this, "_files", []);
36488
+ __publicField(this, "_fileSizes", []);
36489
+ __publicField(this, "_outputLines", []);
36490
+ __publicField(this, "_remoteSocket");
36491
+ __publicField(this, "_workerSocket");
36492
+ this.dcmrecv = dcmrecv;
36493
+ this.port = port;
36494
+ this.tempDir = tempDir;
36495
+ }
36496
+ /** Current worker state. */
36497
+ get state() {
36498
+ return this._state;
36499
+ }
36500
+ /** Active association context, or undefined when idle. */
36501
+ get context() {
36502
+ return this._context;
36503
+ }
36504
+ /** Files moved to the association directory during the current association. */
36505
+ get files() {
36506
+ return this._files;
36507
+ }
36508
+ /** Byte sizes parallel to files[], for transfer stats. */
36509
+ get fileSizes() {
36510
+ return this._fileSizes;
36511
+ }
36512
+ /** Captured output lines during the current association. */
36513
+ get outputLines() {
36514
+ return this._outputLines;
36515
+ }
36516
+ /** Marks the worker busy with a new association context. */
36517
+ beginAssociation(ctx) {
36518
+ this._state = "busy";
36519
+ this._context = ctx;
36520
+ this._files.length = 0;
36521
+ this._fileSizes.length = 0;
36522
+ this._outputLines.length = 0;
36523
+ this._pending.clear();
36524
+ }
36525
+ /** Returns the worker to idle and clears all association state. */
36526
+ endAssociation() {
36527
+ this._state = "idle";
36528
+ this._context = void 0;
36529
+ this._files.length = 0;
36530
+ this._fileSizes.length = 0;
36531
+ this._outputLines.length = 0;
36532
+ this._pending.clear();
36533
+ this._remoteSocket = void 0;
36534
+ this._workerSocket = void 0;
36535
+ }
36536
+ /** Tracks an in-flight file handling promise; auto-removes on completion. */
36537
+ trackFile(promise) {
36538
+ this._pending.add(promise);
36539
+ void promise.finally(() => {
36540
+ this._pending.delete(promise);
36541
+ });
36542
+ }
36543
+ /** Records a successfully received file path and its size. */
36544
+ recordFile(filePath, size) {
36545
+ this._files.push(filePath);
36546
+ this._fileSizes.push(size);
36547
+ }
36548
+ /** Awaits all in-flight file handling promises. */
36549
+ async drainPendingFiles() {
36550
+ if (this._pending.size > 0) {
36551
+ await Promise.all(this._pending);
36552
+ }
36553
+ }
36554
+ /** Appends a line to the output buffer, respecting the cap. */
36555
+ captureOutput(text) {
36556
+ if (this._outputLines.length < MAX_OUTPUT_LINES_PER_ASSOCIATION) {
36557
+ this._outputLines.push(text);
36558
+ }
36559
+ }
36560
+ /** Stores the remote and worker sockets for later cleanup. */
36561
+ setSockets(remote, worker) {
36562
+ this._remoteSocket = remote;
36563
+ this._workerSocket = worker;
36564
+ }
36565
+ /** Destroys both sockets if they exist and are not already destroyed. */
36566
+ destroySockets() {
36567
+ if (this._remoteSocket !== void 0 && !this._remoteSocket.destroyed) {
36568
+ this._remoteSocket.destroy();
36569
+ }
36570
+ if (this._workerSocket !== void 0 && !this._workerSocket.destroyed) {
36571
+ this._workerSocket.destroy();
36572
+ }
36573
+ }
36574
+ };
36300
36575
  function allocatePort() {
36301
36576
  return new Promise((resolve) => {
36302
36577
  const server = net__namespace.createServer();
@@ -36314,6 +36589,13 @@ function allocatePort() {
36314
36589
  });
36315
36590
  });
36316
36591
  }
36592
+ function sumArray(arr) {
36593
+ let total = 0;
36594
+ for (let i = 0; i < arr.length; i++) {
36595
+ total += arr[i] ?? 0;
36596
+ }
36597
+ return total;
36598
+ }
36317
36599
  var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36318
36600
  constructor(options) {
36319
36601
  super();
@@ -36328,8 +36610,6 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36328
36610
  __publicField(this, "stopping", false);
36329
36611
  __publicField(this, "abortHandler");
36330
36612
  this.setMaxListeners(20);
36331
- this.on("error", () => {
36332
- });
36333
36613
  this.options = options;
36334
36614
  this.minPoolSize = options.minPoolSize ?? DEFAULT_MIN_POOL_SIZE;
36335
36615
  this.maxPoolSize = options.maxPoolSize ?? DEFAULT_MAX_POOL_SIZE;
@@ -36537,14 +36817,12 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36537
36817
  this.emit("error", { error: mkdirResult.error });
36538
36818
  return;
36539
36819
  }
36540
- worker.state = "busy";
36541
- worker.associationId = associationId;
36542
- worker.associationDir = associationDir;
36543
- worker.files = [];
36544
- worker.fileSizes = [];
36545
- worker.outputLines = [];
36546
- worker.pendingFiles = [];
36547
- worker.startAt = Date.now();
36820
+ const ctx = {
36821
+ associationId,
36822
+ associationDir,
36823
+ startAt: Date.now()
36824
+ };
36825
+ worker.beginAssociation(ctx);
36548
36826
  this.pipeConnection(worker, remoteSocket);
36549
36827
  void this.replenishPool();
36550
36828
  }
@@ -36554,7 +36832,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36554
36832
  if (idle !== void 0) return idle;
36555
36833
  const maxRetries = Math.min(Math.ceil(this.connectionTimeoutMs / CONNECTION_RETRY_INTERVAL_MS), MAX_CONNECTION_RETRIES);
36556
36834
  for (let i = 0; i < maxRetries; i++) {
36557
- await delay(CONNECTION_RETRY_INTERVAL_MS);
36835
+ await promises.setTimeout(CONNECTION_RETRY_INTERVAL_MS);
36558
36836
  const found = this.getIdleWorker();
36559
36837
  if (found !== void 0) return found;
36560
36838
  if (this.stopping) return void 0;
@@ -36571,8 +36849,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36571
36849
  /** Pipes remote socket bidirectionally to the worker's port. */
36572
36850
  pipeConnection(worker, remoteSocket) {
36573
36851
  const workerSocket = net__namespace.createConnection({ port: worker.port, host: "127.0.0.1" });
36574
- worker.remoteSocket = remoteSocket;
36575
- worker.workerSocket = workerSocket;
36852
+ worker.setSockets(remoteSocket, workerSocket);
36576
36853
  const cleanup = () => {
36577
36854
  remoteSocket.unpipe(workerSocket);
36578
36855
  workerSocket.unpipe(remoteSocket);
@@ -36631,21 +36908,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36631
36908
  const createResult = this.createDcmrecv(port, tempDir);
36632
36909
  if (!createResult.ok) return createResult;
36633
36910
  const dcmrecv = createResult.value;
36634
- const worker = {
36635
- dcmrecv,
36636
- port,
36637
- tempDir,
36638
- state: "idle",
36639
- associationId: void 0,
36640
- associationDir: void 0,
36641
- files: [],
36642
- fileSizes: [],
36643
- outputLines: [],
36644
- pendingFiles: [],
36645
- startAt: void 0,
36646
- remoteSocket: void 0,
36647
- workerSocket: void 0
36648
- };
36911
+ const worker = new Worker(dcmrecv, port, tempDir);
36649
36912
  this.wireWorkerEvents(worker);
36650
36913
  const startResult = await dcmrecv.start();
36651
36914
  if (!startResult.ok) {
@@ -36654,14 +36917,9 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36654
36917
  this.workers.set(port, worker);
36655
36918
  return ok(worker);
36656
36919
  }
36657
- /** Stops a single worker: stop process, clean temp dir, remove from pool. */
36920
+ /** Stops a single worker: destroy sockets, stop process, clean temp dir, remove from pool. */
36658
36921
  async stopWorker(worker) {
36659
- if (worker.remoteSocket !== void 0 && !worker.remoteSocket.destroyed) {
36660
- worker.remoteSocket.destroy();
36661
- }
36662
- if (worker.workerSocket !== void 0 && !worker.workerSocket.destroyed) {
36663
- worker.workerSocket.destroy();
36664
- }
36922
+ worker.destroySockets();
36665
36923
  await worker.dcmrecv.stop();
36666
36924
  worker.dcmrecv[Symbol.dispose]();
36667
36925
  await removeDirSafe(worker.tempDir);
@@ -36713,12 +36971,11 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36713
36971
  this.wireRefusingAssociation(worker);
36714
36972
  this.wireOutputCapture(worker);
36715
36973
  }
36716
- /** Wires FILE_RECEIVED from dcmrecv worker — files are processed serially per worker. */
36974
+ /** Wires FILE_RECEIVED from dcmrecv worker — captures context synchronously. */
36717
36975
  wireFileReceived(worker) {
36718
36976
  worker.dcmrecv.onFileReceived((data) => {
36719
- const assocId = worker.associationId;
36720
- const assocDir = worker.associationDir;
36721
- if (assocDir === void 0 || assocId === void 0) {
36977
+ const ctx = worker.context;
36978
+ if (ctx === void 0) {
36722
36979
  this.emit("error", {
36723
36980
  error: new Error(`DicomReceiver: FILE_RECEIVED with no active association (worker state: ${worker.state})`),
36724
36981
  filePath: data.filePath,
@@ -36728,21 +36985,21 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36728
36985
  });
36729
36986
  return;
36730
36987
  }
36731
- const promise = this.handleFileReceived(worker, data, assocId, assocDir);
36732
- worker.pendingFiles.push(promise);
36988
+ const promise = this.handleFileReceived(worker, data, ctx);
36989
+ worker.trackFile(promise);
36733
36990
  });
36734
36991
  }
36735
36992
  /** Moves a received file, opens it as DicomInstance, and emits FILE_RECEIVED. */
36736
- async handleFileReceived(worker, data, assocId, assocDir) {
36993
+ async handleFileReceived(worker, data, ctx) {
36737
36994
  try {
36738
- await this.moveAndEmitFile(worker, data, assocId, assocDir);
36995
+ await this.moveAndEmitFile(worker, data, ctx);
36739
36996
  } catch (thrown) {
36740
36997
  const error = thrown instanceof Error ? thrown : new Error(String(thrown));
36741
36998
  this.emit("error", {
36742
36999
  error,
36743
37000
  filePath: data.filePath,
36744
- associationId: assocId,
36745
- associationDir: assocDir,
37001
+ associationId: ctx.associationId,
37002
+ associationDir: ctx.associationDir,
36746
37003
  callingAE: data.callingAE,
36747
37004
  calledAE: data.calledAE,
36748
37005
  source: data.source
@@ -36750,21 +37007,20 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36750
37007
  }
36751
37008
  }
36752
37009
  /** Inner handler: move file, parse DICOM, emit FILE_RECEIVED or error. */
36753
- async moveAndEmitFile(worker, data, assocId, assocDir) {
37010
+ async moveAndEmitFile(worker, data, ctx) {
36754
37011
  const srcPath = data.filePath;
36755
- const destPath = path__namespace.join(assocDir, path__namespace.basename(srcPath));
37012
+ const destPath = path__namespace.join(ctx.associationDir, path__namespace.basename(srcPath));
36756
37013
  const moveResult = await moveFile(srcPath, destPath);
36757
37014
  const finalPath = moveResult.ok ? destPath : srcPath;
36758
37015
  const fileSize = await statFileSafe(finalPath);
36759
- worker.files.push(finalPath);
36760
- worker.fileSizes.push(fileSize);
37016
+ worker.recordFile(finalPath, fileSize);
36761
37017
  const openResult = await DicomInstance.open(finalPath);
36762
37018
  if (!openResult.ok) {
36763
37019
  this.emit("error", {
36764
37020
  error: openResult.error,
36765
37021
  filePath: finalPath,
36766
- associationId: assocId,
36767
- associationDir: assocDir,
37022
+ associationId: ctx.associationId,
37023
+ associationDir: ctx.associationDir,
36768
37024
  callingAE: data.callingAE,
36769
37025
  calledAE: data.calledAE,
36770
37026
  source: data.source
@@ -36774,8 +37030,8 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36774
37030
  this.emit("FILE_RECEIVED", {
36775
37031
  filePath: finalPath,
36776
37032
  fileSize,
36777
- associationId: assocId,
36778
- associationDir: assocDir,
37033
+ associationId: ctx.associationId,
37034
+ associationDir: ctx.associationDir,
36779
37035
  callingAE: data.callingAE,
36780
37036
  calledAE: data.calledAE,
36781
37037
  source: data.source,
@@ -36790,21 +37046,20 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36790
37046
  }
36791
37047
  /** Awaits ALL pending file operations, emits ASSOCIATION_COMPLETE, resets worker state. */
36792
37048
  async finalizeAssociation(worker, data) {
36793
- if (worker.pendingFiles.length > 0) {
36794
- await Promise.all(worker.pendingFiles);
36795
- }
37049
+ await worker.drainPendingFiles();
36796
37050
  this.emitAssociationComplete(worker, data);
36797
- this.resetWorker(worker);
37051
+ worker.endAssociation();
36798
37052
  void this.scaleDown();
36799
37053
  }
36800
37054
  /** Emits the ASSOCIATION_COMPLETE event with transfer stats. */
36801
37055
  emitAssociationComplete(worker, data) {
36802
- const assocId = worker.associationId ?? data.associationId;
36803
- const assocDir = worker.associationDir ?? "";
37056
+ const ctx = worker.context;
37057
+ const assocId = ctx?.associationId ?? data.associationId;
37058
+ const assocDir = ctx?.associationDir ?? "";
37059
+ const startAt = ctx?.startAt ?? Date.now();
36804
37060
  const files = [...worker.files];
36805
37061
  const output = [...worker.outputLines];
36806
37062
  const endAt = Date.now();
36807
- const startAt = worker.startAt ?? endAt;
36808
37063
  const totalBytes = sumArray(worker.fileSizes);
36809
37064
  const elapsedMs = endAt - startAt;
36810
37065
  const bytesPerSecond = elapsedMs > 0 ? Math.round(totalBytes / elapsedMs * 1e3) : 0;
@@ -36824,24 +37079,11 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36824
37079
  output
36825
37080
  });
36826
37081
  }
36827
- /** Resets worker state to idle after an association completes. */
36828
- resetWorker(worker) {
36829
- worker.state = "idle";
36830
- worker.associationId = void 0;
36831
- worker.associationDir = void 0;
36832
- worker.files = [];
36833
- worker.fileSizes = [];
36834
- worker.outputLines = [];
36835
- worker.pendingFiles = [];
36836
- worker.startAt = void 0;
36837
- worker.remoteSocket = void 0;
36838
- worker.workerSocket = void 0;
36839
- }
36840
37082
  /** Bubbles ASSOCIATION_RECEIVED from dcmrecv worker. */
36841
37083
  wireAssociationReceived(worker) {
36842
37084
  worker.dcmrecv.onEvent("ASSOCIATION_RECEIVED", (data) => {
36843
37085
  this.emit("ASSOCIATION_RECEIVED", {
36844
- associationId: worker.associationId ?? "",
37086
+ associationId: worker.context?.associationId ?? "",
36845
37087
  callingAE: data.callingAE,
36846
37088
  calledAE: data.calledAE,
36847
37089
  source: data.source
@@ -36852,7 +37094,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36852
37094
  wireCStoreRequest(worker) {
36853
37095
  worker.dcmrecv.onEvent("C_STORE_REQUEST", (data) => {
36854
37096
  this.emit("C_STORE_REQUEST", {
36855
- associationId: worker.associationId ?? "",
37097
+ associationId: worker.context?.associationId ?? "",
36856
37098
  raw: data.raw
36857
37099
  });
36858
37100
  });
@@ -36861,7 +37103,7 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36861
37103
  wireEchoRequest(worker) {
36862
37104
  worker.dcmrecv.onEvent("ECHO_REQUEST", () => {
36863
37105
  this.emit("ECHO_REQUEST", {
36864
- associationId: worker.associationId ?? ""
37106
+ associationId: worker.context?.associationId ?? ""
36865
37107
  });
36866
37108
  });
36867
37109
  }
@@ -36876,8 +37118,8 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36876
37118
  /** Captures worker output lines during busy associations. */
36877
37119
  wireOutputCapture(worker) {
36878
37120
  worker.dcmrecv.on("line", ({ text }) => {
36879
- if (worker.state === "busy" && worker.outputLines.length < MAX_OUTPUT_LINES_PER_ASSOCIATION) {
36880
- worker.outputLines.push(text);
37121
+ if (worker.state === "busy") {
37122
+ worker.captureOutput(text);
36881
37123
  }
36882
37124
  });
36883
37125
  }
@@ -36896,56 +37138,6 @@ var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
36896
37138
  signal.addEventListener("abort", this.abortHandler, { once: true });
36897
37139
  }
36898
37140
  };
36899
- async function ensureDirectory(dirPath) {
36900
- try {
36901
- await fs__namespace.mkdir(dirPath, { recursive: true });
36902
- return ok(void 0);
36903
- } catch (e) {
36904
- const msg = e instanceof Error ? e.message : String(e);
36905
- return err(new Error(`Failed to create directory ${dirPath}: ${msg}`));
36906
- }
36907
- }
36908
- async function moveFile(src, dest) {
36909
- try {
36910
- await fs__namespace.rename(src, dest);
36911
- return ok(void 0);
36912
- } catch {
36913
- try {
36914
- await fs__namespace.copyFile(src, dest);
36915
- await fs__namespace.unlink(src);
36916
- return ok(void 0);
36917
- } catch (e) {
36918
- const msg = e instanceof Error ? e.message : String(e);
36919
- return err(new Error(`Failed to move file ${src} \u2192 ${dest}: ${msg}`));
36920
- }
36921
- }
36922
- }
36923
- async function statFileSafe(filePath) {
36924
- try {
36925
- const stat4 = await fs__namespace.stat(filePath);
36926
- return stat4.size;
36927
- } catch {
36928
- return 0;
36929
- }
36930
- }
36931
- function sumArray(arr) {
36932
- let total = 0;
36933
- for (let i = 0; i < arr.length; i++) {
36934
- total += arr[i] ?? 0;
36935
- }
36936
- return total;
36937
- }
36938
- async function removeDirSafe(dirPath) {
36939
- try {
36940
- await fs__namespace.rm(dirPath, { recursive: true, force: true });
36941
- } catch {
36942
- }
36943
- }
36944
- function delay(ms) {
36945
- return new Promise((resolve) => {
36946
- setTimeout(resolve, ms);
36947
- });
36948
- }
36949
37141
 
36950
37142
  // src/senders/types.ts
36951
37143
  var SenderMode = {
@@ -37368,7 +37560,7 @@ function createStorescuExecutor(options) {
37368
37560
  return ok({ stdout: result.value.stdout, stderr: result.value.stderr });
37369
37561
  };
37370
37562
  }
37371
- function resolveConfig(options) {
37563
+ function resolveConfig2(options) {
37372
37564
  const mode = options.mode ?? "multiple";
37373
37565
  const rawMax = options.maxAssociations ?? DEFAULT_MAX_ASSOCIATIONS;
37374
37566
  return {
@@ -37415,7 +37607,7 @@ var DicomSender = class _DicomSender extends events.EventEmitter {
37415
37607
  if (!validation.success) {
37416
37608
  return err(createValidationError("DicomSender", validation.error));
37417
37609
  }
37418
- const cfg = resolveConfig(options);
37610
+ const cfg = resolveConfig2(options);
37419
37611
  const senderRef = { current: void 0 };
37420
37612
  const engine = new SenderEngine({
37421
37613
  ...cfg,
@@ -37617,7 +37809,7 @@ function createDcmsendExecutor(options) {
37617
37809
  return ok({ stdout: result.value.stdout, stderr: result.value.stderr });
37618
37810
  };
37619
37811
  }
37620
- function resolveConfig2(options) {
37812
+ function resolveConfig3(options) {
37621
37813
  const mode = options.mode ?? "multiple";
37622
37814
  const rawMax = options.maxAssociations ?? DEFAULT_MAX_ASSOCIATIONS2;
37623
37815
  return {
@@ -37664,7 +37856,7 @@ var DicomSend = class _DicomSend extends events.EventEmitter {
37664
37856
  if (!validation.success) {
37665
37857
  return err(createValidationError("DicomSend", validation.error));
37666
37858
  }
37667
- const cfg = resolveConfig2(options);
37859
+ const cfg = resolveConfig3(options);
37668
37860
  const senderRef = { current: void 0 };
37669
37861
  const engine = new SenderEngine({
37670
37862
  ...cfg,
@@ -37922,13 +38114,13 @@ function buildWorklistKeys(filter) {
37922
38114
  var MAX_RESPONSE_FILES = 1e4;
37923
38115
  async function createTempDir() {
37924
38116
  return stderrLib.tryCatch(
37925
- () => fs.mkdtemp(path.join(os.tmpdir(), "dcmtk-pacs-")),
38117
+ () => promises$1.mkdtemp(path.join(os.tmpdir(), "dcmtk-pacs-")),
37926
38118
  (e) => new Error(`Failed to create temp directory: ${e.message}`)
37927
38119
  );
37928
38120
  }
37929
38121
  async function listDcmFiles(directory) {
37930
38122
  try {
37931
- const entries = await fs.readdir(directory);
38123
+ const entries = await promises$1.readdir(directory);
37932
38124
  const dcmFiles = [];
37933
38125
  for (let i = 0; i < entries.length; i += 1) {
37934
38126
  const entry = entries[i];
@@ -37946,7 +38138,7 @@ async function listDcmFiles(directory) {
37946
38138
  }
37947
38139
  async function cleanupTempDir(directory) {
37948
38140
  try {
37949
- await fs.rm(directory, { recursive: true, force: true });
38141
+ await promises$1.rm(directory, { recursive: true, force: true });
37950
38142
  } catch {
37951
38143
  }
37952
38144
  }
@@ -38265,70 +38457,6 @@ var PacsClient = class _PacsClient {
38265
38457
  return ok({ success: true, outputDirectory: options.outputDirectory });
38266
38458
  }
38267
38459
  };
38268
- var MIN_CONCURRENCY = 1;
38269
- var MAX_CONCURRENCY = 64;
38270
- var DEFAULT_CONCURRENCY = 4;
38271
- function clampConcurrency(value) {
38272
- if (value === void 0) return DEFAULT_CONCURRENCY;
38273
- return Math.max(MIN_CONCURRENCY, Math.min(MAX_CONCURRENCY, Math.floor(value)));
38274
- }
38275
- function createBatchState(total) {
38276
- const results = [];
38277
- for (let i = 0; i < total; i += 1) {
38278
- results.push(void 0);
38279
- }
38280
- return { results, succeeded: 0, failed: 0, completed: 0 };
38281
- }
38282
- async function processItem(item, index, operation, state) {
38283
- let result;
38284
- try {
38285
- result = await operation(item);
38286
- } catch (thrown) {
38287
- result = err(stderrLib.stderr(thrown));
38288
- }
38289
- state.results[index] = result;
38290
- state.completed += 1;
38291
- if (result.ok) {
38292
- state.succeeded += 1;
38293
- } else {
38294
- state.failed += 1;
38295
- }
38296
- }
38297
- async function batch(items, operation, options) {
38298
- const concurrency = clampConcurrency(options?.concurrency);
38299
- const signal = options?.signal;
38300
- const onProgress = options?.onProgress;
38301
- const total = items.length;
38302
- if (total === 0) {
38303
- return { results: [], succeeded: 0, failed: 0 };
38304
- }
38305
- const state = createBatchState(total);
38306
- const inFlight = /* @__PURE__ */ new Set();
38307
- let nextIndex = 0;
38308
- while (nextIndex < total) {
38309
- if (signal?.aborted) break;
38310
- const itemIndex = nextIndex;
38311
- nextIndex += 1;
38312
- const promise = processItem(items[itemIndex], itemIndex, operation, state).then(() => {
38313
- if (onProgress) {
38314
- const result = state.results[itemIndex];
38315
- onProgress(state.completed, total, result);
38316
- }
38317
- });
38318
- inFlight.add(promise);
38319
- promise.then(
38320
- () => inFlight.delete(promise),
38321
- () => inFlight.delete(promise)
38322
- );
38323
- if (inFlight.size >= concurrency) {
38324
- await Promise.race(inFlight);
38325
- }
38326
- }
38327
- if (inFlight.size > 0) {
38328
- await Promise.all(inFlight);
38329
- }
38330
- return { results: state.results, succeeded: state.succeeded, failed: state.failed };
38331
- }
38332
38460
 
38333
38461
  // src/testing/types.ts
38334
38462
  var HammerPhase = {
@@ -38465,7 +38593,7 @@ var DicomHammer = class _DicomHammer extends events.EventEmitter {
38465
38593
  const start = Date.now();
38466
38594
  const dir = path.join(this.opts.outputDir, `dcmtk-hammer-${Date.now()}`);
38467
38595
  try {
38468
- await fs.mkdir(dir, { recursive: true });
38596
+ await promises$1.mkdir(dir, { recursive: true });
38469
38597
  } catch {
38470
38598
  return err(new Error(`Failed to create output directory: ${dir}`));
38471
38599
  }
@@ -38587,7 +38715,7 @@ var DicomHammer = class _DicomHammer extends events.EventEmitter {
38587
38715
  /** Gets the file size in bytes, returning 0 on error. */
38588
38716
  async getFileSize(filePath) {
38589
38717
  try {
38590
- const s = await fs.stat(filePath);
38718
+ const s = await promises$1.stat(filePath);
38591
38719
  return s.size;
38592
38720
  } catch {
38593
38721
  return 0;
@@ -38628,7 +38756,7 @@ var DicomHammer = class _DicomHammer extends events.EventEmitter {
38628
38756
  async cleanup() {
38629
38757
  if (this.generatedDir === void 0) return ok(void 0);
38630
38758
  try {
38631
- await fs.rm(this.generatedDir, { recursive: true, force: true });
38759
+ await promises$1.rm(this.generatedDir, { recursive: true, force: true });
38632
38760
  this.generatedDir = void 0;
38633
38761
  this.generatedFiles = [];
38634
38762
  return ok(void 0);
@@ -38665,80 +38793,6 @@ var DicomHammer = class _DicomHammer extends events.EventEmitter {
38665
38793
  }
38666
38794
  };
38667
38795
 
38668
- // src/utils/retry.ts
38669
- var DEFAULT_MAX_ATTEMPTS = 3;
38670
- var DEFAULT_INITIAL_DELAY_MS = 1e3;
38671
- var DEFAULT_MAX_DELAY_MS = 3e4;
38672
- var DEFAULT_BACKOFF_MULTIPLIER = 2;
38673
- var JITTER_FRACTION = 0.1;
38674
- var DEFAULT_CONFIG = {
38675
- maxAttempts: DEFAULT_MAX_ATTEMPTS,
38676
- initialDelayMs: DEFAULT_INITIAL_DELAY_MS,
38677
- maxDelayMs: DEFAULT_MAX_DELAY_MS,
38678
- backoffMultiplier: DEFAULT_BACKOFF_MULTIPLIER,
38679
- shouldRetry: void 0,
38680
- signal: void 0,
38681
- onRetry: void 0
38682
- };
38683
- function resolveConfig3(opts) {
38684
- if (!opts) return DEFAULT_CONFIG;
38685
- return {
38686
- ...DEFAULT_CONFIG,
38687
- ...Object.fromEntries(Object.entries(opts).filter(([, v]) => v !== void 0)),
38688
- maxAttempts: Math.max(1, Math.floor(opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS)),
38689
- initialDelayMs: Math.max(0, opts.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS),
38690
- maxDelayMs: Math.max(0, opts.maxDelayMs ?? DEFAULT_MAX_DELAY_MS),
38691
- backoffMultiplier: Math.max(1, opts.backoffMultiplier ?? DEFAULT_BACKOFF_MULTIPLIER)
38692
- };
38693
- }
38694
- function computeDelay(attempt, config) {
38695
- const baseDelay = Math.min(config.initialDelayMs * Math.pow(config.backoffMultiplier, attempt), config.maxDelayMs);
38696
- const jitter = baseDelay * JITTER_FRACTION * (2 * Math.random() - 1);
38697
- return Math.max(0, Math.round(baseDelay + jitter));
38698
- }
38699
- async function waitWithAbort(delayMs, signal) {
38700
- if (signal?.aborted) {
38701
- return false;
38702
- }
38703
- return new Promise((resolve) => {
38704
- const timer = setTimeout(() => {
38705
- cleanup();
38706
- resolve(true);
38707
- }, delayMs);
38708
- const onAbort = () => {
38709
- clearTimeout(timer);
38710
- cleanup();
38711
- resolve(false);
38712
- };
38713
- const cleanup = () => {
38714
- signal?.removeEventListener("abort", onAbort);
38715
- };
38716
- if (signal) {
38717
- signal.addEventListener("abort", onAbort, { once: true });
38718
- }
38719
- });
38720
- }
38721
- function shouldBreakAfterFailure(attempt, lastError, config) {
38722
- if (attempt === config.maxAttempts - 1) return true;
38723
- if (config.shouldRetry && !config.shouldRetry(lastError, attempt + 1)) return true;
38724
- if (config.signal?.aborted) return true;
38725
- return false;
38726
- }
38727
- async function retry(operation, options) {
38728
- const config = resolveConfig3(options);
38729
- let lastResult = err(new Error("No attempts executed"));
38730
- for (let attempt = 0; attempt < config.maxAttempts; attempt += 1) {
38731
- lastResult = await operation();
38732
- if (lastResult.ok) return lastResult;
38733
- if (shouldBreakAfterFailure(attempt, lastResult.error, config)) break;
38734
- const delayMs = computeDelay(attempt, config);
38735
- if (config.onRetry) config.onRetry(lastResult.error, attempt + 1, delayMs);
38736
- const waitCompleted = await waitWithAbort(delayMs, config.signal);
38737
- if (!waitCompleted) break;
38738
- }
38739
- return lastResult;
38740
- }
38741
-
38742
38796
  exports.AETitleSchema = AETitleSchema;
38743
38797
  exports.AssociationTracker = AssociationTracker;
38744
38798
  exports.ChangeSet = ChangeSet;