fix: fixed shutdown race
This commit is contained in:
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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([]))
|
||||
|
||||
Reference in New Issue
Block a user