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
# 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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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