@push.rocks/smartrust 1.5.0 → 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,10 @@
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';
5
8
 
6
9
  export interface ISocketTransportOptions {
7
10
  /** Path to Unix socket (Linux/macOS) or named pipe path (Windows) */
@@ -30,6 +33,18 @@ interface IResolvedSocketTransportOptions {
30
33
  maxReconnectAttempts: number;
31
34
  }
32
35
 
36
+ interface ISocketTransportGeneration {
37
+ socket: plugins.net.Socket;
38
+ lifecycleToken: number;
39
+ reconnectAttempt: boolean;
40
+ everConnected: boolean;
41
+ connectSettled: boolean;
42
+ closed: boolean;
43
+ closePromise: Promise<void>;
44
+ resolveClose: () => void;
45
+ disconnectPromise: Promise<void> | null;
46
+ }
47
+
33
48
  /**
34
49
  * Transport that connects to an already-running process via Unix socket or Windows named pipe.
35
50
  * The JSON-over-newline protocol is identical to stdio; only the transport changes.
@@ -37,11 +52,15 @@ interface IResolvedSocketTransportOptions {
37
52
  export class SocketTransport extends plugins.events.EventEmitter implements IRustTransport {
38
53
  private options: IResolvedSocketTransportOptions;
39
54
  private socket: plugins.net.Socket | null = null;
55
+ private generation: ISocketTransportGeneration | null = null;
40
56
  private lineScanner: LineScanner;
41
57
  private _connected: boolean = false;
42
58
  private intentionalDisconnect: boolean = false;
43
59
  private reconnectAttempts: number = 0;
44
60
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
61
+ private lifecycleToken: number = 0;
62
+ private _reconnecting: boolean = false;
63
+ private reconnectFailed: boolean = false;
45
64
 
46
65
  constructor(options: ISocketTransportOptions) {
47
66
  super();
@@ -59,105 +78,274 @@ export class SocketTransport extends plugins.events.EventEmitter implements IRus
59
78
  return this._connected;
60
79
  }
61
80
 
81
+ public get reconnecting(): boolean {
82
+ return !this.intentionalDisconnect && this._reconnecting;
83
+ }
84
+
62
85
  /**
63
86
  * Connect to the socket. Resolves when the TCP/Unix connection is established.
64
87
  */
65
- public async connect(): Promise<void> {
88
+ public connect(): Promise<void> {
89
+ if (this.generation && !this.generation.closed) {
90
+ return Promise.reject(
91
+ new Error('Socket transport already has an active connection attempt or connection'),
92
+ );
93
+ }
94
+
95
+ this.cancelReconnectTimer();
96
+ const lifecycleToken = ++this.lifecycleToken;
66
97
  this.intentionalDisconnect = false;
98
+ this._reconnecting = false;
99
+ this.reconnectFailed = false;
67
100
  this.reconnectAttempts = 0;
68
- return this.doConnect();
101
+ return this.doConnect(lifecycleToken, false);
69
102
  }
70
103
 
71
- private doConnect(): Promise<void> {
72
- return new Promise<void>((resolve, reject) => {
73
- let settled = false;
104
+ private doConnect(
105
+ lifecycleToken: number,
106
+ reconnectAttempt: boolean,
107
+ ): Promise<void> {
108
+ if (
109
+ lifecycleToken !== this.lifecycleToken
110
+ || this.intentionalDisconnect
111
+ ) {
112
+ return Promise.reject(new Error('Socket connection attempt was cancelled'));
113
+ }
114
+ if (this.generation && !this.generation.closed) {
115
+ return Promise.reject(
116
+ new Error('Socket transport already has an active connection attempt or connection'),
117
+ );
118
+ }
74
119
 
75
- this.socket = plugins.net.connect({ path: this.options.socketPath });
120
+ const socket = new plugins.net.Socket();
121
+ let resolveConnect!: () => void;
122
+ let rejectConnect!: (error: Error) => void;
123
+ const connectPromise = new Promise<void>((resolve, reject) => {
124
+ resolveConnect = resolve;
125
+ rejectConnect = reject;
126
+ });
127
+ let resolveClose!: () => void;
128
+ const closePromise = new Promise<void>((resolve) => {
129
+ resolveClose = resolve;
130
+ });
131
+ const generation: ISocketTransportGeneration = {
132
+ socket,
133
+ lifecycleToken,
134
+ reconnectAttempt,
135
+ everConnected: false,
136
+ connectSettled: false,
137
+ closed: false,
138
+ closePromise,
139
+ resolveClose,
140
+ disconnectPromise: null,
141
+ };
76
142
 
77
- this.socket.on('connect', () => {
78
- if (!settled) {
79
- settled = true;
80
- this._connected = true;
81
- this.reconnectAttempts = 0;
82
- resolve();
83
- }
84
- });
143
+ this.generation = generation;
144
+ this.socket = socket;
85
145
 
86
- this.socket.on('data', (chunk: Buffer) => {
87
- this.lineScanner.push(chunk, (line) => {
88
- this.emit('line', line);
89
- });
90
- });
146
+ socket.on('connect', () => {
147
+ if (this.generation !== generation || generation.closed) {
148
+ return;
149
+ }
91
150
 
92
- this.socket.on('close', () => {
93
- const wasConnected = this._connected;
94
- this._connected = false;
95
- this.lineScanner.clear();
151
+ generation.everConnected = true;
152
+ this._connected = true;
153
+ if (generation.reconnectAttempt) {
154
+ this._reconnecting = false;
155
+ }
156
+ this.reconnectAttempts = 0;
157
+ if (!generation.connectSettled) {
158
+ generation.connectSettled = true;
159
+ resolveConnect();
160
+ }
161
+ });
96
162
 
97
- if (!this.intentionalDisconnect && wasConnected && this.options.autoReconnect) {
98
- this.attemptReconnect();
99
- }
100
- this.emit('close');
101
- });
163
+ socket.on('data', (chunk: Buffer) => {
164
+ if (this.generation !== generation || generation.closed) {
165
+ return;
166
+ }
102
167
 
103
- this.socket.on('error', (err: Error) => {
104
- this._connected = false;
105
- if (!settled) {
106
- settled = true;
107
- reject(err);
108
- } else if (!this.intentionalDisconnect) {
109
- this.emit('error', err);
168
+ this.lineScanner.push(chunk, (line) => {
169
+ if (this.generation === generation && !generation.closed) {
170
+ this.emit('line', line);
110
171
  }
111
172
  });
112
173
  });
174
+
175
+ socket.on('close', () => {
176
+ if (generation.closed) {
177
+ return;
178
+ }
179
+
180
+ generation.closed = true;
181
+ if (!generation.connectSettled) {
182
+ generation.connectSettled = true;
183
+ rejectConnect(new Error('Socket closed before connection was established'));
184
+ }
185
+ generation.resolveClose();
186
+
187
+ if (this.generation !== generation) {
188
+ return;
189
+ }
190
+
191
+ this._connected = false;
192
+ this.socket = null;
193
+ this.lineScanner.clear();
194
+
195
+ const shouldReconnect = (
196
+ !this.intentionalDisconnect
197
+ && generation.lifecycleToken === this.lifecycleToken
198
+ && this.options.autoReconnect
199
+ && (generation.everConnected || generation.reconnectAttempt)
200
+ );
201
+ if (shouldReconnect) {
202
+ this._reconnecting = true;
203
+ }
204
+ this.emit('close');
205
+
206
+ // A close listener may intentionally disconnect or start a new generation.
207
+ // Only the still-current closed generation may schedule the retry.
208
+ if (
209
+ shouldReconnect
210
+ && this.generation === generation
211
+ && !this.intentionalDisconnect
212
+ && generation.lifecycleToken === this.lifecycleToken
213
+ ) {
214
+ this.attemptReconnect(generation.lifecycleToken);
215
+ }
216
+ });
217
+
218
+ socket.on('error', (error: Error) => {
219
+ if (this.generation !== generation || generation.closed) {
220
+ return;
221
+ }
222
+
223
+ this._connected = false;
224
+ if (!generation.connectSettled) {
225
+ generation.connectSettled = true;
226
+ rejectConnect(error);
227
+ } else if (!this.intentionalDisconnect) {
228
+ this.emit('error', error);
229
+ }
230
+ });
231
+
232
+ try {
233
+ socket.connect({ path: this.options.socketPath });
234
+ } catch (error: unknown) {
235
+ const connectError = this.toError(error, 'Failed to initiate socket connection');
236
+ generation.closed = true;
237
+ if (!generation.connectSettled) {
238
+ generation.connectSettled = true;
239
+ rejectConnect(connectError);
240
+ }
241
+ generation.resolveClose();
242
+ if (this.generation === generation) {
243
+ this._connected = false;
244
+ this.socket = null;
245
+ this.lineScanner.clear();
246
+ }
247
+ if (
248
+ reconnectAttempt
249
+ && !this.intentionalDisconnect
250
+ && lifecycleToken === this.lifecycleToken
251
+ ) {
252
+ this.attemptReconnect(lifecycleToken);
253
+ }
254
+ }
255
+
256
+ return connectPromise;
113
257
  }
114
258
 
115
- private attemptReconnect(): void {
259
+ private attemptReconnect(lifecycleToken: number): void {
260
+ if (
261
+ lifecycleToken !== this.lifecycleToken
262
+ || this.intentionalDisconnect
263
+ || this.reconnectFailed
264
+ || this.reconnectTimer
265
+ || (this.generation && !this.generation.closed)
266
+ ) {
267
+ return;
268
+ }
269
+
116
270
  if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
117
- this.options.logger.log('error', `Max reconnect attempts (${this.options.maxReconnectAttempts}) reached`);
271
+ this._reconnecting = false;
272
+ this.reconnectFailed = true;
273
+ ++this.lifecycleToken;
274
+ this.options.logger.log(
275
+ 'error',
276
+ 'Max reconnect attempts (' + this.options.maxReconnectAttempts + ') reached',
277
+ );
118
278
  this.emit('reconnect_failed');
119
279
  return;
120
280
  }
121
281
 
282
+ this._reconnecting = true;
283
+
122
284
  const delay = Math.min(
123
285
  this.options.reconnectBaseDelayMs * Math.pow(2, this.reconnectAttempts),
124
286
  this.options.reconnectMaxDelayMs,
125
287
  );
126
288
  this.reconnectAttempts++;
127
289
 
128
- this.options.logger.log('info', `Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.options.maxReconnectAttempts})`);
290
+ this.options.logger.log(
291
+ 'info',
292
+ 'Reconnecting in '
293
+ + delay
294
+ + 'ms (attempt '
295
+ + this.reconnectAttempts
296
+ + '/'
297
+ + this.options.maxReconnectAttempts
298
+ + ')',
299
+ );
129
300
 
130
- this.reconnectTimer = setTimeout(async () => {
301
+ this.reconnectTimer = setTimeout(() => {
131
302
  this.reconnectTimer = null;
132
- try {
133
- await this.doConnect();
134
- this.emit('reconnected');
135
- } catch {
136
- // doConnect rejected — the 'close' handler on the new socket will trigger another attempt
303
+ if (
304
+ lifecycleToken !== this.lifecycleToken
305
+ || this.intentionalDisconnect
306
+ ) {
307
+ return;
137
308
  }
309
+
310
+ void this.doConnect(lifecycleToken, true).then(() => {
311
+ if (
312
+ lifecycleToken === this.lifecycleToken
313
+ && !this.intentionalDisconnect
314
+ ) {
315
+ this.emit('reconnected');
316
+ }
317
+ }).catch(() => {
318
+ // The generation's close handler schedules the next reconnect attempt.
319
+ });
138
320
  }, delay);
139
321
  }
140
322
 
141
323
  /**
142
324
  * Write data to the socket with backpressure support.
143
325
  */
144
- public async write(data: string): Promise<void> {
326
+ public write(data: string): Promise<void> {
145
327
  return new Promise<void>((resolve, reject) => {
146
- if (!this.socket || !this._connected) {
328
+ const generation = this.generation;
329
+ if (
330
+ !generation
331
+ || generation.closed
332
+ || generation.socket !== this.socket
333
+ || !this._connected
334
+ ) {
147
335
  reject(new Error('Socket not connected'));
148
336
  return;
149
337
  }
150
338
 
151
- const canContinue = this.socket.write(data, 'utf8', (err) => {
152
- if (err) {
153
- reject(err);
339
+ const canContinue = generation.socket.write(data, 'utf8', (error) => {
340
+ if (error) {
341
+ reject(error);
154
342
  }
155
343
  });
156
344
 
157
345
  if (canContinue) {
158
346
  resolve();
159
347
  } else {
160
- this.socket.once('drain', () => {
348
+ generation.socket.once('drain', () => {
161
349
  resolve();
162
350
  });
163
351
  }
@@ -165,23 +353,79 @@ export class SocketTransport extends plugins.events.EventEmitter implements IRus
165
353
  }
166
354
 
167
355
  /**
168
- * Close the socket connection. Does NOT kill the remote daemon.
356
+ * Close the local socket without waiting for confirmation.
357
+ * This does not kill the remote daemon.
169
358
  */
170
359
  public disconnect(): void {
360
+ try {
361
+ void this.disconnectAndWait().catch((error: unknown) => {
362
+ this.options.logger.log(
363
+ 'error',
364
+ 'Failed to disconnect socket transport: ' + this.getErrorMessage(error),
365
+ );
366
+ });
367
+ } catch (error: unknown) {
368
+ this.options.logger.log(
369
+ 'error',
370
+ 'Failed to disconnect socket transport: ' + this.getErrorMessage(error),
371
+ );
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Destroy the local socket and wait until its 'close' event confirms closure.
377
+ * This does not signal or otherwise terminate the remote daemon.
378
+ */
379
+ public disconnectAndWait(
380
+ _options: IRustTransportDisconnectOptions = {},
381
+ ): Promise<void> {
171
382
  this.intentionalDisconnect = true;
383
+ this._reconnecting = false;
384
+ ++this.lifecycleToken;
385
+ this.cancelReconnectTimer();
386
+
387
+ const generation = this.generation;
388
+ if (!generation) {
389
+ return Promise.resolve();
390
+ }
391
+ if (generation.disconnectPromise) {
392
+ return generation.disconnectPromise;
393
+ }
394
+ if (generation.closed) {
395
+ generation.disconnectPromise = generation.closePromise;
396
+ return generation.disconnectPromise;
397
+ }
398
+
399
+ this._connected = false;
400
+ let rejectDisconnect!: (error: Error) => void;
401
+ const disconnectPromise = new Promise<void>((resolve, reject) => {
402
+ rejectDisconnect = reject;
403
+ void generation.closePromise.then(resolve);
404
+ });
405
+ generation.disconnectPromise = disconnectPromise;
172
406
 
407
+ // The close waiter above must exist before destroy() can cause 'close'.
408
+ try {
409
+ generation.socket.destroy();
410
+ } catch (error: unknown) {
411
+ rejectDisconnect(this.toError(error, 'Failed to destroy local socket'));
412
+ }
413
+
414
+ return disconnectPromise;
415
+ }
416
+
417
+ private cancelReconnectTimer(): void {
173
418
  if (this.reconnectTimer) {
174
419
  clearTimeout(this.reconnectTimer);
175
420
  this.reconnectTimer = null;
176
421
  }
422
+ }
177
423
 
178
- if (this.socket) {
179
- const sock = this.socket;
180
- this.socket = null;
181
- this._connected = false;
182
- this.lineScanner.clear();
183
- sock.removeAllListeners();
184
- sock.destroy();
185
- }
424
+ private toError(error: unknown, prefix: string): Error {
425
+ return new Error(prefix + ': ' + this.getErrorMessage(error));
426
+ }
427
+
428
+ private getErrorMessage(error: unknown): string {
429
+ return error instanceof Error ? error.message : String(error);
186
430
  }
187
431
  }