Enclave
Process-ancestry-based sandboxing for Phoenix.PubSub.
Enclave allows concurrent (async) tests to share a single Phoenix.PubSub
instance without leaking broadcasts between test processes. It extends the
Ecto SQL sandbox ownership model -- walking $callers and $ancestors to
find the owning test process -- to PubSub delivery. A broadcast is delivered
only to subscribers whose enclave matches the publisher's.
Motivation
When testing a LiveView (or any process subscribing to Phoenix.PubSub) with
async: true:
-
Test A mounts a LiveView that subscribes to
"user:#{id}". - Test B, running concurrently, updates a user and broadcasts.
- Test A's LiveView receives Test B's message, producing a flaky test or a test that exits while downstream database work is still in flight.
The Ecto SQL sandbox solves the database half of this problem by scoping connections to an owning test process. Enclave applies the same approach to PubSub delivery.
Installation
Add :enclave to the list of dependencies in mix.exs:
def deps do
[
{:enclave, "~> 0.1.0", only: :test}
]
endUsage
1. Wrap the PubSub module
Most Phoenix applications start Phoenix.PubSub directly in their
supervision tree:
# Before
children = [
# ...
{Phoenix.PubSub, name: MyApp.PubSub}
]
Replace it with a wrapper module that injects Enclave.Dispatcher in the
test environment:
# lib/my_app/pub_sub.ex
defmodule MyApp.PubSub do
@dispatcher (if Mix.env() == :test, do: Enclave.Dispatcher, else: Phoenix.PubSub)
def child_spec(opts),
do: Phoenix.PubSub.child_spec(Keyword.put(opts, :name, __MODULE__))
def subscribe(topic), do: Phoenix.PubSub.subscribe(__MODULE__, topic)
def unsubscribe(topic), do: Phoenix.PubSub.unsubscribe(__MODULE__, topic)
def broadcast(topic, msg),
do: Phoenix.PubSub.broadcast(__MODULE__, topic, msg, @dispatcher)
def broadcast_from(from, topic, msg),
do: Phoenix.PubSub.broadcast_from(__MODULE__, from, topic, msg, @dispatcher)
def local_broadcast(topic, msg),
do: Phoenix.PubSub.local_broadcast(__MODULE__, topic, msg, @dispatcher)
endThen update the application supervision tree and endpoint configuration:
# lib/my_app/application.ex
children = [
# ...
MyApp.PubSub
]
# config/config.exs
config :my_app, MyAppWeb.Endpoint,
pubsub_server: MyApp.PubSub
In production, @dispatcher is Phoenix.PubSub (the default), so the
wrapper compiles to a plain pass-through with no runtime overhead.
2. Register the test process as an enclave owner
defmodule MyAppWeb.UserLiveTest do
use MyAppWeb.ConnCase, async: true
setup do
:ok = Enclave.start_owner()
:ok
end
test "shows updates for the current user", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/users/1")
MyApp.PubSub.broadcast("user:1", {:updated, %{name: "new"}})
assert render(view) =~ "new"
end
end
A concurrently running test broadcasting to "user:1" from its own enclave
will not be delivered to this test's LiveView.
3. (Optional) Allow background processes
If a test delegates work to a process that was not spawned from the test process (for example, a globally registered GenServer), explicitly allow it:
:ok = Enclave.start_owner()
:ok = Enclave.allow(self(), Process.whereis(MyApp.Worker))Ownership resolution
Given any pid, Enclave resolves its owner by checking in order:
- Direct registration -- the pid called
start_owner/0, or was named in anallow/2call. $callers-- the process dictionary key thatTask,GenServer, and Phoenix propagate to track caller chains.$ancestors-- the OTP-managed chain set by:proc_libspawning.
If no match is found, the pid resolves to :no_owner. Two pids are
deliverable to each other if they resolve to the same owner, including both
resolving to :no_owner. This property is what makes the wrapper a no-op in
production.
Limitations
- Phoenix internals bypass the filter. LiveView channel push fan-out,
Endpoint.broadcast/3, and other framework-level broadcasts callPhoenix.PubSub.broadcast/4with their own dispatcher. Only broadcasts routed throughEnclave.Dispatcher(via the application wrapper) are filtered. For most test suites this covers the broadcast path that causes flaky tests. - Single-node only. Cross-node broadcasts delegate to the configured PubSub adapter, which is outside of Enclave's scope.
- Requires a wrapper module.
Phoenix.PubSubdoes not support a configurable default dispatcher, so there is no way to enable filtering without routing broadcasts through a module the application controls.
License
MIT -- see LICENSE.