From b586349c8f70a5ada04cd4546fc06ce64126d14a Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 28 May 2026 01:03:35 +0200 Subject: [PATCH 1/3] erlang: support OTP 29 and HTTP/3 server verification Replace the deprecated catch expression with try across the codebase so hackney compiles cleanly on OTP 29, reusing small stop/close helpers instead of repeating the wrapper. Wire hackney's TLS options through to the QUIC connection so HTTP/3 honors the request's insecure option and uses certifi as the default trust store, matching the HTTPS path, now that quic 1.4.4 verifies the server certificate by default. Add OTP 29 to the CI matrix. --- .github/workflows/erlang.yml | 2 +- NEWS.md | 12 ++++++++ src/hackney.erl | 44 +++++++++++++++++++++--------- src/hackney_conn.erl | 53 ++++++++++++++++++++++++++++++++---- src/hackney_h3.erl | 10 +++++-- src/hackney_happy.erl | 23 +++++++++++----- src/hackney_pool.erl | 20 ++++++++------ src/hackney_trace.erl | 22 +++++++++------ src/hackney_ws.erl | 6 ++-- 9 files changed, 142 insertions(+), 50 deletions(-) diff --git a/.github/workflows/erlang.yml b/.github/workflows/erlang.yml index c5101cf7..3e0f8b26 100644 --- a/.github/workflows/erlang.yml +++ b/.github/workflows/erlang.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - otp: ["27.2", "28.0"] + otp: ["27.2", "28.0", "29.0"] rebar3: ['3.24.0'] steps: - uses: actions/checkout@v4 diff --git a/NEWS.md b/NEWS.md index 9c61f5bd..e03a3a5e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -3,6 +3,18 @@ 4.0.3 - 2026-05-28 ------------------ +### Security + +- HTTP/3 now verifies the server certificate. quic 1.4.4 authenticates the + server by default; hackney passes its TLS options through to the QUIC + connection so the request's `insecure` option and CA configuration are + honored, with certifi as the default trust store to match the HTTPS path. + +### Changed + +- Replace the deprecated `catch Expr` form with `try ... catch` so hackney + compiles cleanly on OTP 29. + ### Dependencies - Bump quic to 1.4.4. diff --git a/src/hackney.erl b/src/hackney.erl index 96442aa8..b920e528 100644 --- a/src/hackney.erl +++ b/src/hackney.erl @@ -122,7 +122,7 @@ connect_direct(Transport, Host, Port, Options) -> ok -> {ok, ConnPid}; {error, Reason} -> - catch hackney_conn:stop(ConnPid), + stop_conn(ConnPid), {error, Reason} end; {error, Reason} -> @@ -235,19 +235,24 @@ try_new_h3_connection(Host, Port, Transport, Options, PoolHandler) -> case hackney_conn:connect(ConnPid, ConnectTimeout) of ok -> %% Verify it's HTTP/3 - case catch hackney_conn:get_protocol(ConnPid) of + try hackney_conn:get_protocol(ConnPid) of http3 -> %% Register for multiplexing PoolHandler:register_h3(Host, Port, Transport, ConnPid, Options), {ok, ConnPid}; _ -> - %% Not HTTP/3 or connection terminated, close and fail - catch hackney_conn:stop(ConnPid), + %% Not HTTP/3, close and fail + stop_conn(ConnPid), + hackney_altsvc:mark_h3_blocked(Host, Port), + false + catch + _:_ -> + %% Connection terminated before we could check hackney_altsvc:mark_h3_blocked(Host, Port), false end; {error, _Reason} -> - catch hackney_conn:stop(ConnPid), + stop_conn(ConnPid), hackney_altsvc:mark_h3_blocked(Host, Port), false end; @@ -277,7 +282,7 @@ connect_pool_new(Transport, Host, Port, Options, PoolHandler) -> {error, Reason} -> %% Upgrade failed - release slot and close connection hackney_load_regulation:release(Host, Port), - catch hackney_conn:stop(ConnPid), + stop_conn(ConnPid), {error, Reason} end; {error, Reason} -> @@ -290,17 +295,18 @@ connect_pool_new(Transport, Host, Port, Options, PoolHandler) -> end. %% @private Register HTTP/2 connection for multiplexing if applicable -%% Uses catch to handle race condition where connection terminates before call +%% Wrapped in try to handle a race where the connection terminates before the call maybe_register_h2(ConnPid, Host, Port, Transport, Options, PoolHandler) -> - case catch hackney_conn:get_protocol(ConnPid) of + try hackney_conn:get_protocol(ConnPid) of http2 -> %% HTTP/2 negotiated - register for connection sharing PoolHandler:register_h2(Host, Port, Transport, ConnPid, Options); http1 -> ok; http3 -> - ok; - {'EXIT', _} -> + ok + catch + _:_ -> %% Connection terminated before we could check - ignore ok end. @@ -315,18 +321,30 @@ maybe_upgrade_ssl(hackney_ssl, ConnPid, Host, Options) -> _ -> [{protocols, Protocols} | SslOpts] end, %% Check if connection is already SSL (e.g., reused SSL connection) - case catch hackney_conn:is_upgraded_ssl(ConnPid) of + try hackney_conn:is_upgraded_ssl(ConnPid) of true -> %% Already SSL, no upgrade needed ok; _ -> %% Upgrade TCP to SSL with ALPN hackney_conn:upgrade_to_ssl(ConnPid, [{server_name_indication, Host} | SslOpts2]) + catch + _:_ -> + %% Connection terminated, attempt upgrade anyway + hackney_conn:upgrade_to_ssl(ConnPid, [{server_name_indication, Host} | SslOpts2]) end; maybe_upgrade_ssl(_, _ConnPid, _Host, _Options) -> %% Not SSL, no upgrade needed ok. +%% @private Stop a connection, tolerating an already-dead process. +stop_conn(ConnPid) -> + try hackney_conn:stop(ConnPid) catch _:_ -> ok end. + +%% @private Signal the websocket process to shut down, ignoring errors. +shutdown_ws(WsPid) -> + try exit(WsPid, shutdown) catch _:_ -> ok end. + %% @doc Close a connection. -spec close(conn()) -> ok. close(ConnPid) when is_pid(ConnPid) -> @@ -678,11 +696,11 @@ ws_connect(URL, Options) when is_binary(URL) orelse is_list(URL) -> ok -> {ok, WsPid}; {error, Reason} -> - catch exit(WsPid, shutdown), + shutdown_ws(WsPid), {error, Reason} catch exit:{timeout, _} -> - catch exit(WsPid, shutdown), + shutdown_ws(WsPid), {error, connect_timeout}; exit:{noproc, _} -> {error, {ws_process_died, noproc}} diff --git a/src/hackney_conn.erl b/src/hackney_conn.erl index 4a81d6d1..d49d92ce 100644 --- a/src/hackney_conn.erl +++ b/src/hackney_conn.erl @@ -570,7 +570,7 @@ terminate(_Reason, _State, #conn_data{socket = Socket, transport = Transport, undefined -> ok; _ -> erlang:demonitor(H2Mon, [flush]) end, - catch h2_connection:close(H2Conn) + close_h2(H2Conn) end, %% Close HTTP/3 connection if present case H3Conn of @@ -2270,9 +2270,9 @@ skip_response_body(Data) -> %% @private Try HTTP/3 connection via QUIC %% lsquic handles its own UDP socket creation and DNS resolution. -try_h3_connect(Host, Port, Timeout, _ConnectOpts) -> +try_h3_connect(Host, Port, Timeout, ConnectOpts) -> HostBin = if is_list(Host) -> list_to_binary(Host); true -> Host end, - case hackney_h3:connect(HostBin, Port, #{}, self()) of + case hackney_h3:connect(HostBin, Port, h3_tls_opts(ConnectOpts), self()) of {ok, ConnRef} -> %% Drive event loop until connected wait_h3_connected(ConnRef, Timeout, erlang:monotonic_time(millisecond)); @@ -2280,6 +2280,43 @@ try_h3_connect(Host, Port, Timeout, _ConnectOpts) -> Error end. +%% @private Map hackney's TLS options to the QUIC client verification +%% options. quic >= 1.4.4 verifies the server certificate by default, so an +%% insecure connection must opt out explicitly. The trust store defaults to +%% certifi to match the HTTPS path instead of relying on the OS store. +h3_tls_opts(ConnectOpts) -> + SslOpts = proplists:get_value(ssl_options, ConnectOpts, []), + Insecure = proplists:get_value(insecure, ConnectOpts, + proplists:get_value(insecure, SslOpts, false)), + case Insecure of + true -> #{verify => verify_none}; + false -> maps:put(verify, verify_peer, h3_ca_opts(SslOpts)) + end. + +%% @private Resolve the CA trust store for an H3 verification. quic only +%% accepts DER-encoded CAs (cacerts), so a cacertfile is decoded here. +h3_ca_opts(SslOpts) -> + case proplists:get_value(cacerts, SslOpts) of + undefined -> + case proplists:get_value(cacertfile, SslOpts) of + undefined -> #{cacerts => certifi:cacerts()}; + File -> #{cacerts => cacertfile_ders(File)} + end; + CACerts -> + #{cacerts => CACerts} + end. + +%% @private Read a PEM cacertfile into a list of DER certificates. A missing +%% or unreadable file yields an empty trust store so verification fails +%% closed rather than silently falling back to another store. +cacertfile_ders(File) -> + case file:read_file(File) of + {ok, Pem} -> + [Der || {'Certificate', Der, _} <- public_key:pem_decode(Pem)]; + {error, _} -> + [] + end. + %% @private Drive QUIC event loop until connected wait_h3_connected(ConnRef, Timeout, StartTime) -> Elapsed = erlang:monotonic_time(millisecond) - StartTime, @@ -2379,11 +2416,11 @@ start_h2_connection(Socket, Data, From, Origin) -> [CancelIdle, {reply, From, ok}]} end; {error, WaitErr} -> - catch h2_connection:close(H2Conn), + close_h2(H2Conn), h2_start_failure(Origin, From, WaitErr) end; {error, ActivateErr} -> - catch h2_connection:close(H2Conn), + close_h2(H2Conn), h2_start_failure(Origin, From, ActivateErr) end; {error, Reason} -> @@ -2395,6 +2432,10 @@ h2_start_failure(first_connect, From, Reason) -> h2_start_failure(after_upgrade, From, Reason) -> {keep_state_and_data, [{reply, From, {error, Reason}}]}. +%% @private Close an HTTP/2 connection, tolerating an already-closed one. +close_h2(H2Conn) -> + try h2_connection:close(H2Conn) catch _:_ -> ok end. + %% @private Send an HTTP/2 request via the h2 library. do_h2_request(From, Method, Path, Headers, Body, Data) -> do_h2_send(From, Method, Path, Headers, Body, @@ -3132,7 +3173,7 @@ handle_h3_termination(Error, Data) -> %% directives are honored even on h3 responses. maybe_record_altsvc(Headers, #conn_data{host = Host, port = Port}) when is_list(Headers) -> - _ = catch hackney_altsvc:parse_and_cache(Host, Port, Headers), + _ = (try hackney_altsvc:parse_and_cache(Host, Port, Headers) catch _:_ -> ok end), ok; maybe_record_altsvc(_Headers, _Data) -> ok. diff --git a/src/hackney_h3.erl b/src/hackney_h3.erl index 9e6d1963..39172cbb 100644 --- a/src/hackney_h3.erl +++ b/src/hackney_h3.erl @@ -744,7 +744,7 @@ handle_call(_Request, _From, State) -> {reply, {error, unknown_request}, State}. handle_cast({close, _Reason}, #state{h3_conn = Conn} = State) -> - catch quic_h3:close(Conn), + close_h3(Conn), {stop, normal, State}; handle_cast(_Msg, State) -> {noreply, State}. @@ -795,7 +795,7 @@ handle_info({quic_h3, Conn, {error, Code, Reason}}, handle_info({'DOWN', MonRef, process, _Pid, _Reason}, #state{owner_mon = MonRef, h3_conn = Conn} = State) -> - catch quic_h3:close(Conn), + close_h3(Conn), {stop, normal, State}; handle_info(_Info, State) -> @@ -808,7 +808,7 @@ terminate(_Reason, #state{conn_ref = Ref, h3_conn = Conn}) -> end, case Conn of undefined -> ok; - _ -> catch quic_h3:close(Conn) + _ -> close_h3(Conn) end, ok. @@ -816,6 +816,10 @@ terminate(_Reason, #state{conn_ref = Ref, h3_conn = Conn}) -> %% Internal adapter helpers %%==================================================================== +%% @private Close a QUIC/HTTP3 connection, tolerating an already-closed one. +close_h3(Conn) -> + try quic_h3:close(Conn) catch _:_ -> ok end. + build_h3_opts(Host, Opts) -> HostStr = binary_to_list(Host), Verify = case maps:get(insecure_skip_verify, Opts, false) of diff --git a/src/hackney_happy.erl b/src/hackney_happy.erl index b155043d..438a6f6e 100644 --- a/src/hackney_happy.erl +++ b/src/hackney_happy.erl @@ -102,7 +102,7 @@ do_connect_2(Pid, MRef, Timeout) -> end. connect_gc(Pid, MRef) -> - catch exit(Pid, normal), + (try exit(Pid, normal) catch _:_ -> ok end), erlang:demonitor(MRef, [flush]). @@ -129,20 +129,26 @@ getaddrs(Name) -> getbyname(Hostname, Type) -> %% First try DNS resolution using inet_res:getbyname - case (catch inet_res:getbyname(Hostname, Type)) of + try inet_res:getbyname(Hostname, Type) of {'ok', #hostent{h_addr_list=AddrList}} -> AddrList; - {error, _Reason} -> + {error, _Reason} -> %% DNS failed, try fallback to /etc/hosts using inet:gethostbyname %% This fixes NXDOMAIN errors in Docker Compose environments where %% hostnames are resolved via /etc/hosts entries fallback_hosts_lookup(Hostname, Type); Else -> - %% ERLANG 22 has an issue when g matching some DNS server messages ?report_debug("DNS error", [{hostname, Hostname} ,{type, Type} ,{error, Else}]), - %% Try fallback on unexpected errors too + %% Try fallback on unexpected results too + fallback_hosts_lookup(Hostname, Type) + catch + Class:Reason -> + ?report_debug("DNS error", [{hostname, Hostname} + ,{type, Type} + ,{error, {Class, Reason}}]), + %% Try fallback on resolver crashes too fallback_hosts_lookup(Hostname, Type) end. @@ -152,10 +158,13 @@ fallback_hosts_lookup(Hostname, Type) -> a -> inet; aaaa -> inet6 end, - case (catch inet:gethostbyname(Hostname, InetType)) of + try inet:gethostbyname(Hostname, InetType) of {'ok', #hostent{h_addr_list=AddrList}} -> AddrList; - _ -> + _ -> + [] + catch + _:_ -> [] end. diff --git a/src/hackney_pool.erl b/src/hackney_pool.erl index c1aaaf93..1113e6b2 100644 --- a/src/hackney_pool.erl +++ b/src/hackney_pool.erl @@ -677,7 +677,7 @@ terminate(_Reason, #state{available=Available, maps:foreach( fun(_Key, Pids) -> lists:foreach(fun(Pid) -> - catch hackney_conn:stop(Pid) + stop_conn(Pid) end, Pids) end, Available @@ -686,7 +686,7 @@ terminate(_Reason, #state{available=Available, %% Stop all HTTP/2 connections maps:foreach( fun(_Key, Pid) -> - catch hackney_conn:stop(Pid) + stop_conn(Pid) end, H2Conns ), @@ -694,7 +694,7 @@ terminate(_Reason, #state{available=Available, %% Stop all HTTP/3 connections maps:foreach( fun(_Key, Pid) -> - catch hackney_conn:stop(Pid) + stop_conn(Pid) end, H3Conns ), @@ -711,6 +711,10 @@ connection_key(Host0, Port, Transport) -> Host = string:lowercase(Host0), {Host, Port, Transport}. +%% @private Stop a connection, tolerating an already-dead process. +stop_conn(Pid) -> + try hackney_conn:stop(Pid) catch _:_ -> ok end. + find_available(Key, Available) -> case maps:find(Key, Available) of {ok, [Pid | Rest]} -> @@ -772,7 +776,7 @@ start_connection(Key, Owner, Opts, State) -> PidMonitors = maps:put(Pid, MonRef, State#state.pid_monitors), {ok, Pid, State#state{pid_monitors=PidMonitors}}; {error, Reason} -> - catch hackney_conn:stop(Pid), + stop_conn(Pid), {error, Reason} end; {error, Reason} -> @@ -798,12 +802,12 @@ do_checkin(Pid, State) -> %% Check if this connection should not be reused: %% - SSL upgraded connections (security requirement) %% - Proxy tunnel connections (SOCKS5, HTTP CONNECT - issue #283) - ShouldClose = (catch hackney_conn:is_upgraded_ssl(Pid)) =:= true orelse - (catch hackney_conn:is_no_reuse(Pid)) =:= true, + ShouldClose = (try hackney_conn:is_upgraded_ssl(Pid) catch _:_ -> false end) =:= true orelse + (try hackney_conn:is_no_reuse(Pid) catch _:_ -> false end) =:= true, case ShouldClose of true -> %% Connection should not be reused - close it - catch hackney_conn:stop(Pid), + stop_conn(Pid), %% Remove monitor if exists PidMonitors2 = case maps:take(Pid, PidMonitors) of {MonRef, PM} -> @@ -1038,7 +1042,7 @@ prewarm_connections(PoolPid, Host, Port, Count, IdleTimeout) -> %% Checkin the new connection to the pool gen_server:cast(PoolPid, {prewarm_checkin, Pid, {Host, Port, hackney_tcp}}); {error, _Reason} -> - catch hackney_conn:stop(Pid) + stop_conn(Pid) end; {error, _Reason} -> ok diff --git a/src/hackney_trace.erl b/src/hackney_trace.erl index 77642a5d..97f19808 100644 --- a/src/hackney_trace.erl +++ b/src/hackney_trace.erl @@ -129,42 +129,46 @@ handle_trace({trace_ts, _Who, call, [_Sev, "stop trace", stop_trace, [stop_trace]]}, Timestamp}, {_, standard_io} = Fd) -> - (catch io:format(standard_io, "stop trace at ~s~n", [format_timestamp(Timestamp)])), + safe(fun() -> io:format(standard_io, "stop trace at ~s~n", [format_timestamp(Timestamp)]) end), Fd; handle_trace({trace_ts, _Who, call, {?MODULE, report_event, [_Sev, "stop trace", stop_trace, [stop_trace]]}, Timestamp}, standard_io = Fd) -> - (catch io:format(Fd, "stop trace at ~s~n", [format_timestamp(Timestamp)])), + safe(fun() -> io:format(Fd, "stop trace at ~s~n", [format_timestamp(Timestamp)]) end), Fd; handle_trace({trace_ts, _Who, call, {?MODULE, report_event, [_Sev, "stop trace", stop_trace, [stop_trace]]}, Timestamp}, {_Service, Fd}) -> - (catch io:format(Fd, "stop trace at ~s~n", [format_timestamp(Timestamp)])), - (catch file:close(Fd)), + safe(fun() -> io:format(Fd, "stop trace at ~s~n", [format_timestamp(Timestamp)]) end), + safe(fun() -> file:close(Fd) end), closed_file; handle_trace({trace_ts, _Who, call, {?MODULE, report_event, [_Sev, "stop trace", stop_trace, [stop_trace]]}, Timestamp}, Fd) -> - (catch io:format(Fd, "stop trace at ~s~n", [format_timestamp(Timestamp)])), - (catch file:close(Fd)), + safe(fun() -> io:format(Fd, "stop trace at ~s~n", [format_timestamp(Timestamp)]) end), + safe(fun() -> file:close(Fd) end), closed_file; handle_trace({trace_ts, Who, call, {?MODULE, report_event, [Sev, Label, Service, Content]}, Timestamp}, Fd) -> - (catch print_hackney_trace(Fd, Sev, Timestamp, Who, - Label, Service, Content)), + safe(fun() -> print_hackney_trace(Fd, Sev, Timestamp, Who, + Label, Service, Content) end), Fd; handle_trace(Event, Fd) -> - (catch print_trace(Fd, Event)), + safe(fun() -> print_trace(Fd, Event) end), Fd. +%% @private Run a best-effort trace side effect, ignoring any failure. +safe(Fun) -> + try Fun() catch _:_ -> ok end. + print_hackney_trace({Service, Fd}, Sev, Timestamp, Who, Label, Service, Content) -> diff --git a/src/hackney_ws.erl b/src/hackney_ws.erl index 0da27024..12759a5c 100644 --- a/src/hackney_ws.erl +++ b/src/hackney_ws.erl @@ -241,8 +241,8 @@ init([Owner, Opts]) -> %% @private terminate(_Reason, _State, #ws_data{socket = undefined}) -> ok; -terminate(_Reason, _State, #ws_data{socket = Socket, transport = Transport}) -> - catch Transport:close(Socket), +terminate(_Reason, _State, #ws_data{} = WsData) -> + close_socket(WsData), ok. %% @private @@ -1073,5 +1073,5 @@ set_socket_active(#ws_data{socket = Socket}, Mode) -> close_socket(#ws_data{socket = undefined}) -> ok; close_socket(#ws_data{socket = Socket, transport = Transport}) -> - catch Transport:close(Socket), + try Transport:close(Socket) catch _:_ -> ok end, ok. From 85e5b7f44977a59e95515b1bedb028c7f93123f1 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 28 May 2026 01:32:38 +0200 Subject: [PATCH 2/3] deps: bump h2 to 0.6.1 for OTP 29 0.6.1 replaces the deprecated catch form, so the dependency builds on OTP 29 without warnings_as_errors failing. --- NEWS.md | 1 + rebar.config | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index e03a3a5e..4b4b409f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -18,6 +18,7 @@ ### Dependencies - Bump quic to 1.4.4. +- Bump h2 to 0.6.1. 4.0.2 - 2026-05-25 ------------------ diff --git a/rebar.config b/rebar.config index ea873835..2960cdbf 100644 --- a/rebar.config +++ b/rebar.config @@ -51,7 +51,7 @@ %% Pure Erlang QUIC + HTTP/3 stack {quic, "1.4.4"}, %% Pure Erlang HTTP/2 stack - {h2, "0.6.0"}, + {h2, "0.6.1"}, {idna, "~>7.1.0"}, {mimerl, "~>1.4"}, {certifi, "~>2.16.0"}, From 8dad3cedc1b701d1ee1ba7470c10a5714ec57afb Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 28 May 2026 01:35:28 +0200 Subject: [PATCH 3/3] erlang: clear dialyzer warnings from the try conversion Drop the now-unreachable catch-all in getbyname (try cannot yield the {'EXIT', _} the old catch did) and have the trace safe/1 helper return ok so its result is not an unmatched union. --- src/hackney_happy.erl | 6 ------ src/hackney_trace.erl | 5 +++-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/hackney_happy.erl b/src/hackney_happy.erl index 438a6f6e..27425ee7 100644 --- a/src/hackney_happy.erl +++ b/src/hackney_happy.erl @@ -136,12 +136,6 @@ getbyname(Hostname, Type) -> %% DNS failed, try fallback to /etc/hosts using inet:gethostbyname %% This fixes NXDOMAIN errors in Docker Compose environments where %% hostnames are resolved via /etc/hosts entries - fallback_hosts_lookup(Hostname, Type); - Else -> - ?report_debug("DNS error", [{hostname, Hostname} - ,{type, Type} - ,{error, Else}]), - %% Try fallback on unexpected results too fallback_hosts_lookup(Hostname, Type) catch Class:Reason -> diff --git a/src/hackney_trace.erl b/src/hackney_trace.erl index 97f19808..9c39c184 100644 --- a/src/hackney_trace.erl +++ b/src/hackney_trace.erl @@ -165,9 +165,10 @@ handle_trace(Event, Fd) -> safe(fun() -> print_trace(Fd, Event) end), Fd. -%% @private Run a best-effort trace side effect, ignoring any failure. +%% @private Run a best-effort trace side effect, ignoring its result and any failure. safe(Fun) -> - try Fun() catch _:_ -> ok end. + _ = (try Fun() catch _:_ -> ok end), + ok. print_hackney_trace({Service, Fd},