From 3675a264071a1a8ba2307f2689900ec5b6ab6262 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 16 Jul 2026 18:39:07 +0200 Subject: [PATCH] fix: local tui mode routes console logging to a file and disables download progress bars so stray output cannot scroll the TUI --- config/runtime.exs | 7 ++-- lib/bds/server.ex | 60 +++++++++++++++++++++++++++++-- specs/server.allium | 8 ++++- test/bds/server_test.exs | 78 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 145 insertions(+), 8 deletions(-) diff --git a/config/runtime.exs b/config/runtime.exs index b16a4c1..9545449 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -2,8 +2,11 @@ import Config # Headless boots (BDS_MODE=server/tui, or desktop mode without a graphical # display — issue #33) must neuter wx before the :desktop dependency -# application starts it and crashes the VM. Runtime config is the only hook -# that runs before dependency applications start. +# application starts it and crashes the VM. Local tui mode additionally +# silences all other terminal writers (console logging goes to a file, +# model-download progress bars are disabled) — the TUI owns the terminal +# and every stray line scrolls its rendering up one row. Runtime config is +# the only hook that runs before dependency applications start. BDS.Server.prepare_boot_env() if config_env() == :prod do diff --git a/lib/bds/server.ex b/lib/bds/server.ex index 1c5daa4..917b272 100644 --- a/lib/bds/server.ex +++ b/lib/bds/server.ex @@ -67,15 +67,71 @@ defmodule BDS.Server do calls into no-ops, so the dependency starts inert and the TUI fallback (issue #33) can actually boot. + In tui mode the app additionally owns the launching terminal, so every + other terminal writer is silenced: the default (console) logger handler + is replaced with a rotating file handler at `tui_log_file/0`, and + Bumblebee's model-download progress bar (raw stdout writes) is disabled. + Each stray line would otherwise scroll the TUI's alternate screen up one + row and corrupt the rendering. Desktop and server modes keep logging to + the terminal, and SSH-served TUI sessions are unaffected either way — + their cells travel over the SSH channel, away from the server console. + Called from `config/runtime.exs`: runtime config is the only hook that runs before dependency applications start, in both `mix run` and releases. """ - @spec prepare_boot_env(:desktop | :server | :tui) :: :desktop | :server | :tui - def prepare_boot_env(mode \\ effective_mode()) do + @spec prepare_boot_env(:desktop | :server | :tui, (-> :ok)) :: :desktop | :server | :tui + def prepare_boot_env(mode \\ effective_mode(), redirect_logs \\ &redirect_logging_to_file/0) do if mode != :desktop, do: System.put_env("NO_WX", "1") + + if mode == :tui do + Application.put_env(:bumblebee, :progress_bar_enabled, false) + redirect_logs.() + end + mode end + @doc """ + Log file that replaces console logging in local tui mode: a + machine-specific, regenerable artifact, so it lives in the private app + dir (PrivateArtifactsLiveInOsAppDir). Overridable via `:bds, :tui_log_file`. + """ + @spec tui_log_file() :: Path.t() + def tui_log_file do + Application.get_env(:bds, :tui_log_file) || + Path.join([BDS.Projects.private_dir(), "logs", "bds.log"]) + end + + @doc """ + Replaces the default (console) logger handler with a rotating file + handler writing to `tui_log_file/0`, keeping the configured level, + filters, and Elixir formatter. See `prepare_boot_env/2`. + """ + @spec redirect_logging_to_file() :: :ok + def redirect_logging_to_file do + log_file = tui_log_file() + File.mkdir_p!(Path.dirname(log_file)) + + with {:ok, handler} <- :logger.get_handler_config(:default) do + :ok = :logger.remove_handler(:default) + + :ok = + :logger.add_handler( + :default, + :logger_std_h, + handler + |> Map.take([:level, :filters, :filter_default, :formatter]) + |> Map.put(:config, %{ + file: String.to_charlist(log_file), + max_no_bytes: 10_000_000, + max_no_files: 5 + }) + ) + end + + :ok + end + @doc """ Whether the HTTP endpoint requires the desktop webview auth token. Only desktop mode does: in server/tui mode the endpoint stays loopback-only diff --git a/specs/server.allium b/specs/server.allium index d04996a..01f5ebb 100644 --- a/specs/server.allium +++ b/specs/server.allium @@ -41,7 +41,13 @@ rule ModeResolution { -- never rewritten. Because the :desktop dependency boots wx in its -- own application start, config/runtime.exs sets NO_WX=1 for every -- non-desktop effective mode (BDS.Server.prepare_boot_env) so the - -- dependency starts inert before any BDS supervisor runs. + -- dependency starts inert before any BDS supervisor runs. In tui + -- mode prepare_boot_env also silences every other terminal writer — + -- the default logger handler moves to a rotating file in the private + -- app dir and Bumblebee's model-download progress bar is disabled — + -- because the TUI owns the terminal and each stray console line + -- scrolls the alternate screen up one row. Desktop/server modes and + -- SSH-served TUI sessions keep normal console logging. ensures: BootMode.created(kind: mode) } diff --git a/test/bds/server_test.exs b/test/bds/server_test.exs index 1a774ae..18d7ccf 100644 --- a/test/bds/server_test.exs +++ b/test/bds/server_test.exs @@ -88,18 +88,90 @@ defmodule BDS.ServerTest do end test "non-desktop modes disable wx so the :desktop dep boots inert" do - assert Server.prepare_boot_env(:tui) == :tui + assert Server.prepare_boot_env(:tui, fn -> :ok end) == :tui assert System.get_env("NO_WX") == "1" System.delete_env("NO_WX") - assert Server.prepare_boot_env(:server) == :server + assert Server.prepare_boot_env(:server, fn -> :ok end) == :server assert System.get_env("NO_WX") == "1" end test "desktop mode leaves wx enabled" do - assert Server.prepare_boot_env(:desktop) == :desktop + assert Server.prepare_boot_env(:desktop, fn -> :ok end) == :desktop assert System.get_env("NO_WX") == nil end + + test "tui mode silences terminal writers; desktop and server modes keep them" do + original = Application.get_env(:bumblebee, :progress_bar_enabled) + + on_exit(fn -> + if original == nil do + Application.delete_env(:bumblebee, :progress_bar_enabled) + else + Application.put_env(:bumblebee, :progress_bar_enabled, original) + end + end) + + parent = self() + + assert Server.prepare_boot_env(:tui, fn -> send(parent, :redirected) end) == :tui + assert Application.get_env(:bumblebee, :progress_bar_enabled) == false + assert_received :redirected + + Server.prepare_boot_env(:desktop, fn -> send(parent, :redirected) end) + Server.prepare_boot_env(:server, fn -> send(parent, :redirected) end) + refute_received :redirected + end + end + + describe "tui logging redirect" do + setup do + dir = Path.join(System.tmp_dir!(), "bds-tui-log-#{System.unique_integer([:positive])}") + log_file = Path.join(dir, "bds.log") + Application.put_env(:bds, :tui_log_file, log_file) + + on_exit(fn -> + Application.delete_env(:bds, :tui_log_file) + File.rm_rf(dir) + end) + + {:ok, log_file: log_file} + end + + test "tui_log_file defaults to the private app dir and honors the override", %{ + log_file: log_file + } do + assert Server.tui_log_file() == log_file + + Application.delete_env(:bds, :tui_log_file) + assert Server.tui_log_file() == Path.join([BDS.Projects.private_dir(), "logs", "bds.log"]) + end + + test "redirect_logging_to_file swaps the default handler to a rotating file handler", %{ + log_file: log_file + } do + {:ok, original} = :logger.get_handler_config(:default) + + on_exit(fn -> + _ = :logger.remove_handler(:default) + + :ok = + :logger.add_handler( + :default, + original.module, + Map.take(original, [:level, :filters, :filter_default, :formatter, :config]) + ) + end) + + assert Server.redirect_logging_to_file() == :ok + + {:ok, handler} = :logger.get_handler_config(:default) + assert handler.module == :logger_std_h + assert handler.config.file == String.to_charlist(log_file) + assert handler.config.max_no_bytes > 0 + # The swap must keep the Elixir log formatter, not fall back to OTP's. + assert handler.formatter == original.formatter + end end describe "ensure_ssh_dir!/1" do