Stabilize preview and sandbox cleanup

This commit is contained in:
2026-06-12 14:40:35 +02:00
parent caaec98225
commit a00e4b85ac
22 changed files with 195 additions and 34 deletions

View File

@@ -4,6 +4,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do
use Phoenix.Component
alias BDS.Metadata
alias BDS.Preview
use Gettext, backend: BDS.Gettext
@themes [
@@ -42,7 +43,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do
applied_theme: current_theme(assigns),
preview_mode: preview_mode,
preview_url:
"http://127.0.0.1:4123/__style-preview?theme=#{selected_theme}&mode=#{preview_mode}"
Preview.base_url() <>
"/__style-preview?theme=#{selected_theme}&mode=#{preview_mode}"
}
end

View File

@@ -12,12 +12,14 @@ defmodule BDS.Preview do
alias BDS.Rendering.TemplateSelection
@host "127.0.0.1"
@port 4123
@preferred_port 4123
@server_table __MODULE__.ServerTable
# Max time to wait for inflight requests to finish during graceful shutdown
# before remaining request tasks are forcibly terminated.
@drain_timeout 5_000
@listen_retry_attempts 10
@listen_retry_delay_ms 50
def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
@@ -41,7 +43,17 @@ defmodule BDS.Preview do
)
end
def base_url, do: "http://#{@host}:#{@port}"
def base_url do
case :ets.whereis(@server_table) do
:undefined -> "http://#{@host}:#{@preferred_port}"
_table ->
case :ets.lookup(@server_table, :current) do
[{:current, %{host: host, port: port}}] -> "http://#{host}:#{port}"
_other -> "http://#{@host}:#{@preferred_port}"
end
end
end
def stop_preview(project_id) when is_binary(project_id) do
GenServer.call(__MODULE__, {:stop_preview, project_id})
@@ -501,14 +513,7 @@ defmodule BDS.Preview do
state = stop_current_server(state)
maybe_allow_repo(owner_pid)
{:ok, listener} =
:gen_tcp.listen(@port, [
:binary,
packet: :raw,
active: false,
reuseaddr: true,
ip: {127, 0, 0, 1}
])
{:ok, listener, port} = listen_with_retry(@listen_retry_attempts)
acceptor_pid = spawn_link(fn -> accept_loop(listener, project_id, owner_pid) end)
@@ -516,7 +521,7 @@ defmodule BDS.Preview do
project_id: project_id,
data_dir: data_dir,
host: @host,
port: @port,
port: port,
is_running: true,
owner_pid: owner_pid,
listener: listener,
@@ -528,6 +533,40 @@ defmodule BDS.Preview do
{{:ok, public_server(server)}, %{state | current: server}}
end
defp listen_with_retry(attempts_left) when attempts_left > 0 do
case listen_on_port(@preferred_port) do
{:ok, listener, port} ->
{:ok, listener, port}
{:error, :eaddrinuse} when attempts_left > 1 ->
Process.sleep(@listen_retry_delay_ms)
listen_with_retry(attempts_left - 1)
{:error, :eaddrinuse} ->
listen_on_port(0)
{:error, reason} ->
{:error, reason}
end
end
defp listen_on_port(port) do
case :gen_tcp.listen(port, [
:binary,
packet: :raw,
active: false,
reuseaddr: true,
ip: {127, 0, 0, 1}
]) do
{:ok, listener} ->
{:ok, {_host, actual_port}} = :inet.sockname(listener)
{:ok, listener, actual_port}
{:error, reason} ->
{:error, reason}
end
end
defp run_tracked_request(fun) when is_function(fun, 0) do
owner_pid = self()

View File

@@ -12,6 +12,8 @@ defmodule BDS.Projects do
@default_project_id "default"
@default_project_name "My Blog"
@create_project_retry_attempts 5
@create_project_retry_delay_ms 50
@typedoc "An attribute map that may use atom or string keys."
@type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()}
@@ -152,22 +154,24 @@ defmodule BDS.Projects do
name = attr(attrs, :name) || ""
slug = unique_slug(attr(attrs, :slug) || Slug.slugify(name))
Repo.transaction(fn ->
project =
%Project{}
|> Project.changeset(%{
id: Ecto.UUID.generate(),
name: name,
slug: slug,
description: attr(attrs, :description),
data_path: attr(attrs, :data_path),
created_at: now,
updated_at: now,
is_active: false
})
|> Repo.insert!()
retry_create_project(fn ->
Repo.transaction(fn ->
project =
%Project{}
|> Project.changeset(%{
id: Ecto.UUID.generate(),
name: name,
slug: slug,
description: attr(attrs, :description),
data_path: attr(attrs, :data_path),
created_at: now,
updated_at: now,
is_active: false
})
|> Repo.insert!()
project
project
end)
end)
|> case do
{:ok, project} ->
@@ -182,6 +186,22 @@ defmodule BDS.Projects do
end
end
defp retry_create_project(fun, attempts_left \\ @create_project_retry_attempts)
defp retry_create_project(fun, attempts_left) when attempts_left > 1 do
fun.()
rescue
error in [Exqlite.Error] ->
if String.contains?(Exception.message(error), "Database busy") do
Process.sleep(@create_project_retry_delay_ms)
retry_create_project(fun, attempts_left - 1)
else
reraise error, __STACKTRACE__
end
end
defp retry_create_project(fun, _attempts_left), do: fun.()
@spec set_active_project(String.t()) :: {:ok, Project.t()} | {:error, :not_found | term()}
def set_active_project(project_id) do
case Repo.get(Project, project_id) do