tywrap 0.2.1 → 0.3.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.
Files changed (138) hide show
  1. package/README.md +65 -31
  2. package/dist/cli.js +49 -2
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config/index.d.ts +11 -7
  5. package/dist/config/index.d.ts.map +1 -1
  6. package/dist/config/index.js +43 -7
  7. package/dist/config/index.js.map +1 -1
  8. package/dist/core/analyzer.d.ts +1 -0
  9. package/dist/core/analyzer.d.ts.map +1 -1
  10. package/dist/core/analyzer.js +47 -12
  11. package/dist/core/analyzer.js.map +1 -1
  12. package/dist/core/annotation-parser.d.ts +8 -0
  13. package/dist/core/annotation-parser.d.ts.map +1 -0
  14. package/dist/core/annotation-parser.js +409 -0
  15. package/dist/core/annotation-parser.js.map +1 -0
  16. package/dist/core/discovery.d.ts +1 -0
  17. package/dist/core/discovery.d.ts.map +1 -1
  18. package/dist/core/discovery.js +9 -9
  19. package/dist/core/discovery.js.map +1 -1
  20. package/dist/core/generator.d.ts +9 -1
  21. package/dist/core/generator.d.ts.map +1 -1
  22. package/dist/core/generator.js +738 -104
  23. package/dist/core/generator.js.map +1 -1
  24. package/dist/core/mapper.d.ts +9 -7
  25. package/dist/core/mapper.d.ts.map +1 -1
  26. package/dist/core/mapper.js +129 -44
  27. package/dist/core/mapper.js.map +1 -1
  28. package/dist/index.d.ts +7 -7
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +4 -4
  31. package/dist/index.js.map +1 -1
  32. package/dist/runtime/bridge-core.d.ts +1 -0
  33. package/dist/runtime/bridge-core.d.ts.map +1 -1
  34. package/dist/runtime/bridge-core.js +52 -17
  35. package/dist/runtime/bridge-core.js.map +1 -1
  36. package/dist/runtime/bridge-protocol.d.ts +17 -0
  37. package/dist/runtime/bridge-protocol.d.ts.map +1 -1
  38. package/dist/runtime/bridge-protocol.js +97 -0
  39. package/dist/runtime/bridge-protocol.js.map +1 -1
  40. package/dist/runtime/errors.d.ts +10 -0
  41. package/dist/runtime/errors.d.ts.map +1 -1
  42. package/dist/runtime/errors.js +9 -0
  43. package/dist/runtime/errors.js.map +1 -1
  44. package/dist/runtime/http-io.d.ts.map +1 -1
  45. package/dist/runtime/http-io.js.map +1 -1
  46. package/dist/runtime/http.d.ts +1 -0
  47. package/dist/runtime/http.d.ts.map +1 -1
  48. package/dist/runtime/http.js +6 -0
  49. package/dist/runtime/http.js.map +1 -1
  50. package/dist/runtime/node.d.ts.map +1 -1
  51. package/dist/runtime/node.js +170 -32
  52. package/dist/runtime/node.js.map +1 -1
  53. package/dist/runtime/optimized-node.d.ts +1 -1
  54. package/dist/runtime/optimized-node.d.ts.map +1 -1
  55. package/dist/runtime/optimized-node.js +1 -1
  56. package/dist/runtime/optimized-node.js.map +1 -1
  57. package/dist/runtime/pooled-transport.d.ts +4 -0
  58. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  59. package/dist/runtime/pooled-transport.js +2 -0
  60. package/dist/runtime/pooled-transport.js.map +1 -1
  61. package/dist/runtime/process-io.d.ts +5 -0
  62. package/dist/runtime/process-io.d.ts.map +1 -1
  63. package/dist/runtime/process-io.js +64 -30
  64. package/dist/runtime/process-io.js.map +1 -1
  65. package/dist/runtime/pyodide-io.d.ts +4 -5
  66. package/dist/runtime/pyodide-io.d.ts.map +1 -1
  67. package/dist/runtime/pyodide-io.js +99 -37
  68. package/dist/runtime/pyodide-io.js.map +1 -1
  69. package/dist/runtime/safe-codec.d.ts +25 -3
  70. package/dist/runtime/safe-codec.d.ts.map +1 -1
  71. package/dist/runtime/safe-codec.js +231 -57
  72. package/dist/runtime/safe-codec.js.map +1 -1
  73. package/dist/runtime/transport.d.ts +6 -1
  74. package/dist/runtime/transport.d.ts.map +1 -1
  75. package/dist/runtime/transport.js.map +1 -1
  76. package/dist/runtime/validators.d.ts +0 -11
  77. package/dist/runtime/validators.d.ts.map +1 -1
  78. package/dist/runtime/validators.js +13 -3
  79. package/dist/runtime/validators.js.map +1 -1
  80. package/dist/runtime/worker-pool.d.ts +12 -0
  81. package/dist/runtime/worker-pool.d.ts.map +1 -1
  82. package/dist/runtime/worker-pool.js +53 -5
  83. package/dist/runtime/worker-pool.js.map +1 -1
  84. package/dist/types/index.d.ts +51 -1
  85. package/dist/types/index.d.ts.map +1 -1
  86. package/dist/tywrap.d.ts.map +1 -1
  87. package/dist/tywrap.js +227 -328
  88. package/dist/tywrap.js.map +1 -1
  89. package/dist/utils/cache.d.ts.map +1 -1
  90. package/dist/utils/cache.js +53 -5
  91. package/dist/utils/cache.js.map +1 -1
  92. package/dist/utils/codec.d.ts.map +1 -1
  93. package/dist/utils/codec.js +10 -16
  94. package/dist/utils/codec.js.map +1 -1
  95. package/dist/utils/ir-cache.d.ts +28 -0
  96. package/dist/utils/ir-cache.d.ts.map +1 -0
  97. package/dist/utils/ir-cache.js +29 -0
  98. package/dist/utils/ir-cache.js.map +1 -0
  99. package/dist/utils/parallel-processor.d.ts +1 -1
  100. package/dist/utils/parallel-processor.d.ts.map +1 -1
  101. package/dist/utils/parallel-processor.js +111 -40
  102. package/dist/utils/parallel-processor.js.map +1 -1
  103. package/dist/utils/runtime.d.ts +5 -1
  104. package/dist/utils/runtime.d.ts.map +1 -1
  105. package/dist/utils/runtime.js +112 -18
  106. package/dist/utils/runtime.js.map +1 -1
  107. package/package.json +40 -18
  108. package/runtime/python_bridge.py +78 -12
  109. package/src/cli.ts +61 -2
  110. package/src/config/index.ts +63 -17
  111. package/src/core/analyzer.ts +52 -15
  112. package/src/core/annotation-parser.ts +500 -0
  113. package/src/core/discovery.ts +26 -12
  114. package/src/core/generator.ts +956 -116
  115. package/src/core/mapper.ts +158 -51
  116. package/src/index.ts +34 -5
  117. package/src/runtime/bridge-core.ts +58 -18
  118. package/src/runtime/bridge-protocol.ts +164 -0
  119. package/src/runtime/errors.ts +14 -0
  120. package/src/runtime/http-io.ts +2 -1
  121. package/src/runtime/http.ts +7 -0
  122. package/src/runtime/node.ts +264 -44
  123. package/src/runtime/optimized-node.ts +4 -1
  124. package/src/runtime/pooled-transport.ts +12 -1
  125. package/src/runtime/process-io.ts +83 -55
  126. package/src/runtime/pyodide-io.ts +110 -44
  127. package/src/runtime/safe-codec.ts +290 -82
  128. package/src/runtime/transport.ts +6 -1
  129. package/src/runtime/validators.ts +14 -3
  130. package/src/runtime/worker-pool.ts +67 -6
  131. package/src/types/global.d.ts +11 -1
  132. package/src/types/index.ts +63 -0
  133. package/src/tywrap.ts +279 -358
  134. package/src/utils/cache.ts +59 -5
  135. package/src/utils/codec.ts +11 -15
  136. package/src/utils/ir-cache.ts +51 -0
  137. package/src/utils/parallel-processor.ts +119 -43
  138. package/src/utils/runtime.ts +129 -18
@@ -147,18 +147,72 @@ export class IntelligentCache {
147
147
 
148
148
  // Add prefix
149
149
  hash.update(prefix);
150
+ hash.update('\0');
150
151
 
151
152
  // Add inputs
152
153
  for (const input of inputs) {
154
+ // Why: disambiguate types so "1" and 1 don't collide, and keep input boundaries so
155
+ // ["a", "bc"] doesn't collide with ["ab", "c"].
156
+ if (input === undefined) {
157
+ hash.update('undef:');
158
+ hash.update('\0');
159
+ continue;
160
+ }
161
+ if (input === null) {
162
+ hash.update('null:');
163
+ hash.update('\0');
164
+ continue;
165
+ }
153
166
  if (typeof input === 'string') {
167
+ hash.update('str:');
154
168
  hash.update(input);
155
- } else if (Buffer.isBuffer(input)) {
169
+ hash.update('\0');
170
+ continue;
171
+ }
172
+ if (typeof input === 'number') {
173
+ hash.update('num:');
174
+ hash.update(String(input));
175
+ hash.update('\0');
176
+ continue;
177
+ }
178
+ if (typeof input === 'boolean') {
179
+ hash.update('bool:');
180
+ hash.update(input ? '1' : '0');
181
+ hash.update('\0');
182
+ continue;
183
+ }
184
+ if (typeof input === 'bigint') {
185
+ hash.update('bigint:');
186
+ hash.update(input.toString());
187
+ hash.update('\0');
188
+ continue;
189
+ }
190
+ if (Buffer.isBuffer(input)) {
191
+ hash.update('buf:');
156
192
  hash.update(input);
157
- } else if (input !== undefined && input !== null) {
158
- hash.update(JSON.stringify(input));
159
- } else {
160
- hash.update('null');
193
+ hash.update('\0');
194
+ continue;
195
+ }
196
+
197
+ if (typeof input === 'symbol') {
198
+ hash.update('sym:');
199
+ hash.update(input.toString());
200
+ hash.update('\0');
201
+ continue;
202
+ }
203
+
204
+ hash.update('json:');
205
+ let json: string;
206
+ try {
207
+ json =
208
+ JSON.stringify(input, (_key, value) =>
209
+ typeof value === 'symbol' ? value.toString() : value
210
+ ) ?? 'undefined';
211
+ } catch {
212
+ json = String(input);
161
213
  }
214
+ hash.update(json);
215
+ hash.update('\0');
162
216
  }
163
217
 
164
218
  return hash.digest('hex').substring(0, 16); // Use first 16 chars for readability
@@ -258,22 +258,16 @@ function typedArrayToPlain(arr: unknown): unknown[] {
258
258
  }
259
259
  // Handle typed arrays (Int32Array, Float64Array, BigInt64Array, etc.)
260
260
  if (ArrayBuffer.isView(arr) && 'length' in arr) {
261
- const typedArr = arr as unknown as { length: number; [index: number]: unknown };
262
- const result: unknown[] = [];
263
- for (let i = 0; i < typedArr.length; i++) {
264
- const val = typedArr[i];
261
+ const values = Array.from(arr as unknown as ArrayLike<unknown>);
262
+ return values.map(value => {
265
263
  // Convert BigInt to Number if within safe integer range
266
- if (typeof val === 'bigint') {
267
- if (val >= BigInt(Number.MIN_SAFE_INTEGER) && val <= BigInt(Number.MAX_SAFE_INTEGER)) {
268
- result.push(Number(val));
269
- } else {
270
- result.push(val); // Keep as BigInt if too large
264
+ if (typeof value === 'bigint') {
265
+ if (value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER)) {
266
+ return Number(value);
271
267
  }
272
- } else {
273
- result.push(val);
274
268
  }
275
- }
276
- return result;
269
+ return value; // Keep BigInt when too large (or preserve non-BigInt values)
270
+ });
277
271
  }
278
272
  // Fallback: check if iterable before converting
279
273
  if (arr !== null && arr !== undefined && typeof arr === 'object' && Symbol.iterator in arr) {
@@ -324,8 +318,10 @@ function reshapeArray(flat: unknown[], shape: readonly number[]): unknown {
324
318
  return flat;
325
319
  }
326
320
 
327
- // shape.length >= 2, so first is always defined
328
- const first = shape[0]!;
321
+ const first = shape[0];
322
+ if (first === undefined) {
323
+ return [];
324
+ }
329
325
  const rest = shape.slice(1);
330
326
  const chunkSize = rest.reduce((a, b) => a * b, 1);
331
327
  const result: unknown[] = [];
@@ -0,0 +1,51 @@
1
+ import { hashUtils } from './runtime.js';
2
+
3
+ export interface IrCacheKeyObject {
4
+ module: string;
5
+ moduleVersion: string | null;
6
+ irVersion: string;
7
+ pythonImportPath?: readonly string[];
8
+ runtime: {
9
+ pythonPath: string;
10
+ virtualEnv: string | null;
11
+ };
12
+ output: {
13
+ format: 'esm' | 'cjs' | 'both';
14
+ declaration: boolean;
15
+ sourceMap: boolean;
16
+ };
17
+ performance: {
18
+ caching: boolean;
19
+ compression: 'auto' | 'gzip' | 'brotli' | 'none';
20
+ };
21
+ typeHints: 'strict' | 'loose' | 'ignore';
22
+ }
23
+
24
+ function stableSortForJson(value: unknown): unknown {
25
+ if (Array.isArray(value)) {
26
+ return value.map(v => stableSortForJson(v));
27
+ }
28
+ if (value && typeof value === 'object') {
29
+ const obj = value as Record<string, unknown>;
30
+ const out: Record<string, unknown> = {};
31
+ for (const key of Object.keys(obj).sort()) {
32
+ out[key] = stableSortForJson(obj[key]);
33
+ }
34
+ return out;
35
+ }
36
+ return value;
37
+ }
38
+
39
+ /**
40
+ * Compute a stable, safe filename for on-disk IR caching.
41
+ *
42
+ * Why: cache filenames must not include user-controlled module names to avoid
43
+ * path traversal or invalid filename characters (especially on Windows).
44
+ */
45
+ export async function computeIrCacheFilename(keyObject: IrCacheKeyObject): Promise<string> {
46
+ // Canonicalize to avoid cache misses when key object property insertion order differs.
47
+ const normalized = JSON.stringify(stableSortForJson(keyObject));
48
+ const digest = await hashUtils.sha256Hex(normalized);
49
+ // Hash-only filename: safe ASCII, no separators, stable length.
50
+ return `ir_${digest.slice(0, 32)}.json`;
51
+ }
@@ -127,10 +127,12 @@ export class ParallelProcessor extends EventEmitter {
127
127
  private workers = new Map<string, Worker>();
128
128
  private workerStats = new Map<string, WorkerStats>();
129
129
  private taskQueue: ParallelTask[] = [];
130
+ private queueInterval?: NodeJS.Timeout;
130
131
  private activeTasks = new Map<string, ParallelTask>();
131
132
  private pendingResults = new Map<
132
133
  string,
133
134
  {
135
+ workerId: string;
134
136
  resolve: (result: ParallelResult<unknown>) => void;
135
137
  reject: (error: Error) => void;
136
138
  timeout?: NodeJS.Timeout;
@@ -329,12 +331,16 @@ export class ParallelProcessor extends EventEmitter {
329
331
  attempts++;
330
332
 
331
333
  try {
332
- const worker = this.selectOptimalWorker();
333
- if (!worker) {
334
+ const selected = this.selectOptimalWorker();
335
+ if (!selected) {
334
336
  throw new Error('No available workers');
335
337
  }
336
338
 
337
- const result = await this.sendTaskToWorker<Data, Result>(worker, task);
339
+ const result = await this.sendTaskToWorker<Data, Result>(
340
+ selected.workerId,
341
+ selected.worker,
342
+ task
343
+ );
338
344
 
339
345
  // Cache successful results
340
346
  if (this.options.enableCaching && result.success) {
@@ -366,10 +372,16 @@ export class ParallelProcessor extends EventEmitter {
366
372
  /**
367
373
  * Select optimal worker using load balancing strategy
368
374
  */
369
- private selectOptimalWorker(): Worker | null {
370
- const availableWorkers = Array.from(this.workers.values()).filter(
371
- worker => worker.threadId > 0
372
- );
375
+ private selectOptimalWorker(): { workerId: string; worker: Worker } | null {
376
+ const availableWorkers = Array.from(this.workers.entries())
377
+ .map(([workerId, worker]) => ({ workerId, worker }))
378
+ .filter(({ workerId, worker }) => {
379
+ if (worker.threadId <= 0) {
380
+ return false;
381
+ }
382
+ const stats = this.workerStats.get(workerId);
383
+ return stats?.isActive !== false;
384
+ });
373
385
 
374
386
  if (availableWorkers.length === 0) {
375
387
  return null;
@@ -384,37 +396,37 @@ export class ParallelProcessor extends EventEmitter {
384
396
 
385
397
  case 'least-loaded': {
386
398
  // Find worker with least active tasks
387
- const workerLoads = availableWorkers.map(w => {
388
- const workerId = this.getWorkerId(w);
399
+ const workerLoads = availableWorkers.map(({ workerId, worker }) => {
389
400
  const stats = this.workerStats.get(workerId);
390
- return { worker: w, load: stats?.tasksCompleted ?? 0 };
401
+ return { workerId, worker, load: stats?.tasksCompleted ?? 0 };
391
402
  });
392
403
 
393
404
  workerLoads.sort((a, b) => a.load - b.load);
394
- return workerLoads[0]?.worker ?? null;
405
+ const first = workerLoads[0];
406
+ return first ? { workerId: first.workerId, worker: first.worker } : null;
395
407
  }
396
408
 
397
409
  case 'weighted': {
398
410
  // Weight by average task completion time
399
- const workerWeights = availableWorkers.map(w => {
400
- const workerId = this.getWorkerId(w);
411
+ const workerWeights = availableWorkers.map(({ workerId, worker }) => {
401
412
  const stats = this.workerStats.get(workerId);
402
413
  const avgTime = stats?.averageTime ?? 1000;
403
- return { worker: w, weight: 1 / avgTime };
414
+ return { workerId, worker, weight: 1 / avgTime };
404
415
  });
405
416
 
406
417
  // Weighted random selection
407
418
  const totalWeight = workerWeights.reduce((sum, w) => sum + w.weight, 0);
408
419
  let random = Math.random() * totalWeight;
409
420
 
410
- for (const { worker: candidate, weight } of workerWeights) {
421
+ for (const { workerId, worker: candidate, weight } of workerWeights) {
411
422
  random -= weight;
412
423
  if (random <= 0) {
413
- return candidate;
424
+ return { workerId, worker: candidate };
414
425
  }
415
426
  }
416
427
 
417
- return workerWeights[0]?.worker ?? null;
428
+ const fallback = workerWeights[0];
429
+ return fallback ? { workerId: fallback.workerId, worker: fallback.worker } : null;
418
430
  }
419
431
 
420
432
  default:
@@ -426,17 +438,29 @@ export class ParallelProcessor extends EventEmitter {
426
438
  * Send task to specific worker
427
439
  */
428
440
  private async sendTaskToWorker<Data, Result>(
441
+ workerId: string,
429
442
  worker: Worker,
430
443
  task: ParallelTask<Data>
431
444
  ): Promise<ParallelResult<Result>> {
432
445
  return new Promise((resolve, reject) => {
446
+ if (this.disposed) {
447
+ reject(new Error('Processor has been disposed'));
448
+ return;
449
+ }
450
+
433
451
  const timeoutMs = task.timeout ?? this.options.taskTimeout;
434
452
  const timeout = setTimeout(() => {
453
+ const pending = this.pendingResults.get(task.id);
454
+ if (!pending) {
455
+ return;
456
+ }
435
457
  this.pendingResults.delete(task.id);
436
- reject(new Error(`Task ${task.id} timed out after ${timeoutMs}ms`));
458
+ this.activeTasks.delete(task.id);
459
+ pending.reject(new Error(`Task ${task.id} timed out after ${timeoutMs}ms`));
437
460
  }, timeoutMs);
438
461
 
439
462
  this.pendingResults.set(task.id, {
463
+ workerId,
440
464
  resolve: resolve as unknown as (result: ParallelResult<unknown>) => void,
441
465
  reject,
442
466
  timeout,
@@ -444,10 +468,17 @@ export class ParallelProcessor extends EventEmitter {
444
468
 
445
469
  this.activeTasks.set(task.id, task);
446
470
 
447
- worker.postMessage({
448
- type: 'task',
449
- task,
450
- });
471
+ try {
472
+ worker.postMessage({
473
+ type: 'task',
474
+ task,
475
+ });
476
+ } catch (error: unknown) {
477
+ this.pendingResults.delete(task.id);
478
+ this.activeTasks.delete(task.id);
479
+ clearTimeout(timeout);
480
+ reject(error instanceof Error ? error : new Error(String(error)));
481
+ }
451
482
  });
452
483
  }
453
484
 
@@ -472,8 +503,9 @@ export class ParallelProcessor extends EventEmitter {
472
503
 
473
504
  // Setup worker error handling
474
505
  worker.on('error', error => {
475
- log.error('Worker error', { workerId, error: String(error) });
476
- this.handleWorkerError(workerId, error);
506
+ const workerError = error instanceof Error ? error : new Error(String(error));
507
+ log.error('Worker error', { workerId, error: String(workerError) });
508
+ this.handleWorkerError(workerId, workerError);
477
509
  });
478
510
 
479
511
  // Setup worker exit handling
@@ -554,15 +586,25 @@ export class ParallelProcessor extends EventEmitter {
554
586
  stats.isActive = false;
555
587
  }
556
588
 
557
- // Reject pending tasks for this worker
589
+ // Reject only tasks owned by this worker.
590
+ const ownedTasks: string[] = [];
558
591
  for (const [taskId, pending] of this.pendingResults) {
559
- const task = this.activeTasks.get(taskId);
560
- if (task) {
561
- pending.reject(new Error(`Worker ${workerId} error: ${error.message}`));
562
- this.pendingResults.delete(taskId);
563
- this.activeTasks.delete(taskId);
592
+ if (pending.workerId === workerId) {
593
+ ownedTasks.push(taskId);
564
594
  }
565
595
  }
596
+ for (const taskId of ownedTasks) {
597
+ const pending = this.pendingResults.get(taskId);
598
+ if (!pending) {
599
+ continue;
600
+ }
601
+ if (pending.timeout) {
602
+ clearTimeout(pending.timeout);
603
+ }
604
+ pending.reject(new Error(`Worker ${workerId} error: ${error.message}`));
605
+ this.pendingResults.delete(taskId);
606
+ this.activeTasks.delete(taskId);
607
+ }
566
608
 
567
609
  this.emit('worker_error', workerId, error);
568
610
  }
@@ -578,6 +620,26 @@ export class ParallelProcessor extends EventEmitter {
578
620
  stats.isActive = false;
579
621
  }
580
622
 
623
+ // Reject any tasks that were in-flight on this worker.
624
+ const ownedTasks: string[] = [];
625
+ for (const [taskId, pending] of this.pendingResults) {
626
+ if (pending.workerId === workerId) {
627
+ ownedTasks.push(taskId);
628
+ }
629
+ }
630
+ for (const taskId of ownedTasks) {
631
+ const pending = this.pendingResults.get(taskId);
632
+ if (!pending) {
633
+ continue;
634
+ }
635
+ if (pending.timeout) {
636
+ clearTimeout(pending.timeout);
637
+ }
638
+ pending.reject(new Error(`Worker ${workerId} exited with code ${code}`));
639
+ this.pendingResults.delete(taskId);
640
+ this.activeTasks.delete(taskId);
641
+ }
642
+
581
643
  // Respawn worker if not disposing
582
644
  if (!this.disposed && this.workers.size < this.options.maxWorkers) {
583
645
  this.spawnWorker(`${workerId}_respawn_${Date.now()}`).catch(error => {
@@ -593,7 +655,10 @@ export class ParallelProcessor extends EventEmitter {
593
655
  */
594
656
  private processQueue(): void {
595
657
  // Simple queue processing - could be enhanced with priority scheduling
596
- setInterval(() => {
658
+ if (this.queueInterval) {
659
+ return;
660
+ }
661
+ this.queueInterval = setInterval(() => {
597
662
  if (this.taskQueue.length > 0 && this.workers.size > 0) {
598
663
  const task = this.taskQueue.shift();
599
664
  if (task) {
@@ -603,6 +668,8 @@ export class ParallelProcessor extends EventEmitter {
603
668
  }
604
669
  }
605
670
  }, 100);
671
+ // Don't keep the event loop alive just for an idle queue pump.
672
+ this.queueInterval.unref?.();
606
673
  }
607
674
 
608
675
  /**
@@ -645,15 +712,6 @@ export class ParallelProcessor extends EventEmitter {
645
712
  return chunks;
646
713
  }
647
714
 
648
- private getWorkerId(worker: Worker): string {
649
- for (const [id, w] of this.workers) {
650
- if (w === worker) {
651
- return id;
652
- }
653
- }
654
- return 'unknown';
655
- }
656
-
657
715
  private sleep(ms: number): Promise<void> {
658
716
  return new Promise(resolve => setTimeout(resolve, ms));
659
717
  }
@@ -670,6 +728,22 @@ export class ParallelProcessor extends EventEmitter {
670
728
 
671
729
  this.debugLog('🛑 Disposing parallel processor...');
672
730
 
731
+ if (this.queueInterval) {
732
+ clearInterval(this.queueInterval);
733
+ this.queueInterval = undefined;
734
+ }
735
+
736
+ // Reject any pending results immediately so callers don't hang.
737
+ const disposedError = new Error('Processor has been disposed');
738
+ for (const pending of this.pendingResults.values()) {
739
+ if (pending.timeout) {
740
+ clearTimeout(pending.timeout);
741
+ }
742
+ pending.reject(disposedError);
743
+ }
744
+ this.pendingResults.clear();
745
+ this.activeTasks.clear();
746
+
673
747
  // Terminate all workers
674
748
  const terminationPromises = Array.from(this.workers.values()).map(worker => {
675
749
  return new Promise<void>(resolve => {
@@ -683,7 +757,11 @@ export class ParallelProcessor extends EventEmitter {
683
757
  resolve();
684
758
  });
685
759
 
686
- worker.postMessage({ type: 'shutdown' });
760
+ try {
761
+ worker.postMessage({ type: 'shutdown' });
762
+ } catch {
763
+ // Ignore: worker may already be dead.
764
+ }
687
765
  });
688
766
  });
689
767
 
@@ -693,8 +771,6 @@ export class ParallelProcessor extends EventEmitter {
693
771
  this.workers.clear();
694
772
  this.workerStats.clear();
695
773
  this.taskQueue.length = 0;
696
- this.activeTasks.clear();
697
- this.pendingResults.clear();
698
774
 
699
775
  this.removeAllListeners();
700
776
 
@@ -432,7 +432,8 @@ export const processUtils = {
432
432
  */
433
433
  async exec(
434
434
  command: string,
435
- args: string[] = []
435
+ args: string[] = [],
436
+ options: { timeoutMs?: number; env?: Record<string, string | undefined>; cwd?: string } = {}
436
437
  ): Promise<{ stdout: string; stderr: string; code: number }> {
437
438
  const runtime = detectRuntime();
438
439
 
@@ -441,28 +442,88 @@ export const processUtils = {
441
442
  }
442
443
 
443
444
  if (runtime.name === 'deno' && Deno) {
444
- const cmd = new Deno.Command(command, { args });
445
- const { code, stdout, stderr } = await cmd.output();
445
+ const timeoutMs = options.timeoutMs;
446
+ const controller =
447
+ typeof timeoutMs === 'number' && timeoutMs > 0 ? new AbortController() : undefined;
448
+ const timeout =
449
+ controller && typeof timeoutMs === 'number'
450
+ ? setTimeout(() => controller.abort(), timeoutMs)
451
+ : undefined;
452
+ const env: Record<string, string> | undefined =
453
+ options.env && Object.keys(options.env).length > 0
454
+ ? Object.fromEntries(
455
+ Object.entries(options.env)
456
+ .filter(([, value]) => typeof value === 'string')
457
+ .map(([key, value]) => [key, value as string])
458
+ )
459
+ : undefined;
446
460
 
447
- return {
448
- code,
449
- stdout: new TextDecoder().decode(stdout),
450
- stderr: new TextDecoder().decode(stderr),
451
- };
461
+ try {
462
+ const cmd = new Deno.Command(command, {
463
+ args,
464
+ ...(options.cwd ? { cwd: options.cwd } : {}),
465
+ ...(env ? { env } : {}),
466
+ ...(controller ? { signal: controller.signal } : {}),
467
+ });
468
+ const { code, stdout, stderr } = await cmd.output();
469
+
470
+ return {
471
+ code,
472
+ stdout: new TextDecoder().decode(stdout),
473
+ stderr: new TextDecoder().decode(stderr),
474
+ };
475
+ } catch (error: unknown) {
476
+ if (controller?.signal.aborted && typeof timeoutMs === 'number') {
477
+ throw new Error(`Command "${command}" timed out after ${timeoutMs}ms`);
478
+ }
479
+ throw error;
480
+ } finally {
481
+ if (timeout) {
482
+ clearTimeout(timeout);
483
+ }
484
+ }
452
485
  }
453
486
 
454
487
  if (runtime.name === 'bun' && Bun) {
488
+ const timeoutMs = options.timeoutMs;
489
+ const mergedEnv: Record<string, string> | undefined = options.env
490
+ ? Object.fromEntries(
491
+ Object.entries({
492
+ ...(typeof process !== 'undefined' ? process.env : {}),
493
+ ...options.env,
494
+ })
495
+ .filter(([, value]) => typeof value === 'string')
496
+ .map(([key, value]) => [key, value as string])
497
+ )
498
+ : undefined;
455
499
  const proc = Bun.spawn([command, ...args], {
456
500
  stdout: 'pipe',
457
501
  stderr: 'pipe',
502
+ ...(options.cwd ? { cwd: options.cwd } : {}),
503
+ ...(mergedEnv ? { env: mergedEnv } : {}),
458
504
  });
459
505
 
506
+ let timedOut = false;
507
+ const timeout =
508
+ typeof timeoutMs === 'number' && timeoutMs > 0
509
+ ? setTimeout(() => {
510
+ timedOut = true;
511
+ proc.kill?.('SIGKILL');
512
+ }, timeoutMs)
513
+ : undefined;
514
+
460
515
  const [stdout, stderr] = await Promise.all([
461
516
  new Response(proc.stdout).text(),
462
517
  new Response(proc.stderr).text(),
463
518
  ]);
464
519
 
465
520
  await proc.exited;
521
+ if (timeout) {
522
+ clearTimeout(timeout);
523
+ }
524
+ if (timedOut && typeof timeoutMs === 'number') {
525
+ throw new Error(`Command "${command}" timed out after ${timeoutMs}ms`);
526
+ }
466
527
 
467
528
  return {
468
529
  code: proc.exitCode ?? 0,
@@ -476,15 +537,51 @@ export const processUtils = {
476
537
  const { delimiter } = await import('node:path');
477
538
 
478
539
  return new Promise((resolve, reject) => {
479
- const extraPyPath = pathUtils.join(process.cwd(), 'tywrap_ir');
480
- const existingPyPath = process.env.PYTHONPATH;
481
- const env = {
482
- ...process.env,
483
- PYTHONPATH: existingPyPath ? `${extraPyPath}${delimiter}${existingPyPath}` : extraPyPath,
484
- };
485
- const child = spawn(command, args, { env });
540
+ const cwd = options.cwd ?? process.cwd();
541
+ const extraPyPath = pathUtils.join(cwd, 'tywrap_ir');
542
+ const env: Record<string, string> = Object.create(null) as Record<string, string>;
543
+ Object.entries(process.env).forEach(([key, value]) => {
544
+ if (value !== undefined) {
545
+ // eslint-disable-next-line security/detect-object-injection -- environment variable keys are user/runtime supplied
546
+ env[key] = value;
547
+ }
548
+ });
549
+ Object.entries(options.env ?? {}).forEach(([key, value]) => {
550
+ if (value === undefined) {
551
+ // eslint-disable-next-line security/detect-object-injection -- environment variable keys are user/runtime supplied
552
+ delete env[key];
553
+ return;
554
+ }
555
+ // eslint-disable-next-line security/detect-object-injection -- environment variable keys are user/runtime supplied
556
+ env[key] = value;
557
+ });
558
+ const existingPyPath = env.PYTHONPATH;
559
+ env.PYTHONPATH = existingPyPath
560
+ ? `${extraPyPath}${delimiter}${existingPyPath}`
561
+ : extraPyPath;
562
+ const child = spawn(command, args, { env, cwd: options.cwd });
486
563
  let stdout = '';
487
564
  let stderr = '';
565
+ let settled = false;
566
+ let timedOut = false;
567
+ const timeout =
568
+ typeof options.timeoutMs === 'number' && options.timeoutMs > 0
569
+ ? setTimeout(() => {
570
+ timedOut = true;
571
+ child.kill('SIGKILL');
572
+ }, options.timeoutMs)
573
+ : undefined;
574
+
575
+ const finish = (fn: () => void): void => {
576
+ if (settled) {
577
+ return;
578
+ }
579
+ settled = true;
580
+ if (timeout) {
581
+ clearTimeout(timeout);
582
+ }
583
+ fn();
584
+ };
488
585
 
489
586
  child.stdout?.on('data', data => {
490
587
  stdout += data.toString();
@@ -495,10 +592,23 @@ export const processUtils = {
495
592
  });
496
593
 
497
594
  child.on('close', code => {
498
- resolve({ code: code ?? 0, stdout, stderr });
595
+ if (timedOut) {
596
+ const timeoutMs = options.timeoutMs;
597
+ finish(() =>
598
+ reject(
599
+ new Error(
600
+ `Command "${command}" timed out after ${typeof timeoutMs === 'number' ? timeoutMs : 0}ms`
601
+ )
602
+ )
603
+ );
604
+ return;
605
+ }
606
+ finish(() => resolve({ code: code ?? 0, stdout, stderr }));
499
607
  });
500
608
 
501
- child.on('error', reject);
609
+ child.on('error', error => {
610
+ finish(() => reject(error));
611
+ });
502
612
  });
503
613
  }
504
614
 
@@ -611,6 +721,7 @@ export const hashUtils = {
611
721
  hash = (hash << 5) + hash + text.charCodeAt(i);
612
722
  hash |= 0;
613
723
  }
614
- return Math.abs(hash).toString(16);
724
+ // Match sha256 hex shape (64 chars) so callers can rely on fixed-length output.
725
+ return Math.abs(hash).toString(16).padStart(64, '0');
615
726
  },
616
727
  };