ExTauri.ShutdownManager (ex_tauri v0.4.2)
View SourceManages 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)
endHow it works
The heartbeat mechanism provides robust shutdown detection:
- 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 to127.0.0.1on an ephemeral port, and the port number is written to<tmpdir>/tauri_heartbeat_<app_name>.portso the Rust frontend can find it - Rust frontend connects and sends a byte every 100ms
- 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)
- Every 500ms, ShutdownManager checks if a heartbeat was received recently
- 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.
- 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_gracehas passed with nothing reconnecting. - Bytes stopping for
:heartbeat_timeoutwhile the connection stays open — the backstop for a shell that is alive but wedged. - 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?/0answers 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 tops, so it runs only after:heartbeat_orphan_graceand is throttled after that, and only in a packaged build — amix ex_tauri.devbackend is regularly a child of init and perfectly healthy. - 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_gracelater 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
Returns a specification to start this module under a supervisor.
See Supervisor.