@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.
@@ -7,6 +7,7 @@ import { SocketTransport } from './classes.sockettransport.js';
7
7
  import type {
8
8
  IRustBridgeOptions,
9
9
  IRustBridgeRequestOptions,
10
+ IRustBridgeTerminateOptions,
10
11
  IRustBridgeLogger,
11
12
  ISocketConnectOptions,
12
13
  TCommandMap,
@@ -16,6 +17,7 @@ import type {
16
17
  TStreamingCommandKeys,
17
18
  TExtractChunk,
18
19
  IRustTransport,
20
+ IRustTransportDisconnectOptions,
19
21
  } from './interfaces/index.js';
20
22
 
21
23
  const defaultLogger: IRustBridgeLogger = {
@@ -36,6 +38,12 @@ type TPendingRequestSettlement =
36
38
  | { outcome: 'resolve'; value: any }
37
39
  | { outcome: 'reject'; error: Error };
38
40
 
41
+ type TRustBridgeLifecycleState = 'idle' | 'starting' | 'ready' | 'stopping';
42
+
43
+ type TConfirmableRustTransport = IRustTransport & {
44
+ disconnectAndWait(optionsArg?: IRustTransportDisconnectOptions): Promise<void>;
45
+ };
46
+
39
47
  /**
40
48
  * Generic bridge between TypeScript and a Rust binary.
41
49
  * Communicates via JSON-over-stdin/stdout IPC protocol (stdio mode)
@@ -47,11 +55,15 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
47
55
  private locator: RustBinaryLocator;
48
56
  private options: Required<Pick<IRustBridgeOptions, 'cliArgs' | 'requestTimeoutMs' | 'readyTimeoutMs' | 'readyEventName' | 'maxPayloadSize'>> & IRustBridgeOptions;
49
57
  private logger: IRustBridgeLogger;
50
- private transport: IRustTransport | null = null;
58
+ private transport: TConfirmableRustTransport | null = null;
51
59
  private pendingRequests = new Map<string, IPendingRequest>();
52
60
  private requestCounter = 0;
53
61
  private isRunning = false;
54
62
  private binaryPath: string | null = null;
63
+ private lifecycleState: TRustBridgeLifecycleState = 'idle';
64
+ private lifecycleGeneration = 0;
65
+ private activationPromise: Promise<boolean> | null = null;
66
+ private terminationPromise: Promise<void> | null = null;
55
67
 
56
68
  constructor(options: IRustBridgeOptions) {
57
69
  super();
@@ -71,21 +83,25 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
71
83
  * Spawn the Rust binary and wait for it to signal readiness.
72
84
  * Returns true if the binary was found and spawned successfully.
73
85
  */
74
- public async spawn(): Promise<boolean> {
75
- this.binaryPath = await this.locator.findBinary();
76
- if (!this.binaryPath) {
77
- return false;
78
- }
86
+ public spawn(): Promise<boolean> {
87
+ return this.activate(async (generationArg) => {
88
+ const binaryPath = await this.locator.findBinary();
89
+ if (!this.isStartingGeneration(generationArg)) {
90
+ return null;
91
+ }
92
+ this.binaryPath = binaryPath;
93
+ if (!binaryPath) {
94
+ return null;
95
+ }
79
96
 
80
- const transport = new StdioTransport({
81
- binaryPath: this.binaryPath,
82
- cliArgs: this.options.cliArgs,
83
- env: this.options.env,
84
- maxPayloadSize: this.options.maxPayloadSize,
85
- logger: this.logger,
97
+ return new StdioTransport({
98
+ binaryPath,
99
+ cliArgs: this.options.cliArgs,
100
+ env: this.options.env,
101
+ maxPayloadSize: this.options.maxPayloadSize,
102
+ logger: this.logger,
103
+ });
86
104
  });
87
-
88
- return this.connectWithTransport(transport);
89
105
  }
90
106
 
91
107
  /**
@@ -95,8 +111,8 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
95
111
  * @param socketPath - Path to Unix socket or Windows named pipe
96
112
  * @param socketOptions - Optional socket connection options (reconnect, etc.)
97
113
  */
98
- public async connect(socketPath: string, socketOptions?: ISocketConnectOptions): Promise<boolean> {
99
- const transport = new SocketTransport({
114
+ public connect(socketPath: string, socketOptions?: ISocketConnectOptions): Promise<boolean> {
115
+ return this.activate(async () => new SocketTransport({
100
116
  socketPath,
101
117
  maxPayloadSize: this.options.maxPayloadSize,
102
118
  logger: this.logger,
@@ -104,68 +120,252 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
104
120
  reconnectBaseDelayMs: socketOptions?.reconnectBaseDelayMs,
105
121
  reconnectMaxDelayMs: socketOptions?.reconnectMaxDelayMs,
106
122
  maxReconnectAttempts: socketOptions?.maxReconnectAttempts,
107
- });
123
+ }));
124
+ }
108
125
 
109
- return this.connectWithTransport(transport);
126
+ /** Reserve activation synchronously, or sequence it after confirmed termination. */
127
+ private activate(
128
+ createTransportArg: (generationArg: number) => Promise<TConfirmableRustTransport | null>,
129
+ ): Promise<boolean> {
130
+ if (this.lifecycleState === 'stopping' && this.terminationPromise) {
131
+ return this.terminationPromise.then(() => this.beginActivation(createTransportArg));
132
+ }
133
+ if (this.lifecycleState !== 'idle') {
134
+ return Promise.reject(
135
+ new Error(`RustBridge cannot activate while lifecycle state is '${this.lifecycleState}'`),
136
+ );
137
+ }
138
+ return this.beginActivation(createTransportArg);
110
139
  }
111
140
 
112
- /**
113
- * Internal: wire up any transport and wait for the ready handshake.
114
- */
115
- private connectWithTransport(transport: IRustTransport): Promise<boolean> {
116
- return new Promise<boolean>((resolve) => {
141
+ /** Start one transport generation. A second concurrent activation is rejected. */
142
+ private beginActivation(
143
+ createTransportArg: (generationArg: number) => Promise<TConfirmableRustTransport | null>,
144
+ ): Promise<boolean> {
145
+ if (this.lifecycleState !== 'idle') {
146
+ throw new Error(`RustBridge cannot activate while lifecycle state is '${this.lifecycleState}'`);
147
+ }
148
+
149
+ const generation = ++this.lifecycleGeneration;
150
+ this.lifecycleState = 'starting';
151
+
152
+ const activationPromise = (async (): Promise<boolean> => {
117
153
  try {
154
+ const transport = await createTransportArg(generation);
155
+ if (!transport || !this.isStartingGeneration(generation)) {
156
+ if (this.isStartingGeneration(generation)) {
157
+ this.lifecycleState = 'idle';
158
+ }
159
+ return false;
160
+ }
161
+
118
162
  this.transport = transport;
163
+ return await this.connectWithTransport(transport, generation);
164
+ } catch (error: unknown) {
165
+ this.logger.log('error', `Failed to connect: ${this.getErrorMessage(error)}`);
166
+ if (this.isStartingGeneration(generation)) {
167
+ this.lifecycleState = 'idle';
168
+ }
169
+ return false;
170
+ }
171
+ })();
119
172
 
120
- // Wire transport events
121
- transport.on('line', (line: string) => this.handleLine(line));
173
+ this.activationPromise = activationPromise;
174
+ void activationPromise.then(
175
+ () => {
176
+ if (this.activationPromise === activationPromise) {
177
+ this.activationPromise = null;
178
+ }
179
+ },
180
+ () => {
181
+ if (this.activationPromise === activationPromise) {
182
+ this.activationPromise = null;
183
+ }
184
+ },
185
+ );
186
+ return activationPromise;
187
+ }
188
+
189
+ /** Wire one transport generation and wait for its ready handshake. */
190
+ private connectWithTransport(
191
+ transportArg: TConfirmableRustTransport,
192
+ generationArg?: number,
193
+ ): Promise<boolean> {
194
+ const generation = generationArg ?? ++this.lifecycleGeneration;
195
+ if (generationArg === undefined) {
196
+ if (this.lifecycleState !== 'idle') {
197
+ throw new Error(`RustBridge cannot activate while lifecycle state is '${this.lifecycleState}'`);
198
+ }
199
+ this.lifecycleState = 'starting';
200
+ this.transport = transportArg;
201
+ }
202
+
203
+ return new Promise<boolean>((resolve) => {
204
+ let settled = false;
205
+ let readyTimeout: ReturnType<typeof setTimeout> | null = null;
206
+ let reconnectReadyTimeout: ReturnType<typeof setTimeout> | null = null;
207
+
208
+ const isCurrent = (): boolean => this.isCurrentTransport(transportArg, generation);
209
+ const settle = (valueArg: boolean): void => {
210
+ if (readyTimeout) {
211
+ clearTimeout(readyTimeout);
212
+ readyTimeout = null;
213
+ }
214
+ if (reconnectReadyTimeout) {
215
+ clearTimeout(reconnectReadyTimeout);
216
+ reconnectReadyTimeout = null;
217
+ }
218
+ if (settled) {
219
+ return;
220
+ }
221
+ settled = true;
222
+ resolve(valueArg);
223
+ };
122
224
 
123
- transport.on('stderr', (line: string) => {
124
- this.logger.log('debug', `[${this.options.binaryName}] ${line}`);
125
- this.emit('stderr', line);
225
+ const requestConfirmedCleanup = (): void => {
226
+ let termination: Promise<void>;
227
+ try {
228
+ termination = this.terminate();
229
+ } catch (error: unknown) {
230
+ this.logger.log('error', `Transport cleanup failed: ${this.getErrorMessage(error)}`);
231
+ settle(false);
232
+ return;
233
+ }
234
+ void termination.then(
235
+ () => settle(false),
236
+ (error: unknown) => {
237
+ this.logger.log('error', `Transport cleanup failed: ${this.getErrorMessage(error)}`);
238
+ settle(false);
239
+ },
240
+ );
241
+ };
242
+
243
+ transportArg.on('line', (line: string) => {
244
+ if (!isCurrent()) {
245
+ return;
246
+ }
247
+ this.handleLine(line, () => {
248
+ if (!isCurrent() || this.lifecycleState !== 'starting') {
249
+ return;
250
+ }
251
+ if (reconnectReadyTimeout) {
252
+ clearTimeout(reconnectReadyTimeout);
253
+ reconnectReadyTimeout = null;
254
+ }
255
+ this.lifecycleState = 'ready';
256
+ this.isRunning = true;
257
+ this.logger.log('info', `Bridge connected to ${this.options.binaryName}`);
258
+ this.emit('ready');
259
+ settle(true);
126
260
  });
261
+ });
262
+
263
+ transportArg.on('stderr', (line: string) => {
264
+ if (!isCurrent()) {
265
+ return;
266
+ }
267
+ this.logger.log('debug', `[${this.options.binaryName}] ${line}`);
268
+ this.emit('stderr', line);
269
+ });
270
+
271
+ transportArg.on('close', (...args: any[]) => {
272
+ if (!isCurrent()) {
273
+ return;
274
+ }
275
+ const intentional = this.lifecycleState === 'stopping';
276
+ this.logger.log('info', 'Transport closed');
277
+ this.isRunning = false;
278
+ this.rejectAllPendingRequests(`${this.options.binaryName} process exited`);
279
+ if (!intentional && transportArg.reconnecting === true) {
280
+ if (reconnectReadyTimeout) {
281
+ clearTimeout(reconnectReadyTimeout);
282
+ reconnectReadyTimeout = null;
283
+ }
284
+ this.lifecycleState = 'starting';
285
+ return;
286
+ }
127
287
 
128
- transport.on('close', (...args: any[]) => {
129
- this.logger.log('info', `Transport closed`);
130
- this.cleanup(`${this.options.binaryName} process exited`);
288
+ this.transport = null;
289
+ if (!intentional) {
290
+ this.lifecycleState = 'idle';
131
291
  this.emit('exit', ...args);
132
- });
292
+ }
293
+ settle(false);
294
+ });
133
295
 
134
- transport.on('error', (err: Error) => {
135
- this.logger.log('error', `Transport error: ${err.message}`);
136
- this.cleanup(`${this.options.binaryName} process exited`, err);
137
- resolve(false);
138
- });
296
+ transportArg.on('error', (errorArg: Error) => {
297
+ if (!isCurrent()) {
298
+ return;
299
+ }
300
+ this.logger.log('error', `Transport error: ${errorArg.message}`);
301
+ this.isRunning = false;
302
+ this.rejectAllPendingRequests(`${this.options.binaryName} process exited`, errorArg);
303
+ });
139
304
 
140
- transport.on('reconnected', () => {
141
- this.logger.log('info', 'Transport reconnected, waiting for ready event');
142
- this.emit('reconnected');
143
- });
305
+ transportArg.on('reconnected', () => {
306
+ if (!isCurrent()) {
307
+ return;
308
+ }
309
+ this.logger.log('info', 'Transport reconnected, waiting for ready event');
310
+ this.lifecycleState = 'starting';
311
+ if (reconnectReadyTimeout) {
312
+ clearTimeout(reconnectReadyTimeout);
313
+ }
314
+ reconnectReadyTimeout = setTimeout(() => {
315
+ if (!isCurrent() || this.lifecycleState !== 'starting') {
316
+ return;
317
+ }
318
+ this.logger.log(
319
+ 'error',
320
+ `Reconnected process did not send ready event within ${this.options.readyTimeoutMs}ms`,
321
+ );
322
+ requestConfirmedCleanup();
323
+ }, this.options.readyTimeoutMs);
324
+ this.emit('reconnected');
325
+ });
144
326
 
145
- // Connect the transport
146
- transport.connect().then(() => {
147
- // Wait for the ready event from the protocol layer
148
- const readyTimeout = setTimeout(() => {
149
- this.logger.log('error', `Process did not send ready event within ${this.options.readyTimeoutMs}ms`);
150
- this.kill();
151
- resolve(false);
152
- }, this.options.readyTimeoutMs);
153
-
154
- this.once(`management:${this.options.readyEventName}`, () => {
155
- clearTimeout(readyTimeout);
156
- this.isRunning = true;
157
- this.logger.log('info', `Bridge connected to ${this.options.binaryName}`);
158
- this.emit('ready');
159
- resolve(true);
160
- });
161
- }).catch((err: Error) => {
162
- this.logger.log('error', `Transport connect failed: ${err.message}`);
163
- resolve(false);
164
- });
165
- } catch (err: any) {
166
- this.logger.log('error', `Failed to connect: ${err.message}`);
167
- resolve(false);
327
+ transportArg.on('reconnect_failed', () => {
328
+ if (!isCurrent()) {
329
+ return;
330
+ }
331
+ if (reconnectReadyTimeout) {
332
+ clearTimeout(reconnectReadyTimeout);
333
+ reconnectReadyTimeout = null;
334
+ }
335
+ this.isRunning = false;
336
+ this.transport = null;
337
+ this.lifecycleState = 'idle';
338
+ this.rejectAllPendingRequests(`${this.options.binaryName} reconnect failed`);
339
+ this.emit('exit');
340
+ settle(false);
341
+ });
342
+
343
+ readyTimeout = setTimeout(() => {
344
+ if (!isCurrent() || this.lifecycleState !== 'starting') {
345
+ return;
346
+ }
347
+ this.logger.log(
348
+ 'error',
349
+ `Process did not send ready event within ${this.options.readyTimeoutMs}ms`,
350
+ );
351
+ requestConfirmedCleanup();
352
+ }, this.options.readyTimeoutMs);
353
+
354
+ let connectionPromise: Promise<void>;
355
+ try {
356
+ connectionPromise = transportArg.connect();
357
+ } catch (error: unknown) {
358
+ this.logger.log('error', `Transport connect failed: ${this.getErrorMessage(error)}`);
359
+ requestConfirmedCleanup();
360
+ return;
168
361
  }
362
+ void connectionPromise.catch((error: unknown) => {
363
+ if (!isCurrent()) {
364
+ return;
365
+ }
366
+ this.logger.log('error', `Transport connect failed: ${this.getErrorMessage(error)}`);
367
+ requestConfirmedCleanup();
368
+ });
169
369
  });
170
370
  }
171
371
 
@@ -349,22 +549,94 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
349
549
  }
350
550
 
351
551
  /**
352
- * Kill the connection and clean up all resources.
353
- * For stdio: kills the child process (SIGTERM, then SIGKILL).
354
- * For socket: closes the socket connection (does NOT kill the daemon).
552
+ * Terminate the active transport and wait for confirmed closure.
553
+ * For stdio this confirms child exit after SIGTERM/SIGKILL escalation.
554
+ * For socket mode this confirms local socket closure and never kills the daemon.
355
555
  */
356
- public kill(): void {
357
- if (this.transport) {
358
- const transport = this.transport;
556
+ public terminate(optionsArg: IRustBridgeTerminateOptions = {}): Promise<void> {
557
+ if (
558
+ optionsArg.gracePeriodMs !== undefined
559
+ && (!Number.isSafeInteger(optionsArg.gracePeriodMs) || optionsArg.gracePeriodMs < 0)
560
+ ) {
561
+ throw new RangeError('RustBridge terminate gracePeriodMs must be a non-negative safe integer');
562
+ }
563
+
564
+ if (this.terminationPromise) {
565
+ return this.terminationPromise;
566
+ }
567
+
568
+ if (
569
+ this.lifecycleState === 'idle'
570
+ && !this.transport
571
+ && !this.activationPromise
572
+ ) {
573
+ return Promise.resolve();
574
+ }
575
+
576
+ const generation = this.lifecycleGeneration;
577
+ const transport = this.transport;
578
+ const activationPromise = this.activationPromise;
579
+
580
+ this.lifecycleState = 'stopping';
581
+ this.isRunning = false;
582
+ this.rejectAllPendingRequests(`${this.options.binaryName} process killed`);
583
+
584
+ const terminationPromise = (async (): Promise<void> => {
585
+ if (transport) {
586
+ await transport.disconnectAndWait({ gracePeriodMs: optionsArg.gracePeriodMs });
587
+ } else if (activationPromise) {
588
+ // No transport exists yet (for example, binary lookup is pending). The
589
+ // stopping state prevents that activation from publishing a transport.
590
+ await activationPromise;
591
+ }
592
+
593
+ if (this.lifecycleGeneration !== generation) {
594
+ throw new Error('RustBridge lifecycle generation changed during termination');
595
+ }
596
+
597
+ const lateTransport = this.transport;
598
+ if (lateTransport && lateTransport !== transport) {
599
+ await lateTransport.disconnectAndWait({ gracePeriodMs: optionsArg.gracePeriodMs });
600
+ }
601
+
359
602
  this.transport = null;
360
603
  this.isRunning = false;
604
+ this.lifecycleState = 'idle';
605
+ })();
606
+
607
+ this.terminationPromise = terminationPromise;
608
+ void terminationPromise.then(
609
+ () => {
610
+ if (this.terminationPromise === terminationPromise) {
611
+ this.terminationPromise = null;
612
+ }
613
+ },
614
+ () => {
615
+ if (this.terminationPromise === terminationPromise) {
616
+ // Retain transport ownership and the stopping state, but allow a
617
+ // later termination attempt after the caller has addressed the
618
+ // transient cleanup failure.
619
+ this.terminationPromise = null;
620
+ }
621
+ },
622
+ );
623
+ return terminationPromise;
624
+ }
361
625
 
362
- // Reject pending requests
363
- this.rejectAllPendingRequests(`${this.options.binaryName} process killed`);
364
-
365
- transport.removeAllListeners();
366
- transport.disconnect();
626
+ /**
627
+ * Fire-and-forget compatibility wrapper around confirmed termination.
628
+ */
629
+ public kill(): void {
630
+ let termination: Promise<void>;
631
+ try {
632
+ termination = this.terminate();
633
+ } catch (error: unknown) {
634
+ this.logger.log('error', `Bridge termination failed: ${this.getErrorMessage(error)}`);
635
+ return;
367
636
  }
637
+ void termination.catch((error: unknown) => {
638
+ this.logger.log('error', `Bridge termination failed: ${this.getErrorMessage(error)}`);
639
+ });
368
640
  }
369
641
 
370
642
  /**
@@ -374,7 +646,24 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
374
646
  return this.isRunning;
375
647
  }
376
648
 
377
- private handleLine(line: string): void {
649
+ private isStartingGeneration(generationArg: number): boolean {
650
+ return (
651
+ this.lifecycleGeneration === generationArg
652
+ && this.lifecycleState === 'starting'
653
+ );
654
+ }
655
+
656
+ private isCurrentTransport(
657
+ transportArg: TConfirmableRustTransport,
658
+ generationArg: number,
659
+ ): boolean {
660
+ return (
661
+ this.lifecycleGeneration === generationArg
662
+ && this.transport === transportArg
663
+ );
664
+ }
665
+
666
+ private handleLine(line: string, onReadyArg: () => void): void {
378
667
  if (!line) return;
379
668
 
380
669
  let parsed: any;
@@ -388,6 +677,9 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
388
677
  // Check if it's an event (has 'event' field, no 'id')
389
678
  if ('event' in parsed && !('id' in parsed)) {
390
679
  const event = parsed as IManagementEvent;
680
+ if (event.event === this.options.readyEventName) {
681
+ onReadyArg();
682
+ }
391
683
  this.emit(`management:${event.event}`, event.data);
392
684
  return;
393
685
  }
@@ -488,13 +780,6 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
488
780
  }
489
781
  }
490
782
 
491
- private cleanup(message: string, cause?: unknown): void {
492
- this.isRunning = false;
493
- this.transport = null;
494
-
495
- this.rejectAllPendingRequests(message, cause);
496
- }
497
-
498
783
  private getErrorMessage(error: unknown): string {
499
784
  return error instanceof Error ? error.message : String(error);
500
785
  }