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