Skip to content

Commit 3b54699

Browse files
authored
Better sandbox lifecycle management (#95)
* Sandbox start/stop owner w/ configurable delay Previously, the test process itself was the made owner of the sandbox process, which meant the sandbox would be terminated when the test terminated. Unlike some other kinds of tests, playwright tests are not linked to the LiveViews and other processes that are being tested, so those processes don't terminate with the test and may go on trying to use the sandbox connection after the test terminates. If they do, they will raise ownership errors. Switch to `Ecto.Adapters.SQL.Sandbox.html.start_owner!/2`, creating a separate process to own the sandbox connection, which is shut down after the test process terminates using `on_exit/1`. This reduces ownership errors that may occur after the test terminates. By default, this is done with no delay, but `sandbox_shutdown_delay` can be configured or specified via tag, which will wait that long before terminating the sandbox during `on_exit/1`.
1 parent 52e002c commit 3b54699

File tree

4 files changed

+97
-16
lines changed

4 files changed

+97
-16
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66
<!-- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -->
77

8+
## [Unreleased]
9+
### Changed
10+
- Ecto sandbox ownership: Use a separate sandbox owner process instead of the test process. This reduces ownership errors when LiveViews continue to use database connections after the test terminates. Commit [7577d5e]
11+
### Added
12+
- Config option `sandbox_shutdown_delay`: Delay in milliseconds before shutting down the Ecto sandbox owner. Use when LiveViews or other processes need time to stop using the connections. Commit [7577d5e]
13+
814
## [0.9.1] 2025-10-29
915
### Added
1016
- Browser pooling (opt-in): Reduced memory, higher speed. See `PhoenixTest.Playwright.BrowserPool`. Commit [00e75c6]
@@ -126,6 +132,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
126132
### Added
127133
- `@tag trace: :open` to auto open recorded Playwright trace in viewer
128134

135+
[7577d5e]: https://github.com/ftes/phoenix_test_playwright/commit/7577d5e
129136
[5ff530]: https://github.com/ftes/phoenix_test_playwright/commit/5ff530
130137
[becf5e]: https://github.com/ftes/phoenix_test_playwright/commit/becf5e
131138
[72edd9]: https://github.com/ftes/phoenix_test_playwright/commit/72edd9

lib/phoenix_test/playwright.ex

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,55 @@ defmodule PhoenixTest.Playwright do
168168
defmodule MyTest do
169169
use PhoenixTest.Playwright.Case, async: true
170170
171-
`PhoenixTest.Playwright.Case` automatically takes care of this.
172-
It passes a user agent referencing your Ecto repos.
173-
This allows for [concurrent browser tests](https://hexdocs.pm/phoenix_ecto/main.html#concurrent-browser-tests).
171+
`PhoenixTest.Playwright.Case` automatically takes care of this. It starts the
172+
sandbox under a separate process than your test and uses
173+
`ExUnit.Callbacks.on_exit/1` to ensure the sandbox is shut down afterward. It
174+
also sends a `User-Agent` header with the
175+
`Phoenix.Ecto.SQL.Sandbox.html.metadata_for/3` your Ecto repos. This allows
176+
the sandbox to be shared with the LiveView and other processes which need to
177+
use the database inside the same transaction as the test. It also allows for
178+
[concurrent browser
179+
tests](https://hexdocs.pm/phoenix_ecto/main.html#concurrent-browser-tests).
180+
181+
### Ownership errors with LiveViews
182+
183+
Unlike `Phoenix.LiveViewTest`, which controls the lifecycle of LiveView
184+
processes being tested, Playwright tests may end while such processes are
185+
still using the sandbox.
186+
187+
In that case, you may encounter ownership errors like:
188+
```
189+
** (DBConnection.OwnershipError) cannot find owner for ...
190+
```
191+
192+
To prevent this, the `sandbox_shutdown_delay` option allows you to delay the
193+
sandbox owner's shutdown, giving LiveViews and other processes time to close
194+
their database connections. The delay happens during
195+
`ExUnit.Callbacks.on_exit/1`, which blocks the running of the next test, so
196+
it affects test runtime as if it were a `Process.sleep/1` at the end of your
197+
test.
198+
199+
So you probably want to use **as small a delay as you can**, and only for the
200+
tests that need it, using `@tag` (or `@describetag` or `@moduletag`) like:
201+
202+
```
203+
@tag sandbox_shutdown_delay: 100 # 100ms
204+
test "does something" do
205+
# ...
206+
end
207+
```
208+
209+
If you want to set a global default, you can:
210+
211+
212+
```elixir
213+
# config/test.exs
214+
config :phoenix_test, playwright: [
215+
sandbox_shutdown_delay: 50 # 50ms
216+
]
217+
```
174218
175-
Make sure to follow the advanced set up instructions if necessary:
219+
For more details on LiveView and Ecto integration, see the advanced set up instructions:
176220
- [with LiveViews](https://hexdocs.pm/phoenix_ecto/Phoenix.Ecto.SQL.Sandbox.html#module-acceptance-tests-with-liveviews)
177221
- [with Channels](https://hexdocs.pm/phoenix_ecto/Phoenix.Ecto.SQL.Sandbox.html#module-acceptance-tests-with-channels)
178222

lib/phoenix_test/playwright/case.ex

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ defmodule PhoenixTest.Playwright.Case do
8989
browser_context_opts =
9090
Enum.into(config[:browser_context_opts], %{
9191
locale: "en",
92-
user_agent: checkout_ecto_repos(context.async) || "No user agent"
92+
user_agent: checkout_ecto_repos(config, context) || "No user agent"
9393
})
9494

9595
{:ok, browser_context_id} = Playwright.Browser.new_context(context.browser_id, browser_context_opts)
@@ -166,29 +166,49 @@ defmodule PhoenixTest.Playwright.Case do
166166
Code.ensure_loaded?(Phoenix.Ecto.SQL.Sandbox)
167167

168168
if @includes_ecto do
169-
defp checkout_ecto_repos(async?) do
169+
defp checkout_ecto_repos(config, context) do
170170
otp_app = Application.fetch_env!(:phoenix_test, :otp_app)
171171
repos = Application.get_env(otp_app, :ecto_repos, [])
172172

173173
repos
174-
|> Enum.map(&checkout_ecto_repo(&1, async?))
174+
|> Enum.map(&maybe_start_sandbox_owner(&1, context, config))
175175
|> Phoenix.Ecto.SQL.Sandbox.metadata_for(self())
176176
|> Phoenix.Ecto.SQL.Sandbox.encode_metadata()
177177
end
178178

179-
defp checkout_ecto_repo(repo, async?) do
180-
case Sandbox.checkout(repo) do
181-
:ok -> :ok
182-
{:already, :allowed} -> :ok
183-
{:already, :owner} -> :ok
184-
end
179+
defp maybe_start_sandbox_owner(repo, context, config) do
180+
case start_sandbox_owner(repo, context) do
181+
{:ok, pid} ->
182+
on_exit(fn -> stop_sandbox_owner(pid, config[:sandbox_shutdown_delay], context.async) end)
185183

186-
if not async?, do: Sandbox.mode(repo, {:shared, self()})
184+
_ ->
185+
:ok
186+
end
187187

188188
repo
189189
end
190+
191+
defp start_sandbox_owner(repo, context) do
192+
pid = Sandbox.start_owner!(repo, shared: !context.async)
193+
{:ok, pid}
194+
rescue
195+
_ -> {:error, :probably_already_started}
196+
end
197+
198+
defp stop_sandbox_owner(checkout_pid, delay, async?) do
199+
if async? do
200+
spawn(fn -> do_stop_sandbox_owner(checkout_pid, delay) end)
201+
else
202+
do_stop_sandbox_owner(checkout_pid, delay)
203+
end
204+
end
205+
206+
defp do_stop_sandbox_owner(checkout_pid, delay) do
207+
if delay > 0, do: Process.sleep(delay)
208+
Sandbox.stop_owner(checkout_pid)
209+
end
190210
else
191-
defp checkout_ecto_repos(_) do
211+
defp checkout_ecto_repos(_, _) do
192212
nil
193213
end
194214
end

lib/phoenix_test/playwright/config.ex

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,16 @@ schema_opts = [
119119
Accepts either a binary executable exposed in PATH or the absolute path to it.
120120
"""
121121
],
122+
sandbox_shutdown_delay: [
123+
default: 0,
124+
type: :non_neg_integer,
125+
doc: """
126+
Delay in milliseconds before shutting down the Ecto sandbox owner after a
127+
test ends. Use this to allow LiveViews and other processes in your app
128+
time to stop using database connections before the sandbox owner is
129+
terminated. Default is 0 (immediate shutdown).
130+
"""
131+
],
122132
screenshot: [
123133
default: false,
124134
type: {:or, [:boolean, non_empty_keyword_list: screenshot_opts_schema]},
@@ -159,7 +169,7 @@ schema_opts = [
159169
schema = NimbleOptions.new!(schema_opts)
160170

161171
setup_all_keys = ~w(browser_pool browser browser_launch_timeout executable_path headless slow_mo)a
162-
setup_keys = ~w(accept_dialogs screenshot trace browser_context_opts browser_page_opts)a
172+
setup_keys = ~w(accept_dialogs sandbox_shutdown_delay screenshot trace browser_context_opts browser_page_opts)a
163173

164174
defmodule PhoenixTest.Playwright.Config do
165175
@moduledoc """

0 commit comments

Comments
 (0)