Skip to content

Commit 4ccc008

Browse files
authored
Treat abortive-close posix reasons as client closures in logging (#663)
Bandit.Logger.maybe_log_protocol_error/4 only suppressed logging for %Bandit.TransportError{error: :closed}, so a client that aborts a connection (RST) during HTTP/2 init - before conn_data/peer_data can be obtained - gets logged at ERROR level even with the default log_client_closures: false, because peername/1 surfaces a different posix reason (e.g. :enotconn, :einval, :econnaborted, :econnreset) for an abortive close rather than :closed. A peer that is already gone is a client closure regardless of which of these reasons the socket happens to report, so treat all of them the same way :closed is treated. Adds an HTTP2ProtocolTest case that opens a TLS connection with linger: {true, 0} (forcing an abortive RST close) and closes it immediately, before any HTTP/2 bytes are exchanged, then asserts nothing is logged at the default settings - the same connection-init code path as the reported issue. Transport.tls_client/2 gains an optional third `opts` argument so the test can pass linger through to the underlying :ssl.connect call. Also loosens two pre-existing HTTP/1 logging assertions that hardcoded "closed" as the only expected posix reason in the log message text; on this platform an abortive close of an in-flight request can legitimately surface as :econnreset or similar, which is exactly the class of reason this fix now handles consistently. Fixes #647
1 parent 274eb55 commit 4ccc008

4 files changed

Lines changed: 44 additions & 11 deletions

File tree

lib/bandit/logger.ex

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@ defmodule Bandit.Logger do
33

44
require Logger
55

6+
# Posix reasons that a peer socket can surface as once the client has gone away.
7+
# `:closed` is the common case, but an abortive close (RST) can also surface as
8+
# `:enotconn` / `:einval` / `:econnaborted` / `:econnreset` depending on platform
9+
# and on how far along the connection was when the peer disappeared.
10+
@client_closure_errors [:closed, :enotconn, :einval, :econnaborted, :econnreset]
11+
612
def maybe_log_protocol_error(error, stacktrace, opts, metadata) do
713
logging_verbosity =
814
case error do
9-
%Bandit.TransportError{error: :closed} ->
15+
%Bandit.TransportError{error: transport_error}
16+
when transport_error in @client_closure_errors ->
1017
Keyword.get(opts.http, :log_client_closures, false)
1118

1219
_error ->

test/bandit/http1/logging_test.exs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,12 @@ defmodule HTTP1LoggingTest do
7676
Transport.close(client)
7777

7878
assert_receive {:log, %{level: :error, msg: {:string, msg}}}, 500
79-
assert msg == "** (Bandit.TransportError) Unrecoverable error: closed"
79+
80+
# An abortive close (which is what we force via linger: {true, 0} in
81+
# SimpleHTTP1Client.tcp_client/1) can surface as any of a handful of posix reasons
82+
# depending on platform and timing, not just :closed (see #647)
83+
assert msg =~
84+
~r/^\*\* \(Bandit\.TransportError\) Unrecoverable error: (closed|enotconn|einval|econnaborted|econnreset)$/
8085
end
8186

8287
@tag :capture_log
@@ -92,7 +97,10 @@ defmodule HTTP1LoggingTest do
9297
Transport.close(client)
9398

9499
assert_receive {:log, %{level: :error, msg: {:string, msg}}}, 500
95-
assert msg =~ "** (Bandit.TransportError) Unrecoverable error: closed"
100+
101+
assert msg =~
102+
~r/^\*\* \(Bandit\.TransportError\) Unrecoverable error: (closed|enotconn|einval|econnaborted|econnreset)/
103+
96104
assert msg =~ "lib/bandit/pipeline.ex:"
97105
end
98106

test/bandit/http2/protocol_test.exs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,20 @@ defmodule HTTP2ProtocolTest do
5050
Process.sleep(100)
5151
end
5252

53+
test "it should not log an error if the client aborts the connection during init " <>
54+
"(https://github.com/mtrudel/bandit/issues/647)",
55+
context do
56+
# Force an abortive close (RST) rather than a graceful one, since it's an abortive close
57+
# that can surface conn_data/peer_data errors as a posix reason other than :closed. Race
58+
# this against connection init by closing as soon as the TLS handshake completes, before
59+
# sending any HTTP/2 bytes.
60+
socket = Transport.tls_client(context, ["h2"], linger: {true, 0})
61+
Transport.close(socket)
62+
Process.sleep(100)
63+
64+
refute_receive {:log, %{level: :error}}
65+
end
66+
5367
@tag :capture_log
5468
test "it should ignore unknown frame types", context do
5569
socket = SimpleH2Client.setup_connection(context)

test/support/transport.ex

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,19 @@ defmodule Transport do
1313
{:client, %{socket: socket, transport: :gen_tcp}}
1414
end
1515

16-
def tls_client(context, protocols) do
16+
def tls_client(context, protocols, opts \\ []) do
1717
{:ok, socket} =
18-
:ssl.connect(~c"localhost", context[:port],
19-
active: false,
20-
mode: :binary,
21-
nodelay: true,
22-
verify: :verify_peer,
23-
cacertfile: Path.join(__DIR__, "../support/ca.pem"),
24-
alpn_advertised_protocols: protocols
18+
:ssl.connect(
19+
~c"localhost",
20+
context[:port],
21+
[
22+
active: false,
23+
mode: :binary,
24+
nodelay: true,
25+
verify: :verify_peer,
26+
cacertfile: Path.join(__DIR__, "../support/ca.pem"),
27+
alpn_advertised_protocols: protocols
28+
] ++ opts
2529
)
2630

2731
{:client, %{socket: socket, transport: :ssl}}

0 commit comments

Comments
 (0)