@push.rocks/smartrust 1.5.0 → 1.6.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.
@@ -1,7 +1,13 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import { LineScanner } from './classes.linescanner.js';
3
3
  import type { IRustBridgeLogger } from './interfaces/index.js';
4
- import type { IRustTransport } from './interfaces/transport.js';
4
+ import type {
5
+ IRustTransport,
6
+ IRustTransportDisconnectOptions,
7
+ } from './interfaces/transport.js';
8
+
9
+ const DEFAULT_DISCONNECT_GRACE_PERIOD_MS = 5000;
10
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
5
11
 
6
12
  export interface IStdioTransportOptions {
7
13
  binaryPath: string;
@@ -11,6 +17,21 @@ export interface IStdioTransportOptions {
11
17
  logger: IRustBridgeLogger;
12
18
  }
13
19
 
20
+ interface IStdioTransportGeneration {
21
+ childProcess: plugins.childProcess.ChildProcess;
22
+ stderrRemainder: string;
23
+ exited: boolean;
24
+ closed: boolean;
25
+ closePromise: Promise<void>;
26
+ resolveClose: () => void;
27
+ disconnectPromise: Promise<void> | null;
28
+ rejectDisconnect: ((error: Error) => void) | null;
29
+ forceKillTimer: ReturnType<typeof setTimeout> | null;
30
+ sigtermAttempted: boolean;
31
+ sigkillAttempted: boolean;
32
+ sigtermError: Error | null;
33
+ }
34
+
14
35
  /**
15
36
  * Transport that spawns a child process and communicates via stdin/stdout.
16
37
  * Extracted from the original RustBridge process management logic.
@@ -18,8 +39,8 @@ export interface IStdioTransportOptions {
18
39
  export class StdioTransport extends plugins.events.EventEmitter implements IRustTransport {
19
40
  private options: IStdioTransportOptions;
20
41
  private childProcess: plugins.childProcess.ChildProcess | null = null;
42
+ private generation: IStdioTransportGeneration | null = null;
21
43
  private lineScanner: LineScanner;
22
- private stderrRemainder: string = '';
23
44
  private _connected: boolean = false;
24
45
 
25
46
  constructor(options: IStdioTransportOptions) {
@@ -35,22 +56,59 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
35
56
  /**
36
57
  * Spawn the child process. Resolves when the process is running (not necessarily ready).
37
58
  */
38
- public async connect(): Promise<void> {
59
+ public connect(): Promise<void> {
60
+ if (this.generation && !this.generation.closed) {
61
+ return Promise.reject(
62
+ new Error('Stdio transport cannot connect until the previous child has closed'),
63
+ );
64
+ }
65
+
39
66
  const env = this.options.env
40
67
  ? { ...process.env, ...this.options.env }
41
68
  : { ...process.env };
42
69
 
43
- this.childProcess = plugins.childProcess.spawn(
44
- this.options.binaryPath,
45
- this.options.cliArgs,
46
- { stdio: ['pipe', 'pipe', 'pipe'], env },
47
- );
70
+ let childProcess: plugins.childProcess.ChildProcess;
71
+ try {
72
+ childProcess = plugins.childProcess.spawn(
73
+ this.options.binaryPath,
74
+ this.options.cliArgs,
75
+ { stdio: ['pipe', 'pipe', 'pipe'], env },
76
+ );
77
+ } catch (error: unknown) {
78
+ return Promise.reject(this.toError(error, 'Failed to spawn child process'));
79
+ }
48
80
 
49
- // Handle stderr with cross-chunk buffering
50
- this.childProcess.stderr?.on('data', (data: Buffer) => {
51
- this.stderrRemainder += data.toString();
52
- const lines = this.stderrRemainder.split('\n');
53
- this.stderrRemainder = lines.pop()!;
81
+ let resolveClose!: () => void;
82
+ const closePromise = new Promise<void>((resolve) => {
83
+ resolveClose = resolve;
84
+ });
85
+ const generation: IStdioTransportGeneration = {
86
+ childProcess,
87
+ stderrRemainder: '',
88
+ exited: false,
89
+ closed: false,
90
+ closePromise,
91
+ resolveClose,
92
+ disconnectPromise: null,
93
+ rejectDisconnect: null,
94
+ forceKillTimer: null,
95
+ sigtermAttempted: false,
96
+ sigkillAttempted: false,
97
+ sigtermError: null,
98
+ };
99
+
100
+ this.generation = generation;
101
+ this.childProcess = childProcess;
102
+
103
+ // Handle stderr with cross-chunk buffering.
104
+ childProcess.stderr?.on('data', (data: Buffer) => {
105
+ if (this.generation !== generation || generation.closed) {
106
+ return;
107
+ }
108
+
109
+ generation.stderrRemainder += data.toString();
110
+ const lines = generation.stderrRemainder.split('\n');
111
+ generation.stderrRemainder = lines.pop()!;
54
112
  for (const line of lines) {
55
113
  const trimmed = line.trim();
56
114
  if (trimmed) {
@@ -59,53 +117,125 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
59
117
  }
60
118
  });
61
119
 
62
- // Handle stdout via LineScanner
63
- this.childProcess.stdout!.on('data', (chunk: Buffer) => {
120
+ // Handle stdout via LineScanner.
121
+ childProcess.stdout!.on('data', (chunk: Buffer) => {
122
+ if (this.generation !== generation || generation.closed) {
123
+ return;
124
+ }
125
+
64
126
  this.lineScanner.push(chunk, (line) => {
65
- this.emit('line', line);
127
+ if (this.generation === generation && !generation.closed) {
128
+ this.emit('line', line);
129
+ }
130
+ });
131
+ });
132
+
133
+ childProcess.on('exit', () => {
134
+ generation.exited = true;
135
+ if (this.generation === generation) {
136
+ this._connected = false;
137
+ }
138
+
139
+ // A descendant may inherit the child's stdio descriptors and otherwise
140
+ // keep ChildProcess 'close' from ever firing after the owned child exits.
141
+ // Allow already-queued data events through this poll turn, then close our
142
+ // endpoints so confirmation cannot retain that pipe graph indefinitely.
143
+ setImmediate(() => {
144
+ if (generation.closed) {
145
+ return;
146
+ }
147
+ this.destroyOwnedStdioEndpoint(generation, 'stdin');
148
+ this.destroyOwnedStdioEndpoint(generation, 'stdout');
149
+ this.destroyOwnedStdioEndpoint(generation, 'stderr');
66
150
  });
67
151
  });
68
152
 
69
- // Handle process exit
70
- this.childProcess.on('exit', (code: number | null, signal: string | null) => {
71
- // Flush remaining stderr
72
- if (this.stderrRemainder.trim()) {
73
- this.emit('stderr', this.stderrRemainder.trim());
153
+ // ChildProcess 'close' is the termination confirmation: the process has exited
154
+ // and its stdio streams have closed.
155
+ childProcess.on('close', (code: number | null, signal: NodeJS.Signals | null) => {
156
+ if (generation.closed) {
157
+ return;
158
+ }
159
+
160
+ // Flush stderr while this generation is still considered active so a
161
+ // synchronous listener cannot start a replacement child mid-cleanup.
162
+ if (
163
+ this.generation === generation
164
+ && generation.stderrRemainder.trim()
165
+ ) {
166
+ this.emit('stderr', generation.stderrRemainder.trim());
74
167
  }
168
+ generation.stderrRemainder = '';
169
+ generation.exited = true;
170
+ generation.closed = true;
171
+ if (generation.forceKillTimer) {
172
+ clearTimeout(generation.forceKillTimer);
173
+ generation.forceKillTimer = null;
174
+ }
175
+ generation.resolveClose();
176
+
177
+ if (this.generation !== generation) {
178
+ return;
179
+ }
180
+
75
181
  this._connected = false;
182
+ this.childProcess = null;
76
183
  this.lineScanner.clear();
77
- this.stderrRemainder = '';
78
184
  this.emit('close', code, signal);
79
185
  });
80
186
 
81
- this.childProcess.on('error', (err: Error) => {
187
+ childProcess.on('error', (error: Error) => {
188
+ if (this.generation !== generation || generation.closed) {
189
+ return;
190
+ }
191
+
82
192
  this._connected = false;
83
- this.emit('error', err);
193
+ if (generation.sigkillAttempted && generation.rejectDisconnect) {
194
+ generation.rejectDisconnect(
195
+ this.toError(error, 'Unable to confirm child termination after SIGKILL'),
196
+ );
197
+ }
198
+
199
+ if (this.listenerCount('error') > 0) {
200
+ this.emit('error', error);
201
+ } else if (generation.disconnectPromise) {
202
+ this.options.logger.log('error', 'Stdio transport error during disconnect: ' + error.message);
203
+ } else {
204
+ this.emit('error', error);
205
+ }
84
206
  });
85
207
 
86
208
  this._connected = true;
209
+ return Promise.resolve();
87
210
  }
88
211
 
89
212
  /**
90
213
  * Write data to stdin with backpressure support.
91
214
  */
92
- public async write(data: string): Promise<void> {
215
+ public write(data: string): Promise<void> {
93
216
  return new Promise<void>((resolve, reject) => {
94
- if (!this.childProcess?.stdin) {
217
+ const generation = this.generation;
218
+ if (
219
+ !generation
220
+ || generation.closed
221
+ || generation.childProcess !== this.childProcess
222
+ || !this._connected
223
+ || !generation.childProcess.stdin
224
+ ) {
95
225
  reject(new Error('stdin not available'));
96
226
  return;
97
227
  }
98
228
 
99
- const canContinue = this.childProcess.stdin.write(data, 'utf8', (err) => {
100
- if (err) {
101
- reject(err);
229
+ const canContinue = generation.childProcess.stdin.write(data, 'utf8', (error) => {
230
+ if (error) {
231
+ reject(error);
102
232
  }
103
233
  });
104
234
 
105
235
  if (canContinue) {
106
236
  resolve();
107
237
  } else {
108
- this.childProcess.stdin.once('drain', () => {
238
+ generation.childProcess.stdin.once('drain', () => {
109
239
  resolve();
110
240
  });
111
241
  }
@@ -113,37 +243,211 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
113
243
  }
114
244
 
115
245
  /**
116
- * Kill the child process. Sends SIGTERM, then SIGKILL after 5s.
246
+ * Kill the child process without waiting for confirmation.
117
247
  */
118
248
  public disconnect(): void {
119
- if (!this.childProcess) return;
249
+ try {
250
+ void this.disconnectAndWait().catch((error: unknown) => {
251
+ this.options.logger.log(
252
+ 'error',
253
+ 'Failed to disconnect stdio transport: ' + this.getErrorMessage(error),
254
+ );
255
+ });
256
+ } catch (error: unknown) {
257
+ this.options.logger.log(
258
+ 'error',
259
+ 'Failed to disconnect stdio transport: ' + this.getErrorMessage(error),
260
+ );
261
+ }
262
+ }
120
263
 
121
- const proc = this.childProcess;
122
- this.childProcess = null;
264
+ /**
265
+ * Send SIGTERM, escalate to SIGKILL after the grace period, and wait for
266
+ * ChildProcess 'close' to confirm termination.
267
+ */
268
+ public disconnectAndWait(
269
+ options: IRustTransportDisconnectOptions = {},
270
+ ): Promise<void> {
271
+ const gracePeriodMs = options.gracePeriodMs ?? DEFAULT_DISCONNECT_GRACE_PERIOD_MS;
272
+ if (!Number.isSafeInteger(gracePeriodMs) || gracePeriodMs < 0) {
273
+ throw new RangeError(
274
+ 'StdioTransport gracePeriodMs must be a non-negative safe integer',
275
+ );
276
+ }
277
+
278
+ const generation = this.generation;
279
+ if (!generation) {
280
+ return Promise.resolve();
281
+ }
282
+ if (generation.disconnectPromise) {
283
+ return generation.disconnectPromise;
284
+ }
285
+ if (generation.closed) {
286
+ generation.disconnectPromise = generation.closePromise;
287
+ return generation.disconnectPromise;
288
+ }
289
+
290
+ let rejectDisconnect!: (error: Error) => void;
291
+ const disconnectPromise = new Promise<void>((resolve, reject) => {
292
+ rejectDisconnect = reject;
293
+ void generation.closePromise.then(resolve);
294
+ });
295
+ generation.disconnectPromise = disconnectPromise;
296
+ generation.rejectDisconnect = rejectDisconnect;
123
297
  this._connected = false;
124
- this.lineScanner.clear();
125
- this.stderrRemainder = '';
126
-
127
- // Remove all listeners
128
- proc.removeAllListeners();
129
- proc.stdout?.removeAllListeners();
130
- proc.stderr?.removeAllListeners();
131
- proc.stdin?.removeAllListeners();
132
-
133
- // Kill the process
134
- try { proc.kill('SIGTERM'); } catch { /* already dead */ }
135
-
136
- // Destroy stdio pipes
137
- try { proc.stdin?.destroy(); } catch { /* ignore */ }
138
- try { proc.stdout?.destroy(); } catch { /* ignore */ }
139
- try { proc.stderr?.destroy(); } catch { /* ignore */ }
140
-
141
- // Unref so Node doesn't wait
142
- try { proc.unref(); } catch { /* ignore */ }
143
-
144
- // Force kill after 5 seconds
145
- setTimeout(() => {
146
- try { proc.kill('SIGKILL'); } catch { /* already dead */ }
147
- }, 5000).unref();
298
+ void disconnectPromise.then(
299
+ () => {
300
+ if (generation.disconnectPromise === disconnectPromise) {
301
+ generation.rejectDisconnect = null;
302
+ }
303
+ },
304
+ () => {
305
+ if (generation.disconnectPromise !== disconnectPromise) {
306
+ return;
307
+ }
308
+ if (generation.forceKillTimer) {
309
+ clearTimeout(generation.forceKillTimer);
310
+ generation.forceKillTimer = null;
311
+ }
312
+ generation.disconnectPromise = null;
313
+ generation.rejectDisconnect = null;
314
+ generation.sigtermAttempted = false;
315
+ generation.sigkillAttempted = false;
316
+ generation.sigtermError = null;
317
+ },
318
+ );
319
+
320
+ if (this.hasExited(generation)) {
321
+ this.destroyOwnedStdioEndpoint(generation, 'stdin');
322
+ this.destroyOwnedStdioEndpoint(generation, 'stdout');
323
+ this.destroyOwnedStdioEndpoint(generation, 'stderr');
324
+ return disconnectPromise;
325
+ }
326
+
327
+ generation.sigtermAttempted = true;
328
+ generation.sigtermError = this.sendSignal(generation, 'SIGTERM');
329
+
330
+ if (generation.closed || this.hasExited(generation)) {
331
+ return disconnectPromise;
332
+ }
333
+
334
+ if (gracePeriodMs === 0) {
335
+ this.forceKill(generation);
336
+ } else {
337
+ this.scheduleForceKill(generation, gracePeriodMs);
338
+ }
339
+
340
+ return disconnectPromise;
341
+ }
342
+
343
+ private scheduleForceKill(
344
+ generation: IStdioTransportGeneration,
345
+ remainingMs: number,
346
+ ): void {
347
+ if (generation.closed || this.hasExited(generation)) {
348
+ return;
349
+ }
350
+
351
+ const delayMs = Math.min(remainingMs, MAX_TIMER_DELAY_MS);
352
+ const scheduledAt = Date.now();
353
+ try {
354
+ generation.forceKillTimer = setTimeout(() => {
355
+ generation.forceKillTimer = null;
356
+ if (generation.closed || this.hasExited(generation)) {
357
+ return;
358
+ }
359
+
360
+ const elapsedMs = Math.max(1, Date.now() - scheduledAt);
361
+ const nextRemainingMs = remainingMs - elapsedMs;
362
+ if (nextRemainingMs > 0) {
363
+ this.scheduleForceKill(generation, nextRemainingMs);
364
+ } else {
365
+ this.forceKill(generation);
366
+ }
367
+ }, delayMs);
368
+ } catch (error: unknown) {
369
+ generation.rejectDisconnect?.(
370
+ this.toError(error, 'Unable to schedule SIGKILL escalation'),
371
+ );
372
+ }
373
+ }
374
+
375
+ private forceKill(generation: IStdioTransportGeneration): void {
376
+ if (
377
+ generation.closed
378
+ || generation.sigkillAttempted
379
+ || this.hasExited(generation)
380
+ ) {
381
+ return;
382
+ }
383
+
384
+ generation.sigkillAttempted = true;
385
+ const sigkillError = this.sendSignal(generation, 'SIGKILL');
386
+ if (sigkillError && !generation.closed && !this.hasExited(generation)) {
387
+ const sigtermDetail = generation.sigtermError
388
+ ? ' SIGTERM also failed: ' + generation.sigtermError.message + '.'
389
+ : '';
390
+ generation.rejectDisconnect?.(
391
+ new Error(sigkillError.message + '.' + sigtermDetail),
392
+ );
393
+ }
394
+ }
395
+
396
+ private sendSignal(
397
+ generation: IStdioTransportGeneration,
398
+ signal: NodeJS.Signals,
399
+ ): Error | null {
400
+ try {
401
+ const delivered = generation.childProcess.kill(signal);
402
+ if (delivered || generation.closed || this.hasExited(generation)) {
403
+ return null;
404
+ }
405
+ return new Error('Failed to send ' + signal + ' to child process');
406
+ } catch (error: unknown) {
407
+ if (generation.closed || this.hasExited(generation)) {
408
+ return null;
409
+ }
410
+ return this.toError(error, 'Failed to send ' + signal + ' to child process');
411
+ }
412
+ }
413
+
414
+ private hasExited(generation: IStdioTransportGeneration): boolean {
415
+ return (
416
+ generation.exited
417
+ || generation.childProcess.exitCode !== null
418
+ || generation.childProcess.signalCode !== null
419
+ );
420
+ }
421
+
422
+ private destroyOwnedStdioEndpoint(
423
+ generation: IStdioTransportGeneration,
424
+ endpointName: 'stdin' | 'stdout' | 'stderr',
425
+ ): void {
426
+ const endpoint = generation.childProcess[endpointName];
427
+ if (!endpoint || endpoint.destroyed) {
428
+ return;
429
+ }
430
+
431
+ try {
432
+ endpoint.destroy();
433
+ } catch (error: unknown) {
434
+ const endpointError = this.toError(
435
+ error,
436
+ 'Failed to close inherited child ' + endpointName,
437
+ );
438
+ generation.rejectDisconnect?.(endpointError);
439
+ this.options.logger.log('error', endpointError.message);
440
+ if (this.listenerCount('error') > 0) {
441
+ this.emit('error', endpointError);
442
+ }
443
+ }
444
+ }
445
+
446
+ private toError(error: unknown, prefix: string): Error {
447
+ return new Error(prefix + ': ' + this.getErrorMessage(error));
448
+ }
449
+
450
+ private getErrorMessage(error: unknown): string {
451
+ return error instanceof Error ? error.message : String(error);
148
452
  }
149
453
  }
@@ -15,6 +15,15 @@ export interface IRustBridgeRequestOptions {
15
15
  timeoutMs?: number;
16
16
  }
17
17
 
18
+ /** Options for confirmed bridge termination. */
19
+ export interface IRustBridgeTerminateOptions {
20
+ /**
21
+ * Time to allow an owned stdio child to exit after SIGTERM before SIGKILL.
22
+ * Zero requests immediate SIGKILL. Defaults to 5000 milliseconds.
23
+ */
24
+ gracePeriodMs?: number;
25
+ }
26
+
18
27
  /**
19
28
  * Options for locating a Rust binary.
20
29
  */
@@ -1,5 +1,11 @@
1
1
  import type * as plugins from '../plugins.js';
2
2
 
3
+ /** Options for confirmed transport disconnection. */
4
+ export interface IRustTransportDisconnectOptions {
5
+ /** Grace period before escalating process termination, in milliseconds. */
6
+ gracePeriodMs?: number;
7
+ }
8
+
3
9
  /**
4
10
  * Abstract transport for communicating with a Rust process.
5
11
  * Both stdio and socket transports implement this interface.
@@ -21,6 +27,12 @@ export interface IRustTransport extends plugins.events.EventEmitter {
21
27
  /** Disconnect the transport. For stdio: kills the process. For socket: closes the connection. */
22
28
  disconnect(): void;
23
29
 
30
+ /** Disconnect and resolve only after the local transport has confirmed closure. */
31
+ disconnectAndWait?(options?: IRustTransportDisconnectOptions): Promise<void>;
32
+
24
33
  /** Whether the transport is currently connected and writable. */
25
34
  readonly connected: boolean;
35
+
36
+ /** Whether the transport is preserving its lifecycle for an automatic reconnect. */
37
+ readonly reconnecting?: boolean;
26
38
  }