fix: fixed shutdown race

This commit is contained in:
2026-05-29 16:16:33 +02:00
parent 74ceaeb971
commit d03d033548
3 changed files with 120 additions and 3 deletions

View File

@@ -61,9 +61,26 @@ defmodule BDS.Desktop.Shutdown do
def command_menu_selected(_event, _command_event), do: :ok
@doc """
Terminate the OS process directly with SIGKILL.
`Desktop.Window.quit/0` routes through `System.halt/1`, which calls the libc
`exit()` and runs the wxWidgets C++ static destructors on the way out. On
macOS that races the still-running wx event loop on the main thread and
segfaults (`wxMenu::~wxMenu` vs `wxAppBase::ProcessIdle`). A SIGKILL is a
kernel-level termination that skips those destructors entirely, so the app
exits cleanly without producing a crash report.
"""
@spec quit() :: :ok
def quit do
kill_heart()
kill_beam()
:ok
end
defp start_shutdown_task do
Task.start(fn ->
MainWindow.persist_now()
persist_safely()
maybe_hide_window()
Process.sleep(50)
quit_module().quit()
@@ -72,6 +89,57 @@ defmodule BDS.Desktop.Shutdown do
:ok
end
defp persist_safely do
MainWindow.persist_now()
:ok
rescue
_error -> :ok
catch
:exit, _reason -> :ok
end
# heart, when present, would relaunch the app after we kill the BEAM, so it
# has to be terminated first. When the app is started without heart (e.g. via
# `mix`) there is nothing to do here.
defp kill_heart do
with heart when is_pid(heart) <- Process.whereis(:heart),
{:links, links} <- Process.info(heart, :links),
port when is_port(port) <- Enum.find(links, &is_port/1),
{:os_pid, heart_pid} <- Port.info(port, :os_pid) do
os_kill(heart_pid)
else
_ -> :ok
end
rescue
_error -> :ok
catch
:exit, _reason -> :ok
end
defp kill_beam do
os_kill(:os.getpid())
end
defp os_kill(os_pid) do
os_kill_fun().(os_pid)
:ok
rescue
_error -> :ok
catch
:exit, _reason -> :ok
end
defp os_kill_fun do
Application.get_env(:bds, :desktop_os_kill_fun, &__MODULE__.hard_kill/1)
end
@doc false
@spec hard_kill(charlist() | integer() | String.t()) :: :ok
def hard_kill(os_pid) do
System.cmd("kill", ["-9", to_string(os_pid)], stderr_to_stdout: true)
:ok
end
defp maybe_hide_window do
module = window_module()
@@ -86,8 +154,10 @@ defmodule BDS.Desktop.Shutdown do
:exit, _reason -> :ok
end
defp quit_module do
Application.get_env(:bds, :desktop_window_quit_module, Window)
@doc false
@spec quit_module() :: module()
def quit_module do
Application.get_env(:bds, :desktop_window_quit_module, __MODULE__)
end
defp window_module do

View File

@@ -256,6 +256,53 @@ defmodule BDS.DesktopTest do
assert_receive :window_quit_requested
end
test "the app owns final termination instead of delegating to Desktop.Window/System.halt" do
# Desktop.Window.quit/0 routes through System.halt/1, which runs the wx C++
# static destructors on exit and crashes on macOS. The app-owned shutdown
# must terminate the OS process directly with SIGKILL instead.
assert BDS.Desktop.Shutdown.quit_module() == BDS.Desktop.Shutdown
end
test "hard quit terminates the BEAM OS process with SIGKILL rather than libc exit" do
previous_kill = Application.get_env(:bds, :desktop_os_kill_fun)
test_pid = self()
Application.put_env(:bds, :desktop_os_kill_fun, fn os_pid ->
send(test_pid, {:os_killed, to_string(os_pid)})
:ok
end)
on_exit(fn -> restore_env(:desktop_os_kill_fun, previous_kill) end)
assert :ok = BDS.Desktop.Shutdown.quit()
assert_receive {:os_killed, beam_pid}
assert beam_pid == to_string(:os.getpid())
end
test "hard quit kills heart before itself so the app is not relaunched" do
previous_kill = Application.get_env(:bds, :desktop_os_kill_fun)
test_pid = self()
{:ok, fake_heart} =
Agent.start(fn -> :ok end, name: :heart)
Application.put_env(:bds, :desktop_os_kill_fun, fn os_pid ->
send(test_pid, {:os_killed, to_string(os_pid)})
:ok
end)
on_exit(fn ->
restore_env(:desktop_os_kill_fun, previous_kill)
if Process.alive?(fake_heart), do: Agent.stop(fake_heart)
end)
assert :ok = BDS.Desktop.Shutdown.quit()
# heart has no linked port here, so only the BEAM itself is killed, and the
# call must not crash while inspecting the heart process.
assert_receive {:os_killed, beam_pid}
assert beam_pid == to_string(:os.getpid())
end
test "desktop root html is a LiveView shell and references the generated asset entrypoints" do
conn = conn(:get, "/?k=#{Desktop.Auth.login_key()}")
conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([]))