defmodule Skuld.Effects.Port do
@moduledoc """
Effect for dispatching function calls to pluggable backends — both plain
(blocking) and effectful (computation-returning).
Part of the `skuld_port` package, which provides the Port dispatch effect,
EffectfulFacade for typed contracts, Adapter for bridging effectful and
plain code, and Command/Transaction for operations. See the
[architecture guide](https://hexdocs.pm/skuld/architecture.html)
for how these fit into the Skuld ecosystem.
This effect lets domain code express "call this function" without binding to a
particular implementation. Each request specifies:
* `mod` – module identity (contract or implementation module)
* `name` – function name
* `args` – list of positional arguments
## Use Cases
Port works with any external code, whether plain or effectful:
* Database queries, HTTP API calls, file system operations
* External service integrations and legacy code
* Effectful implementations behind typed contracts — decompose a large
effect system into swappable cells with compile-time verified boundaries
## Result Tuple Convention (plain resolvers)
Plain handlers should return `{:ok, value}` or `{:error, reason}` tuples.
This convention enables two request modes:
* `request/3` – returns the result tuple as-is for caller to handle
* `request!/3` – unwraps `{:ok, value}` or dispatches `Throw` on error
Effectful resolvers return computations that are inlined into the current
effect context — no result tuple convention applies.
## Example
alias Skuld.Effects.Port
# Plain implementation
defmodule MyApp.UserQueries do
def find_by_id(id) do
case Repo.get(User, id) do
nil -> {:error, {:not_found, User, id}}
user -> {:ok, user}
end
end
end
# Using request/3 - returns result tuple
defcomp find_user(id) do
result <- Port.request(MyApp.UserQueries, :find_by_id, [id])
case result do
{:ok, user} -> user
{:error, _} -> nil
end
end
# Using request!/3 - unwraps or throws
defcomp find_user!(id) do
user <- Port.request!(MyApp.UserQueries, :find_by_id, [id])
user
end
# Runtime: dispatch to actual modules
find_user!(123)
|> Port.with_handler(%{MyApp.UserQueries => :direct})
|> Throw.with_handler()
|> Comp.run!()
# Test: stub responses with exact key matching
find_user!(123)
|> Port.with_test_handler(%{
Port.key(MyApp.UserQueries, :find_by_id, [123]) => {:ok, %{id: 123, name: "Alice"}}
})
|> Throw.with_handler()
|> Comp.run!()
# Test: function-based handler with pattern matching
find_user!(123)
|> Port.with_fn_handler(fn
MyApp.UserQueries, :find_by_id, [id] -> {:ok, %{id: id, name: "User \#{id}"}}
end)
|> Throw.with_handler()
|> Comp.run!()
"""
@behaviour Skuld.Comp.IHandle
@behaviour Skuld.Comp.IInstall
use Skuld.Comp.DefOp
alias Skuld.Comp
alias Skuld.Comp.Env
alias Skuld.Comp.Throw, as: ThrowResult
alias Skuld.Comp.Types
alias Skuld.Effects.Throw
# Single atom key for all Port state — sig() is __MODULE__ (a plain atom),
# faster than tuple keys in map lookups.
@state_key "Elixir.Skuld.Effects.Port"
#############################################################################
## Effect State
#############################################################################
defmodule State do
@moduledoc false
@enforce_keys [:registry]
defstruct [:registry, :log, :handler_state]
@type t :: %__MODULE__{
registry: Skuld.Effects.Port.registry(),
log: list() | nil,
handler_state: term()
}
@doc false
@spec new(Skuld.Effects.Port.registry()) :: t()
def new(registry), do: %__MODULE__{registry: registry}
@doc false
@spec new(Skuld.Effects.Port.registry(), atom() | nil) :: t()
def new(registry, log), do: %__MODULE__{registry: registry, log: log}
end
#############################################################################
## Operations (generated by def_op)
#############################################################################
def_op(request(mod, name, args))
@request_op @__request_op__
#############################################################################
## Types
#############################################################################
@typedoc "Module identity (contract or implementation module)"
@type port_module :: module()
@typedoc "Function exported by `port_module`"
@type port_name :: atom()
@typedoc "List of positional arguments"
@type args :: list()
@typedoc """
Registry entry for dispatching requests.
## Runtime resolvers (used with `with_handler/3`)
* `:direct` – call `apply(mod, name, args)`, result is a plain value
* `module` – invokes `apply(module, name, args)` (implementation module).
Modules where `__port_effectful__?/0` returns truthy (e.g. via
`use MyContract.Effectful`) are auto-detected as effectful resolvers
whose return values are computations inlined into the current effect
context. Returning `false` opts out of auto-detection.
* `{:effectful, module}` – explicit effectful resolver (same as above,
for backward compatibility or modules without the marker)
* `function` (arity 3) – `fun.(mod, name, args)`
* `{module, function}` – invokes `apply(module, function, [mod, name, args])`
## Default resolvers (used as `:__default__` catch-all)
* `{:test_stub, responses}` – map-based test stubs keyed by `Port.key/3`
* `{:test_stub, responses, fallback}` – test stubs with fallback function
* `{:fn_dispatch, handler_fn}` – function-based dispatch via `fn(mod, name, args)`
* `{:stateful_dispatch, handler_fn}` – stateful dispatch via
`fn(mod, name, args, state) -> {result, new_state}`
"""
@type resolver ::
:direct
| {:effectful, module()}
| (port_module(), port_name(), args() -> term())
| {module(), atom()}
| module()
| {:test_stub, map()}
| {:test_stub, map(), fn_handler()}
| {:fn_dispatch, fn_handler()}
| {:stateful_dispatch, stateful_handler()}
@typedoc "Registry mapping port modules (or `:__default__`) to resolvers"
@type registry :: %{(port_module() | :__default__) => resolver()}
@typedoc "Function handler for test scenarios - receives (mod, name, args)"
@type fn_handler :: (port_module(), port_name(), args() -> term())
@typedoc "Stateful handler function - receives (mod, name, args, state), returns {result, new_state}"
@type stateful_handler :: (port_module(), port_name(), args(), term() -> {term(), term()})
# Sentinel registry key for catch-all resolvers (test stubs, fn handlers)
@default_key :__default__
#############################################################################
## Operations
#############################################################################
@doc """
Build a request for the given module/function.
Returns the result tuple `{:ok, value}` or `{:error, reason}` as-is,
allowing the caller to handle errors explicitly.
## Example
Port.request(MyApp.UserQueries, :find_by_id, [123])
# => {:ok, %User{...}} or {:error, {:not_found, User, 123}}
"""
@spec request(port_module(), port_name()) :: Types.computation()
def request(mod, name), do: request(mod, name, [])
@doc """
Build a request that unwraps the result or throws on error.
Expects the handler to return `{:ok, value}` or `{:error, reason}`.
On success, returns the unwrapped `value`. On error, dispatches a
`Skuld.Effects.Throw` effect with the `reason`.
Requires a `Throw.with_handler/1` in the handler chain.
## Example
Port.request!(MyApp.UserQueries, :find_by_id, [123])
# => %User{...} or throws {:not_found, User, 123}
"""
@spec request!(port_module(), port_name(), args()) :: Types.computation()
def request!(mod, name, args \\ []) do
Comp.bind(request(mod, name, args), fn
{:ok, value} -> value
{:error, reason} -> Throw.throw(reason)
end)
end
@doc """
Build a request that applies a custom unwrap function, then unwraps or throws.
The `unwrap_fn` receives the raw result from the handler and must return
`{:ok, value}` or `{:error, reason}`. The result is then handled like
`request!/3`: success values are unwrapped, errors dispatch `Throw`.
This is used by `Port.Contract` when `bang:` is set to a custom function.
Requires a `Throw.with_handler/1` in the handler chain.
## Example
Port.request_bang(MyApp.Users, :find_by_id, [123], fn
nil -> {:error, :not_found}
user -> {:ok, user}
end)
# => %User{...} or throws :not_found
"""
@spec request_bang(port_module(), port_name(), args(), (term() ->
{:ok, term()} | {:error, term()})) ::
Types.computation()
def request_bang(mod, name, args, unwrap_fn) do
Comp.bind(request(mod, name, args), fn result ->
case unwrap_fn.(result) do
{:ok, value} -> value
{:error, reason} -> Throw.throw(reason)
end
end)
end
#############################################################################
## Key Generation (for test stubs)
#############################################################################
@doc """
Build a canonical key usable with `with_test_handler/2`.
Arguments are normalized to produce consistent keys.
## Example
Port.key(MyApp.UserQueries, :find_by_id, [123])
"""
@spec key(port_module(), port_name(), args()) ::
{port_module(), port_name(), binary()}
def key(mod, name, args) do
{mod, name, normalize_args(args)}
end
@doc false
@spec normalize_args(term()) :: binary()
def normalize_args(args) do
args
|> canonical_term()
|> :erlang.term_to_binary()
end
defp canonical_term(%_{} = struct) do
# Convert struct to list of {key, value} pairs including __struct__
struct_name = struct.__struct__
struct
|> Map.from_struct()
|> Enum.map(fn {k, v} -> {k, canonical_term(v)} end)
|> Enum.concat([{:__struct__, struct_name}])
|> Enum.sort_by(&elem(&1, 0))
end
defp canonical_term(map) when is_map(map) do
map
|> Enum.map(fn {k, v} -> {k, canonical_term(v)} end)
|> Enum.sort_by(&elem(&1, 0))
end
defp canonical_term(list) when is_list(list) do
if Keyword.keyword?(list) do
# Keyword list - sort by key for canonical form
list
|> Enum.map(fn {k, v} -> {k, canonical_term(v)} end)
|> Enum.sort_by(&elem(&1, 0))
else
# Regular list - preserve order
Enum.map(list, &canonical_term/1)
end
end
defp canonical_term(tuple) when is_tuple(tuple) do
{:__tuple__, tuple |> Tuple.to_list() |> Enum.map(&canonical_term/1)}
end
defp canonical_term(other), do: other
#############################################################################
## Handler Installation - Runtime
#############################################################################
@doc """
Install a scoped Port handler for a computation.
Pass a registry map keyed by module to control how requests are
dispatched. Each entry can be one of:
* `:direct` – call `apply(mod, name, args)`, returns a plain value
* `module` – invokes `apply(module, name, args)`. Modules where
`__port_effectful__?/0` returns truthy (e.g. via
`use MyContract.Effectful`) are auto-detected as effectful resolvers
whose return values are computations inlined into the current effect
context. Returning `false` opts out of auto-detection.
* `{:effectful, module}` – explicit effectful resolver (same as above,
for backward compatibility or modules without the marker)
* `function` (arity 3) – `fun.(mod, name, args)`
* `{module, function}` – invokes `apply(module, function, [mod, name, args])`
Plain resolvers (`:direct`, function, `{mod, fun}`, module) return values
that are passed directly to the continuation. Effectful resolvers (auto-
detected or explicit `{:effectful, mod}`) return computations that
participate in the surrounding effect context.
## Nested Handlers
Nested `with_handler` calls **merge** registries rather than shadowing.
Inner entries win on conflict. When the inner scope exits, the previous
registry is restored.
# Outer registers ModuleA, inner adds ModuleB — both are available
my_comp
|> Port.with_handler(%{ModuleB => :direct}) # inner: adds ModuleB
|> Port.with_handler(%{ModuleA => :direct}) # outer: registers ModuleA
|> Comp.run!()
Note: `with_test_handler` and `with_fn_handler` do **not** merge with
runtime registries — they replace the dispatch mode entirely, which is
the expected behaviour for test stubs.
## Options
* `:log` — when truthy, enables dispatch logging. Every Port dispatch
records a `{mod, name, args, result}` 4-tuple in `Port.State.log`.
Disabled by default (nil) for zero overhead in production.
* `:output` — transform function `(result, %Port.State{}) -> output`
called on scope exit. When logging is enabled, `state.log` contains
the log entries in chronological order.
## Example
# Plain implementations
my_comp
|> Port.with_handler(%{
MyApp.UserQueries => :direct,
MyApp.Repository => MyApp.Repository.Ecto
})
|> Comp.run!()
# Effectful implementation — auto-detected via __port_effectful__?/0
my_comp
|> Port.with_handler(%{
MyApp.Repository => MyApp.Repository.EffectfulImpl
})
|> Throw.with_handler()
|> Comp.run!()
# With dispatch logging in tests
{result, log} =
my_comp
|> Port.with_handler(
%{MyApp.Repo => MyApp.Repo.Test},
log: true,
output: fn result, state -> {result, state.log} end
)
|> Throw.with_handler()
|> Comp.run!()
"""
@spec with_handler(Types.computation(), registry(), keyword()) :: Types.computation()
def with_handler(comp, registry \\ %{}, opts \\ []) do
install_registry(comp, registry, opts)
end
@doc """
Install Port handler via catch clause syntax.
Config is the registry map, or `{registry, opts}`:
catch
Port -> %{MyModule => :direct}
Port -> {%{MyModule => :direct}, output: fn r, s -> {r, s} end}
"""
@impl Skuld.Comp.IInstall
def __handle__(comp, {registry, opts}) when is_map(registry) and is_list(opts),
do: with_handler(comp, registry, opts)
def __handle__(comp, registry) when is_map(registry), do: with_handler(comp, registry)
#############################################################################
## Handler Installation - Test (Map-based)
#############################################################################
@doc """
Install a test handler with canned responses.
Provide a map of responses keyed by `Port.key/3`. Missing keys will
throw `{:port_not_stubbed, key}` unless a `fallback:` function is provided.
## Options
* `:fallback` - A function `(mod, name, args) -> result` to call when
no exact key match is found. Useful for handling dynamic arguments
while still using exact matching for known cases.
* `:log` — enable dispatch logging (see `with_handler/3`)
* `:output` - Transform `(result, %Port.State{}) -> output` on scope exit
## Example
responses = %{
Port.key(MyApp.UserQueries, :find_by_id, [123]) => {:ok, %{name: "Alice"}},
Port.key(MyApp.UserQueries, :find_by_id, [456]) => {:error, :not_found}
}
my_comp
|> Port.with_test_handler(responses)
|> Throw.with_handler()
|> Comp.run!()
# With fallback for dynamic cases
my_comp
|> Port.with_test_handler(responses, fallback: fn
MyApp.AuditQueries, _name, _args -> :ok
mod, name, args -> raise "Unhandled: \#{inspect(mod)}.\#{name}(\#{inspect(args)})"
end)
|> Throw.with_handler()
|> Comp.run!()
"""
@spec with_test_handler(Types.computation(), map(), keyword()) :: Types.computation()
def with_test_handler(comp, responses, opts \\ []) when is_map(responses) do
fallback = Keyword.get(opts, :fallback)
resolver =
if fallback do
{:test_stub, responses, fallback}
else
{:test_stub, responses}
end
install_registry(comp, %{@default_key => resolver}, opts)
end
#############################################################################
## Handler Installation - Test (Function-based)
#############################################################################
@doc """
Install a function-based test handler.
The handler function receives `(mod, name, args)` and can use Elixir's
full pattern matching power including guards, pins, and wildcards.
If no function clause matches, throws `{:port_not_handled, mod, name, args}`.
## Example
handler = fn
# Pin specific values
MyApp.UserQueries, :find_by_id, [^expected_id] ->
{:ok, %{id: expected_id, name: "Expected"}}
# Match any value with wildcard
MyApp.UserQueries, :find_by_id, [_any_id] ->
{:ok, %{id: "default", name: "Default"}}
# Match with guards
MyApp.Queries, :paginate, [_query, limit] when limit > 100 ->
{:error, :limit_too_high}
# Match specific module, any function
MyApp.AuditQueries, _function, _args ->
:ok
# Catch-all (optional)
mod, fun, args ->
raise "Unhandled: \#{inspect(mod)}.\#{fun}(\#{inspect(args)})"
end
my_comp
|> Port.with_fn_handler(handler)
|> Throw.with_handler()
|> Comp.run!()
## Property-Based Testing
Function handlers are ideal for property-based tests where exact values
aren't known upfront:
property "user lookup succeeds" do
check all user_id <- uuid_generator() do
handler = fn
UserQueries, :find_by_id, [^user_id] ->
{:ok, %{id: user_id, name: "Test User"}}
end
result =
find_user(user_id)
|> Port.with_fn_handler(handler)
|> Throw.with_handler()
|> Comp.run!()
assert {:ok, _} = result
end
end
"""
@spec with_fn_handler(Types.computation(), fn_handler(), keyword()) :: Types.computation()
def with_fn_handler(comp, handler_fn, opts \\ []) when is_function(handler_fn, 3) do
install_registry(comp, %{@default_key => {:fn_dispatch, handler_fn}}, opts)
end
#############################################################################
## Handler Installation - Test (Stateful Function-based)
#############################################################################
@doc """
Install a stateful function-based test handler.
The handler function receives `(mod, name, args, state)` and returns
`{result, new_state}`. State is threaded across Port calls within the
scope, enabling test doubles where writes are visible to subsequent reads.
## Options
* `:log` — enable dispatch logging (see `with_handler/3`)
* `:output` — transform `(result, %Port.State{}) -> output` on scope exit.
The `state.handler_state` field contains the final handler state.
## Example
# Stateful in-memory store: insert then read back
handler = fn
MyRepo, :insert, [record], state ->
key = {record.__struct__, record.id}
{{:ok, record}, Map.put(state, key, record)}
MyRepo, :get, [schema, id], state ->
key = {schema, id}
{Map.get(state, key), state}
end
{result, final_state} =
my_insert_then_read_comp
|> Port.with_stateful_handler(%{}, handler,
output: fn result, state -> {result, state.handler_state} end
)
|> Throw.with_handler()
|> Comp.run!()
## Property-Based Testing
Stateful handlers pair naturally with property-based tests where
the in-memory model serves as the oracle:
property "insert then get returns the same record" do
check all id <- integer(), name <- string(:alphanumeric) do
record = %User{id: id, name: name}
handler = fn
Repo, :insert, [r], state -> {{:ok, r}, Map.put(state, {User, r.id}, r)}
Repo, :get, [schema, id], state -> {Map.get(state, {schema, id}), state}
end
result =
insert_then_get(record)
|> Port.with_stateful_handler(%{}, handler)
|> Throw.with_handler()
|> Comp.run!()
assert result == record
end
end
"""
@spec with_stateful_handler(Types.computation(), term(), stateful_handler(), keyword()) ::
Types.computation()
def with_stateful_handler(comp, initial_state, handler_fn, opts \\ [])
when is_function(handler_fn, 4) do
install_registry(
comp,
%{@default_key => {:stateful_dispatch, handler_fn}},
Keyword.put(opts, :initial_handler_state, initial_state)
)
end
#############################################################################
## Shared Registry Installation
#############################################################################
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp install_registry(comp, registry, opts) do
output = Keyword.get(opts, :output)
suspend = Keyword.get(opts, :suspend)
log_enabled = Keyword.has_key?(opts, :log) and opts[:log] != nil
initial_handler_state = Keyword.get(opts, :initial_handler_state)
state_key = @state_key
comp
|> Comp.scoped(fn env ->
previous = Env.get_state(env, state_key)
# Auto-detect effectful resolvers: plain module atoms where
# __port_effectful__?/0 returns truthy are wrapped as {:effectful, module}.
resolved_registry =
Map.new(registry, fn
{key, module} when is_atom(module) and module not in [:direct] ->
Code.ensure_loaded(module)
if function_exported?(module, :__port_effectful__?, 0) and
module.__port_effectful__?() do
{key, {:effectful, module}}
else
{key, module}
end
entry ->
entry
end)
# Merge with any existing registry so nested handler installations
# accumulate registrations rather than shadowing them.
merged_registry =
case previous do
%State{registry: outer_registry} -> Map.merge(outer_registry, resolved_registry)
_ -> resolved_registry
end
# Log is a list (enabled) or nil (disabled).
# Inner :log option starts a fresh log; otherwise inherit outer's log.
merged_log =
if log_enabled do
[]
else
case previous do
%State{log: outer_log} -> outer_log
_ -> nil
end
end
new_state = %State{
registry: merged_registry,
log: merged_log,
handler_state: initial_handler_state
}
env_with_state = Env.put_state(env, state_key, new_state)
# If :suspend option provided, compose into transform_suspend
{modified_env, previous_transform} =
if suspend do
old_transform = Env.get_transform_suspend(env_with_state)
new_transform = fn susp, e ->
{susp1, e1} = old_transform.(susp, e)
suspend.(susp1, e1)
end
{Env.with_transform_suspend(env_with_state, new_transform), old_transform}
else
{env_with_state, nil}
end
finally_k = fn value, e ->
current_state = Env.get_state(e, state_key)
# Restore previous state (or remove key if there was none).
# Only propagate accumulated log entries to the outer scope when
# this scope inherited the outer log (i.e. no fresh :log option).
# When :log started a fresh log, entries stay in this scope only.
restored_env =
case previous do
nil ->
%{e | state: Map.delete(e.state, state_key)}
%State{log: outer_log} when is_list(outer_log) and not log_enabled ->
# Inherited outer log — carry forward accumulated entries.
Env.put_state(e, state_key, %{previous | log: current_state.log})
_ ->
Env.put_state(e, state_key, previous)
end
# Restore previous transform_suspend if we modified it
restored_env =
if previous_transform do
Env.with_transform_suspend(restored_env, previous_transform)
else
restored_env
end
# Output callback receives (value, state) — log is in state.log
# reversed to chronological order.
transformed_value =
if output do
output_state =
case current_state do
%State{log: log} when is_list(log) ->
%{current_state | log: Enum.reverse(log)}
_ ->
current_state
end
output.(value, output_state)
else
value
end
{transformed_value, restored_env}
end
{modified_env, finally_k}
end)
|> Comp.with_handler(@__sig__, &__MODULE__.handle/3)
end
#############################################################################
## IHandler Implementation
#############################################################################
@impl Skuld.Comp.IHandle
def handle({@request_op, mod, name, args}, env, k) do
%State{registry: registry, log: log} = Env.get_state!(env, @state_key)
# Look up module-specific resolver, fall back to :__default__, then error
resolver =
case Map.fetch(registry, mod) do
{:ok, r} -> {:ok, r}
:error -> Map.fetch(registry, @default_key)
end
case resolver do
{:ok, {:test_stub, responses}} ->
dispatch_test_stub(responses, nil, mod, name, args, log, env, k)
{:ok, {:test_stub, responses, fallback}} ->
dispatch_test_stub(responses, fallback, mod, name, args, log, env, k)
{:ok, {:fn_dispatch, handler_fn}} ->
dispatch_fn(handler_fn, mod, name, args, log, env, k)
{:ok, {:stateful_dispatch, handler_fn}} ->
dispatch_stateful(handler_fn, mod, name, args, log, env, k)
{:ok, runtime_resolver} ->
dispatch_runtime(runtime_resolver, mod, name, args, log, env, k)
:error ->
emit_log_and_return(
%ThrowResult{error: {:unknown_port_module, mod}},
mod,
name,
args,
log,
env
)
end
end
#############################################################################
## Dispatch Logic
#############################################################################
defp dispatch_runtime(resolver, mod, name, args, log, env, k) do
case invoke(resolver, mod, name, args) do
{:computation, comp} ->
# Effectful resolver: inline the computation, capturing result for logging
if log do
Comp.call(comp, env, fn result, env2 ->
k.(result, append_log(env2, mod, name, args, result))
end)
else
Comp.call(comp, env, k)
end
{:value, result} ->
emit_log_and_continue(result, mod, name, args, log, env, k)
end
rescue
exception ->
emit_log_and_return(
%ThrowResult{error: {:port_failed, mod, name, exception}},
mod,
name,
args,
log,
env
)
end
defp dispatch_test_stub(responses, fallback, mod, name, args, log, env, k) do
request_key = key(mod, name, args)
case Map.fetch(responses, request_key) do
{:ok, result} ->
emit_log_and_continue(result, mod, name, args, log, env, k)
:error when is_function(fallback, 3) ->
# Try fallback function
dispatch_fn(fallback, mod, name, args, log, env, k)
:error ->
# No match and no fallback
emit_log_and_return(
%ThrowResult{error: {:port_not_stubbed, request_key}},
mod,
name,
args,
log,
env
)
end
end
defp dispatch_fn(handler_fn, mod, name, args, log, env, k) do
result = handler_fn.(mod, name, args)
emit_log_and_continue(result, mod, name, args, log, env, k)
rescue
e in FunctionClauseError ->
# No matching clause - report what we received
emit_log_and_return(
%ThrowResult{error: {:port_not_handled, mod, name, args, e}},
mod,
name,
args,
log,
env
)
e ->
# Other error in handler
emit_log_and_return(
%ThrowResult{error: {:port_handler_error, mod, name, e}},
mod,
name,
args,
log,
env
)
end
defp dispatch_stateful(handler_fn, mod, name, args, log, env, k) do
state_key = @state_key
port_state = Env.get_state!(env, state_key)
handler_state = port_state.handler_state
{result, new_handler_state} = handler_fn.(mod, name, args, handler_state)
# Unwrap DoubleDown.Dispatch.Defer — DD's fakes use Defer to
# defer execution outside the NimbleOwnership lock. Skuld doesn't use
# NimbleOwnership, so we invoke the deferred function immediately.
result = unwrap_defer(result)
updated_port_state = %{port_state | handler_state: new_handler_state}
env_with_state = Env.put_state(env, state_key, updated_port_state)
emit_log_and_continue(result, mod, name, args, log, env_with_state, k)
rescue
e in FunctionClauseError ->
# No matching clause - report what we received
emit_log_and_return(
%ThrowResult{error: {:port_not_handled, mod, name, args, e}},
mod,
name,
args,
log,
env
)
e ->
# Other error in handler
emit_log_and_return(
%ThrowResult{error: {:port_handler_error, mod, name, e}},
mod,
name,
args,
log,
env
)
end
#############################################################################
## Defer Unwrapping
#############################################################################
# DoubleDown's stateful fakes (InMemory, OpenInMemory) use %Defer{} to
# defer execution outside the NimbleOwnership lock. Skuld doesn't use
# NimbleOwnership, so we invoke the deferred function immediately.
defp unwrap_defer(%DoubleDown.Contract.Dispatch.Defer{fun: fun}), do: fun.()
defp unwrap_defer(result), do: result
#############################################################################
## Logging Helpers
#############################################################################
# Prepend a log entry to Port.State.log in the env. No effect dispatch,
# just a direct env mutation — fast path for non-DB test performance.
defp append_log(env, mod, name, args, result) do
state_key = @state_key
state = Env.get_state(env, state_key)
updated = %{state | log: [{mod, name, args, result} | state.log]}
Env.put_state(env, state_key, updated)
end
# For plain results: emit log (if log enabled) then continue with k.
defp emit_log_and_continue(result, _mod, _name, _args, nil, env, k) do
k.(result, env)
end
defp emit_log_and_continue(result, mod, name, args, _log, env, k) do
k.(result, append_log(env, mod, name, args, result))
end
# For error results (ThrowResult): emit log (if log enabled) then return the error.
defp emit_log_and_return(throw_result, _mod, _name, _args, nil, env) do
{throw_result, env}
end
defp emit_log_and_return(throw_result, mod, name, args, _log, env) do
{throw_result, append_log(env, mod, name, args, throw_result)}
end
defp invoke(:direct, mod, name, args) do
{:value, apply(mod, name, args)}
end
defp invoke({:effectful, module}, _mod, name, args) when is_atom(module) do
{:computation, apply(module, name, args)}
end
defp invoke(fun, mod, name, args) when is_function(fun, 3) do
{:value, fun.(mod, name, args)}
end
defp invoke({module, function}, mod, name, args) do
{:value, apply(module, function, [mod, name, args])}
end
defp invoke(module, _mod, name, args) when is_atom(module) do
{:value, apply(module, name, args)}
end
end