Stirling-Tools/Stirling-PDF · critical · Error

Backend did not become healthy after restart

Error message

Backend did not become healthy after restart

What it means

Thrown by TauriBackendService during automated crash recovery after startBackend() resolved (port is known) but waitUntilHealthy(60_000) returned false — meaning the /health (or equivalent) probe never returned a healthy status within the 60-second window. It is wrapped in the recovery try/catch, so it triggers another restart attempt (up to MAX_RESTART_ATTEMPTS = 3) and is not surfaced directly to the caller.

Source

Thrown at frontend/editor/src/desktop/services/tauriBackendService.ts:133

      title: "Backend stopped unexpectedly",
      body: `Attempting to restart... (${this.restartAttempts}/${TauriBackendService.MAX_RESTART_ATTEMPTS})`,
      durationMs: 5000,
    });
    this.isRecovering = true;
    // Reset started flag so startBackend() will run again
    this.backendStarted = false;
    this.startPromise = null;
    // Fresh grace window: the restarted backend needs boot time before failed
    // health checks may count as unhealthy again.
    this.hasBeenHealthy = false;
    this.setStatus("starting");
    try {
      await this.startBackend();
      // startBackend resolves once the port is known, not once Spring is up -
      // only declare success after a real health check passes.
      const healthy = await this.waitUntilHealthy(60_000);
      if (!healthy) {
        throw new Error("Backend did not become healthy after restart");
      }
      this.restartAttempts = 0; // Reset on successful restart
      this.isRecovering = false;
      console.log("[TauriBackendService] Backend restarted successfully.");
      alert({
        alertType: "success",
        title: "Backend restarted",
        body: "The local backend is back online.",
        durationMs: 4000,
      });
    } catch (err) {
      console.error("[TauriBackendService] Restart failed:", err);
      // Set isRecovering = false BEFORE setStatus to prevent re-triggering scheduleRecovery
      // if the max attempts check above doesn't catch it next time.
      this.isRecovering = false;
      if (this.restartAttempts < TauriBackendService.MAX_RESTART_ATTEMPTS) {
        this.setStatus("unhealthy"); // Will trigger another scheduleRecovery
      } else {

View on GitHub (pinned to 9ef20dcab8)

Solutions

  1. Inspect the bundled backend's stdout/stderr (Tauri sidecar logs) for the Spring boot failure — a stuck context is the usual cause.
  2. Verify the health-probe contract (URL + expected body) in waitUntilHealthy matches the backend's actual actuator/health endpoint.
  3. Increase the 60_000ms budget if the target machines are known-slow, or make the timeout configurable.
  4. Ensure required native dependencies are bundled/installed for the target OS.
  5. After MAX_RESTART_ATTEMPTS the service stops retrying — surface the 'restart the app' alert to the user rather than looping forever.

Example fix

// before
const healthy = await this.waitUntilHealthy(60_000);
if (!healthy) {
  throw new Error("Backend did not become healthy after restart");
}

// after — capture the last health-probe failure for diagnostics
const probe = await this.waitUntilHealthy(60_000);
if (!probe.healthy) {
  throw new BackendUnhealthyError({ lastStatus: probe.lastStatus, lastBody: probe.lastBody });
}
Defensive patterns

Strategy: retry

Validate before calling

import { tauriBackendService } from "@app/desktop/services/tauriBackendService";

function canRecover(): boolean {
  // recovery continues only while attempts remain
  return tauriBackendService.restartAttempts < 3; // MAX_RESTART_ATTEMPTS
}

Type guard

function isBackendUnhealthy(e: unknown): boolean {
  return e instanceof Error && /did not become healthy/.test(e.message);
}

Try / catch

// The service already retries up to MAX_RESTART_ATTEMPTS internally.
// At the app layer, react to the terminal 'unhealthy' status rather than catching here:
tauriBackendService.subscribeToStatus((s) => {
  if (s === 'unhealthy') showRestartAppPrompt();
});

Prevention

When it happens

Trigger: Backend was marked unhealthy (scheduleRecovery invoked), restartAttempts < 3, startBackend() succeeds (port assigned) but the health endpoint never responds 200/healthy within 60s. Causes: the Spring context fails to finish booting (port binding succeeded but app context errored), a dependency (DB, LibreOffice) is missing, or the health endpoint path/expected body changed.

Common situations: Bundled backend's required native dependency (LibreOffice, PDFTk) is missing on the user's machine so the Spring context stalls; the health check URL or expected status string drifted from what waitUntilHealthy expects; the machine is slow and 60s is insufficient; a port conflict causes a partial bind.

Related errors


AI-assisted analysis of Stirling-Tools/Stirling-PDF@9ef20dcab8 (2026-08-13). Data as JSON: /api/errors/25eba75e0d997f03. Report an issue: GitHub.