From 1bd2322262aafb75817d0f99c5e94be47e42380a Mon Sep 17 00:00:00 2001 From: Damirados Date: Wed, 15 Apr 2026 18:19:00 +0200 Subject: [PATCH] Add controller handle_info fallback for non-Solve messages Use the same state-returning leading-subset callback shape as events so controllers can react to pubsub, timers, and monitors without handling Solve internals directly. --- README.md | 24 +++ lib/solve/controller.ex | 235 ++++++++++++++++----- test/solve/controller_test.exs | 359 ++++++++++++++++++++++++++++++++- 3 files changed, 568 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index b99fcba..6373764 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,30 @@ decrementing a counter. That is the right place to begin. Start with one interaction and give it one controller. +Controllers can also react to non-Solve messages through a Solve-style +`handle_info` callback. Like event handlers, `handle_info` takes the leading +subset of runtime inputs it needs and returns the next state directly. + +```elixir +defmodule MyApp.CounterController do + use Solve.Controller, events: [:increment] + + @impl true + def init(_params, _dependencies) do + Process.send_after(self(), :tick, 1_000) + %{count: 0} + end + + def increment(_payload, state), do: %{state | count: state.count + 1} + + def handle_info(:tick, state), do: %{state | count: state.count + 1} +end +``` + +Solve still reserves `{:solve_event, ...}`, `%Solve.Message{}`, +`%Solve.DependencyUpdate{}`, and its own subscriber `{:DOWN, ...}` messages for +controller internals. + ## Run controllers in an app Controllers run inside a Solve app. diff --git a/lib/solve/controller.ex b/lib/solve/controller.ex index 07de18d..88d6ba7 100644 --- a/lib/solve/controller.ex +++ b/lib/solve/controller.ex @@ -89,10 +89,31 @@ defmodule Solve.Controller do current exposed state synchronously. - `dispatch(controller, event, payload \\ %{})` sends an event to the controller. + Controllers can also define a Solve-style `handle_info` callback to react to non-Solve + messages such as PubSub notifications, timer messages, or process monitors set up in + `init/2`. + + `handle_info` uses the same leading-subset convention as events and returns the next + user state directly: + + handle_info(message, state) + handle_info(message, state, dependencies) + handle_info(message, state, dependencies, callbacks) + handle_info(message, state, dependencies, callbacks, init_params) + In normal app code, prefer `Solve.dispatch/4` so dispatch goes through the `Solve` runtime and stays aligned with controller lifecycle changes. `Solve.Controller.dispatch/3` is the low-level primitive for callers that already have a controller pid. + Solve reserves these message shapes for controller internals and they will not be + forwarded to controller-defined `handle_info` clauses: + + - `{:solve_event, event}` + - `{:solve_event, event, payload}` + - `%Solve.Message{}` + - `%Solve.DependencyUpdate{}` + - subscriber monitor `{:DOWN, ref, :process, pid, reason}` messages owned by Solve + ## Exposed State A running controller must expose a plain map. `nil` is reserved for the Solve runtime @@ -144,6 +165,9 @@ defmodule Solve.Controller do @behaviour Solve.Controller @before_compile Solve.Controller @solve_controller_events events + Module.register_attribute(__MODULE__, :solve_controller_user_handle_info_arities, + accumulate: true + ) @impl Solve.Controller def expose(state, _dependencies, _init_params), do: state @@ -182,59 +206,31 @@ defmodule Solve.Controller do Solve.Controller.__handle_event__(event, payload, server_state) end - @impl GenServer - def handle_info({:solve_event, event}, server_state) when is_atom(event) do - Solve.Controller.__handle_direct_event__(event, %{}, server_state) - end - - @impl GenServer - def handle_info({:solve_event, event, payload}, server_state) when is_atom(event) do - Solve.Controller.__handle_direct_event__(event, payload, server_state) - end - - @impl GenServer - def handle_info( - %Solve.Message{ - type: :update, - payload: %Solve.Update{ - app: solve_app, - controller_name: dependency_name, - exposed_state: exposed_state - } - }, - server_state - ) do - Solve.Controller.__handle_dependency_update__( - solve_app, - dependency_name, - exposed_state, - server_state - ) - end - - @impl GenServer - def handle_info(%Solve.DependencyUpdate{} = dependency_update, server_state) do - Solve.Controller.__handle_dependency_update_message__(dependency_update, server_state) - end + def handle_info(_message, state), do: state - @impl GenServer - def handle_info({:DOWN, _ref, :process, subscriber, _reason}, server_state) do - Solve.Controller.__handle_subscriber_down__(subscriber, server_state) - end - - @impl GenServer - def handle_info(_message, server_state) do - {:noreply, server_state} - end - - defoverridable expose: 3 + defoverridable expose: 3, handle_info: 2 + @on_definition Solve.Controller end end + @doc false + def __on_definition__(env, _kind, :handle_info, args, _guards, _body) do + Module.put_attribute(env.module, :solve_controller_user_handle_info_arities, length(args)) + end + + def __on_definition__(_env, _kind, _name, _args, _guards, _body), do: :ok + defmacro __before_compile__(env) do events = Module.get_attribute(env.module, :solve_controller_events) || [] definitions = Module.definitions_in(env.module) + handle_info_arities = + env.module + |> Module.get_attribute(:solve_controller_user_handle_info_arities) + |> Kernel.||([]) + |> Enum.uniq() + |> Enum.sort() + event_arities = Map.new(events, fn event -> arities = @@ -277,14 +273,97 @@ defmodule Solve.Controller do "#{inspect(env.module)} must not define declared event callback(s) at multiple arities: #{callbacks}" end + invalid_handle_info_arities = Enum.reject(handle_info_arities, &valid_handle_info_arity?/1) + + if invalid_handle_info_arities != [] do + callbacks = Enum.map_join(invalid_handle_info_arities, ", ", &format_handle_info_callback/1) + + raise CompileError, + file: env.file, + line: 1, + description: + "#{inspect(env.module)} must define handle_info callback(s) with exactly one arity between /2 and /5: #{callbacks}" + end + + if length(handle_info_arities) > 1 do + callbacks = Enum.map_join(handle_info_arities, ", ", &format_handle_info_callback/1) + + raise CompileError, + file: env.file, + line: 1, + description: + "#{inspect(env.module)} must not define handle_info callback(s) at multiple arities: #{callbacks}" + end + resolved_event_arities = Enum.map(event_arities, fn {event, [arity]} -> {event, arity} end) + handle_info_arity = List.first(handle_info_arities) - quote bind_quoted: [event_arities: resolved_event_arities] do - @solve_controller_event_arities Map.new(event_arities) + quote do + @solve_controller_event_arities Map.new(unquote(Macro.escape(resolved_event_arities))) + @solve_controller_handle_info_arity unquote(handle_info_arity) def __solve_event_arity__(event) when is_atom(event) do Map.fetch!(@solve_controller_event_arities, event) end + + def __solve_handle_info_arity__, do: @solve_controller_handle_info_arity + + defoverridable handle_info: 2 + + @impl GenServer + def handle_info(message, server_state) do + case message do + {:solve_event, event} when is_atom(event) -> + Solve.Controller.__handle_direct_event__(event, %{}, server_state) + + {:solve_event, event, payload} when is_atom(event) -> + Solve.Controller.__handle_direct_event__(event, payload, server_state) + + %Solve.Message{ + type: :update, + payload: %Solve.Update{ + app: solve_app, + controller_name: dependency_name, + exposed_state: exposed_state + } + } -> + Solve.Controller.__handle_dependency_update__( + solve_app, + dependency_name, + exposed_state, + server_state + ) + + %Solve.Message{} -> + {:noreply, server_state} + + %Solve.DependencyUpdate{} = dependency_update -> + Solve.Controller.__handle_dependency_update_message__(dependency_update, server_state) + + {:DOWN, ref, :process, subscriber, _reason} + when is_reference(ref) and is_pid(subscriber) -> + if Solve.Controller.__subscriber_monitor_match__?(ref, subscriber, server_state) do + Solve.Controller.__handle_subscriber_down__(subscriber, server_state) + else + Solve.Controller.__handle_fallback_info__( + message, + __MODULE__, + @solve_controller_handle_info_arity, + server_state, + fn -> super(message, server_state.state) end + ) + end + + _message -> + Solve.Controller.__handle_fallback_info__( + message, + __MODULE__, + @solve_controller_handle_info_arity, + server_state, + fn -> super(message, server_state.state) end + ) + end + end end end @@ -539,6 +618,58 @@ defmodule Solve.Controller do {:noreply, server_state} end + @doc false + def __subscriber_monitor_match__?(ref, subscriber, %{ + subscriber_monitor_refs_by_pid: monitor_refs + }) + when is_reference(ref) and is_pid(subscriber) do + Map.get(monitor_refs, subscriber) == ref + end + + @doc false + def __handle_fallback_info__(_message, _module, nil, server_state, _super_handle_info) do + {:noreply, server_state} + end + + def __handle_fallback_info__( + _message, + _module, + 2, + %{solve_app: solve_app} = server_state, + super_handle_info + ) + when is_function(super_handle_info, 0) do + new_state = with_solve_app(solve_app, super_handle_info) + {:noreply, refresh_exposed_state(%{server_state | state: new_state})} + end + + def __handle_fallback_info__( + message, + module, + handle_info_arity, + %{solve_app: solve_app} = server_state, + _super_handle_info + ) + when handle_info_arity in 3..5 do + new_state = + with_solve_app(solve_app, fn -> + apply( + module, + :handle_info, + handle_info_args( + handle_info_arity, + message, + server_state.state, + server_state.dependencies, + server_state.callbacks, + server_state.params + ) + ) + end) + + {:noreply, refresh_exposed_state(%{server_state | state: new_state})} + end + defp validate_events_option!(opts, caller) when is_list(opts) do opts |> Keyword.get(:events, []) @@ -587,6 +718,9 @@ defmodule Solve.Controller do defp valid_event_arities?([arity]) when arity in 1..5, do: true defp valid_event_arities?(_arities), do: false + defp valid_handle_info_arity?(arity) when arity in 2..5, do: true + defp valid_handle_info_arity?(_arity), do: false + defp format_invalid_event_callback({event, []}), do: Atom.to_string(event) defp format_invalid_event_callback({event, [arity]}), do: "#{event}/#{arity}" @@ -596,11 +730,18 @@ defmodule Solve.Controller do |> then(&"#{event} (#{&1})") end + defp format_handle_info_callback(arity), do: "handle_info/#{arity}" + defp event_args(event_arity, payload, state, dependencies, callbacks, init_params) do [payload, state, dependencies, callbacks, init_params] |> Enum.take(event_arity) end + defp handle_info_args(handle_info_arity, message, state, dependencies, callbacks, init_params) do + [message, state, dependencies, callbacks, init_params] + |> Enum.take(handle_info_arity) + end + defp with_solve_app(solve_app, fun) when is_function(fun, 0) do missing_app = make_ref() previous_app = Process.get(:solve_app, missing_app) diff --git a/test/solve/controller_test.exs b/test/solve/controller_test.exs index 41813ad..4a8f48c 100644 --- a/test/solve/controller_test.exs +++ b/test/solve/controller_test.exs @@ -105,6 +105,117 @@ defmodule Solve.ControllerTest do end end + defmodule ExternalPublisher do + use GenServer + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, MapSet.new(), opts) + end + + def subscribe(server, subscriber \\ self()) when is_pid(subscriber) do + GenServer.call(server, {:subscribe, subscriber}) + end + + def publish(server, message) do + GenServer.cast(server, {:publish, message}) + end + + @impl true + def init(subscribers), do: {:ok, subscribers} + + @impl true + def handle_call({:subscribe, subscriber}, _from, subscribers) do + {:reply, :ok, MapSet.put(subscribers, subscriber)} + end + + @impl true + def handle_cast({:publish, message}, subscribers) do + Enum.each(subscribers, &send(&1, message)) + {:noreply, subscribers} + end + end + + defmodule HandleInfoController do + use Solve.Controller, events: [:increment] + + @impl true + def init(%{test_pid: test_pid} = params, dependencies) do + if publisher = params[:publisher] do + :ok = ExternalPublisher.subscribe(publisher) + end + + if monitor_pid = params[:monitor_pid] do + Process.monitor(monitor_pid) + end + + send(test_pid, {:handle_info_init, dependencies, params}) + + %{ + count: Map.get(params, :initial, 0), + messages: [], + test_pid: test_pid + } + end + + def increment(payload, state) do + %{state | count: state.count + payload} + end + + @impl true + def expose(state, dependencies, _init_params) do + %{ + count: state.count, + messages: Enum.reverse(state.messages), + source: Map.get(dependencies, :source) + } + end + + def handle_info(message, state, dependencies, callbacks, init_params) do + send( + state.test_pid, + {:handle_info_args, message, state, dependencies, callbacks, init_params} + ) + + new_state = + case message do + {:external_increment, value} -> %{state | count: state.count + value} + _ -> state + end + + %{new_state | messages: [message | new_state.messages]} + end + end + + defmodule HandleInfoArityTwoController do + use Solve.Controller, events: [] + + @impl true + def init(%{publisher: publisher, test_pid: test_pid} = params, dependencies) do + :ok = ExternalPublisher.subscribe(publisher) + send(test_pid, {:handle_info_two_init, dependencies, params}) + + %{ + count: Map.get(params, :initial, 0), + test_pid: test_pid + } + end + + @impl true + def expose(state, _dependencies, _init_params) do + %{count: state.count} + end + + def handle_info({:external_increment, value}, state) do + send(state.test_pid, {:handle_info_two_args, {:external_increment, value}, state}) + %{state | count: state.count + value} + end + + def handle_info(message, state) do + send(state.test_pid, {:handle_info_two_args, message, state}) + state + end + end + test "use Solve.Controller rejects invalid events lists" do module = unique_module_name("InvalidEvents") @@ -145,12 +256,49 @@ defmodule Solve.ControllerTest do fn -> Code.compile_string(""" defmodule #{inspect(module)} do - use Solve.Controller, events: [:increment] + use Solve.Controller, events: [:increment] + + def init(_params, _dependencies), do: %{} + + def increment(payload), do: %{payload: payload} + def increment(payload, state), do: {payload, state} + end + """) + end + end + + test "use Solve.Controller requires handle_info callbacks to use arity 2 through 5" do + module = unique_module_name("InvalidHandleInfoArity") + + assert_raise CompileError, + ~r/must define handle_info callback\(s\) with exactly one arity between \/2 and \/5: handle_info\/1/, + fn -> + Code.compile_string(""" + defmodule #{inspect(module)} do + use Solve.Controller, events: [] + + def init(_params, _dependencies), do: %{} + + def handle_info(message), do: message + end + """) + end + end + + test "use Solve.Controller rejects handle_info callbacks defined at multiple arities" do + module = unique_module_name("DuplicateHandleInfoArity") + + assert_raise CompileError, + ~r/must not define handle_info callback\(s\) at multiple arities: handle_info\/2, handle_info\/3/, + fn -> + Code.compile_string(""" + defmodule #{inspect(module)} do + use Solve.Controller, events: [] def init(_params, _dependencies), do: %{} - def increment(payload), do: %{payload: payload} - def increment(payload, state), do: {payload, state} + def handle_info(_message, state), do: state + def handle_info(_message, state, _dependencies), do: state end """) end @@ -209,6 +357,211 @@ defmodule Solve.ControllerTest do assert Solve.Controller.subscribe(pid) == %{last: {:five, :five_payload}} end + test "ordinary handle_info receives external messages with Solve context and rebroadcasts exposed state" do + assert {:ok, publisher} = ExternalPublisher.start_link() + + callbacks = %{audit: :enabled} + params = %{initial: 1, label: :demo, publisher: publisher, test_pid: self()} + + assert {:ok, pid} = + HandleInfoController.start_link( + solve_app: :app, + controller_name: :handle_info, + params: params, + dependencies: %{source: %{value: 10}}, + callbacks: callbacks + ) + + assert_receive {:handle_info_init, %{source: %{value: 10}}, ^params} + + assert Solve.Controller.subscribe(pid) == %{ + count: 1, + messages: [], + source: %{value: 10} + } + + assert :ok = ExternalPublisher.publish(publisher, {:external_increment, 2}) + + assert_receive {:handle_info_args, {:external_increment, 2}, %{count: 1, messages: []}, + %{source: %{value: 10}}, ^callbacks, ^params}, + 100 + + assert_receive %Solve.Message{ + type: :update, + payload: %Solve.Update{ + app: :app, + controller_name: :handle_info, + exposed_state: %{ + count: 3, + messages: [{:external_increment, 2}], + source: %{value: 10} + } + } + } + + assert Solve.Controller.subscribe(pid) == %{ + count: 3, + messages: [{:external_increment, 2}], + source: %{value: 10} + } + end + + test "handle_info/2 returns Solve state directly for non-Solve messages" do + assert {:ok, publisher} = ExternalPublisher.start_link() + + params = %{initial: 2, publisher: publisher, test_pid: self()} + + assert {:ok, pid} = + HandleInfoArityTwoController.start_link( + solve_app: :app, + controller_name: :handle_info_two, + params: params, + dependencies: %{}, + callbacks: %{} + ) + + assert_receive {:handle_info_two_init, %{}, ^params} + assert Solve.Controller.subscribe(pid) == %{count: 2} + + assert :ok = ExternalPublisher.publish(publisher, {:external_increment, 3}) + + assert_receive {:handle_info_two_args, {:external_increment, 3}, %{count: 2}}, 100 + + assert_receive %Solve.Message{ + type: :update, + payload: %Solve.Update{ + app: :app, + controller_name: :handle_info_two, + exposed_state: %{count: 5} + } + } + + assert Solve.Controller.subscribe(pid) == %{count: 5} + end + + test "direct solve_event messages stay reserved when controller defines handle_info" do + callbacks = %{audit: :enabled} + params = %{initial: 2, test_pid: self()} + + assert {:ok, pid} = + HandleInfoController.start_link( + solve_app: :app, + controller_name: :handle_info, + params: params, + dependencies: %{source: nil}, + callbacks: callbacks + ) + + assert_receive {:handle_info_init, %{source: nil}, ^params} + assert Solve.Controller.subscribe(pid) == %{count: 2, messages: [], source: nil} + + send(pid, {:solve_event, :increment, 3}) + + assert_receive %Solve.Message{ + type: :update, + payload: %Solve.Update{ + app: :app, + controller_name: :handle_info, + exposed_state: %{count: 5, messages: [], source: nil} + } + } + + refute_receive {:handle_info_args, {:solve_event, :increment, 3}, _, _, _, _}, 50 + end + + test "dependency updates stay reserved when controller defines handle_info" do + callbacks = %{audit: :enabled} + params = %{test_pid: self()} + + assert {:ok, pid} = + HandleInfoController.start_link( + solve_app: :app, + controller_name: :handle_info, + params: params, + dependencies: %{source: nil}, + callbacks: callbacks + ) + + assert_receive {:handle_info_init, %{source: nil}, ^params} + assert Solve.Controller.subscribe(pid) == %{count: 0, messages: [], source: nil} + + send(pid, Solve.Message.update(:app, :source, %{value: 42})) + + assert_receive %Solve.Message{ + type: :update, + payload: %Solve.Update{ + app: :app, + controller_name: :handle_info, + exposed_state: %{count: 0, messages: [], source: %{value: 42}} + } + } + + refute_receive {:handle_info_args, %Solve.Message{}, _, _, _, _}, 50 + end + + test "raw Solve messages remain reserved from controller handle_info" do + callbacks = %{audit: :enabled} + params = %{test_pid: self()} + + assert {:ok, pid} = + HandleInfoController.start_link( + solve_app: :app, + controller_name: :handle_info, + params: params, + dependencies: %{source: nil}, + callbacks: callbacks + ) + + assert_receive {:handle_info_init, %{source: nil}, ^params} + assert Solve.Controller.subscribe(pid) == %{count: 0, messages: [], source: nil} + + send(pid, Solve.Message.dispatch(:app, :handle_info, :increment, 4)) + + send(pid, %Solve.DependencyUpdate{app: :other_app, key: :source, op: :replace, value: %{x: 1}}) + + refute_receive {:handle_info_args, %Solve.Message{}, _, _, _, _}, 50 + refute_receive {:handle_info_args, %Solve.DependencyUpdate{}, _, _, _, _}, 50 + + assert Solve.Controller.subscribe(pid) == %{count: 0, messages: [], source: nil} + end + + test "unrelated DOWN messages fall through to controller handle_info" do + monitored = spawn(fn -> Process.sleep(:infinity) end) + callbacks = %{audit: :enabled} + params = %{monitor_pid: monitored, test_pid: self()} + + assert {:ok, pid} = + HandleInfoController.start_link( + solve_app: :app, + controller_name: :handle_info, + params: params, + dependencies: %{source: nil}, + callbacks: callbacks + ) + + assert_receive {:handle_info_init, %{source: nil}, ^params} + assert Solve.Controller.subscribe(pid) == %{count: 0, messages: [], source: nil} + + Process.exit(monitored, :shutdown) + + assert_receive {:handle_info_args, {:DOWN, _ref, :process, ^monitored, :shutdown}, + %{count: 0}, %{source: nil}, ^callbacks, ^params}, + 100 + + assert_receive %Solve.Message{ + type: :update, + payload: %Solve.Update{ + app: :app, + controller_name: :handle_info, + exposed_state: %{ + count: 0, + messages: [{:DOWN, _, :process, ^monitored, :shutdown}], + source: nil + } + } + } + end + test "subscribe/2 returns the current exposed state using default expose/3" do params = %{initial: 2, test_pid: self()} callbacks = %{audit: fn _ -> :ok end}