@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.
@@ -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
+ })();
172
+
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
+ };
119
224
 
120
- // Wire transport events
121
- transport.on('line', (line: string) => this.handleLine(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
+ };
122
242
 
123
- transport.on('stderr', (line: string) => {
124
- this.logger.log('debug', `[${this.options.binaryName}] ${line}`);
125
- this.emit('stderr', line);
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
+ });
127
262
 
128
- transport.on('close', (...args: any[]) => {
129
- this.logger.log('info', `Transport closed`);
130
- this.cleanup(`${this.options.binaryName} process exited`);
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
+ }
287
+
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,89 @@ 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
+ // Keep the rejected promise and stopping state cached: closure was not confirmed.
616
+ },
617
+ );
618
+ return terminationPromise;
619
+ }
361
620
 
362
- // Reject pending requests
363
- this.rejectAllPendingRequests(`${this.options.binaryName} process killed`);
364
-
365
- transport.removeAllListeners();
366
- transport.disconnect();
621
+ /**
622
+ * Fire-and-forget compatibility wrapper around confirmed termination.
623
+ */
624
+ public kill(): void {
625
+ let termination: Promise<void>;
626
+ try {
627
+ termination = this.terminate();
628
+ } catch (error: unknown) {
629
+ this.logger.log('error', `Bridge termination failed: ${this.getErrorMessage(error)}`);
630
+ return;
367
631
  }
632
+ void termination.catch((error: unknown) => {
633
+ this.logger.log('error', `Bridge termination failed: ${this.getErrorMessage(error)}`);
634
+ });
368
635
  }
369
636
 
370
637
  /**
@@ -374,7 +641,24 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
374
641
  return this.isRunning;
375
642
  }
376
643
 
377
- private handleLine(line: string): void {
644
+ private isStartingGeneration(generationArg: number): boolean {
645
+ return (
646
+ this.lifecycleGeneration === generationArg
647
+ && this.lifecycleState === 'starting'
648
+ );
649
+ }
650
+
651
+ private isCurrentTransport(
652
+ transportArg: TConfirmableRustTransport,
653
+ generationArg: number,
654
+ ): boolean {
655
+ return (
656
+ this.lifecycleGeneration === generationArg
657
+ && this.transport === transportArg
658
+ );
659
+ }
660
+
661
+ private handleLine(line: string, onReadyArg: () => void): void {
378
662
  if (!line) return;
379
663
 
380
664
  let parsed: any;
@@ -388,6 +672,9 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
388
672
  // Check if it's an event (has 'event' field, no 'id')
389
673
  if ('event' in parsed && !('id' in parsed)) {
390
674
  const event = parsed as IManagementEvent;
675
+ if (event.event === this.options.readyEventName) {
676
+ onReadyArg();
677
+ }
391
678
  this.emit(`management:${event.event}`, event.data);
392
679
  return;
393
680
  }
@@ -488,13 +775,6 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
488
775
  }
489
776
  }
490
777
 
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
778
  private getErrorMessage(error: unknown): string {
499
779
  return error instanceof Error ? error.message : String(error);
500
780
  }