ExTauri.ShutdownManager (ex_tauri v0.4.2)

View Source

Manages graceful shutdown of the Phoenix application when running as a Tauri sidecar.

This GenServer implements a heartbeat-based mechanism to detect when the Tauri frontend exits. The Rust frontend sends heartbeat signals every 100ms — over a Unix domain socket on macOS/Linux, or a localhost TCP socket on Windows — and if the Phoenix sidecar doesn't receive a heartbeat within 1500ms (configurable), it initiates graceful shutdown.

Usage

Add this to your application's supervision tree in application.ex:

def start(_type, _args) do
  children = [
    ExTauri.ShutdownManager,
    # ... your other children
  ]

  opts = [strategy: :one_for_one, name: MyApp.Supervisor]
  Supervisor.start_link(children, opts)
end

How it works

The heartbeat mechanism provides robust shutdown detection:

  1. ShutdownManager opens a listener. On macOS/Linux this is a Unix domain socket at <tmpdir>/tauri_heartbeat_<app_name>.sock. On Windows (where the BEAM cannot listen on Unix domain sockets) it is a TCP socket bound to 127.0.0.1 on an ephemeral port, and the port number is written to <tmpdir>/tauri_heartbeat_<app_name>.port so the Rust frontend can find it
  2. Rust frontend connects and sends a byte every 100ms
  3. The acceptor reads bytes in the same process that accepted the connection and casts each one back as a heartbeat (reading from another process would fail)
  4. Every 500ms, ShutdownManager checks if a heartbeat was received recently
  5. Once the frontend has connected at least once, no heartbeat for 1500ms initiates graceful shutdown; before the first connection the timeout is not enforced, so a slow boot can't shut the app down before the window attaches

Generated shells pick the endpoint per launch and pass it in the environment — EX_TAURI_HEARTBEAT_SOCKET (Unix) or EX_TAURI_HEARTBEAT_PORT_FILE (Windows) — and this listens exactly there. A path named only for the application let a new window attach to an abandoned backend still listening on it: the old one got heartbeated forever, the new one never heard from its window. Without the variable (shells generated before 0.3.1) the per-application path above is used, so the socket stays unique across different ExTauri apps.

The shell also passes its own pid as EX_TAURI_SHELL_PID, which is what the orphan check and the live-shell check below ask about — see ExTauri.Sidecar.Orphan.shell_pid/0.

This works even when:

  • The app is force-quit (CMD+Q on macOS)
  • The app crashes unexpectedly
  • The process is killed without cleanup

The four stop signals

A heartbeat that stops is not the only way a window goes away, and each of the three additions below closes a case that left real sidecars running for days.

  1. The connection closing. The kernel closes a dead process's sockets, so this is the unambiguous "window gone" signal and the one a crash or a force-quit actually trips. The frontend reconnects on drop, so it only counts after :heartbeat_reconnect_grace has passed with nothing reconnecting.
  2. Bytes stopping for :heartbeat_timeout while the connection stays open — the backstop for a shell that is alive but wedged.
  3. A heartbeat that never started, on a process reparented to init. The startup grace deliberately makes "no heartbeat yet" immortal, because a slow boot must not kill itself — so a shell that died before it could connect, or whose socket a second sidecar unlinked, left its backend running until the machine rebooted. ExTauri.Sidecar.Orphan.orphan?/0 answers it from the other end: the shell is the only thing that spawns a packaged sidecar, so a PPID of 1 proves the window is gone whatever the socket says. It shells out to ps, so it runs only after :heartbeat_orphan_grace and is throttled after that, and only in a packaged build — a mix ex_tauri.dev backend is regularly a child of init and perfectly healthy.
  4. A check that runs late rebaselines instead of shutting down. When the machine sleeps both sides freeze, they do not resume together, and the monotonic clock keeps running across the sleep — so the first check after a wake measures a gap that says nothing about whether the window is alive. That is how an ordinary lid-close killed a backend: "heartbeat timeout (1610ms)" moments after the wake, with the shell still on screen. A check more than :heartbeat_stall_grace later than it was scheduled proves this process was not running either, which makes every elapsed measurement across it meaningless.

A quiet heartbeat from a live shell is not a dead window

The stall check above only covers this process freezing. The opposite case is the frontend being throttled while the BEAM runs on: macOS App Nap slows the timers of an app whose window has been hidden for a while, and the heartbeat thread's 100ms sleep is one of those timers. Nothing about the window is wrong, the socket stays open, and the bytes simply arrive late — late enough, once, to stop a backend that six agents were using, with the window back on screen a second later.

So on Unix, wherever the shell can be named — it passed EX_TAURI_SHELL_PID, or this is a packaged build whose process tree says — a heartbeat timeout with the connection still open first asks ExTauri.Sidecar.Orphan.shell_alive?/0. A shell that is still running keeps the backend up: the stall is logged once when it starts and once when it ends, the baseline is reset, and the next check is a full timeout away — the question forks ps. Everything that does mean "window gone" still stops it:

  • a quit sends SIGTERM (Unix frontends always do);
  • a crash or force-quit closes the socket, which is signal 1;
  • a shell that dies stops answering shell_alive?/0, so the next timeout stops it.

What it gives up is signal 2 for a shell that is alive but wedged: that backend now lives until the shell is killed, at which point the closed socket stops it. A wedged window is something a person can see and quit; a backend that dies under a hidden window is not.

Windows keeps the plain timeout, and so does a backend whose shell cannot be named (a mix ex_tauri.dev backend from a shell generated before 0.3.1, whose parent says nothing about a window). Inject :shell_alive_check to decide otherwise.

:heartbeat_timeout stays at 1500ms because the Windows frontend has no SIGTERM to send and quits by letting this time out — raising the default would make every Windows quit that much slower. On macOS and Linux the closed connection does the real work, so an app that sees false positives on a loaded machine can raise the timeout without losing anything.

Configuration

Heartbeat timing can be configured in your config/config.exs:

config :ex_tauri,
  heartbeat_interval: 500,  # How often to check heartbeat (ms, default: 500)
  heartbeat_timeout: 1500   # Time without heartbeat before shutdown (ms, default: 1500)

The transport is selected automatically from the OS (:unix on macOS/Linux, :tcp on Windows). It can be forced with the :heartbeat_transport config key or the :transport start option, which is mainly useful for tests.

The desktop channel

Beyond liveness, the socket carries a newline-delimited JSON protocol in both directions: the Rust frontend sends heartbeats and native events (menu and tray clicks), and Elixir sends desktop commands (notifications, tray setup). Use ExTauri.Desktop rather than talking to this server directly.

Summary

Functions

Returns a specification to start this module under a supervisor.

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

start_link(opts \\ [])