@push.rocks/smartmta 6.4.1 → 6.5.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.
@@ -406,7 +406,9 @@ export class RustSecurityBridge extends EventEmitter {
406
406
  private _state: BridgeState = BridgeState.Idle;
407
407
  private _restartAttempts = 0;
408
408
  private _restartTimer: ReturnType<typeof setTimeout> | null = null;
409
+ private _spawnInFlight = false;
409
410
  private _healthCheckTimer: ReturnType<typeof setInterval> | null = null;
411
+ private _healthCheckStrikes = 0;
410
412
  private _deliberateStop = false;
411
413
  private _smtpServerConfig: ISmtpServerConfig | null = null;
412
414
 
@@ -454,12 +456,15 @@ export class RustSecurityBridge extends EventEmitter {
454
456
 
455
457
  this.bridge.on('exit', (code: number | null, signal: string | null) => {
456
458
  this._running = false;
457
- logger.log('warn', `Rust security bridge exited (code=${code}, signal=${signal})`);
459
+ logger.log('error', `Rust security bridge exited (code=${code}, signal=${signal})`);
458
460
 
459
461
  if (this._deliberateStop) {
460
462
  this.setState(BridgeState.Stopped);
461
- } else if (this._state === BridgeState.Running) {
462
- // Unexpected exit attempt restart
463
+ } else if (!this._restartTimer && !this._spawnInFlight && this._state !== BridgeState.Starting) {
464
+ // Any unexpected exit triggers recovery, regardless of the state we
465
+ // were in. Exits during Starting resolve through start() itself;
466
+ // exits while a restart is already pending/spawning are absorbed by
467
+ // the in-flight recovery cycle.
463
468
  this.attemptRestart();
464
469
  }
465
470
  });
@@ -540,7 +545,7 @@ export class RustSecurityBridge extends EventEmitter {
540
545
  case BridgeState.Restarting:
541
546
  throw new Error('Rust bridge is restarting after a crash. Commands will resume once it recovers.');
542
547
  case BridgeState.Failed:
543
- throw new Error('Rust bridge has failed after exhausting all restart attempts.');
548
+ throw new Error('Rust bridge is in degraded state after repeated crashes. Recovery attempts continue in the background.');
544
549
  case BridgeState.Stopped:
545
550
  throw new Error('Rust bridge has been stopped. Call start() to restart it.');
546
551
  default:
@@ -563,6 +568,11 @@ export class RustSecurityBridge extends EventEmitter {
563
568
 
564
569
  this._deliberateStop = false;
565
570
  this._restartAttempts = 0;
571
+ // An explicit start supersedes any pending background recovery attempt
572
+ if (this._restartTimer) {
573
+ clearTimeout(this._restartTimer);
574
+ this._restartTimer = null;
575
+ }
566
576
  this.setState(BridgeState.Starting);
567
577
 
568
578
  try {
@@ -619,69 +629,97 @@ export class RustSecurityBridge extends EventEmitter {
619
629
  const config = RustSecurityBridge._resilienceConfig;
620
630
  this._restartAttempts++;
621
631
 
622
- if (this._restartAttempts > config.maxRestartAttempts) {
623
- logger.log('error', `Rust bridge exceeded max restart attempts (${config.maxRestartAttempts}). Giving up.`);
632
+ const exhausted = this._restartAttempts > config.maxRestartAttempts;
633
+ if (exhausted) {
634
+ // Degraded, but never terminal: a mail backend that silently stays dead
635
+ // is worse than one that keeps retrying at the slow cadence. The Failed
636
+ // state signals the degradation to embedders (stateChange event) while
637
+ // recovery attempts continue at restartBackoffMaxMs.
638
+ logger.log('error', `Rust bridge exhausted ${config.maxRestartAttempts} fast restart attempts — continuing in degraded slow-retry every ${config.restartBackoffMaxMs}ms (attempt ${this._restartAttempts})`);
624
639
  this.setState(BridgeState.Failed);
625
- return;
640
+ } else {
641
+ this.setState(BridgeState.Restarting);
626
642
  }
627
-
628
- this.setState(BridgeState.Restarting);
629
643
  this.stopHealthCheck();
630
644
 
631
- const delay = Math.min(
632
- config.restartBackoffBaseMs * Math.pow(2, this._restartAttempts - 1),
633
- config.restartBackoffMaxMs,
634
- );
645
+ const delay = exhausted
646
+ ? config.restartBackoffMaxMs
647
+ : Math.min(
648
+ config.restartBackoffBaseMs * Math.pow(2, this._restartAttempts - 1),
649
+ config.restartBackoffMaxMs,
650
+ );
635
651
 
636
- logger.log('info', `Rust bridge restart attempt ${this._restartAttempts}/${config.maxRestartAttempts} in ${delay}ms`);
652
+ logger.log('info', `Rust bridge restart attempt ${this._restartAttempts} in ${delay}ms`);
637
653
 
638
- this._restartTimer = setTimeout(async () => {
654
+ this._restartTimer = setTimeout(() => {
639
655
  this._restartTimer = null;
656
+ void this.executeRestartAttempt();
657
+ }, delay);
658
+ }
640
659
 
641
- // Guard: if stop() was called while we were waiting, don't restart
642
- if (this._deliberateStop) {
643
- this.setState(BridgeState.Stopped);
644
- return;
660
+ private async executeRestartAttempt(): Promise<void> {
661
+ // Guard: if stop() was called while we were waiting, don't restart
662
+ if (this._deliberateStop) {
663
+ this.setState(BridgeState.Stopped);
664
+ return;
665
+ }
666
+
667
+ this._spawnInFlight = true;
668
+ try {
669
+ const ok = await this.bridge.spawn();
670
+ this._running = ok;
671
+ if (!ok) {
672
+ throw new Error('spawn returned false');
645
673
  }
646
674
 
647
- try {
648
- const ok = await this.bridge.spawn();
649
- this._running = ok;
650
-
651
- if (ok) {
652
- logger.log('info', 'Rust bridge restarted successfully');
653
- this._restartAttempts = 0;
654
- this.setState(BridgeState.Running);
655
- this.startHealthCheck();
656
- await this.restoreAfterRestart();
657
- } else {
658
- logger.log('warn', 'Rust bridge restart failed (spawn returned false)');
659
- this.attemptRestart();
675
+ const restored = await this.restoreAfterRestart();
676
+ if (!restored) {
677
+ // A bridge without its SMTP listener must not report Running — kill
678
+ // and go through another restart cycle instead of sitting half-alive.
679
+ logger.log('error', 'Rust bridge restarted but the SMTP server could not be restored — killing process to retry');
680
+ try {
681
+ this.bridge.kill();
682
+ } catch {
683
+ // Already dead
660
684
  }
661
- } catch (err) {
662
- logger.log('error', `Rust bridge restart failed: ${(err as Error).message}`);
663
- this.attemptRestart();
685
+ this._running = false;
686
+ throw new Error('SMTP server restore failed');
664
687
  }
665
- }, delay);
688
+
689
+ logger.log('info', 'Rust bridge restarted successfully');
690
+ this._restartAttempts = 0;
691
+ this.setState(BridgeState.Running);
692
+ this.startHealthCheck();
693
+ } catch (err) {
694
+ logger.log('error', `Rust bridge restart failed: ${(err as Error).message}`);
695
+ this.attemptRestart();
696
+ } finally {
697
+ this._spawnInFlight = false;
698
+ }
666
699
  }
667
700
 
668
701
  /**
669
702
  * Restore state after a successful restart:
670
703
  * - Re-send startSmtpServer command if the SMTP server was running
704
+ *
705
+ * @returns `true` if there was nothing to restore or the restore succeeded.
671
706
  */
672
- private async restoreAfterRestart(): Promise<void> {
673
- if (this._smtpServerConfig) {
674
- try {
675
- logger.log('info', 'Restoring SMTP server after bridge restart');
676
- const result = await this.bridge.sendCommand('startSmtpServer', this._smtpServerConfig);
677
- if (result?.started) {
678
- logger.log('info', 'SMTP server restored after bridge restart');
679
- } else {
680
- logger.log('warn', 'SMTP server failed to restore after bridge restart');
681
- }
682
- } catch (err) {
683
- logger.log('error', `Failed to restore SMTP server after restart: ${(err as Error).message}`);
707
+ private async restoreAfterRestart(): Promise<boolean> {
708
+ if (!this._smtpServerConfig) {
709
+ return true;
710
+ }
711
+ try {
712
+ logger.log('info', 'Restoring SMTP server after bridge restart');
713
+ const result = await this.bridge.sendCommand('startSmtpServer', this._smtpServerConfig);
714
+ if (result?.started) {
715
+ logger.log('info', 'SMTP server restored after bridge restart');
716
+ return true;
684
717
  }
718
+ logger.log('warn', 'SMTP server failed to restore after bridge restart');
719
+ return false;
720
+ } catch (err) {
721
+ logger.log('error', `Failed to restore SMTP server after restart: ${(err as Error).message}`);
722
+ return false;
685
723
  }
686
724
  }
687
725
 
@@ -693,6 +731,7 @@ export class RustSecurityBridge extends EventEmitter {
693
731
  this.stopHealthCheck();
694
732
  const config = RustSecurityBridge._resilienceConfig;
695
733
 
734
+ this._healthCheckStrikes = 0;
696
735
  this._healthCheckTimer = setInterval(async () => {
697
736
  if (this._state !== BridgeState.Running || !this._running) {
698
737
  return;
@@ -707,8 +746,17 @@ export class RustSecurityBridge extends EventEmitter {
707
746
  if (!(res as any)?.pong) {
708
747
  throw new Error('Health check: unexpected ping response');
709
748
  }
749
+ this._healthCheckStrikes = 0;
710
750
  } catch (err) {
711
- logger.log('warn', `Rust bridge health check failed: ${(err as Error).message}. Killing process to trigger restart.`);
751
+ // Two consecutive failures before killing: a single missed ping can
752
+ // be momentary load, and killing mid-transaction is expensive.
753
+ this._healthCheckStrikes++;
754
+ if (this._healthCheckStrikes < 2) {
755
+ logger.log('warn', `Rust bridge health check failed (strike ${this._healthCheckStrikes}/2): ${(err as Error).message}`);
756
+ return;
757
+ }
758
+ logger.log('error', `Rust bridge health check failed twice in a row: ${(err as Error).message}. Killing process to trigger restart.`);
759
+ this._healthCheckStrikes = 0;
712
760
  try {
713
761
  this.bridge.kill();
714
762
  } catch {