diff --git a/lib/bds/desktop/shutdown.ex b/lib/bds/desktop/shutdown.ex index cb47d37..c67199e 100644 --- a/lib/bds/desktop/shutdown.ex +++ b/lib/bds/desktop/shutdown.ex @@ -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 diff --git a/priv/data/projects/aceac6d3-8f3f-4a9c-a2b4-517b59b20f44/embeddings.usearch b/priv/data/projects/aceac6d3-8f3f-4a9c-a2b4-517b59b20f44/embeddings.usearch index 9d8cd07..a1caa7c 100644 Binary files a/priv/data/projects/aceac6d3-8f3f-4a9c-a2b4-517b59b20f44/embeddings.usearch and b/priv/data/projects/aceac6d3-8f3f-4a9c-a2b4-517b59b20f44/embeddings.usearch differ diff --git a/test/bds/desktop_test.exs b/test/bds/desktop_test.exs index ece82e3..55c7ee6 100644 --- a/test/bds/desktop_test.exs +++ b/test/bds/desktop_test.exs @@ -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([]))