tywrap 0.2.0 → 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 (163) 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 +23 -7
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +23 -3
  31. package/dist/index.js.map +1 -1
  32. package/dist/runtime/base.d.ts +17 -7
  33. package/dist/runtime/base.d.ts.map +1 -1
  34. package/dist/runtime/base.js +18 -1
  35. package/dist/runtime/base.js.map +1 -1
  36. package/dist/runtime/bounded-context.d.ts +252 -0
  37. package/dist/runtime/bounded-context.d.ts.map +1 -0
  38. package/dist/runtime/bounded-context.js +454 -0
  39. package/dist/runtime/bounded-context.js.map +1 -0
  40. package/dist/runtime/bridge-core.d.ts +1 -0
  41. package/dist/runtime/bridge-core.d.ts.map +1 -1
  42. package/dist/runtime/bridge-core.js +52 -17
  43. package/dist/runtime/bridge-core.js.map +1 -1
  44. package/dist/runtime/bridge-protocol.d.ts +184 -0
  45. package/dist/runtime/bridge-protocol.d.ts.map +1 -0
  46. package/dist/runtime/bridge-protocol.js +344 -0
  47. package/dist/runtime/bridge-protocol.js.map +1 -0
  48. package/dist/runtime/disposable.d.ts +40 -0
  49. package/dist/runtime/disposable.d.ts.map +1 -0
  50. package/dist/runtime/disposable.js +49 -0
  51. package/dist/runtime/disposable.js.map +1 -0
  52. package/dist/runtime/errors.d.ts +10 -0
  53. package/dist/runtime/errors.d.ts.map +1 -1
  54. package/dist/runtime/errors.js +9 -0
  55. package/dist/runtime/errors.js.map +1 -1
  56. package/dist/runtime/http-io.d.ts +91 -0
  57. package/dist/runtime/http-io.d.ts.map +1 -0
  58. package/dist/runtime/http-io.js +195 -0
  59. package/dist/runtime/http-io.js.map +1 -0
  60. package/dist/runtime/http.d.ts +48 -13
  61. package/dist/runtime/http.d.ts.map +1 -1
  62. package/dist/runtime/http.js +60 -73
  63. package/dist/runtime/http.js.map +1 -1
  64. package/dist/runtime/node.d.ts +97 -130
  65. package/dist/runtime/node.d.ts.map +1 -1
  66. package/dist/runtime/node.js +392 -521
  67. package/dist/runtime/node.js.map +1 -1
  68. package/dist/runtime/optimized-node.d.ts +1 -1
  69. package/dist/runtime/optimized-node.d.ts.map +1 -1
  70. package/dist/runtime/optimized-node.js +1 -1
  71. package/dist/runtime/optimized-node.js.map +1 -1
  72. package/dist/runtime/pooled-transport.d.ts +135 -0
  73. package/dist/runtime/pooled-transport.d.ts.map +1 -0
  74. package/dist/runtime/pooled-transport.js +177 -0
  75. package/dist/runtime/pooled-transport.js.map +1 -0
  76. package/dist/runtime/process-io.d.ts +209 -0
  77. package/dist/runtime/process-io.d.ts.map +1 -0
  78. package/dist/runtime/process-io.js +729 -0
  79. package/dist/runtime/process-io.js.map +1 -0
  80. package/dist/runtime/pyodide-io.d.ts +154 -0
  81. package/dist/runtime/pyodide-io.d.ts.map +1 -0
  82. package/dist/runtime/pyodide-io.js +459 -0
  83. package/dist/runtime/pyodide-io.js.map +1 -0
  84. package/dist/runtime/pyodide.d.ts +51 -19
  85. package/dist/runtime/pyodide.d.ts.map +1 -1
  86. package/dist/runtime/pyodide.js +57 -186
  87. package/dist/runtime/pyodide.js.map +1 -1
  88. package/dist/runtime/safe-codec.d.ts +103 -0
  89. package/dist/runtime/safe-codec.d.ts.map +1 -0
  90. package/dist/runtime/safe-codec.js +519 -0
  91. package/dist/runtime/safe-codec.js.map +1 -0
  92. package/dist/runtime/transport.d.ts +191 -0
  93. package/dist/runtime/transport.d.ts.map +1 -0
  94. package/dist/runtime/transport.js +86 -0
  95. package/dist/runtime/transport.js.map +1 -0
  96. package/dist/runtime/validators.d.ts +120 -0
  97. package/dist/runtime/validators.d.ts.map +1 -0
  98. package/dist/runtime/validators.js +229 -0
  99. package/dist/runtime/validators.js.map +1 -0
  100. package/dist/runtime/worker-pool.d.ts +208 -0
  101. package/dist/runtime/worker-pool.d.ts.map +1 -0
  102. package/dist/runtime/worker-pool.js +419 -0
  103. package/dist/runtime/worker-pool.js.map +1 -0
  104. package/dist/types/index.d.ts +51 -1
  105. package/dist/types/index.d.ts.map +1 -1
  106. package/dist/tywrap.d.ts.map +1 -1
  107. package/dist/tywrap.js +227 -328
  108. package/dist/tywrap.js.map +1 -1
  109. package/dist/utils/cache.d.ts.map +1 -1
  110. package/dist/utils/cache.js +53 -5
  111. package/dist/utils/cache.js.map +1 -1
  112. package/dist/utils/codec.d.ts.map +1 -1
  113. package/dist/utils/codec.js +114 -1
  114. package/dist/utils/codec.js.map +1 -1
  115. package/dist/utils/ir-cache.d.ts +28 -0
  116. package/dist/utils/ir-cache.d.ts.map +1 -0
  117. package/dist/utils/ir-cache.js +29 -0
  118. package/dist/utils/ir-cache.js.map +1 -0
  119. package/dist/utils/parallel-processor.d.ts +1 -1
  120. package/dist/utils/parallel-processor.d.ts.map +1 -1
  121. package/dist/utils/parallel-processor.js +111 -40
  122. package/dist/utils/parallel-processor.js.map +1 -1
  123. package/dist/utils/runtime.d.ts +5 -1
  124. package/dist/utils/runtime.d.ts.map +1 -1
  125. package/dist/utils/runtime.js +112 -18
  126. package/dist/utils/runtime.js.map +1 -1
  127. package/package.json +40 -18
  128. package/runtime/python_bridge.py +108 -15
  129. package/runtime/safe_codec.py +344 -0
  130. package/src/cli.ts +61 -2
  131. package/src/config/index.ts +63 -17
  132. package/src/core/analyzer.ts +52 -15
  133. package/src/core/annotation-parser.ts +500 -0
  134. package/src/core/discovery.ts +26 -12
  135. package/src/core/generator.ts +956 -116
  136. package/src/core/mapper.ts +158 -51
  137. package/src/index.ts +78 -6
  138. package/src/runtime/base.ts +18 -26
  139. package/src/runtime/bounded-context.ts +608 -0
  140. package/src/runtime/bridge-core.ts +58 -18
  141. package/src/runtime/bridge-protocol.ts +483 -0
  142. package/src/runtime/disposable.ts +65 -0
  143. package/src/runtime/errors.ts +14 -0
  144. package/src/runtime/http-io.ts +245 -0
  145. package/src/runtime/http.ts +76 -115
  146. package/src/runtime/node.ts +563 -676
  147. package/src/runtime/optimized-node.ts +4 -1
  148. package/src/runtime/pooled-transport.ts +263 -0
  149. package/src/runtime/process-io.ts +930 -0
  150. package/src/runtime/pyodide-io.ts +551 -0
  151. package/src/runtime/pyodide.ts +75 -215
  152. package/src/runtime/safe-codec.ts +651 -0
  153. package/src/runtime/transport.ts +278 -0
  154. package/src/runtime/validators.ts +252 -0
  155. package/src/runtime/worker-pool.ts +559 -0
  156. package/src/types/global.d.ts +11 -1
  157. package/src/types/index.ts +63 -0
  158. package/src/tywrap.ts +279 -358
  159. package/src/utils/cache.ts +59 -5
  160. package/src/utils/codec.ts +122 -1
  161. package/src/utils/ir-cache.ts +51 -0
  162. package/src/utils/parallel-processor.ts +119 -43
  163. package/src/utils/runtime.ts +129 -18
@@ -0,0 +1,608 @@
1
+ /**
2
+ * BoundedContext - Unified abstraction for cross-boundary concerns.
3
+ *
4
+ * This base class provides consistent handling of:
5
+ * - Lifecycle management (init/dispose state machine)
6
+ * - Validation helpers
7
+ * - Error classification
8
+ * - Bounded execution (timeout, retry)
9
+ * - Resource ownership tracking
10
+ *
11
+ * All runtime bridges (NodeBridge, PyodideBridge, HttpBridge) extend this class.
12
+ *
13
+ * @see https://github.com/bbopen/tywrap/issues/149
14
+ */
15
+
16
+ import type { RuntimeExecution } from '../types/index.js';
17
+ import {
18
+ BridgeDisposedError,
19
+ BridgeError,
20
+ BridgeExecutionError,
21
+ BridgeProtocolError,
22
+ BridgeTimeoutError,
23
+ } from './errors.js';
24
+ import type { Disposable } from './disposable.js';
25
+ import { disposeAll } from './disposable.js';
26
+ import {
27
+ assertFiniteNumber,
28
+ assertNonEmptyString,
29
+ assertPositive,
30
+ assertString,
31
+ ValidationError,
32
+ } from './validators.js';
33
+
34
+ // ═══════════════════════════════════════════════════════════════════════════
35
+ // TYPES
36
+ // ═══════════════════════════════════════════════════════════════════════════
37
+
38
+ /**
39
+ * Lifecycle states for a BoundedContext.
40
+ *
41
+ * State transitions:
42
+ * - idle → initializing → ready (on successful init)
43
+ * - idle → initializing → idle (on failed init, allows retry)
44
+ * - ready → disposing → disposed (on dispose)
45
+ * - disposed → (terminal, no further transitions)
46
+ */
47
+ export type ContextState = 'idle' | 'initializing' | 'ready' | 'disposing' | 'disposed';
48
+
49
+ /**
50
+ * Options for bounded execution.
51
+ */
52
+ export interface ExecuteOptions<T = unknown> {
53
+ /** Timeout in milliseconds. Default: 30000 (30s). Set to 0 to disable. */
54
+ timeoutMs?: number;
55
+ /** Number of retry attempts on retryable errors. Default: 0. */
56
+ retries?: number;
57
+ /** Base delay between retries in ms. Multiplied by attempt number. Default: 100. */
58
+ retryDelayMs?: number;
59
+ /** Optional validation function applied to the result. */
60
+ validate?: (result: T) => T;
61
+ /** Optional abort signal for external cancellation. */
62
+ signal?: AbortSignal;
63
+ }
64
+
65
+ // ═══════════════════════════════════════════════════════════════════════════
66
+ // BOUNDED CONTEXT BASE CLASS
67
+ // ═══════════════════════════════════════════════════════════════════════════
68
+
69
+ /**
70
+ * Abstract base class for runtime bridges with unified boundary management.
71
+ *
72
+ * Provides lifecycle management, validation, error classification,
73
+ * bounded execution, and resource tracking.
74
+ */
75
+ export abstract class BoundedContext implements RuntimeExecution {
76
+ // ═══════════════════════════════════════════════════════════════════════════
77
+ // STATE
78
+ // ═══════════════════════════════════════════════════════════════════════════
79
+
80
+ private _state: ContextState = 'idle';
81
+ private _initPromise?: Promise<void>;
82
+ private readonly _resources = new Set<Disposable>();
83
+
84
+ /**
85
+ * Current lifecycle state of the context.
86
+ */
87
+ get state(): ContextState {
88
+ return this._state;
89
+ }
90
+
91
+ /**
92
+ * Whether the context is ready for operations.
93
+ */
94
+ get isReady(): boolean {
95
+ return this._state === 'ready';
96
+ }
97
+
98
+ /**
99
+ * Whether the context has been disposed.
100
+ */
101
+ get isDisposed(): boolean {
102
+ return this._state === 'disposed';
103
+ }
104
+
105
+ // ═══════════════════════════════════════════════════════════════════════════
106
+ // LIFECYCLE (addresses #142, #148)
107
+ // ═══════════════════════════════════════════════════════════════════════════
108
+
109
+ /**
110
+ * Initialize the context.
111
+ *
112
+ * This method:
113
+ * - Is idempotent (safe to call multiple times)
114
+ * - Deduplicates concurrent calls (returns the same promise)
115
+ * - Allows retry after failure (resets to idle state)
116
+ * - Throws BridgeDisposedError if already disposed
117
+ *
118
+ * @throws BridgeDisposedError if the context has been disposed
119
+ * @throws BridgeError subclass if initialization fails
120
+ */
121
+ async init(): Promise<void> {
122
+ // Guard against disposed or disposing states
123
+ if (this._state === 'disposed' || this._state === 'disposing') {
124
+ throw new BridgeDisposedError('Context has been disposed');
125
+ }
126
+ if (this._state === 'ready') {
127
+ return;
128
+ }
129
+ if (this._initPromise) {
130
+ return this._initPromise;
131
+ }
132
+
133
+ this._state = 'initializing';
134
+ this._initPromise = this.doInit()
135
+ .then(() => {
136
+ // Guard against dispose() being called during init
137
+ if (this._state === 'initializing') {
138
+ this._state = 'ready';
139
+ }
140
+ })
141
+ .catch(err => {
142
+ // Only allow retry by resetting to idle if still initializing
143
+ // (don't revive a disposed/disposing context)
144
+ if (this._state === 'initializing') {
145
+ this._state = 'idle';
146
+ this._initPromise = undefined;
147
+ }
148
+ throw this.classifyError(err);
149
+ });
150
+
151
+ return this._initPromise;
152
+ }
153
+
154
+ /**
155
+ * Dispose the context and all tracked resources.
156
+ *
157
+ * This method:
158
+ * - Is idempotent (safe to call multiple times)
159
+ * - Disposes all tracked resources before calling doDispose()
160
+ * - Collects errors from resource disposal and reports them
161
+ * - Transitions to disposed state even if disposal errors occur
162
+ *
163
+ * @throws AggregateError if multiple disposal errors occur
164
+ * @throws Error if a single disposal error occurs
165
+ */
166
+ async dispose(): Promise<void> {
167
+ if (this._state === 'disposed') {
168
+ return;
169
+ }
170
+ if (this._state === 'disposing') {
171
+ // Another dispose is in progress; don't overlap
172
+ return;
173
+ }
174
+
175
+ this._state = 'disposing';
176
+
177
+ // Dispose all tracked resources
178
+ const resourceErrors = await disposeAll(this._resources);
179
+ this._resources.clear();
180
+
181
+ // Call subclass dispose logic
182
+ let doDisposeError: Error | undefined;
183
+ try {
184
+ await this.doDispose();
185
+ } catch (e) {
186
+ doDisposeError = e instanceof Error ? e : new Error(String(e));
187
+ }
188
+
189
+ // Always transition to disposed
190
+ this._state = 'disposed';
191
+ this._initPromise = undefined;
192
+
193
+ // Report any errors
194
+ const allErrors = doDisposeError ? [...resourceErrors, doDisposeError] : resourceErrors;
195
+
196
+ if (allErrors.length === 1) {
197
+ throw allErrors[0];
198
+ }
199
+ if (allErrors.length > 1) {
200
+ throw new AggregateError(allErrors, 'Multiple errors during dispose');
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Subclass initialization logic.
206
+ * Called during init() after state transitions to 'initializing'.
207
+ */
208
+ protected abstract doInit(): Promise<void>;
209
+
210
+ /**
211
+ * Subclass disposal logic.
212
+ * Called during dispose() after tracked resources are disposed.
213
+ */
214
+ protected abstract doDispose(): Promise<void>;
215
+
216
+ // ═══════════════════════════════════════════════════════════════════════════
217
+ // VALIDATION (addresses #141, #145, #146, #147)
218
+ // ═══════════════════════════════════════════════════════════════════════════
219
+
220
+ /**
221
+ * Validate that a value is a finite number.
222
+ * Wraps validation errors in appropriate BridgeError.
223
+ *
224
+ * @param value - The value to validate
225
+ * @param name - Parameter name for error messages
226
+ * @returns The validated number
227
+ * @throws BridgeError if validation fails
228
+ */
229
+ protected validateNumeric(value: unknown, name: string): number {
230
+ try {
231
+ return assertFiniteNumber(value, name);
232
+ } catch (error) {
233
+ throw this.classifyError(error);
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Validate that a value is a positive number.
239
+ * Wraps validation errors in appropriate BridgeError.
240
+ *
241
+ * @param value - The value to validate
242
+ * @param name - Parameter name for error messages
243
+ * @returns The validated number
244
+ * @throws BridgeError if validation fails
245
+ */
246
+ protected validatePositive(value: unknown, name: string): number {
247
+ try {
248
+ return assertPositive(value, name);
249
+ } catch (error) {
250
+ throw this.classifyError(error);
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Validate that a value is a string.
256
+ * Wraps validation errors in appropriate BridgeError.
257
+ *
258
+ * @param value - The value to validate
259
+ * @param name - Parameter name for error messages
260
+ * @returns The validated string
261
+ * @throws BridgeError if validation fails
262
+ */
263
+ protected validateString(value: unknown, name: string): string {
264
+ try {
265
+ return assertString(value, name);
266
+ } catch (error) {
267
+ throw this.classifyError(error);
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Validate that a value is a non-empty string.
273
+ * Wraps validation errors in appropriate BridgeError.
274
+ *
275
+ * @param value - The value to validate
276
+ * @param name - Parameter name for error messages
277
+ * @returns The validated string
278
+ * @throws BridgeError if validation fails
279
+ */
280
+ protected validateNonEmptyString(value: unknown, name: string): string {
281
+ try {
282
+ return assertNonEmptyString(value, name);
283
+ } catch (error) {
284
+ throw this.classifyError(error);
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Validate input before processing.
290
+ * Override in subclasses for domain-specific input validation.
291
+ *
292
+ * @param input - The input to validate
293
+ * @returns The validated input
294
+ */
295
+ protected validateInput<T>(input: T): T {
296
+ return input;
297
+ }
298
+
299
+ /**
300
+ * Validate output before returning.
301
+ * Override in subclasses for domain-specific output validation.
302
+ *
303
+ * @param output - The output to validate
304
+ * @returns The validated output
305
+ */
306
+ protected validateOutput<T>(output: T): T {
307
+ return output;
308
+ }
309
+
310
+ // ═══════════════════════════════════════════════════════════════════════════
311
+ // ERROR CLASSIFICATION (addresses #143)
312
+ // ═══════════════════════════════════════════════════════════════════════════
313
+
314
+ /**
315
+ * Classify an error into the appropriate BridgeError subtype.
316
+ *
317
+ * This method:
318
+ * - Passes through existing BridgeError instances
319
+ * - Uses context state to determine appropriate error type
320
+ * - Pattern-matches error messages for classification
321
+ *
322
+ * @param error - The error to classify
323
+ * @returns A BridgeError instance
324
+ */
325
+ protected classifyError(error: unknown): BridgeError {
326
+ // Pass through existing BridgeErrors
327
+ if (error instanceof BridgeError) {
328
+ return error;
329
+ }
330
+
331
+ const message = error instanceof Error ? error.message : String(error);
332
+ const cause = error instanceof Error ? error : undefined;
333
+
334
+ // Context state takes precedence
335
+ if (this._state === 'disposed') {
336
+ return new BridgeDisposedError(message, { cause });
337
+ }
338
+
339
+ // Pattern matching for classification
340
+ const lowerMessage = message.toLowerCase();
341
+
342
+ // Timeout patterns
343
+ if (
344
+ lowerMessage.includes('timeout') ||
345
+ lowerMessage.includes('etimedout') ||
346
+ lowerMessage.includes('timed out') ||
347
+ lowerMessage.includes('aborted')
348
+ ) {
349
+ return new BridgeTimeoutError(message, { cause });
350
+ }
351
+
352
+ // Protocol patterns
353
+ if (
354
+ lowerMessage.includes('protocol') ||
355
+ lowerMessage.includes('invalid json') ||
356
+ lowerMessage.includes('parse error') ||
357
+ lowerMessage.includes('unexpected token') ||
358
+ lowerMessage.includes('not found') ||
359
+ error instanceof ValidationError
360
+ ) {
361
+ return new BridgeProtocolError(message, { cause });
362
+ }
363
+
364
+ // Default to execution error
365
+ return new BridgeExecutionError(message, { cause });
366
+ }
367
+
368
+ // ═══════════════════════════════════════════════════════════════════════════
369
+ // BOUNDED EXECUTION (addresses #141, #147, #148)
370
+ // ═══════════════════════════════════════════════════════════════════════════
371
+
372
+ /**
373
+ * Execute an operation with bounded execution guarantees.
374
+ *
375
+ * This method:
376
+ * - Auto-initializes if not ready
377
+ * - Enforces timeout limits
378
+ * - Supports retry on retryable errors
379
+ * - Respects abort signals
380
+ * - Validates results if a validator is provided
381
+ *
382
+ * @param operation - The async operation to execute
383
+ * @param options - Execution options (timeout, retries, etc.)
384
+ * @returns The operation result
385
+ * @throws BridgeTimeoutError if the operation times out
386
+ * @throws BridgeDisposedError if the context is disposed
387
+ * @throws BridgeError for other failures
388
+ */
389
+ protected async execute<T>(
390
+ operation: () => Promise<T>,
391
+ options: ExecuteOptions<T> = {}
392
+ ): Promise<T> {
393
+ // Auto-initialize if needed
394
+ if (this._state !== 'ready') {
395
+ await this.init();
396
+ }
397
+
398
+ // Check disposed state after potential init
399
+ if (this._state === 'disposed') {
400
+ throw new BridgeDisposedError('Context disposed');
401
+ }
402
+
403
+ const { timeoutMs = 30000, retries = 0, retryDelayMs = 100, validate, signal } = options;
404
+
405
+ let lastError: BridgeError | undefined;
406
+
407
+ for (let attempt = 0; attempt <= retries; attempt++) {
408
+ // Check for abort before each attempt
409
+ if (signal?.aborted) {
410
+ throw new BridgeTimeoutError('Operation aborted');
411
+ }
412
+
413
+ try {
414
+ const result = await this.withTimeout(operation(), timeoutMs, signal);
415
+ return validate ? validate(result) : result;
416
+ } catch (error) {
417
+ lastError = this.classifyError(error);
418
+
419
+ // Retry if appropriate
420
+ if (attempt < retries && this.isRetryable(lastError)) {
421
+ await this.delay(retryDelayMs * (attempt + 1));
422
+ continue;
423
+ }
424
+
425
+ throw lastError;
426
+ }
427
+ }
428
+
429
+ // Should not reach here, but TypeScript doesn't know that
430
+ /* istanbul ignore next */
431
+ throw lastError ?? new BridgeExecutionError('Unexpected execution flow');
432
+ }
433
+
434
+ /**
435
+ * Wrap a promise with a timeout.
436
+ *
437
+ * @param promise - The promise to wrap
438
+ * @param ms - Timeout in milliseconds (0 or negative disables timeout)
439
+ * @param signal - Optional abort signal
440
+ * @returns The promise result
441
+ * @throws BridgeTimeoutError if the timeout expires
442
+ */
443
+ private async withTimeout<T>(promise: Promise<T>, ms: number, signal?: AbortSignal): Promise<T> {
444
+ // Check if already aborted
445
+ if (signal?.aborted) {
446
+ throw new BridgeTimeoutError('Operation aborted');
447
+ }
448
+
449
+ // No timeout if ms is 0 or negative, but still honor abort signal
450
+ if (ms <= 0 || !Number.isFinite(ms)) {
451
+ if (!signal) {
452
+ return promise;
453
+ }
454
+ // Wrap promise to honor abort signal even without timeout
455
+ return new Promise<T>((resolve, reject) => {
456
+ const abortHandler = (): void => {
457
+ reject(new BridgeTimeoutError('Operation aborted'));
458
+ };
459
+ signal.addEventListener('abort', abortHandler, { once: true });
460
+ promise
461
+ .then(result => {
462
+ signal.removeEventListener('abort', abortHandler);
463
+ resolve(result);
464
+ })
465
+ .catch(error => {
466
+ signal.removeEventListener('abort', abortHandler);
467
+ reject(error);
468
+ });
469
+ });
470
+ }
471
+
472
+ return new Promise<T>((resolve, reject) => {
473
+ const timer = setTimeout(() => {
474
+ reject(new BridgeTimeoutError(`Operation timed out after ${ms}ms`));
475
+ }, ms);
476
+
477
+ // Handle external abort signal
478
+ const abortHandler = (): void => {
479
+ clearTimeout(timer);
480
+ reject(new BridgeTimeoutError('Operation aborted'));
481
+ };
482
+
483
+ if (signal) {
484
+ signal.addEventListener('abort', abortHandler, { once: true });
485
+ }
486
+
487
+ promise
488
+ .then(result => {
489
+ clearTimeout(timer);
490
+ signal?.removeEventListener('abort', abortHandler);
491
+ resolve(result);
492
+ })
493
+ .catch(error => {
494
+ clearTimeout(timer);
495
+ signal?.removeEventListener('abort', abortHandler);
496
+ reject(error);
497
+ });
498
+ });
499
+ }
500
+
501
+ /**
502
+ * Determine if an error is retryable.
503
+ * Override in subclasses to customize retry logic.
504
+ *
505
+ * @param error - The error to check
506
+ * @returns True if the operation should be retried
507
+ */
508
+ protected isRetryable(error: BridgeError): boolean {
509
+ // Timeout and connection errors are typically retryable
510
+ if (error instanceof BridgeTimeoutError) {
511
+ return true;
512
+ }
513
+
514
+ const message = error.message.toLowerCase();
515
+ return (
516
+ message.includes('econnreset') ||
517
+ message.includes('econnrefused') ||
518
+ message.includes('epipe') ||
519
+ message.includes('connection reset')
520
+ );
521
+ }
522
+
523
+ /**
524
+ * Delay for a specified duration.
525
+ *
526
+ * @param ms - Milliseconds to delay
527
+ */
528
+ private delay(ms: number): Promise<void> {
529
+ return new Promise(resolve => setTimeout(resolve, ms));
530
+ }
531
+
532
+ // ═══════════════════════════════════════════════════════════════════════════
533
+ // RESOURCE OWNERSHIP (addresses #144, #148)
534
+ // ═══════════════════════════════════════════════════════════════════════════
535
+
536
+ /**
537
+ * Track a disposable resource for automatic cleanup.
538
+ *
539
+ * When the context is disposed, all tracked resources will be
540
+ * disposed automatically.
541
+ *
542
+ * @param resource - The resource to track
543
+ * @returns The same resource (for chaining)
544
+ */
545
+ protected trackResource<T extends Disposable>(resource: T): T {
546
+ this._resources.add(resource);
547
+ return resource;
548
+ }
549
+
550
+ /**
551
+ * Stop tracking a resource.
552
+ *
553
+ * Use this when a resource is disposed manually and should
554
+ * not be disposed again during context disposal.
555
+ *
556
+ * @param resource - The resource to untrack
557
+ * @returns True if the resource was being tracked
558
+ */
559
+ protected untrackResource(resource: Disposable): boolean {
560
+ return this._resources.delete(resource);
561
+ }
562
+
563
+ /**
564
+ * Number of currently tracked resources.
565
+ */
566
+ protected get resourceCount(): number {
567
+ return this._resources.size;
568
+ }
569
+
570
+ // ═══════════════════════════════════════════════════════════════════════════
571
+ // ABSTRACT METHODS (RuntimeExecution interface)
572
+ // ═══════════════════════════════════════════════════════════════════════════
573
+
574
+ /**
575
+ * Call a Python function.
576
+ */
577
+ abstract call<T = unknown>(
578
+ module: string,
579
+ functionName: string,
580
+ args: unknown[],
581
+ kwargs?: Record<string, unknown>
582
+ ): Promise<T>;
583
+
584
+ /**
585
+ * Instantiate a Python class.
586
+ */
587
+ abstract instantiate<T = unknown>(
588
+ module: string,
589
+ className: string,
590
+ args: unknown[],
591
+ kwargs?: Record<string, unknown>
592
+ ): Promise<T>;
593
+
594
+ /**
595
+ * Call a method on a Python instance.
596
+ */
597
+ abstract callMethod<T = unknown>(
598
+ handle: string,
599
+ methodName: string,
600
+ args: unknown[],
601
+ kwargs?: Record<string, unknown>
602
+ ): Promise<T>;
603
+
604
+ /**
605
+ * Dispose a Python instance.
606
+ */
607
+ abstract disposeInstance(handle: string): Promise<void>;
608
+ }
@@ -252,17 +252,34 @@ export class BridgeCore {
252
252
  return;
253
253
  }
254
254
 
255
- this.pending.delete(msg.id);
256
- if (pending.timer) {
257
- clearTimeout(pending.timer);
255
+ const hasResult = Object.prototype.hasOwnProperty.call(msg, 'result');
256
+ const hasError = Object.prototype.hasOwnProperty.call(msg, 'error');
257
+
258
+ if (hasResult && hasError) {
259
+ this.handleProtocolError('Response cannot include both "result" and "error"', line);
260
+ return;
258
261
  }
259
262
 
260
- if ('error' in msg) {
263
+ if (hasError) {
261
264
  const errorPayload = this.normalizeErrorPayload(msg.error);
265
+ if (!errorPayload) {
266
+ const reason = this.describeInvalidErrorPayload(msg.error);
267
+ this.handleProtocolError(`Invalid response "error" payload: ${reason}`, line);
268
+ return;
269
+ }
270
+ this.pending.delete(msg.id);
271
+ if (pending.timer) {
272
+ clearTimeout(pending.timer);
273
+ }
262
274
  pending.reject(this.errorFrom(errorPayload));
263
275
  return;
264
276
  }
265
277
 
278
+ this.pending.delete(msg.id);
279
+ if (pending.timer) {
280
+ clearTimeout(pending.timer);
281
+ }
282
+
266
283
  Promise.resolve(this.options.decodeValue(msg.result))
267
284
  .then(decoded => pending.resolve(decoded))
268
285
  .catch(err => {
@@ -280,21 +297,44 @@ export class BridgeCore {
280
297
  return error;
281
298
  }
282
299
 
283
- private normalizeErrorPayload(err: unknown): { type: string; message: string; traceback?: string } {
284
- if (err && typeof err === 'object') {
285
- const candidate = err as { type?: unknown; message?: unknown; traceback?: unknown };
286
- if (typeof candidate.type === 'string' && typeof candidate.message === 'string') {
287
- const normalized: { type: string; message: string; traceback?: string } = {
288
- type: candidate.type,
289
- message: candidate.message,
290
- };
291
- if (typeof candidate.traceback === 'string') {
292
- normalized.traceback = candidate.traceback;
293
- }
294
- return normalized;
295
- }
300
+ private normalizeErrorPayload(
301
+ err: unknown
302
+ ): { type: string; message: string; traceback?: string } | null {
303
+ if (!err || typeof err !== 'object' || Array.isArray(err)) {
304
+ return null;
305
+ }
306
+ const candidate = err as { type?: unknown; message?: unknown; traceback?: unknown };
307
+ if (typeof candidate.type !== 'string' || typeof candidate.message !== 'string') {
308
+ return null;
309
+ }
310
+ if (candidate.traceback !== undefined && typeof candidate.traceback !== 'string') {
311
+ return null;
312
+ }
313
+ const normalized: { type: string; message: string; traceback?: string } = {
314
+ type: candidate.type,
315
+ message: candidate.message,
316
+ };
317
+ if (typeof candidate.traceback === 'string') {
318
+ normalized.traceback = candidate.traceback;
319
+ }
320
+ return normalized;
321
+ }
322
+
323
+ private describeInvalidErrorPayload(err: unknown): string {
324
+ if (!err || typeof err !== 'object' || Array.isArray(err)) {
325
+ return 'expected an object with string "type" and "message" fields';
326
+ }
327
+ const candidate = err as { type?: unknown; message?: unknown; traceback?: unknown };
328
+ if (typeof candidate.type !== 'string') {
329
+ return '"type" must be a string';
330
+ }
331
+ if (typeof candidate.message !== 'string') {
332
+ return '"message" must be a string';
333
+ }
334
+ if (candidate.traceback !== undefined && typeof candidate.traceback !== 'string') {
335
+ return '"traceback" must be a string when provided';
296
336
  }
297
- return { type: 'UnknownError', message: String(err) };
337
+ return 'expected an object with string "type" and "message" fields';
298
338
  }
299
339
 
300
340
  private failRequest(id: number, error: Error): void {