tywrap 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.d.ts +20 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -2
- package/dist/index.js.map +1 -1
- package/dist/runtime/base.d.ts +17 -7
- package/dist/runtime/base.d.ts.map +1 -1
- package/dist/runtime/base.js +18 -1
- package/dist/runtime/base.js.map +1 -1
- package/dist/runtime/bounded-context.d.ts +252 -0
- package/dist/runtime/bounded-context.d.ts.map +1 -0
- package/dist/runtime/bounded-context.js +454 -0
- package/dist/runtime/bounded-context.js.map +1 -0
- package/dist/runtime/bridge-protocol.d.ts +167 -0
- package/dist/runtime/bridge-protocol.d.ts.map +1 -0
- package/dist/runtime/bridge-protocol.js +247 -0
- package/dist/runtime/bridge-protocol.js.map +1 -0
- package/dist/runtime/disposable.d.ts +40 -0
- package/dist/runtime/disposable.d.ts.map +1 -0
- package/dist/runtime/disposable.js +49 -0
- package/dist/runtime/disposable.js.map +1 -0
- package/dist/runtime/http-io.d.ts +91 -0
- package/dist/runtime/http-io.d.ts.map +1 -0
- package/dist/runtime/http-io.js +195 -0
- package/dist/runtime/http-io.js.map +1 -0
- package/dist/runtime/http.d.ts +47 -13
- package/dist/runtime/http.d.ts.map +1 -1
- package/dist/runtime/http.js +55 -74
- package/dist/runtime/http.js.map +1 -1
- package/dist/runtime/node.d.ts +97 -130
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +256 -523
- package/dist/runtime/node.js.map +1 -1
- package/dist/runtime/pooled-transport.d.ts +131 -0
- package/dist/runtime/pooled-transport.d.ts.map +1 -0
- package/dist/runtime/pooled-transport.js +175 -0
- package/dist/runtime/pooled-transport.js.map +1 -0
- package/dist/runtime/process-io.d.ts +204 -0
- package/dist/runtime/process-io.d.ts.map +1 -0
- package/dist/runtime/process-io.js +695 -0
- package/dist/runtime/process-io.js.map +1 -0
- package/dist/runtime/pyodide-io.d.ts +155 -0
- package/dist/runtime/pyodide-io.d.ts.map +1 -0
- package/dist/runtime/pyodide-io.js +397 -0
- package/dist/runtime/pyodide-io.js.map +1 -0
- package/dist/runtime/pyodide.d.ts +51 -19
- package/dist/runtime/pyodide.d.ts.map +1 -1
- package/dist/runtime/pyodide.js +57 -186
- package/dist/runtime/pyodide.js.map +1 -1
- package/dist/runtime/safe-codec.d.ts +81 -0
- package/dist/runtime/safe-codec.d.ts.map +1 -0
- package/dist/runtime/safe-codec.js +345 -0
- package/dist/runtime/safe-codec.js.map +1 -0
- package/dist/runtime/transport.d.ts +186 -0
- package/dist/runtime/transport.d.ts.map +1 -0
- package/dist/runtime/transport.js +86 -0
- package/dist/runtime/transport.js.map +1 -0
- package/dist/runtime/validators.d.ts +131 -0
- package/dist/runtime/validators.d.ts.map +1 -0
- package/dist/runtime/validators.js +219 -0
- package/dist/runtime/validators.js.map +1 -0
- package/dist/runtime/worker-pool.d.ts +196 -0
- package/dist/runtime/worker-pool.d.ts.map +1 -0
- package/dist/runtime/worker-pool.js +371 -0
- package/dist/runtime/worker-pool.js.map +1 -0
- package/dist/utils/codec.d.ts.map +1 -1
- package/dist/utils/codec.js +120 -1
- package/dist/utils/codec.js.map +1 -1
- package/package.json +2 -2
- package/runtime/python_bridge.py +30 -3
- package/runtime/safe_codec.py +344 -0
- package/src/index.ts +48 -5
- package/src/runtime/base.ts +18 -26
- package/src/runtime/bounded-context.ts +608 -0
- package/src/runtime/bridge-protocol.ts +319 -0
- package/src/runtime/disposable.ts +65 -0
- package/src/runtime/http-io.ts +244 -0
- package/src/runtime/http.ts +71 -117
- package/src/runtime/node.ts +358 -691
- package/src/runtime/pooled-transport.ts +252 -0
- package/src/runtime/process-io.ts +902 -0
- package/src/runtime/pyodide-io.ts +485 -0
- package/src/runtime/pyodide.ts +75 -215
- package/src/runtime/safe-codec.ts +443 -0
- package/src/runtime/transport.ts +273 -0
- package/src/runtime/validators.ts +241 -0
- package/src/runtime/worker-pool.ts +498 -0
- package/src/utils/codec.ts +126 -1
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProcessIO Transport - Subprocess-based Python communication for Node.js.
|
|
3
|
+
*
|
|
4
|
+
* This transport implements the Transport interface for spawning and communicating
|
|
5
|
+
* with a Python subprocess via stdio streams. It provides:
|
|
6
|
+
* - JSONL (JSON Lines) protocol for request/response messaging
|
|
7
|
+
* - Backpressure handling for stdin writes
|
|
8
|
+
* - Line-based stdout parsing with buffering
|
|
9
|
+
* - Stderr capture for error diagnostics
|
|
10
|
+
* - Request timeout management
|
|
11
|
+
* - Optional process restart after N requests
|
|
12
|
+
*
|
|
13
|
+
* @see https://github.com/bbopen/tywrap/issues/149
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawn, type ChildProcess } from 'child_process';
|
|
17
|
+
import { BoundedContext } from './bounded-context.js';
|
|
18
|
+
import {
|
|
19
|
+
BridgeDisposedError,
|
|
20
|
+
BridgeProtocolError,
|
|
21
|
+
BridgeTimeoutError,
|
|
22
|
+
BridgeExecutionError,
|
|
23
|
+
} from './errors.js';
|
|
24
|
+
import type { Transport } from './transport.js';
|
|
25
|
+
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// CONSTANTS
|
|
28
|
+
// =============================================================================
|
|
29
|
+
|
|
30
|
+
/** Default maximum response line length: 100MB */
|
|
31
|
+
const DEFAULT_MAX_LINE_LENGTH = 100 * 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
/** Maximum stderr bytes to retain for diagnostics: 8KB */
|
|
34
|
+
const MAX_STDERR_BYTES = 8 * 1024;
|
|
35
|
+
|
|
36
|
+
/** Default write queue timeout: 30 seconds */
|
|
37
|
+
const DEFAULT_WRITE_QUEUE_TIMEOUT_MS = 30_000;
|
|
38
|
+
|
|
39
|
+
/** Regex for ANSI escape sequences */
|
|
40
|
+
const ANSI_ESCAPE_RE = /\u001b\[[0-9;]*[A-Za-z]/g;
|
|
41
|
+
|
|
42
|
+
/** Regex for control characters */
|
|
43
|
+
const CONTROL_CHARS_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u0080-\u009F]/g;
|
|
44
|
+
|
|
45
|
+
// =============================================================================
|
|
46
|
+
// TYPES
|
|
47
|
+
// =============================================================================
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Options for ProcessIO transport.
|
|
51
|
+
*/
|
|
52
|
+
export interface ProcessIOOptions {
|
|
53
|
+
/** Python executable path. Default: 'python3' */
|
|
54
|
+
pythonPath?: string;
|
|
55
|
+
|
|
56
|
+
/** Path to the bridge script */
|
|
57
|
+
bridgeScript: string;
|
|
58
|
+
|
|
59
|
+
/** Environment variables to pass to the subprocess */
|
|
60
|
+
env?: Record<string, string>;
|
|
61
|
+
|
|
62
|
+
/** Working directory for the subprocess. Default: process.cwd() */
|
|
63
|
+
cwd?: string;
|
|
64
|
+
|
|
65
|
+
/** Maximum line length for responses. Default: 100MB */
|
|
66
|
+
maxLineLength?: number;
|
|
67
|
+
|
|
68
|
+
/** Restart process after N requests (0 = never). Default: 0 */
|
|
69
|
+
restartAfterRequests?: number;
|
|
70
|
+
|
|
71
|
+
/** Write queue timeout in milliseconds. Default: 30000ms */
|
|
72
|
+
writeQueueTimeoutMs?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Pending request entry for tracking in-flight requests.
|
|
77
|
+
*/
|
|
78
|
+
interface PendingRequest {
|
|
79
|
+
resolve: (value: string) => void;
|
|
80
|
+
reject: (error: Error) => void;
|
|
81
|
+
timer?: NodeJS.Timeout;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Queued write entry for backpressure handling.
|
|
86
|
+
*/
|
|
87
|
+
interface QueuedWrite {
|
|
88
|
+
data: string;
|
|
89
|
+
resolve: () => void;
|
|
90
|
+
reject: (error: Error) => void;
|
|
91
|
+
/** Timestamp when the write was queued */
|
|
92
|
+
queuedAt: number;
|
|
93
|
+
/** Timeout handle for write queue timeout */
|
|
94
|
+
timeoutHandle?: NodeJS.Timeout;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// =============================================================================
|
|
98
|
+
// UTILITIES
|
|
99
|
+
// =============================================================================
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Sanitize stderr output by removing ANSI codes and control characters.
|
|
103
|
+
*/
|
|
104
|
+
function sanitizeStderr(value: string): string {
|
|
105
|
+
return value.replace(ANSI_ESCAPE_RE, '').replace(CONTROL_CHARS_RE, '');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Extract message ID from a JSON string without full parsing.
|
|
110
|
+
* Returns null if ID cannot be extracted.
|
|
111
|
+
*/
|
|
112
|
+
function extractMessageId(json: string): number | null {
|
|
113
|
+
// Look for "id": <number> (integer IDs)
|
|
114
|
+
const match = json.match(/"id"\s*:\s*(-?\d+)/);
|
|
115
|
+
return match?.[1] ? parseInt(match[1], 10) : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// =============================================================================
|
|
119
|
+
// PROCESS IO TRANSPORT
|
|
120
|
+
// =============================================================================
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Transport implementation for subprocess-based Python communication.
|
|
124
|
+
*
|
|
125
|
+
* ProcessIO spawns a Python child process and communicates via stdio:
|
|
126
|
+
* - Requests are written to stdin as JSON lines
|
|
127
|
+
* - Responses are read from stdout as JSON lines
|
|
128
|
+
* - Stderr is captured for diagnostics
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* ```typescript
|
|
132
|
+
* const transport = new ProcessIO({
|
|
133
|
+
* bridgeScript: '/path/to/bridge.py',
|
|
134
|
+
* pythonPath: 'python3',
|
|
135
|
+
* });
|
|
136
|
+
*
|
|
137
|
+
* await transport.init();
|
|
138
|
+
*
|
|
139
|
+
* const response = await transport.send(
|
|
140
|
+
* JSON.stringify({ id: '1', type: 'call', module: 'math', args: [16] }),
|
|
141
|
+
* 5000
|
|
142
|
+
* );
|
|
143
|
+
*
|
|
144
|
+
* await transport.dispose();
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
export class ProcessIO extends BoundedContext implements Transport {
|
|
148
|
+
// Configuration
|
|
149
|
+
private readonly pythonPath: string;
|
|
150
|
+
private readonly bridgeScript: string;
|
|
151
|
+
private readonly envOverrides: Record<string, string>;
|
|
152
|
+
private readonly cwd: string | undefined;
|
|
153
|
+
private readonly maxLineLength: number;
|
|
154
|
+
private readonly restartAfterRequests: number;
|
|
155
|
+
private readonly writeQueueTimeoutMs: number;
|
|
156
|
+
|
|
157
|
+
// Process state
|
|
158
|
+
private process: ChildProcess | null = null;
|
|
159
|
+
private processExited = false;
|
|
160
|
+
private processError: Error | null = null;
|
|
161
|
+
|
|
162
|
+
// Stream buffers
|
|
163
|
+
private stdoutBuffer = '';
|
|
164
|
+
private stderrBuffer = '';
|
|
165
|
+
|
|
166
|
+
// Request tracking
|
|
167
|
+
private readonly pending = new Map<number, PendingRequest>();
|
|
168
|
+
private requestCount = 0;
|
|
169
|
+
private needsRestart = false;
|
|
170
|
+
|
|
171
|
+
// Write queue for backpressure
|
|
172
|
+
private readonly writeQueue: QueuedWrite[] = [];
|
|
173
|
+
private draining = false;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Create a new ProcessIO transport.
|
|
177
|
+
*
|
|
178
|
+
* @param options - Transport configuration options
|
|
179
|
+
*/
|
|
180
|
+
constructor(options: ProcessIOOptions) {
|
|
181
|
+
super();
|
|
182
|
+
|
|
183
|
+
this.pythonPath = options.pythonPath ?? 'python3';
|
|
184
|
+
this.bridgeScript = options.bridgeScript;
|
|
185
|
+
this.envOverrides = options.env ?? {};
|
|
186
|
+
this.cwd = options.cwd;
|
|
187
|
+
this.maxLineLength = options.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
|
|
188
|
+
this.restartAfterRequests = options.restartAfterRequests ?? 0;
|
|
189
|
+
this.writeQueueTimeoutMs = options.writeQueueTimeoutMs ?? DEFAULT_WRITE_QUEUE_TIMEOUT_MS;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ===========================================================================
|
|
193
|
+
// TRANSPORT INTERFACE
|
|
194
|
+
// ===========================================================================
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Send a message and wait for the response.
|
|
198
|
+
*
|
|
199
|
+
* @param message - The JSON-encoded protocol message
|
|
200
|
+
* @param timeoutMs - Timeout in milliseconds (0 = no timeout)
|
|
201
|
+
* @param signal - Optional AbortSignal for cancellation
|
|
202
|
+
* @returns The raw JSON response string
|
|
203
|
+
*/
|
|
204
|
+
async send(message: string, timeoutMs: number, signal?: AbortSignal): Promise<string> {
|
|
205
|
+
// Check disposed state
|
|
206
|
+
if (this.isDisposed) {
|
|
207
|
+
throw new BridgeDisposedError('Transport has been disposed');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Auto-initialize if needed
|
|
211
|
+
if (!this.isReady) {
|
|
212
|
+
await this.init();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Check if process is alive
|
|
216
|
+
if (this.processExited || !this.process) {
|
|
217
|
+
const stderrTail = this.getStderrTail();
|
|
218
|
+
const baseMsg = 'Python process is not running';
|
|
219
|
+
const msg = stderrTail ? `${baseMsg}. Stderr:\n${stderrTail}` : baseMsg;
|
|
220
|
+
throw new BridgeProtocolError(msg);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Check if already aborted
|
|
224
|
+
if (signal?.aborted) {
|
|
225
|
+
throw new BridgeTimeoutError('Operation aborted');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Extract message ID for response correlation
|
|
229
|
+
const messageId = extractMessageId(message);
|
|
230
|
+
if (!messageId) {
|
|
231
|
+
throw new BridgeProtocolError('Message must contain an "id" field');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Check for restart condition (either scheduled restart or forced by stream error)
|
|
235
|
+
if (this.needsRestart || (this.restartAfterRequests > 0 && this.requestCount >= this.restartAfterRequests)) {
|
|
236
|
+
await this.restartProcess();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Create promise for response
|
|
240
|
+
return new Promise<string>((resolve, reject) => {
|
|
241
|
+
// Set up timeout if specified
|
|
242
|
+
let timer: NodeJS.Timeout | undefined;
|
|
243
|
+
if (timeoutMs > 0) {
|
|
244
|
+
timer = setTimeout(() => {
|
|
245
|
+
this.pending.delete(messageId);
|
|
246
|
+
const stderrTail = this.getStderrTail();
|
|
247
|
+
const baseMsg = `Operation timed out after ${timeoutMs}ms`;
|
|
248
|
+
const msg = stderrTail ? `${baseMsg}. Recent stderr:\n${stderrTail}` : baseMsg;
|
|
249
|
+
reject(new BridgeTimeoutError(msg));
|
|
250
|
+
}, timeoutMs);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Set up abort handler
|
|
254
|
+
const abortHandler = (): void => {
|
|
255
|
+
if (timer) {
|
|
256
|
+
clearTimeout(timer);
|
|
257
|
+
}
|
|
258
|
+
this.pending.delete(messageId);
|
|
259
|
+
reject(new BridgeTimeoutError('Operation aborted'));
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
if (signal) {
|
|
263
|
+
signal.addEventListener('abort', abortHandler, { once: true });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Wrap resolve/reject to clean up abort listener
|
|
267
|
+
const wrappedResolve = (value: string): void => {
|
|
268
|
+
if (timer) {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
}
|
|
271
|
+
signal?.removeEventListener('abort', abortHandler);
|
|
272
|
+
resolve(value);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const wrappedReject = (error: Error): void => {
|
|
276
|
+
if (timer) {
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
}
|
|
279
|
+
signal?.removeEventListener('abort', abortHandler);
|
|
280
|
+
reject(error);
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// Register pending request
|
|
284
|
+
this.pending.set(messageId, {
|
|
285
|
+
resolve: wrappedResolve,
|
|
286
|
+
reject: wrappedReject,
|
|
287
|
+
timer,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Write message to stdin
|
|
291
|
+
this.writeToStdin(`${message}\n`).catch(err => {
|
|
292
|
+
this.pending.delete(messageId);
|
|
293
|
+
if (timer) {
|
|
294
|
+
clearTimeout(timer);
|
|
295
|
+
}
|
|
296
|
+
signal?.removeEventListener('abort', abortHandler);
|
|
297
|
+
reject(this.classifyError(err));
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
this.requestCount++;
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ===========================================================================
|
|
305
|
+
// BOUNDED CONTEXT LIFECYCLE
|
|
306
|
+
// ===========================================================================
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Initialize the transport by spawning the Python process.
|
|
310
|
+
*/
|
|
311
|
+
protected async doInit(): Promise<void> {
|
|
312
|
+
await this.spawnProcess();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Dispose the transport by killing the Python process.
|
|
317
|
+
*/
|
|
318
|
+
protected async doDispose(): Promise<void> {
|
|
319
|
+
// Reject all pending requests
|
|
320
|
+
const stderrTail = this.getStderrTail();
|
|
321
|
+
const msg = stderrTail
|
|
322
|
+
? `Transport disposed. Stderr:\n${stderrTail}`
|
|
323
|
+
: 'Transport disposed';
|
|
324
|
+
const error = new BridgeDisposedError(msg);
|
|
325
|
+
|
|
326
|
+
for (const [, pending] of this.pending) {
|
|
327
|
+
if (pending.timer) {
|
|
328
|
+
clearTimeout(pending.timer);
|
|
329
|
+
}
|
|
330
|
+
pending.reject(error);
|
|
331
|
+
}
|
|
332
|
+
this.pending.clear();
|
|
333
|
+
|
|
334
|
+
// Clear write queue
|
|
335
|
+
for (const queued of this.writeQueue) {
|
|
336
|
+
this.clearQueuedWriteTimeout(queued);
|
|
337
|
+
queued.reject(error);
|
|
338
|
+
}
|
|
339
|
+
this.writeQueue.length = 0;
|
|
340
|
+
|
|
341
|
+
// Kill process
|
|
342
|
+
await this.killProcess();
|
|
343
|
+
|
|
344
|
+
// Clear buffers
|
|
345
|
+
this.stdoutBuffer = '';
|
|
346
|
+
this.stderrBuffer = '';
|
|
347
|
+
this.requestCount = 0;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ===========================================================================
|
|
351
|
+
// ABSTRACT METHOD STUBS (from BoundedContext)
|
|
352
|
+
// ===========================================================================
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Not implemented - ProcessIO is a transport, not a full bridge.
|
|
356
|
+
*/
|
|
357
|
+
call<T = unknown>(
|
|
358
|
+
_module: string,
|
|
359
|
+
_functionName: string,
|
|
360
|
+
_args: unknown[],
|
|
361
|
+
_kwargs?: Record<string, unknown>
|
|
362
|
+
): Promise<T> {
|
|
363
|
+
throw new BridgeExecutionError('ProcessIO is a transport, use BridgeProtocol for operations');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Not implemented - ProcessIO is a transport, not a full bridge.
|
|
368
|
+
*/
|
|
369
|
+
instantiate<T = unknown>(
|
|
370
|
+
_module: string,
|
|
371
|
+
_className: string,
|
|
372
|
+
_args: unknown[],
|
|
373
|
+
_kwargs?: Record<string, unknown>
|
|
374
|
+
): Promise<T> {
|
|
375
|
+
throw new BridgeExecutionError('ProcessIO is a transport, use BridgeProtocol for operations');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Not implemented - ProcessIO is a transport, not a full bridge.
|
|
380
|
+
*/
|
|
381
|
+
callMethod<T = unknown>(
|
|
382
|
+
_handle: string,
|
|
383
|
+
_methodName: string,
|
|
384
|
+
_args: unknown[],
|
|
385
|
+
_kwargs?: Record<string, unknown>
|
|
386
|
+
): Promise<T> {
|
|
387
|
+
throw new BridgeExecutionError('ProcessIO is a transport, use BridgeProtocol for operations');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Not implemented - ProcessIO is a transport, not a full bridge.
|
|
392
|
+
*/
|
|
393
|
+
disposeInstance(_handle: string): Promise<void> {
|
|
394
|
+
throw new BridgeExecutionError('ProcessIO is a transport, use BridgeProtocol for operations');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ===========================================================================
|
|
398
|
+
// PROCESS MANAGEMENT
|
|
399
|
+
// ===========================================================================
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Spawn the Python subprocess.
|
|
403
|
+
*/
|
|
404
|
+
private async spawnProcess(): Promise<void> {
|
|
405
|
+
// Build environment - use provided env or inherit from process.env
|
|
406
|
+
// If env is provided, it should be the complete environment (already filtered by NodeBridge)
|
|
407
|
+
// We only add Python-specific variables on top
|
|
408
|
+
const baseEnv = Object.keys(this.envOverrides).length > 0 ? this.envOverrides : process.env;
|
|
409
|
+
const env: NodeJS.ProcessEnv = {
|
|
410
|
+
...baseEnv,
|
|
411
|
+
// Ensure Python uses UTF-8
|
|
412
|
+
PYTHONUTF8: '1',
|
|
413
|
+
PYTHONIOENCODING: 'UTF-8',
|
|
414
|
+
// Disable Python buffering
|
|
415
|
+
PYTHONUNBUFFERED: '1',
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// Spawn process
|
|
419
|
+
this.process = spawn(this.pythonPath, [this.bridgeScript], {
|
|
420
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
421
|
+
env,
|
|
422
|
+
cwd: this.cwd,
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
this.processExited = false;
|
|
426
|
+
this.processError = null;
|
|
427
|
+
|
|
428
|
+
// Set up event handlers
|
|
429
|
+
this.process.on('error', this.handleProcessError.bind(this));
|
|
430
|
+
this.process.on('exit', this.handleProcessExit.bind(this));
|
|
431
|
+
|
|
432
|
+
if (this.process.stdout) {
|
|
433
|
+
this.process.stdout.on('data', this.handleStdoutData.bind(this));
|
|
434
|
+
this.process.stdout.on('error', this.handleStdoutError.bind(this));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (this.process.stderr) {
|
|
438
|
+
this.process.stderr.on('data', this.handleStderrData.bind(this));
|
|
439
|
+
this.process.stderr.on('error', this.handleStderrError.bind(this));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (this.process.stdin) {
|
|
443
|
+
this.process.stdin.on('drain', this.handleStdinDrain.bind(this));
|
|
444
|
+
this.process.stdin.on('error', this.handleStdinError.bind(this));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Wait for process to be ready (first heartbeat could be here)
|
|
448
|
+
// For now, just resolve immediately - the process is spawned
|
|
449
|
+
await Promise.resolve();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Kill the Python subprocess.
|
|
454
|
+
*/
|
|
455
|
+
private async killProcess(): Promise<void> {
|
|
456
|
+
if (!this.process) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const proc = this.process;
|
|
461
|
+
this.process = null;
|
|
462
|
+
|
|
463
|
+
// Add a catch-all error handler to prevent uncaught exceptions during shutdown
|
|
464
|
+
// This must be added BEFORE removing other listeners and ending stdin
|
|
465
|
+
const noopErrorHandler = (): void => {
|
|
466
|
+
// Ignore errors during shutdown (e.g., EPIPE)
|
|
467
|
+
};
|
|
468
|
+
proc.stdin?.on('error', noopErrorHandler);
|
|
469
|
+
proc.on('error', noopErrorHandler);
|
|
470
|
+
|
|
471
|
+
// Gracefully end stdin to prevent EPIPE on pending writes
|
|
472
|
+
try {
|
|
473
|
+
proc.stdin?.end();
|
|
474
|
+
} catch {
|
|
475
|
+
// Ignore errors ending stdin
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Remove other listeners to prevent callbacks after disposal
|
|
479
|
+
proc.removeAllListeners('exit');
|
|
480
|
+
proc.removeAllListeners('close');
|
|
481
|
+
proc.stdout?.removeAllListeners();
|
|
482
|
+
proc.stderr?.removeAllListeners();
|
|
483
|
+
|
|
484
|
+
// Kill the process
|
|
485
|
+
if (!proc.killed) {
|
|
486
|
+
proc.kill('SIGTERM');
|
|
487
|
+
|
|
488
|
+
// Wait briefly for graceful exit
|
|
489
|
+
await new Promise<void>(resolve => {
|
|
490
|
+
const timeout = setTimeout(() => {
|
|
491
|
+
if (!proc.killed) {
|
|
492
|
+
proc.kill('SIGKILL');
|
|
493
|
+
}
|
|
494
|
+
resolve();
|
|
495
|
+
}, 1000);
|
|
496
|
+
|
|
497
|
+
proc.once('exit', () => {
|
|
498
|
+
clearTimeout(timeout);
|
|
499
|
+
resolve();
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Restart the Python process.
|
|
507
|
+
*/
|
|
508
|
+
private async restartProcess(): Promise<void> {
|
|
509
|
+
// Kill existing process
|
|
510
|
+
await this.killProcess();
|
|
511
|
+
|
|
512
|
+
// Clear buffers and restart flags
|
|
513
|
+
this.stdoutBuffer = '';
|
|
514
|
+
this.stderrBuffer = '';
|
|
515
|
+
this.requestCount = 0;
|
|
516
|
+
this.needsRestart = false;
|
|
517
|
+
|
|
518
|
+
// Spawn new process
|
|
519
|
+
await this.spawnProcess();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Mark the process for restart on the next send.
|
|
524
|
+
* This is called after stream errors to ensure the next request uses a fresh process.
|
|
525
|
+
* Works independently of restartAfterRequests setting.
|
|
526
|
+
*/
|
|
527
|
+
private markForRestart(): void {
|
|
528
|
+
this.needsRestart = true;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ===========================================================================
|
|
532
|
+
// STREAM HANDLERS
|
|
533
|
+
// ===========================================================================
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Handle stdout data from the Python process.
|
|
537
|
+
*/
|
|
538
|
+
private handleStdoutData(chunk: Buffer | string): void {
|
|
539
|
+
this.stdoutBuffer += chunk.toString();
|
|
540
|
+
|
|
541
|
+
// Check for excessive line length without newline
|
|
542
|
+
if (
|
|
543
|
+
this.stdoutBuffer.length > this.maxLineLength &&
|
|
544
|
+
!this.stdoutBuffer.includes('\n')
|
|
545
|
+
) {
|
|
546
|
+
const snippet = this.stdoutBuffer.slice(0, 500);
|
|
547
|
+
this.stdoutBuffer = '';
|
|
548
|
+
this.handleProtocolError(
|
|
549
|
+
`Response line exceeded ${this.maxLineLength} bytes`,
|
|
550
|
+
snippet
|
|
551
|
+
);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Process complete lines
|
|
556
|
+
let newlineIndex: number;
|
|
557
|
+
while ((newlineIndex = this.stdoutBuffer.indexOf('\n')) !== -1) {
|
|
558
|
+
const line = this.stdoutBuffer.slice(0, newlineIndex);
|
|
559
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
|
560
|
+
|
|
561
|
+
// Skip empty lines
|
|
562
|
+
if (!line.trim()) {
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Check line length
|
|
567
|
+
if (line.length > this.maxLineLength) {
|
|
568
|
+
const snippet = line.slice(0, 500);
|
|
569
|
+
this.handleProtocolError(
|
|
570
|
+
`Response line exceeded ${this.maxLineLength} bytes`,
|
|
571
|
+
snippet
|
|
572
|
+
);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
this.handleResponseLine(line);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Handle a complete response line from stdout.
|
|
582
|
+
*/
|
|
583
|
+
private handleResponseLine(line: string): void {
|
|
584
|
+
// Extract ID to find pending request
|
|
585
|
+
const messageId = extractMessageId(line);
|
|
586
|
+
if (!messageId) {
|
|
587
|
+
this.handleProtocolError('Response missing "id" field', line);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const pending = this.pending.get(messageId);
|
|
592
|
+
if (!pending) {
|
|
593
|
+
// Response for unknown request - could be for a timed-out request
|
|
594
|
+
// Log but don't fail
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Remove from pending
|
|
599
|
+
this.pending.delete(messageId);
|
|
600
|
+
|
|
601
|
+
// Clear timeout
|
|
602
|
+
if (pending.timer) {
|
|
603
|
+
clearTimeout(pending.timer);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Resolve with raw response
|
|
607
|
+
pending.resolve(line);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Handle stderr data from the Python process.
|
|
612
|
+
*/
|
|
613
|
+
private handleStderrData(chunk: Buffer | string): void {
|
|
614
|
+
try {
|
|
615
|
+
this.stderrBuffer += sanitizeStderr(chunk.toString());
|
|
616
|
+
|
|
617
|
+
// Keep only the tail
|
|
618
|
+
if (this.stderrBuffer.length > MAX_STDERR_BYTES) {
|
|
619
|
+
this.stderrBuffer = this.stderrBuffer.slice(
|
|
620
|
+
this.stderrBuffer.length - MAX_STDERR_BYTES
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
} catch {
|
|
624
|
+
// Ignore stderr buffering errors
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Handle process error event.
|
|
630
|
+
*/
|
|
631
|
+
private handleProcessError(err: Error): void {
|
|
632
|
+
this.processError = err;
|
|
633
|
+
this.processExited = true;
|
|
634
|
+
|
|
635
|
+
const msg = `Python process error: ${err.message}`;
|
|
636
|
+
const error = new BridgeProtocolError(msg);
|
|
637
|
+
|
|
638
|
+
this.rejectAllPending(error);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Handle process exit event.
|
|
643
|
+
*/
|
|
644
|
+
private handleProcessExit(code: number | null, signal: string | null): void {
|
|
645
|
+
this.processExited = true;
|
|
646
|
+
|
|
647
|
+
const stderrTail = this.getStderrTail();
|
|
648
|
+
let msg: string;
|
|
649
|
+
|
|
650
|
+
if (signal) {
|
|
651
|
+
msg = `Python process killed by signal ${signal}`;
|
|
652
|
+
} else if (code !== null && code !== 0) {
|
|
653
|
+
msg = `Python process exited with code ${code}`;
|
|
654
|
+
} else {
|
|
655
|
+
msg = 'Python process exited';
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (stderrTail) {
|
|
659
|
+
msg += `. Stderr:\n${stderrTail}`;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const error = new BridgeProtocolError(msg);
|
|
663
|
+
this.rejectAllPending(error);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Handle stdin drain event (backpressure relief).
|
|
668
|
+
*/
|
|
669
|
+
private handleStdinDrain(): void {
|
|
670
|
+
this.draining = false;
|
|
671
|
+
this.flushWriteQueue();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Handle stdin error event.
|
|
676
|
+
*/
|
|
677
|
+
private handleStdinError(err: Error): void {
|
|
678
|
+
// EPIPE means process died
|
|
679
|
+
const error = new BridgeProtocolError(`stdin error: ${err.message}`);
|
|
680
|
+
|
|
681
|
+
// Reject all pending writes
|
|
682
|
+
for (const queued of this.writeQueue) {
|
|
683
|
+
this.clearQueuedWriteTimeout(queued);
|
|
684
|
+
queued.reject(error);
|
|
685
|
+
}
|
|
686
|
+
this.writeQueue.length = 0;
|
|
687
|
+
|
|
688
|
+
// Reject all pending requests
|
|
689
|
+
this.rejectAllPending(error);
|
|
690
|
+
|
|
691
|
+
// Mark for restart on next send
|
|
692
|
+
this.markForRestart();
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Handle stdout error event.
|
|
697
|
+
* This can occur during pipe errors or when the process crashes.
|
|
698
|
+
*/
|
|
699
|
+
private handleStdoutError(err: Error): void {
|
|
700
|
+
const error = new BridgeProtocolError(`stdout error: ${err.message}`);
|
|
701
|
+
this.rejectAllPending(error);
|
|
702
|
+
this.markForRestart();
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Handle stderr error event.
|
|
707
|
+
* This can occur during pipe errors or when the process crashes.
|
|
708
|
+
*/
|
|
709
|
+
private handleStderrError(err: Error): void {
|
|
710
|
+
// Stderr errors are less critical but still indicate process health issues
|
|
711
|
+
const error = new BridgeProtocolError(`stderr error: ${err.message}`);
|
|
712
|
+
this.rejectAllPending(error);
|
|
713
|
+
this.markForRestart();
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ===========================================================================
|
|
717
|
+
// WRITE MANAGEMENT
|
|
718
|
+
// ===========================================================================
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Create a queued write entry with a timeout timer.
|
|
722
|
+
* The timer fires if the drain event never comes.
|
|
723
|
+
*/
|
|
724
|
+
private createQueuedWrite(
|
|
725
|
+
data: string,
|
|
726
|
+
resolve: () => void,
|
|
727
|
+
reject: (error: Error) => void
|
|
728
|
+
): QueuedWrite {
|
|
729
|
+
const queuedAt = Date.now();
|
|
730
|
+
const entry: QueuedWrite = { data, resolve, reject, queuedAt };
|
|
731
|
+
|
|
732
|
+
// Set up timeout timer that fires if drain never happens
|
|
733
|
+
entry.timeoutHandle = setTimeout(() => {
|
|
734
|
+
// Remove this entry from the queue
|
|
735
|
+
const index = this.writeQueue.indexOf(entry);
|
|
736
|
+
if (index !== -1) {
|
|
737
|
+
this.writeQueue.splice(index, 1);
|
|
738
|
+
reject(new BridgeTimeoutError(
|
|
739
|
+
`Write queue timeout: entry waited ${this.writeQueueTimeoutMs}ms without drain`
|
|
740
|
+
));
|
|
741
|
+
}
|
|
742
|
+
}, this.writeQueueTimeoutMs);
|
|
743
|
+
|
|
744
|
+
// Unref the timer so it doesn't keep the process alive
|
|
745
|
+
entry.timeoutHandle.unref();
|
|
746
|
+
|
|
747
|
+
return entry;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Clear the timeout for a queued write entry.
|
|
752
|
+
*/
|
|
753
|
+
private clearQueuedWriteTimeout(entry: QueuedWrite): void {
|
|
754
|
+
if (entry.timeoutHandle) {
|
|
755
|
+
clearTimeout(entry.timeoutHandle);
|
|
756
|
+
entry.timeoutHandle = undefined;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Write data to stdin with backpressure handling.
|
|
762
|
+
*/
|
|
763
|
+
private writeToStdin(data: string): Promise<void> {
|
|
764
|
+
return new Promise<void>((resolve, reject) => {
|
|
765
|
+
if (!this.process?.stdin || this.processExited) {
|
|
766
|
+
reject(new BridgeProtocolError('Process stdin not available'));
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if (this.draining || this.writeQueue.length > 0) {
|
|
771
|
+
// Queue the write with timestamp and timeout timer
|
|
772
|
+
this.writeQueue.push(this.createQueuedWrite(data, resolve, reject));
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Try direct write (wrap in try-catch for synchronous EPIPE errors)
|
|
777
|
+
try {
|
|
778
|
+
const canWrite = this.process.stdin.write(data);
|
|
779
|
+
|
|
780
|
+
if (canWrite) {
|
|
781
|
+
resolve();
|
|
782
|
+
} else {
|
|
783
|
+
// Backpressure - queue this write and set draining flag
|
|
784
|
+
this.draining = true;
|
|
785
|
+
this.writeQueue.push(this.createQueuedWrite(data, resolve, reject));
|
|
786
|
+
}
|
|
787
|
+
} catch (err) {
|
|
788
|
+
// Synchronous write error (e.g., EPIPE)
|
|
789
|
+
this.markForRestart();
|
|
790
|
+
reject(new BridgeProtocolError(`Write error: ${err instanceof Error ? err.message : 'unknown'}`));
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Flush queued writes when backpressure clears.
|
|
797
|
+
*/
|
|
798
|
+
private flushWriteQueue(): void {
|
|
799
|
+
const now = Date.now();
|
|
800
|
+
|
|
801
|
+
while (this.writeQueue.length > 0 && !this.draining) {
|
|
802
|
+
if (!this.process?.stdin || this.processExited) {
|
|
803
|
+
// Process died - reject all queued writes
|
|
804
|
+
for (const q of this.writeQueue) {
|
|
805
|
+
this.clearQueuedWriteTimeout(q);
|
|
806
|
+
q.reject(new BridgeProtocolError('Process stdin not available'));
|
|
807
|
+
}
|
|
808
|
+
this.writeQueue.length = 0;
|
|
809
|
+
this.markForRestart();
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const queued = this.writeQueue.shift();
|
|
814
|
+
if (!queued) {
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Clear the timeout since we're processing this entry now
|
|
819
|
+
this.clearQueuedWriteTimeout(queued);
|
|
820
|
+
|
|
821
|
+
// Check for write queue timeout (fallback check, timer should have handled this)
|
|
822
|
+
if (now - queued.queuedAt > this.writeQueueTimeoutMs) {
|
|
823
|
+
queued.reject(new BridgeTimeoutError(
|
|
824
|
+
`Write queue timeout: entry waited ${now - queued.queuedAt}ms (limit: ${this.writeQueueTimeoutMs}ms)`
|
|
825
|
+
));
|
|
826
|
+
continue; // Process next entry
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
try {
|
|
830
|
+
const canWrite = this.process.stdin.write(queued.data);
|
|
831
|
+
|
|
832
|
+
if (canWrite) {
|
|
833
|
+
queued.resolve();
|
|
834
|
+
} else {
|
|
835
|
+
// Still under pressure - put it back with a new timeout
|
|
836
|
+
this.writeQueue.unshift(this.createQueuedWrite(
|
|
837
|
+
queued.data,
|
|
838
|
+
queued.resolve,
|
|
839
|
+
queued.reject
|
|
840
|
+
));
|
|
841
|
+
this.draining = true;
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
} catch (err) {
|
|
845
|
+
// Synchronous write error (e.g., EPIPE) - reject this and all remaining writes
|
|
846
|
+
const error = new BridgeProtocolError(
|
|
847
|
+
`Write error: ${err instanceof Error ? err.message : 'unknown'}`
|
|
848
|
+
);
|
|
849
|
+
queued.reject(error);
|
|
850
|
+
for (const q of this.writeQueue) {
|
|
851
|
+
this.clearQueuedWriteTimeout(q);
|
|
852
|
+
q.reject(error);
|
|
853
|
+
}
|
|
854
|
+
this.writeQueue.length = 0;
|
|
855
|
+
this.markForRestart();
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ===========================================================================
|
|
862
|
+
// HELPERS
|
|
863
|
+
// ===========================================================================
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Get the tail of stderr for diagnostics.
|
|
867
|
+
*/
|
|
868
|
+
private getStderrTail(): string {
|
|
869
|
+
return this.stderrBuffer.trim();
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Handle a protocol error by rejecting all pending requests.
|
|
874
|
+
*/
|
|
875
|
+
private handleProtocolError(details: string, line?: string): void {
|
|
876
|
+
const snippet = line
|
|
877
|
+
? line.length > 500 ? `${line.slice(0, 500)}...` : line
|
|
878
|
+
: undefined;
|
|
879
|
+
|
|
880
|
+
const hint = 'Ensure Python code does not print to stdout and bridge outputs only JSON lines.';
|
|
881
|
+
|
|
882
|
+
const msg = snippet
|
|
883
|
+
? `Protocol error: ${details}\n${hint}\nOffending line: ${snippet}`
|
|
884
|
+
: `Protocol error: ${details}\n${hint}`;
|
|
885
|
+
|
|
886
|
+
const error = new BridgeProtocolError(msg);
|
|
887
|
+
this.rejectAllPending(error);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Reject all pending requests with an error.
|
|
892
|
+
*/
|
|
893
|
+
private rejectAllPending(error: Error): void {
|
|
894
|
+
for (const [, pending] of this.pending) {
|
|
895
|
+
if (pending.timer) {
|
|
896
|
+
clearTimeout(pending.timer);
|
|
897
|
+
}
|
|
898
|
+
pending.reject(error);
|
|
899
|
+
}
|
|
900
|
+
this.pending.clear();
|
|
901
|
+
}
|
|
902
|
+
}
|