ExTauri.Sidecar.Orphan (ex_tauri v0.4.2)

View Source

Recognising — and clearing out — packaged sidecars whose window is gone.

The Tauri shell is the only thing that ever spawns a packaged sidecar. A sidecar whose shell is gone has definitively lost its window: nothing will heartbeat it again, nothing will SIGTERM it on quit, and it keeps every process its supervision tree owns running until the machine reboots.

Which process is the shell takes some care. Generated shells say so, in EX_TAURI_SHELL_PID, and that answer is used whenever it is there — see shell_pid/0. Without it the shell is read off the process tree. Burrito's launcher (1.6, which ex_tauri requires) spawns the BEAM as its child and waits on it, so a packaged BEAM's parent is the launcher and the shell is one level further up; a release script or the dev shim execs the VM, so there the parent is the shell itself. A launcher is recognised by its executable being named after the release, which is what Tauri names the bundled sidecar.

That is not hypothetical. It has now been observed in two ex_tauri apps on one machine: seven sidecars of one running at once, the oldest holding its HTTP port for three and a half days so that every later launch died on :eaddrinuse; and four of another, two days old, whose only remaining trace was the ps output — their unpack directories had already been replaced.

Three parts to the answer:

  • reclaim_port/2 takes the port back from an orphan before your endpoint tries to bind it, so a stale sidecar can no longer lock the user out;
  • reap_orphans/1 stops the rest, which hold no port but go on running timers, pollers and child processes with no window to show them in;
  • orphan?/0 lets a running sidecar notice that its own shell is gone, which is the case a heartbeat cannot see — a heartbeat that never arrived is indistinguishable from a slow boot, and ExTauri.ShutdownManager uses this as its fourth stop signal.

Whose sidecar is it

Burrito names the unpack directory after the release, and mix ex_tauri.install scaffolded that release as :desktop for everyone until it started deriving the name from the application. Every app installed before that shares one directory: two of them both unpack to .burrito/desktop_erts-<vsn>_<app vsn>/, and every predicate that answers on the path alone answers the same for both. That is not a hypothetical — it is how four sidecars of one app were read as abandoned instances of another, one blind sweep away from being SIGTERMed by a program that did not own them.

A unique release name is the real fix and new installs get one, but this check has to hold regardless: the machine an app runs on is not the machine it was installed on, and the orphan it finds may well predate the rename.

identify/2 is what separates them: the release name cannot, but the payload can, because Burrito unpacks each application into lib/<app>-<vsn>. It has three answers rather than two, since the unpack directory of a running sidecar can be deleted out from under it — an upgrade that cleans up after itself leaves the old version running from a path that no longer exists, which is precisely the long-lived orphan this module exists for. "Cannot tell" has to stay distinguishable from "not ours", and the two callers weigh it differently:

  • reclaim_port/2 evicts on :unknown. The port is yours by configuration, so a parentless Burrito sidecar squatting it is an old copy of you whichever way its directory went.
  • reap_orphans/1 requires :ours. It sweeps the whole machine on nothing but a path shape, and leaking one of your own orphans is a far smaller harm than killing someone else's running app.

Options

Both entry points take:

  • :otp_app — the application Burrito unpacked, used to recognise your own payload. Defaults to the sanitised :app_name, which is already what most apps are called ("Codrift" -> :codrift).
  • :release_name — the release Burrito wrapped. Defaults to "desktop", what mix ex_tauri.install generates.

Unix only. On Windows every predicate answers "not an orphan", which degrades to doing nothing rather than guessing.

Summary

Types

Whose release a sidecar is carrying.

Outcome of trying to make the port bindable.

Functions

Whether command is running your release, another app's, or one that can no longer be read.

True when this process has lost the shell that spawned it.

True when pid is a packaged sidecar — of any ex_tauri app — with no parent shell.

True when this process is a Burrito-wrapped sidecar rather than a dev server.

True when command is the emulator of some packaged sidecar.

Stops every abandoned sidecar of this application, returning the pids it signalled.

Makes port bindable, evicting an abandoned sidecar if that is what holds it.

True when the shell that spawned this process is provably still running.

The Tauri shell's OS pid, when the shell passed it in EX_TAURI_SHELL_PID.

Types

identity()

@type identity() :: :ours | :foreign | :unknown

Whose release a sidecar is carrying.

:unknown is a directory that was deleted while its process kept running, not a guess — see "Whose sidecar is it" above.

reclaim()

@type reclaim() :: :free | :reclaimed | {:blocked, String.t()}

Outcome of trying to make the port bindable.

Functions

ensure_port_available(port, opts \\ [])

@spec ensure_port_available(
  pos_integer(),
  keyword()
) :: :ok

reclaim_port/2 plus reap_orphans/1, with the outcome logged.

The convenience form, and the one to reach for at boot: freeing the port only deals with the one orphan that happened to win the race for it.

identify(command, opts \\ [])

@spec identify(
  String.t() | nil,
  keyword()
) :: identity()

Whether command is running your release, another app's, or one that can no longer be read.

Answers from the unpack directory the process named in its own argv, so it costs a File.ls/1 and no ps. A directory that is gone answers :unknown rather than :foreign: its payload was yours or it was not, and this cannot say which any more.

orphan?()

@spec orphan?() :: boolean()

True when this process has lost the shell that spawned it.

With shell_pid/0 known, that pid is simply checked. Without it the answer comes from the process tree, which is only meaningful for a packaged sidecar: a plain mix ex_tauri.dev backend is regularly a child of init and perfectly healthy. ExTauri.ShutdownManager gates it on packaged?/0 or shell_pid/0 for that reason.

orphan_sidecar?(pid, opts \\ [])

@spec orphan_sidecar?(
  pos_integer(),
  keyword()
) :: boolean()

True when pid is a packaged sidecar — of any ex_tauri app — with no parent shell.

packaged?()

@spec packaged?() :: boolean()

True when this process is a Burrito-wrapped sidecar rather than a dev server.

Burrito's launcher sets __BURRITO in the environment it starts the BEAM with. It does not set RELEASE_NAME, which is the trap that makes desktop-only behaviour gated on RELEASE_NAME pass every local test and no-op in every shipped build.

packaged_sidecar?(command, opts \\ [])

@spec packaged_sidecar?(
  String.t() | nil,
  keyword()
) :: boolean()

True when command is the emulator of some packaged sidecar.

Requiring beam.smp keeps the release's helper processes (erl_child_setup, inet_gethost) out of the match: they share the unpack path but are not the sidecar, and killing one tears the ports out from under a healthy instance. CLI invocations are excluded for the same reason from the other direction — see cli_invocation?/1.

reap_orphans(opts \\ [])

@spec reap_orphans(keyword()) :: [pos_integer()]

Stops every abandoned sidecar of this application, returning the pids it signalled.

Freeing the port only deals with the one orphan that happened to win the race for it; the rest go on running with no window to show anything in. They can only be cleaned up from outside, so a starting sidecar does it on everyone's behalf.

Only sidecars that prove they are yours: nothing swept here holds a port of yours or answers to you in any way, so a match on the path shape alone is the whole evidence, and it is evidence another ex_tauri app satisfies too.

reclaim_port(port, opts \\ [])

@spec reclaim_port(
  pos_integer(),
  keyword()
) :: reclaim()

Makes port bindable, evicting an abandoned sidecar if that is what holds it.

Call it from Application.start/2 before your endpoint's child spec, so the bind that follows cannot fail on a process nobody is coming back for:

def start(_type, _args) do
  ExTauri.Sidecar.Orphan.ensure_port_available(4000)
  ...
end

Returns :free when nothing was in the way, :reclaimed when an orphan was stopped, and {:blocked, reason} when the holder is something that must not be killed — an instance that still has a window, another app, or a process that cannot be identified at all.

shell_alive?()

@spec shell_alive?() :: boolean()

True when the shell that spawned this process is provably still running.

Not the negation of orphan?/0: anything that cannot be read — ps missing or failing, Windows — answers false here, because the caller is deciding whether to ignore a heartbeat timeout, and only proof should do that. ExTauri.ShutdownManager gates it on packaged?/0 or shell_pid/0, for the same reason as orphan?/0.

shell_pid()

@spec shell_pid() :: pos_integer() | nil

The Tauri shell's OS pid, when the shell passed it in EX_TAURI_SHELL_PID.

Generated shells always do. It is the one answer that does not depend on how the sidecar was launched — a Burrito launcher, a release script, a dev shim — and it means something in mix ex_tauri.dev too, where the process tree says nothing about a window. A pid the OS has since reused would read as alive; that window is the gap between a shell dying and another process being handed its number, and the closed heartbeat socket covers it.