fix: local tui mode routes console logging to a file and disables download progress bars so stray output cannot scroll the TUI

This commit is contained in:
2026-07-16 18:39:07 +02:00
parent 9a1f301527
commit 3675a26407
4 changed files with 145 additions and 8 deletions

View File

@@ -2,8 +2,11 @@ import Config
# Headless boots (BDS_MODE=server/tui, or desktop mode without a graphical # Headless boots (BDS_MODE=server/tui, or desktop mode without a graphical
# display — issue #33) must neuter wx before the :desktop dependency # display — issue #33) must neuter wx before the :desktop dependency
# application starts it and crashes the VM. Runtime config is the only hook # application starts it and crashes the VM. Local tui mode additionally
# that runs before dependency applications start. # 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() BDS.Server.prepare_boot_env()
if config_env() == :prod do if config_env() == :prod do

View File

@@ -67,15 +67,71 @@ defmodule BDS.Server do
calls into no-ops, so the dependency starts inert and the TUI fallback calls into no-ops, so the dependency starts inert and the TUI fallback
(issue #33) can actually boot. (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 Called from `config/runtime.exs`: runtime config is the only hook that
runs before dependency applications start, in both `mix run` and releases. runs before dependency applications start, in both `mix run` and releases.
""" """
@spec prepare_boot_env(:desktop | :server | :tui) :: :desktop | :server | :tui @spec prepare_boot_env(:desktop | :server | :tui, (-> :ok)) :: :desktop | :server | :tui
def prepare_boot_env(mode \\ effective_mode()) do 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 != :desktop, do: System.put_env("NO_WX", "1")
if mode == :tui do
Application.put_env(:bumblebee, :progress_bar_enabled, false)
redirect_logs.()
end
mode mode
end 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 """ @doc """
Whether the HTTP endpoint requires the desktop webview auth token. Only Whether the HTTP endpoint requires the desktop webview auth token. Only
desktop mode does: in server/tui mode the endpoint stays loopback-only desktop mode does: in server/tui mode the endpoint stays loopback-only

View File

@@ -41,7 +41,13 @@ rule ModeResolution {
-- never rewritten. Because the :desktop dependency boots wx in its -- never rewritten. Because the :desktop dependency boots wx in its
-- own application start, config/runtime.exs sets NO_WX=1 for every -- own application start, config/runtime.exs sets NO_WX=1 for every
-- non-desktop effective mode (BDS.Server.prepare_boot_env) so the -- 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) ensures: BootMode.created(kind: mode)
} }

View File

@@ -88,18 +88,90 @@ defmodule BDS.ServerTest do
end end
test "non-desktop modes disable wx so the :desktop dep boots inert" do 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" assert System.get_env("NO_WX") == "1"
System.delete_env("NO_WX") 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" assert System.get_env("NO_WX") == "1"
end end
test "desktop mode leaves wx enabled" do 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 assert System.get_env("NO_WX") == nil
end 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 end
describe "ensure_ssh_dir!/1" do describe "ensure_ssh_dir!/1" do