%% @doc Validation and bounded invocation for optional ADK services. -module(adk_service_ref). -export([validate/2, call/4]). -type kind() :: memory | artifact. -type service_ref() :: {module(), term()}. -export_type([kind/0, service_ref/0]). -spec validate(kind(), undefined | term()) -> {ok, undefined | service_ref()} | {error, term()}. validate(_Kind, undefined) -> {ok, undefined}; validate(Kind, {Module, Handle}) when is_atom(Module), Handle =/= undefined -> case required_callback_sets(Kind) of {ok, CallbackSets} -> validate_module(Module, Handle, CallbackSets); error -> {error, {unknown_service_kind, Kind}} end; validate(Kind, _Other) -> {error, {invalid_service_ref, Kind, expected_module_handle_tuple}}. %% @doc Invoke a service callback in an isolated lightweight process. %% The service handle is prepended to Args. Exceptions, exits, and timeouts are %% converted to values so a third-party adapter cannot crash or indefinitely %% block an invocation process. -spec call(service_ref(), atom(), [term()], pos_integer()) -> term(). call({Module, Handle}, Function, Args, Timeout) when is_atom(Module), is_atom(Function), is_list(Args), is_integer(Timeout), Timeout > 0 -> Caller = self(), Ref = make_ref(), {Coordinator, Monitor} = spawn_monitor( fun() -> service_call_coordinator( Caller, Ref, Module, Handle, Function, Args, Timeout) end), receive {Ref, Result} -> erlang:demonitor(Monitor, [flush]), Result; {'DOWN', Monitor, process, Coordinator, Reason} -> flush_service_reply(Ref), {error, {service_coordinator_down, Reason}} after Timeout + 100 -> %% The coordinator enforces the actual deadline. This outer guard only %% handles an implementation bug in the coordinator itself. exit(Coordinator, kill), receive {'DOWN', Monitor, process, Coordinator, _} -> ok after 100 -> erlang:demonitor(Monitor, [flush]) end, flush_service_reply(Ref), {error, service_timeout} end; call(_ServiceRef, _Function, _Args, Timeout) -> {error, {invalid_service_timeout, Timeout}}. service_call_coordinator(Caller, Ref, Module, Handle, Function, Args, Timeout) -> process_flag(trap_exit, true), CallerMonitor = erlang:monitor(process, Caller), Coordinator = self(), Worker = spawn_link( fun() -> Reply = try apply(Module, Function, [Handle | Args]) of Result -> Result catch Class:Reason -> {error, {service_exception, Class, Reason}} end, Coordinator ! {service_result, self(), Reply} end), receive {service_result, Worker, Reply} -> unlink(Worker), erlang:demonitor(CallerMonitor, [flush]), Caller ! {Ref, Reply}; {'EXIT', Worker, Reason} -> erlang:demonitor(CallerMonitor, [flush]), Caller ! {Ref, {error, {service_process_down, Reason}}}; {'DOWN', CallerMonitor, process, Caller, _Reason} -> exit(Worker, kill), ok after Timeout -> exit(Worker, kill), receive {'EXIT', Worker, _} -> ok after 100 -> ok end, erlang:demonitor(CallerMonitor, [flush]), Caller ! {Ref, {error, service_timeout}} end. required_callback_sets(memory) -> %% A v2-only adapter is valid. Built-in adapters retain the v1 set, but %% requiring those ambient-scope wrappers would otherwise prevent a %% least-authority implementation from being plugged into the Runner. {ok, [[{capabilities, 1}, {add_entry, 4}, {add_events, 5}, {add_session_to_memory, 5}, {search, 4}, {delete_entry, 3}, {delete_session, 3}, {delete_user, 2}], [{add, 3}, {search, 4}, {delete, 2}, {add_session_to_memory, 3}]]}; required_callback_sets(artifact) -> {ok, [[{put, 5}, {get, 4}, {list, 2}, {delete, 4}]]}; required_callback_sets(_) -> error. validate_module(Module, Handle, CallbackSets) -> case code:ensure_loaded(Module) of {module, Module} -> MissingSets = [[Callback || {Function, Arity} = Callback <- Callbacks, not erlang:function_exported( Module, Function, Arity)] || Callbacks <- CallbackSets], case lists:any(fun(Missing) -> Missing =:= [] end, MissingSets) of true -> {ok, {Module, Handle}}; false -> %% Preserve the public structural error. Reporting the %% closest supported contract keeps it concise while a %% v2-only adapter remains valid. Missing = lists:foldl( fun(Current, Best) when length(Current) < length(Best) -> Current; (_Current, Best) -> Best end, hd(MissingSets), tl(MissingSets)), {error, {missing_service_callbacks, Module, Missing}} end; {error, Reason} -> {error, {service_module_unavailable, Module, Reason}} end. flush_service_reply(Ref) -> receive {Ref, _} -> ok after 0 -> ok end.