fix: A1-11 graceful preview shutdown drains inflight requests before stopping

This commit is contained in:
2026-05-29 09:49:54 +02:00
parent b3434b3054
commit 5b619f492a
3 changed files with 130 additions and 18 deletions

View File

@@ -20,7 +20,7 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
| A1-8 | ~~`ValidateLiquid`/`ValidateScript` before publish~~ | template.allium:110, script.allium:165 | `publish_template` validates Liquid via `Liquex.parse`, `publish_script` validates Lua via `BDS.Scripting.validate` | **Resolved:** validation gates added to `publish_template/1` and `publish_script/1`, invalid content returns `{:error, {:invalid_liquid|:invalid_script, reason}}`, 4 tests added | | A1-8 | ~~`ValidateLiquid`/`ValidateScript` before publish~~ | template.allium:110, script.allium:165 | `publish_template` validates Liquid via `Liquex.parse`, `publish_script` validates Lua via `BDS.Scripting.validate` | **Resolved:** validation gates added to `publish_template/1` and `publish_script/1`, invalid content returns `{:error, {:invalid_liquid|:invalid_script, reason}}`, 4 tests added |
| A1-9 | ~~17 preset colors + custom hex in tag picker~~ | editor_tags.allium | `ColourPicker` hook + popover with 17 preset swatches grid and custom hex input, wired to both create and edit forms | **Resolved:** replaced native `<input type="color">` with `ColourPickerPopover` component (17 presets, custom hex #RRGGBB, immediate selection), JS hook for click-away dismiss, 1 test added | | A1-9 | ~~17 preset colors + custom hex in tag picker~~ | editor_tags.allium | `ColourPicker` hook + popover with 17 preset swatches grid and custom hex input, wired to both create and edit forms | **Resolved:** replaced native `<input type="color">` with `ColourPickerPopover` component (17 presets, custom hex #RRGGBB, immediate selection), JS hook for click-away dismiss, 1 test added |
| A1-10 | ~~Template file written on create~~ | engine_side_effects.allium:151-153 | `create_template` now computes `file_path` and writes template file with YAML frontmatter on create | **Resolved:** `create_template/1` writes `templates/{slug}.liquid` on create, `next_template_file_path` always computes path, 1 test added | | A1-10 | ~~Template file written on create~~ | engine_side_effects.allium:151-153 | `create_template` now computes `file_path` and writes template file with YAML frontmatter on create | **Resolved:** `create_template/1` writes `templates/{slug}.liquid` on create, `next_template_file_path` always computes path, 1 test added |
| A1-11 | Graceful shutdown with inflight request tracking | preview.allium:47-48 | Kills acceptor process, no inflight tracking | Fix code: track inflight requests, drain before shutdown | | A1-11 | ~~Graceful shutdown with inflight request tracking~~ | preview.allium:47-48 | `stop_preview` now closes the listener, parks the reply, and drains monitored inflight request tasks before reporting stopped | **Resolved:** acceptor transfers socket ownership to each request task; GenServer monitors inflight tasks, `begin_graceful_stop` stops accepting and finalizes via `:DOWN`/`:drain_timeout` (5s force-kill cap), 1 test added |
| A1-12 | Real Pagefind integration for search | generation.allium:208 | Stub only: `pagefind-ui.js` is one-liner, `PagefindUI` never defined, search-runtime.js silently bails, client-side search non-functional | Fix code: bundle real Pagefind, build proper fragment index, wire PagefindUI | | A1-12 | Real Pagefind integration for search | generation.allium:208 | Stub only: `pagefind-ui.js` is one-liner, `PagefindUI` never defined, search-runtime.js silently bails, client-side search non-functional | Fix code: bundle real Pagefind, build proper fragment index, wire PagefindUI |
| A1-13 | Git sidebar shows only "Working tree" placeholder | sidebar_views.allium:651-770 | `sidebar.ex:782-798` returns single entity_list item; `BDS.Git` has full status/diff/commit/history/fetch/pull/push/prune_lfs but sidebar doesn't use it | Fix code: wire sidebar `git_view/0` to `BDS.Git` — render branch, ahead/behind, status file list, commit input, history entries, action buttons per spec | | A1-13 | Git sidebar shows only "Working tree" placeholder | sidebar_views.allium:651-770 | `sidebar.ex:782-798` returns single entity_list item; `BDS.Git` has full status/diff/commit/history/fetch/pull/push/prune_lfs but sidebar doesn't use it | Fix code: wire sidebar `git_view/0` to `BDS.Git` — render branch, ahead/behind, status file list, commit input, history entries, action buttons per spec |
| A1-14 | Embedding uses TF-IDF hash projection instead of real neural model | embedding.allium:44-53, invariants ModelCaching/VectorCacheInDb | `backends/in_app.ex` hashes terms into sparse vectors via `:erlang.phash2`; no ONNX model, no `"query: "` prefix, no mean pooling, vectors stored as JSON text not Float32Array BLOB, snapshot-based neighbor lookup instead of USearch HNSW index | Fix code: (1) add Bumblebee + ONNX runtime deps to run `Xenova/multilingual-e5-small`, (2) implement lazy model download + cache in app data dir, (3) `"query: "` prefix + mean pooling + L2 norm in backend, (4) store vectors as binary BLOB (1536 bytes), (5) replace JSON snapshot with USearch HNSW index (cosine, M=16, ef=128/64, 5s debounce), (6) cross-language semantic similarity must work | | A1-14 | Embedding uses TF-IDF hash projection instead of real neural model | embedding.allium:44-53, invariants ModelCaching/VectorCacheInDb | `backends/in_app.ex` hashes terms into sparse vectors via `:erlang.phash2`; no ONNX model, no `"query: "` prefix, no mean pooling, vectors stored as JSON text not Float32Array BLOB, snapshot-based neighbor lookup instead of USearch HNSW index | Fix code: (1) add Bumblebee + ONNX runtime deps to run `Xenova/multilingual-e5-small`, (2) implement lazy model download + cache in app data dir, (3) `"query: "` prefix + mean pooling + L2 norm in backend, (4) store vectors as binary BLOB (1536 bytes), (5) replace JSON snapshot with USearch HNSW index (cosine, M=16, ef=128/64, 5s debounce), (6) cross-language semantic similarity must work |

View File

@@ -14,6 +14,10 @@ defmodule BDS.Preview do
@host "127.0.0.1" @host "127.0.0.1"
@port 4123 @port 4123
# Max time to wait for inflight requests to finish during graceful shutdown
# before remaining request tasks are forcibly terminated.
@drain_timeout 5_000
def start_link(_opts) do def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__) GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end end
@@ -56,7 +60,7 @@ defmodule BDS.Preview do
@impl true @impl true
def init(_state) do def init(_state) do
{:ok, %{current: nil}} {:ok, %{current: nil, stopping: nil}}
end end
@impl true @impl true
@@ -78,15 +82,12 @@ defmodule BDS.Preview do
{:reply, reply, next_state} {:reply, reply, next_state}
end end
def handle_call({:stop_preview, project_id}, _from, state) do def handle_call({:stop_preview, project_id}, from, state) do
next_state = if match?(%{project_id: ^project_id}, state.current) do
if match?(%{project_id: ^project_id}, state.current) do begin_graceful_stop(state, from)
stop_current_server(state) else
else {:reply, :ok, state}
state end
end
{:reply, :ok, next_state}
end end
def handle_call({:request, project_id, request_path, query_params}, _from, state) do def handle_call({:request, project_id, request_path, query_params}, _from, state) do
@@ -141,6 +142,25 @@ defmodule BDS.Preview do
end end
@impl true @impl true
def handle_cast({:track_request, pid}, %{current: %{} = current} = state) when is_pid(pid) do
ref = Process.monitor(pid)
inflight = Map.put(current.inflight, ref, pid)
{:noreply, %{state | current: %{current | inflight: inflight}}}
end
def handle_cast({:track_request, _pid}, state), do: {:noreply, state}
@impl true
def handle_info({:DOWN, ref, :process, _pid, _reason}, %{current: %{} = current} = state) do
inflight = Map.delete(current.inflight, ref)
state = %{state | current: %{current | inflight: inflight}}
{:noreply, maybe_finalize_stop(state)}
end
def handle_info(:drain_timeout, state) do
{:noreply, force_finalize_stop(state)}
end
def handle_info(_msg, state) do def handle_info(_msg, state) do
{:noreply, state} {:noreply, state}
end end
@@ -287,9 +307,18 @@ defmodule BDS.Preview do
defp accept_loop(listener, project_id) do defp accept_loop(listener, project_id) do
case :gen_tcp.accept(listener) do case :gen_tcp.accept(listener) do
{:ok, socket} -> {:ok, socket} ->
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn -> case Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
serve_client(socket, project_id) serve_client(socket, project_id)
end) end) do
{:ok, pid} ->
# Hand the socket to the request task so an inflight request survives
# the acceptor being shut down (it would otherwise close the socket).
_ = :gen_tcp.controlling_process(socket, pid)
GenServer.cast(__MODULE__, {:track_request, pid})
_other ->
:ok
end
accept_loop(listener, project_id) accept_loop(listener, project_id)
@@ -412,14 +441,58 @@ defmodule BDS.Preview do
end end
end end
defp stop_current_server(%{current: %{listener: listener, acceptor_pid: acceptor_pid}} = state) do # Graceful shutdown: stop accepting new connections, then wait for inflight
_ = :gen_tcp.close(listener) # request tasks to finish before reporting the server stopped. The stop call
if is_pid(acceptor_pid), do: Process.exit(acceptor_pid, :normal) # is parked (no immediate reply) and finalized from the :DOWN handlers, so the
# GenServer stays available to serve the requests it is draining.
defp begin_graceful_stop(%{current: current} = state, from) do
_ = :gen_tcp.close(current.listener)
if is_pid(current.acceptor_pid), do: Process.exit(current.acceptor_pid, :normal)
if map_size(current.inflight) == 0 do
{:reply, :ok, %{state | current: nil, stopping: nil}}
else
timer = Process.send_after(self(), :drain_timeout, @drain_timeout)
{:noreply, %{state | stopping: %{from: from, timer: timer}}}
end
end
defp maybe_finalize_stop(
%{stopping: %{from: from, timer: timer}, current: %{inflight: inflight}} = state
)
when map_size(inflight) == 0 do
if is_reference(timer), do: Process.cancel_timer(timer)
GenServer.reply(from, :ok)
%{state | current: nil, stopping: nil}
end
defp maybe_finalize_stop(state), do: state
defp force_finalize_stop(%{stopping: %{from: from}, current: %{inflight: inflight}} = state) do
kill_inflight(inflight)
GenServer.reply(from, :ok)
%{state | current: nil, stopping: nil}
end
defp force_finalize_stop(state), do: state
# Hard stop used when restarting the server in place (no graceful drain).
defp stop_current_server(%{current: %{} = current} = state) do
_ = :gen_tcp.close(current.listener)
if is_pid(current.acceptor_pid), do: Process.exit(current.acceptor_pid, :normal)
kill_inflight(current.inflight)
%{state | current: nil} %{state | current: nil}
end end
defp stop_current_server(state), do: state defp stop_current_server(state), do: state
defp kill_inflight(inflight) do
Enum.each(inflight, fn {ref, pid} ->
Process.demonitor(ref, [:flush])
if is_pid(pid), do: Process.exit(pid, :kill)
end)
end
defp start_server(state, project_id, data_dir, owner_pid) do defp start_server(state, project_id, data_dir, owner_pid) do
state = stop_current_server(state) state = stop_current_server(state)
maybe_allow_repo(owner_pid) maybe_allow_repo(owner_pid)
@@ -442,7 +515,8 @@ defmodule BDS.Preview do
port: @port, port: @port,
is_running: true, is_running: true,
listener: listener, listener: listener,
acceptor_pid: acceptor_pid acceptor_pid: acceptor_pid,
inflight: %{}
} }
{{:ok, public_server(server)}, %{state | current: server}} {{:ok, public_server(server)}, %{state | current: server}}

View File

@@ -706,6 +706,44 @@ defmodule BDS.PreviewTest do
assert :ok = BDS.Preview.stop_preview(project.id) assert :ok = BDS.Preview.stop_preview(project.id)
end end
test "stop_preview drains an inflight request before completing", %{project: project} do
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
main_language: "en",
blog_languages: ["en"]
})
assert {:ok, server} = BDS.Preview.start_preview(project.id)
# Open a raw connection and hold it open without sending the request line,
# so the server has a request task blocked in recv (an inflight request).
{:ok, socket} =
:gen_tcp.connect(to_charlist(server.host), server.port, [
:binary,
packet: :raw,
active: false
])
# Give the acceptor time to accept the connection and register the task.
Process.sleep(100)
test_pid = self()
spawn(fn ->
send(test_pid, {:stopped, BDS.Preview.stop_preview(project.id)})
end)
# Shutdown must not complete while the request is still inflight.
refute_receive {:stopped, _}, 300
# Completing the request lets the server drain and finish shutting down.
:ok = :gen_tcp.send(socket, "GET / HTTP/1.1\r\nhost: localhost\r\n\r\n")
assert_receive {:stopped, :ok}, 2_000
:gen_tcp.close(socket)
end
test "preview query params can override the rendered theme for generated and draft pages", %{ test "preview query params can override the rendered theme for generated and draft pages", %{
project: project project: project
} do } do