tywrap 0.8.0 → 0.9.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 (157) hide show
  1. package/README.md +22 -7
  2. package/SECURITY.md +39 -0
  3. package/dist/config/index.d.ts.map +1 -1
  4. package/dist/config/index.js +8 -0
  5. package/dist/config/index.js.map +1 -1
  6. package/dist/core/annotation-parser.d.ts +2 -1
  7. package/dist/core/annotation-parser.d.ts.map +1 -1
  8. package/dist/core/annotation-parser.js +6 -3
  9. package/dist/core/annotation-parser.js.map +1 -1
  10. package/dist/core/generator.d.ts +23 -4
  11. package/dist/core/generator.d.ts.map +1 -1
  12. package/dist/core/generator.js +243 -170
  13. package/dist/core/generator.js.map +1 -1
  14. package/dist/core/mapper.d.ts +3 -2
  15. package/dist/core/mapper.d.ts.map +1 -1
  16. package/dist/core/mapper.js +5 -5
  17. package/dist/core/mapper.js.map +1 -1
  18. package/dist/index.d.ts +7 -5
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +6 -4
  21. package/dist/index.js.map +1 -1
  22. package/dist/runtime/base-bridge.d.ts +3 -7
  23. package/dist/runtime/base-bridge.d.ts.map +1 -1
  24. package/dist/runtime/base-bridge.js +4 -17
  25. package/dist/runtime/base-bridge.js.map +1 -1
  26. package/dist/runtime/bounded-context.d.ts +3 -22
  27. package/dist/runtime/bounded-context.d.ts.map +1 -1
  28. package/dist/runtime/bounded-context.js +13 -53
  29. package/dist/runtime/bounded-context.js.map +1 -1
  30. package/dist/runtime/bridge-codec.d.ts +1 -1
  31. package/dist/runtime/bridge-codec.d.ts.map +1 -1
  32. package/dist/runtime/bridge-codec.js +78 -55
  33. package/dist/runtime/bridge-codec.js.map +1 -1
  34. package/dist/runtime/errors.d.ts +16 -0
  35. package/dist/runtime/errors.d.ts.map +1 -1
  36. package/dist/runtime/errors.js +17 -0
  37. package/dist/runtime/errors.js.map +1 -1
  38. package/dist/runtime/http-transport.d.ts +1 -1
  39. package/dist/runtime/http-transport.d.ts.map +1 -1
  40. package/dist/runtime/http-transport.js +1 -1
  41. package/dist/runtime/http-transport.js.map +1 -1
  42. package/dist/runtime/index.d.ts +1 -0
  43. package/dist/runtime/index.d.ts.map +1 -1
  44. package/dist/runtime/index.js +1 -0
  45. package/dist/runtime/index.js.map +1 -1
  46. package/dist/runtime/node.d.ts +7 -36
  47. package/dist/runtime/node.d.ts.map +1 -1
  48. package/dist/runtime/node.js +2 -80
  49. package/dist/runtime/node.js.map +1 -1
  50. package/dist/runtime/pooled-transport.d.ts +120 -59
  51. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  52. package/dist/runtime/pooled-transport.js +345 -78
  53. package/dist/runtime/pooled-transport.js.map +1 -1
  54. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  55. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  56. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  57. package/dist/runtime/pyodide-transport.d.ts +1 -1
  58. package/dist/runtime/pyodide-transport.d.ts.map +1 -1
  59. package/dist/runtime/pyodide-transport.js +2 -3
  60. package/dist/runtime/pyodide-transport.js.map +1 -1
  61. package/dist/runtime/rpc-client.d.ts +7 -36
  62. package/dist/runtime/rpc-client.d.ts.map +1 -1
  63. package/dist/runtime/rpc-client.js +20 -102
  64. package/dist/runtime/rpc-client.js.map +1 -1
  65. package/dist/runtime/subprocess-transport.d.ts +30 -69
  66. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  67. package/dist/runtime/subprocess-transport.js +179 -236
  68. package/dist/runtime/subprocess-transport.js.map +1 -1
  69. package/dist/runtime/timed-out-request-tracker.d.ts +2 -1
  70. package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -1
  71. package/dist/runtime/transport.d.ts +9 -19
  72. package/dist/runtime/transport.d.ts.map +1 -1
  73. package/dist/runtime/transport.js +1 -1
  74. package/dist/runtime/transport.js.map +1 -1
  75. package/dist/runtime/validators.d.ts +49 -0
  76. package/dist/runtime/validators.d.ts.map +1 -1
  77. package/dist/runtime/validators.js +152 -0
  78. package/dist/runtime/validators.js.map +1 -1
  79. package/dist/types/index.d.ts +14 -39
  80. package/dist/types/index.d.ts.map +1 -1
  81. package/dist/tywrap.d.ts +3 -2
  82. package/dist/tywrap.d.ts.map +1 -1
  83. package/dist/tywrap.js +140 -13
  84. package/dist/tywrap.js.map +1 -1
  85. package/dist/utils/cache.d.ts +3 -16
  86. package/dist/utils/cache.d.ts.map +1 -1
  87. package/dist/utils/codec.d.ts +1 -0
  88. package/dist/utils/codec.d.ts.map +1 -1
  89. package/dist/utils/codec.js +13 -13
  90. package/dist/utils/codec.js.map +1 -1
  91. package/dist/utils/ir-cache.d.ts +2 -1
  92. package/dist/utils/ir-cache.d.ts.map +1 -1
  93. package/dist/utils/runtime.d.ts +0 -29
  94. package/dist/utils/runtime.d.ts.map +1 -1
  95. package/dist/utils/runtime.js +16 -107
  96. package/dist/utils/runtime.js.map +1 -1
  97. package/dist/version.js +1 -1
  98. package/package.json +7 -6
  99. package/runtime/frame_codec.py +7 -1
  100. package/runtime/python_bridge.py +85 -125
  101. package/runtime/tywrap_bridge_core.py +133 -150
  102. package/src/config/index.ts +11 -0
  103. package/src/core/annotation-parser.ts +7 -4
  104. package/src/core/generator.ts +315 -205
  105. package/src/core/mapper.ts +8 -8
  106. package/src/index.ts +7 -4
  107. package/src/runtime/base-bridge.ts +5 -30
  108. package/src/runtime/bounded-context.ts +12 -67
  109. package/src/runtime/bridge-codec.ts +94 -65
  110. package/src/runtime/errors.ts +21 -0
  111. package/src/runtime/http-transport.ts +6 -1
  112. package/src/runtime/index.ts +6 -0
  113. package/src/runtime/node.ts +9 -120
  114. package/src/runtime/pooled-transport.ts +424 -90
  115. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  116. package/src/runtime/pyodide-transport.ts +7 -3
  117. package/src/runtime/rpc-client.ts +37 -139
  118. package/src/runtime/subprocess-transport.ts +210 -285
  119. package/src/runtime/timed-out-request-tracker.ts +1 -1
  120. package/src/runtime/transport.ts +15 -23
  121. package/src/runtime/validators.ts +204 -0
  122. package/src/types/index.ts +21 -60
  123. package/src/tywrap.ts +157 -22
  124. package/src/utils/cache.ts +3 -3
  125. package/src/utils/codec.ts +21 -13
  126. package/src/utils/ir-cache.ts +1 -1
  127. package/src/utils/runtime.ts +17 -128
  128. package/src/version.ts +1 -1
  129. package/dist/core/discovery.d.ts +0 -103
  130. package/dist/core/discovery.d.ts.map +0 -1
  131. package/dist/core/discovery.js +0 -380
  132. package/dist/core/discovery.js.map +0 -1
  133. package/dist/core/validation.d.ts +0 -102
  134. package/dist/core/validation.d.ts.map +0 -1
  135. package/dist/core/validation.js +0 -490
  136. package/dist/core/validation.js.map +0 -1
  137. package/dist/runtime/base.d.ts +0 -22
  138. package/dist/runtime/base.d.ts.map +0 -1
  139. package/dist/runtime/base.js +0 -23
  140. package/dist/runtime/base.js.map +0 -1
  141. package/dist/runtime/transport-pool.d.ts +0 -196
  142. package/dist/runtime/transport-pool.d.ts.map +0 -1
  143. package/dist/runtime/transport-pool.js +0 -418
  144. package/dist/runtime/transport-pool.js.map +0 -1
  145. package/runtime/__pycache__/_tywrap_conformance_chunking_fixtures.cpython-311.pyc +0 -0
  146. package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
  147. package/runtime/__pycache__/_tywrap_w4_chunking_fixture.cpython-311.pyc +0 -0
  148. package/runtime/__pycache__/_tywrap_w5_request_chunking_fixture.cpython-311.pyc +0 -0
  149. package/runtime/__pycache__/_tywrap_w6_pool_chunking_fixture.cpython-311.pyc +0 -0
  150. package/runtime/__pycache__/frame_codec.cpython-311.pyc +0 -0
  151. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  152. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  153. package/runtime/safe_codec.py +0 -352
  154. package/src/core/discovery.ts +0 -477
  155. package/src/core/validation.ts +0 -729
  156. package/src/runtime/base.ts +0 -24
  157. package/src/runtime/transport-pool.ts +0 -538
@@ -1,36 +1,33 @@
1
1
  /**
2
- * PooledTransport - Transport adapter that wraps TransportPool for multi-process support.
2
+ * PooledTransport - Manages multiple Transport instances for concurrent request handling.
3
3
  *
4
- * This transport implements the Transport interface by delegating to a TransportPool
5
- * of SubprocessTransport transports. Each send() acquires a worker, sends the message,
6
- * and releases the worker back to the pool.
4
+ * Provides semaphore-based concurrency control with configurable limits per worker
5
+ * and a wait queue for callers when all workers are at capacity.
7
6
  *
8
7
  * @see https://github.com/bbopen/tywrap/issues/149
9
8
  */
10
9
 
11
10
  import { DisposableBase } from './bounded-context.js';
12
- import { BridgeDisposedError, BridgeExecutionError } from './errors.js';
11
+ import {
12
+ BridgeDisposedError,
13
+ BridgeTimeoutError,
14
+ BridgeExecutionError,
15
+ BridgeProtocolError,
16
+ } from './errors.js';
13
17
  import type { Transport, TransportCapabilities } from './transport.js';
14
- import { TransportPool, type TransportLease } from './transport-pool.js';
15
18
 
16
19
  // =============================================================================
17
20
  // TYPES
18
21
  // =============================================================================
19
22
 
20
23
  /**
21
- * Options for creating a PooledTransport.
24
+ * Configuration options for the PooledTransport.
22
25
  */
23
26
  export interface PooledTransportOptions {
24
- /**
25
- * Factory function to create transports for each worker.
26
- *
27
- * Construction MUST be side-effect-free — spawn processes/open connections in
28
- * `init()`/`send()`, never in the constructor. The pool may build a probe
29
- * instance solely to read its {@link Transport.capabilities} descriptor.
30
- */
27
+ /** Factory function to create transports */
31
28
  createTransport: () => Transport;
32
29
 
33
- /** Maximum number of workers in the pool. Default: 1 */
30
+ /** Maximum number of workers in the pool */
34
31
  maxWorkers?: number;
35
32
 
36
33
  /** Minimum number of workers to pre-spawn during init. Default: 0 (lazy) */
@@ -39,7 +36,7 @@ export interface PooledTransportOptions {
39
36
  /** Timeout for waiting in queue (ms). Default: 30000 */
40
37
  queueTimeoutMs?: number;
41
38
 
42
- /** Maximum concurrent requests per worker. Default: 10 */
39
+ /** Maximum concurrent requests per worker. Default: 1 */
43
40
  maxConcurrentPerWorker?: number;
44
41
 
45
42
  /**
@@ -49,55 +46,82 @@ export interface PooledTransportOptions {
49
46
  onWorkerReady?: (worker: TransportLease) => Promise<void>;
50
47
 
51
48
  /**
52
- * Optional callback used only for background replacement workers.
49
+ * Optional callback used only for background replacement workers after a
50
+ * fatal timeout/crash. This lets callers publish a replacement only after it
51
+ * is proven ready, without charging hidden work to normal request startup.
53
52
  */
54
53
  onReplacementWorkerReady?: (worker: TransportLease) => Promise<void>;
55
54
  }
56
55
 
56
+ /**
57
+ * A pooled worker with its transport and current in-flight request count.
58
+ */
59
+ export interface TransportLease {
60
+ /** The underlying transport instance */
61
+ transport: Transport;
62
+
63
+ /** Number of requests currently being processed by this worker */
64
+ inFlightCount: number;
65
+ }
66
+
67
+ /**
68
+ * Internal representation of a waiter in the queue.
69
+ */
70
+ interface QueuedWaiter {
71
+ /** Resolve function to fulfill the promise with a worker */
72
+ resolve: (worker: TransportLease) => void;
73
+
74
+ /** Reject function to reject the promise with an error */
75
+ reject: (error: Error) => void;
76
+
77
+ /** Timeout timer for queue timeout */
78
+ timer: NodeJS.Timeout;
79
+ }
80
+
57
81
  // =============================================================================
58
- // POOLED TRANSPORT
82
+ // WORKER POOL
59
83
  // =============================================================================
60
84
 
61
85
  /**
62
- * Transport adapter that wraps TransportPool for multi-process message handling.
63
- *
64
- * PooledTransport presents a single Transport interface while internally
65
- * distributing requests across multiple worker transports (typically SubprocessTransport).
86
+ * Pool of Transport workers with semaphore-based concurrency control.
66
87
  *
67
88
  * Features:
68
- * - Lazy worker creation (transports created on demand)
69
- * - Configurable pool size and concurrency per worker
70
- * - Automatic worker acquisition and release
71
- * - Queue timeout for backpressure management
89
+ * - Lazy worker creation (workers created on demand)
90
+ * - Configurable concurrency per worker
91
+ * - Wait queue with timeout for callers when pool is at capacity
92
+ * - Automatic cleanup of timers and workers on disposal
72
93
  *
73
94
  * @example
74
95
  * ```typescript
75
- * const transport = new PooledTransport({
76
- * createTransport: () => new SubprocessTransport({
77
- * bridgeScript: '/path/to/bridge.py',
78
- * }),
96
+ * const pool = new PooledTransport({
97
+ * createTransport: () => new SubprocessTransport({ pythonPath: 'python3' }),
79
98
  * maxWorkers: 4,
80
99
  * maxConcurrentPerWorker: 2,
100
+ * queueTimeoutMs: 5000,
81
101
  * });
82
102
  *
83
- * await transport.init();
103
+ * await pool.init();
84
104
  *
85
- * // send() automatically uses pool
86
- * const response = await transport.send(message, timeout);
105
+ * // Use withWorker for automatic acquire/release
106
+ * const result = await pool.withWorker(async (worker) => {
107
+ * return worker.transport.send(message, timeout);
108
+ * });
87
109
  *
88
- * await transport.dispose();
110
+ * await pool.dispose();
89
111
  * ```
90
112
  */
91
113
  export class PooledTransport extends DisposableBase implements Transport {
92
- private readonly poolOptions: Omit<
114
+ private readonly options: Omit<
93
115
  Required<PooledTransportOptions>,
94
116
  'onWorkerReady' | 'onReplacementWorkerReady'
95
117
  > & {
96
118
  onWorkerReady?: (worker: TransportLease) => Promise<void>;
97
119
  onReplacementWorkerReady?: (worker: TransportLease) => Promise<void>;
98
120
  };
99
- private pool?: TransportPool;
100
- /** Memoized capability descriptor built at most once (see {@link capabilities}). */
121
+ private readonly workers: TransportLease[] = [];
122
+ private readonly waitQueue: QueuedWaiter[] = [];
123
+ /** Tracks workers being created to prevent race condition in acquire() */
124
+ private pendingCreations = 0;
101
125
  private cachedCapabilities?: TransportCapabilities;
102
126
 
103
127
  /**
@@ -108,100 +132,254 @@ export class PooledTransport extends DisposableBase implements Transport {
108
132
  constructor(options: PooledTransportOptions) {
109
133
  super();
110
134
 
135
+ // Validate required options
111
136
  if (typeof options.createTransport !== 'function') {
112
137
  throw new BridgeExecutionError('createTransport must be a function');
113
138
  }
139
+ const maxWorkers = options.maxWorkers ?? 1;
140
+ if (typeof maxWorkers !== 'number' || maxWorkers < 1) {
141
+ throw new BridgeExecutionError('maxWorkers must be a positive number');
142
+ }
143
+
144
+ const minWorkers = options.minWorkers ?? 0;
145
+ if (minWorkers > maxWorkers) {
146
+ throw new BridgeExecutionError('minWorkers cannot exceed maxWorkers');
147
+ }
114
148
 
115
- this.poolOptions = {
149
+ this.options = {
116
150
  createTransport: options.createTransport,
117
- maxWorkers: options.maxWorkers ?? 1,
118
- minWorkers: options.minWorkers ?? 0,
151
+ maxWorkers,
152
+ minWorkers,
119
153
  queueTimeoutMs: options.queueTimeoutMs ?? 30000,
120
- maxConcurrentPerWorker: options.maxConcurrentPerWorker ?? 10,
154
+ maxConcurrentPerWorker: options.maxConcurrentPerWorker ?? 1,
121
155
  onWorkerReady: options.onWorkerReady,
122
156
  onReplacementWorkerReady: options.onReplacementWorkerReady,
123
157
  };
124
158
  }
125
159
 
160
+ async send(
161
+ message: string,
162
+ timeoutMs: number,
163
+ signal?: AbortSignal,
164
+ requestId?: number
165
+ ): Promise<string> {
166
+ if (this.isDisposed) {
167
+ throw new BridgeDisposedError('Transport has been disposed');
168
+ }
169
+ if (!this.isReady) {
170
+ await this.init();
171
+ }
172
+ return this.withWorker(worker => worker.transport.send(message, timeoutMs, signal, requestId));
173
+ }
174
+
175
+ capabilities(): TransportCapabilities {
176
+ this.cachedCapabilities ??= this.options.createTransport().capabilities();
177
+ return this.cachedCapabilities;
178
+ }
179
+
126
180
  // ===========================================================================
127
181
  // LIFECYCLE
128
182
  // ===========================================================================
129
183
 
130
184
  /**
131
- * Initialize the pooled transport.
185
+ * Initialize the pool.
132
186
  *
133
- * Creates and initializes the internal TransportPool.
134
- * If minWorkers > 0, workers are pre-spawned during init.
187
+ * If minWorkers > 0, pre-spawns workers during initialization.
188
+ * Otherwise, workers are created lazily on demand.
135
189
  */
136
190
  protected async doInit(): Promise<void> {
137
- this.pool = new TransportPool({
138
- createTransport: this.poolOptions.createTransport,
139
- maxWorkers: this.poolOptions.maxWorkers,
140
- minWorkers: this.poolOptions.minWorkers,
141
- queueTimeoutMs: this.poolOptions.queueTimeoutMs,
142
- maxConcurrentPerWorker: this.poolOptions.maxConcurrentPerWorker,
143
- onWorkerReady: this.poolOptions.onWorkerReady,
144
- onReplacementWorkerReady: this.poolOptions.onReplacementWorkerReady,
145
- });
146
-
147
- await this.pool.init();
191
+ // Pre-spawn minimum workers if configured
192
+ await this.fillToMinimumWorkers();
148
193
  }
149
194
 
150
195
  /**
151
- * Dispose the pooled transport.
196
+ * Dispose the pool and all workers.
152
197
  *
153
- * Disposes the internal TransportPool, which disposes all workers.
198
+ * - Rejects all waiters in the queue
199
+ * - Disposes all transport instances
200
+ * - Clears internal state
154
201
  */
155
202
  protected async doDispose(): Promise<void> {
156
- if (this.pool) {
157
- await this.pool.dispose();
158
- this.pool = undefined;
203
+ // Reject all waiters in the queue
204
+ for (const waiter of this.waitQueue) {
205
+ clearTimeout(waiter.timer);
206
+ waiter.reject(new BridgeExecutionError('Pool disposed'));
207
+ }
208
+ this.waitQueue.length = 0;
209
+
210
+ // Dispose all workers
211
+ const errors: Error[] = [];
212
+ for (const worker of this.workers) {
213
+ try {
214
+ await worker.transport.dispose();
215
+ } catch (e) {
216
+ errors.push(e instanceof Error ? e : new Error(String(e)));
217
+ }
218
+ }
219
+ this.workers.length = 0;
220
+
221
+ // Report errors if any
222
+ if (errors.length === 1) {
223
+ throw errors[0];
224
+ }
225
+ if (errors.length > 1) {
226
+ throw new AggregateError(errors, 'Multiple errors during worker disposal');
159
227
  }
160
228
  }
161
229
 
162
230
  // ===========================================================================
163
- // TRANSPORT INTERFACE
231
+ // WORKER MANAGEMENT
164
232
  // ===========================================================================
165
233
 
166
234
  /**
167
- * Send a message through a pooled worker.
235
+ * Acquire a worker from the pool.
168
236
  *
169
237
  * This method:
170
- * 1. Acquires a worker from the pool (waiting if necessary)
171
- * 2. Sends the message through the worker's transport
172
- * 3. Releases the worker back to the pool
238
+ * - Returns an available worker if one exists (inFlightCount < maxConcurrentPerWorker)
239
+ * - Creates a new worker if under the maxWorkers limit
240
+ * - Waits in queue if all workers are at capacity
241
+ *
242
+ * @returns Promise resolving to a pooled worker
243
+ * @throws BridgeTimeoutError if queue timeout expires
244
+ * @throws BridgeExecutionError if pool is disposed while waiting
245
+ */
246
+ async acquire(): Promise<TransportLease> {
247
+ // Check for disposed state
248
+ if (this.isDisposed || this.state === 'disposing') {
249
+ throw new BridgeExecutionError('Pool has been disposed');
250
+ }
251
+
252
+ // Find an available worker (one with capacity)
253
+ const availableWorker = this.findAvailableWorker();
254
+ if (availableWorker) {
255
+ availableWorker.inFlightCount++;
256
+ return availableWorker;
257
+ }
258
+
259
+ // Create a new worker if under the limit
260
+ // Include pendingCreations to prevent race condition where multiple
261
+ // concurrent acquire() calls all pass the length check before any
262
+ // worker is actually added to the array
263
+ if (this.workers.length + this.pendingCreations < this.options.maxWorkers) {
264
+ this.pendingCreations++;
265
+ try {
266
+ const newWorker = await this.createWorker();
267
+ if (this.isShuttingDown()) {
268
+ this.removeWorker(newWorker);
269
+ throw new BridgeExecutionError('Pool has been disposed');
270
+ }
271
+ newWorker.inFlightCount++;
272
+ this.publishAvailableWorker(newWorker);
273
+ return newWorker;
274
+ } finally {
275
+ this.pendingCreations--;
276
+ }
277
+ }
278
+
279
+ // All workers at capacity - wait in queue
280
+ return this.waitForWorker();
281
+ }
282
+
283
+ /**
284
+ * Release a worker back to the pool.
173
285
  *
174
- * @param message - The JSON-encoded protocol message
175
- * @param timeoutMs - Timeout in milliseconds (0 = no timeout)
176
- * @param signal - Optional AbortSignal for cancellation
177
- * @returns The raw JSON response string
286
+ * Decrements the worker's in-flight count and notifies any waiters
287
+ * that a worker may be available.
178
288
  *
179
- * @throws BridgeDisposedError if transport is disposed
180
- * @throws BridgeTimeoutError if queue timeout or request timeout expires
289
+ * @param worker - The worker to release
181
290
  */
182
- async send(message: string, timeoutMs: number, signal?: AbortSignal): Promise<string> {
183
- if (this.isDisposed || !this.pool) {
184
- throw new BridgeDisposedError('Transport has been disposed');
291
+ release(worker: TransportLease): void {
292
+ // Validate the worker belongs to this pool
293
+ if (!this.workers.includes(worker)) {
294
+ return;
185
295
  }
186
296
 
187
- return this.pool.withWorker(async worker => {
188
- return worker.transport.send(message, timeoutMs, signal);
189
- });
297
+ // Decrement in-flight count (minimum 0)
298
+ worker.inFlightCount = Math.max(0, worker.inFlightCount - 1);
299
+ this.publishAvailableWorker(worker);
190
300
  }
191
301
 
192
302
  /**
193
- * Static capability descriptor for the pool.
303
+ * Execute a function with an acquired worker, automatically releasing afterward.
304
+ *
305
+ * This is the recommended way to use the pool, as it ensures proper cleanup
306
+ * even if the function throws an error.
194
307
  *
195
- * A pool's wire behavior is exactly that of the workers it distributes across,
196
- * so this reads the descriptor from one probe transport built by the same
197
- * factory. `createTransport` MUST be construction-side-effect-free — the
198
- * built-in transports spawn nothing until `init()`/`send()`. The result is
199
- * memoized so at most one probe is ever built regardless of call count, and
200
- * this stays safe to call at any lifecycle point.
308
+ * @param fn - Async function to execute with the worker
309
+ * @returns Promise resolving to the function's return value
310
+ *
311
+ * @example
312
+ * ```typescript
313
+ * const result = await pool.withWorker(async (worker) => {
314
+ * return worker.transport.send(message, timeout);
315
+ * });
316
+ * ```
201
317
  */
202
- capabilities(): TransportCapabilities {
203
- this.cachedCapabilities ??= this.poolOptions.createTransport().capabilities();
204
- return this.cachedCapabilities;
318
+ async withWorker<T>(fn: (worker: TransportLease) => Promise<T>): Promise<T> {
319
+ const worker = await this.acquire();
320
+ let workerRemoved = false;
321
+
322
+ try {
323
+ return await fn(worker);
324
+ } catch (error) {
325
+ // If this is a fatal error indicating the worker is dead, remove it from the pool
326
+ if (this.isFatalWorkerError(error)) {
327
+ this.removeWorker(worker);
328
+ workerRemoved = true;
329
+ }
330
+ throw error;
331
+ } finally {
332
+ // Only release if worker wasn't removed due to fatal error
333
+ if (!workerRemoved) {
334
+ this.release(worker);
335
+ }
336
+ }
337
+ }
338
+
339
+ // ===========================================================================
340
+ // WORKER HEALTH
341
+ // ===========================================================================
342
+
343
+ /**
344
+ * Check if an error indicates the worker is dead and should be removed.
345
+ *
346
+ * Fatal errors include:
347
+ * - Process not running
348
+ * - Process exited unexpectedly
349
+ * - Pipe errors (EPIPE)
350
+ * - Connection reset errors (ECONNRESET)
351
+ */
352
+ private isFatalWorkerError(error: unknown): boolean {
353
+ if (error instanceof BridgeProtocolError) {
354
+ const msg = error.message.toLowerCase();
355
+ return (
356
+ msg.includes('not running') ||
357
+ msg.includes('process exited') ||
358
+ msg.includes('epipe') ||
359
+ msg.includes('econnreset')
360
+ );
361
+ }
362
+ return false;
363
+ }
364
+
365
+ /**
366
+ * Remove a worker from the pool.
367
+ *
368
+ * This is called when a worker is detected as dead (crashed, pipe error, etc.).
369
+ * The worker's transport is disposed in the background.
370
+ */
371
+ private removeWorker(worker: TransportLease): void {
372
+ const index = this.workers.indexOf(worker);
373
+ if (index !== -1) {
374
+ this.workers.splice(index, 1);
375
+ // Dispose transport in background - don't await to avoid blocking
376
+ worker.transport.dispose().catch(() => {
377
+ // Ignore disposal errors for dead workers
378
+ });
379
+ if (this.state === 'ready') {
380
+ this.scheduleReplacementWorker();
381
+ }
382
+ }
205
383
  }
206
384
 
207
385
  // ===========================================================================
@@ -212,20 +390,176 @@ export class PooledTransport extends DisposableBase implements Transport {
212
390
  * Current number of workers in the pool.
213
391
  */
214
392
  get workerCount(): number {
215
- return this.pool?.workerCount ?? 0;
393
+ return this.workers.length;
216
394
  }
217
395
 
218
396
  /**
219
397
  * Number of callers waiting in the queue.
220
398
  */
221
399
  get queueLength(): number {
222
- return this.pool?.queueLength ?? 0;
400
+ return this.waitQueue.length;
223
401
  }
224
402
 
225
403
  /**
226
404
  * Total number of in-flight requests across all workers.
227
405
  */
228
406
  get totalInFlight(): number {
229
- return this.pool?.totalInFlight ?? 0;
407
+ return this.workers.reduce((sum, w) => sum + w.inFlightCount, 0);
408
+ }
409
+
410
+ // ===========================================================================
411
+ // PRIVATE HELPERS
412
+ // ===========================================================================
413
+
414
+ /**
415
+ * Find an available worker with capacity for another request.
416
+ */
417
+ private findAvailableWorker(): TransportLease | undefined {
418
+ return this.workers.find(w => w.inFlightCount < this.options.maxConcurrentPerWorker);
419
+ }
420
+
421
+ private getMinimumWorkerDeficit(): number {
422
+ return Math.max(0, this.options.minWorkers - (this.workers.length + this.pendingCreations));
423
+ }
424
+
425
+ private isShuttingDown(): boolean {
426
+ return this.isDisposed || this.state === 'disposing';
427
+ }
428
+
429
+ private async fillToMinimumWorkers(): Promise<void> {
430
+ await this.spawnWorkers(this.getMinimumWorkerDeficit());
431
+ }
432
+
433
+ private async spawnWorkers(count: number): Promise<void> {
434
+ if (count === 0) {
435
+ return;
436
+ }
437
+ this.pendingCreations += count;
438
+ try {
439
+ await Promise.all(Array.from({ length: count }, () => this.spawnWorkerToPool()));
440
+ } finally {
441
+ this.pendingCreations = Math.max(0, this.pendingCreations - count);
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Replace a removed worker in the background so the next caller does not pay
447
+ * the full worker cold-start penalty after a timeout or crash.
448
+ */
449
+ private scheduleReplacementWorker(): void {
450
+ if (this.state !== 'ready') {
451
+ return;
452
+ }
453
+ if (this.workers.length + this.pendingCreations >= this.options.maxWorkers) {
454
+ return;
455
+ }
456
+
457
+ this.pendingCreations++;
458
+ const replacementReady = this.options.onReplacementWorkerReady ?? this.options.onWorkerReady;
459
+ this.spawnWorkerToPool(replacementReady)
460
+ .catch(() => {
461
+ // Ignore background replacement failures. A later acquire() can retry.
462
+ })
463
+ .finally(() => {
464
+ this.pendingCreations = Math.max(0, this.pendingCreations - 1);
465
+ });
466
+ }
467
+
468
+ private async spawnWorkerToPool(onWorkerReady = this.options.onWorkerReady): Promise<void> {
469
+ if (this.isShuttingDown()) {
470
+ return;
471
+ }
472
+
473
+ const worker = await this.createWorker(onWorkerReady);
474
+ if (this.isShuttingDown()) {
475
+ this.removeWorker(worker);
476
+ return;
477
+ }
478
+
479
+ this.publishAvailableWorker(worker);
480
+ }
481
+
482
+ private publishAvailableWorker(worker: TransportLease): void {
483
+ while (
484
+ this.waitQueue.length > 0 &&
485
+ worker.inFlightCount < this.options.maxConcurrentPerWorker
486
+ ) {
487
+ const waiter = this.waitQueue.shift();
488
+ if (!waiter) {
489
+ return;
490
+ }
491
+ clearTimeout(waiter.timer);
492
+ worker.inFlightCount++;
493
+ waiter.resolve(worker);
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Create a new worker and add it to the pool.
499
+ *
500
+ * If onWorkerReady is configured, calls it after the transport is initialized.
501
+ * This is useful for per-worker warmup (importing modules, running setup).
502
+ */
503
+ private async createWorker(onWorkerReady = this.options.onWorkerReady): Promise<TransportLease> {
504
+ const transport = this.options.createTransport();
505
+
506
+ // Initialize the transport
507
+ await transport.init();
508
+
509
+ const worker: TransportLease = {
510
+ transport,
511
+ inFlightCount: 0,
512
+ };
513
+
514
+ try {
515
+ // Call onWorkerReady callback if provided
516
+ if (onWorkerReady) {
517
+ await onWorkerReady(worker);
518
+ }
519
+ } catch (error) {
520
+ // Ensure partially initialized workers do not leak when warmup fails.
521
+ await transport.dispose().catch(() => {
522
+ // Ignore disposal failures during warmup failure handling.
523
+ });
524
+ throw error;
525
+ }
526
+
527
+ if (this.state === 'disposing' || this.state === 'disposed') {
528
+ await transport.dispose().catch(() => {
529
+ // Ignore disposal failures if the pool was torn down mid-creation.
530
+ });
531
+ throw new BridgeExecutionError('Pool disposed during worker creation');
532
+ }
533
+
534
+ this.workers.push(worker);
535
+
536
+ return worker;
537
+ }
538
+
539
+ /**
540
+ * Wait in queue for a worker to become available.
541
+ */
542
+ private waitForWorker(): Promise<TransportLease> {
543
+ return new Promise<TransportLease>((resolve, reject) => {
544
+ const timer = setTimeout(() => {
545
+ // Remove this waiter from the queue
546
+ const index = this.waitQueue.findIndex(w => w.timer === timer);
547
+ if (index !== -1) {
548
+ this.waitQueue.splice(index, 1);
549
+ }
550
+ reject(
551
+ new BridgeTimeoutError(
552
+ `Timed out waiting for available worker after ${this.options.queueTimeoutMs}ms`
553
+ )
554
+ );
555
+ }, this.options.queueTimeoutMs);
556
+
557
+ // Unref the timer so it doesn't keep the Node.js process alive
558
+ if (typeof timer.unref === 'function') {
559
+ timer.unref();
560
+ }
561
+
562
+ this.waitQueue.push({ resolve, reject, timer });
563
+ });
230
564
  }
231
565
  }