You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
EPOLLHUP or EPOLLRDHUP during connection establishment leads to a wrong decision that a backend is down #1863
Sometimes (quite frequently under load) haproxy marks a backend as being down even though the liveness check (httpchk) is successful.
Expected Behavior
Backends should not be marked as down if they aren't really down.
Steps to Reproduce the Behavior
It is easy for me to reproduce it - just start about 10 simultaneous curl clients to pass requests continuously through haproxy at localhost. I have the feeling that this won't be easy for everybody, though...
Do you have any idea what may have caused this?
After connect() is called on the socket that will be used for the liveness check, the socket file descriptor is added to an epoll descriptor. Occasionally (but frequently enough under load) epoll_wait() returns the following two events for the socket fd before the socket gets connected:
EPOLLIN|EPOLLHUP|EPOLLRDHUP
EPOLLIN|EPOLLRDHUP
These two events are always returned by the same call to epoll_wait() and always in this order (as witnessed by strace).
As a result of EPOLLHUP, _do_poll() in ev_epoll.c calls fd_update_events() with FD_EV_SHUT_W set. In the latter, the condition below the following comment:
/* SHUTW reported while FD was active for writes is an error */
is true, and FD_POLL_ERR gets set in fdtab[fd].state.
This flag never gets reset.
I have no idea what this EPOLLHUP means for a not-yet-connected socket, but there is a comment in sock_conn_check() in sock.c that suggest that this is to be expected. The comment is long and starts with:
/* Here we have 2 cases :
Since FD_POLL_ERR is set, connect() gets called again. It returns 0 and the connection to the backend is established.
The liveness check proceeds as usual - the request is sent and the response is received (as witnessed by strace).
Eventually, raw_sock_to_buf() in raw_sock.c gets called. Check its very end, after the label read0 and remember that FD_POLL_ERR is still set in fdtab[conn->handle.fd].state. conn->flags gets CO_FL_ERROR set and this leads to the liveness check to be considered as failed.
Do you have an idea how to solve the issue?
I suspect that the trouble comes from FD_POLL_ERR not being reset before the second call to connect(). If I put the following line:
fdtab[fd].state &= ~(FD_POLL_ERR|FD_POLL_HUP);
in sock_conn_check() after the following comment:
/* error present, fall through common error check path */
the issue seems to get solved.
So a few points on this. First, if you get EPOLLHUP, it means the server closed, possibly with a response but not necessarily. Since it happened in response to a connect() and the FD was being polled for writes, it indicates the connect() call didn't succeed so the request wasn't sent. As such the response you're getting there is not the response to the health check but a failure reported by the server (or the server just closed because there were too many connections for it).
Regardless, haproxy should not return a misleading error here. It should accurately report what happened (and possibly any pending response the server might have sent, which could contain precious information about what the server was not happy with).
Your deep analysis of the sequence is particularly useful because, as you saw, it shows we're abusing FD_POLL_ERR. But that one must not be cleared (though in your test that was useful to confirm the cause), because a socket cannot be only temporarily in error, once in error, it's dead.
For me the problem here is that POLL_HUP was mapped to FD_EV_SHUT_RW since commit 6b30898 ("MEDIUM: fd: do not use the FD_POLL_* flags in the pollers anymore") in 2.1, and I can't find any valid reason why. Oh yes I've just found the reason why. I have a length test log here from the same date as the commit showing that when RDHUP is reported, then HUP is reported only if the sending side was previously closed. Even if that's true in practice with data transfers, it's a bad idea to make this shortcut because as you've seen, connect() does poll for writes but doesn't try to transfer anything hence and doesn't have to suffer from this shortcut.
Individually, none of these operations are fundamentally wrong, as they help determine a maximum of information from what's reported by the poller. But I do think that we should not set POLL_ERR when the output is closed while it's being polled for writes. The first reason is that this is a sticky flag and that it was not really reported by the lower layer here so we can be wrong (as was the case here), the second reason is that if it's reported we can expect getsockopt(SO_ERROR) to return an error and here it's not the case. As such, I think that the best solution is to undo a part of commit 2aaeee3, that is, to remove the assignment of FD_POLL_ERR in fd_update_events() below the comment saying "SHUTW reported while FD was active for writes is an error", because this is the one that guesses wrong here. Anyway its sole purpose was to avoid a few send() errors on aborted connections, but these are totally marginal. And if we decided that we needed to reintroduce it, then we'd adopt distinct poll-for-writes mechanisms, with one indicating we're polling for a connect() instead.
As such I would appreciate it if you could test with this:
--- a/src/fd.c
+++ b/src/fd.c
@@ -587,10 +587,6 @@ int fd_update_events(int fd, uint evts)
((evts & FD_EV_SHUT_R) ? FD_POLL_HUP : 0) |
((evts & FD_EV_ERR_RW) ? FD_POLL_ERR : 0);
- /* SHUTW reported while FD was active for writes is an error */
- if ((fdtab[fd].state & FD_EV_ACTIVE_W) && (evts & FD_EV_SHUT_W))
- new_flags |= FD_POLL_ERR;
-
/* compute the inactive events reported late that must be stopped */
must_stop = 0;
if (unlikely(!fd_active(fd))) {
Thank you for the response and sorry for taking me so long to test your fix proposal.
I forgot to mention, but "Socket error" is not the only reason I see. There is one more that is very frequent: reason: Layer7 invalid response
Your fix proposal does not fix the "Layer7 invalid response" reason. It fixes only the "Socket error" reason.
My pseudo-fix fixes both.
I sometimes get "Layer6 invalid response, info: SSL handshake failure (various errors here)" even with my pseudo-fix. However, these might be real - they are very infrequent and the way I reproduce the situation loads the CPU of the VM haproxy is running on. I am running haproxy itself and 10 concurrent curl's accessing a backend through haproxy at localhost.
Interesting. That's stranger then because it would indicate that either the error is set at another point, and/or that there are incorrect checks at some point. The problem with clearing the error is that it will be lost and may seriously result in stuck connections or such situations where an error event is lost. Errors should never be cleared. I'll have a check at sock_conn_check(), maybe it sets the error while there are still pending data and it should not do it.
I think I can explain this situation, too. Mind you, I haven't got the time to check the behaviour at runtime, I just analyzed the source. Here is a modification of the original description for the "Layer7 invalid response" case:
Unlike in the original description, let's assume that we receive only the following event:
EPOLLIN|EPOLLRDHUP
(and that we do not receive the one with EPOLLHUP).
As a result of EPOLLRDHUP, _do_poll() in ev_epoll.c calls fd_update_events() with FD_EV_SHUT_R set and FD_EV_SHUT_W unset. In the latter, FD_POLL_HUP gets set in fdtab[fd].state but FD_POLL_ERR stays unset.
In sock_conn_check() in sock.c we reach the comment:
/* error present, fall through common error check path */
(i.e., just like in the original description - we go to wait only if both FD_POLL_HUP and FD_POLL_ERR are unset).
This explains why my pseudo-fix works for this case, too.
...
raw_sock_to_buf() in raw_sock.c gets called.
fdtab[conn->handle.fd].state & (FD_POLL_ERR|FD_POLL_HUP)) == FD_POLL_HUP is true upon entry to the function (unlike in the original description) and we go to read0. The response from the backend is never read and haproxy assumes an application level error.
changed the title [-]EPOLLHUP during connection establishment leads to a wrong decision that a backend is down[/-][+]EPOLLHUP or EPOLLRDHUP during connection establishment leads to a wrong decision that a backend is down[/+]on Sep 27, 2022
I forgot that there's this dirty special case for HUP as well, so it's not just a matter of disabling RDHUP support :-( For this one I'm not sure how to proceed since it will affect all platforms, some more thinking is deserved. Maybe we'll end up with a configurable mask but I find this ugly :-/
Activity
wtarreau commentedon Sep 16, 2022
Thanks for your report, will have a look at it ASAP, sorry for the delay.
wtarreau commentedon Sep 17, 2022
So a few points on this. First, if you get EPOLLHUP, it means the server closed, possibly with a response but not necessarily. Since it happened in response to a connect() and the FD was being polled for writes, it indicates the connect() call didn't succeed so the request wasn't sent. As such the response you're getting there is not the response to the health check but a failure reported by the server (or the server just closed because there were too many connections for it).
Regardless, haproxy should not return a misleading error here. It should accurately report what happened (and possibly any pending response the server might have sent, which could contain precious information about what the server was not happy with).
Your deep analysis of the sequence is particularly useful because, as you saw, it shows we're abusing FD_POLL_ERR. But that one must not be cleared (though in your test that was useful to confirm the cause), because a socket cannot be only temporarily in error, once in error, it's dead.
For me the problem here is that POLL_HUP was mapped to FD_EV_SHUT_RW since commit 6b30898 ("MEDIUM: fd: do not use the FD_POLL_* flags in the pollers anymore") in 2.1, and I can't find any valid reason why. Oh yes I've just found the reason why. I have a length test log here from the same date as the commit showing that when RDHUP is reported, then HUP is reported only if the sending side was previously closed. Even if that's true in practice with data transfers, it's a bad idea to make this shortcut because as you've seen, connect() does poll for writes but doesn't try to transfer anything hence and doesn't have to suffer from this shortcut.
Individually, none of these operations are fundamentally wrong, as they help determine a maximum of information from what's reported by the poller. But I do think that we should not set POLL_ERR when the output is closed while it's being polled for writes. The first reason is that this is a sticky flag and that it was not really reported by the lower layer here so we can be wrong (as was the case here), the second reason is that if it's reported we can expect getsockopt(SO_ERROR) to return an error and here it's not the case. As such, I think that the best solution is to undo a part of commit 2aaeee3, that is, to remove the assignment of FD_POLL_ERR in fd_update_events() below the comment saying "SHUTW reported while FD was active for writes is an error", because this is the one that guesses wrong here. Anyway its sole purpose was to avoid a few send() errors on aborted connections, but these are totally marginal. And if we decided that we needed to reintroduce it, then we'd adopt distinct poll-for-writes mechanisms, with one indicating we're polling for a connect() instead.
As such I would appreciate it if you could test with this:
Thank you!
gbrdead commentedon Sep 27, 2022
Thank you for the response and sorry for taking me so long to test your fix proposal.
I forgot to mention, but "Socket error" is not the only reason I see. There is one more that is very frequent:
reason: Layer7 invalid response
Your fix proposal does not fix the "Layer7 invalid response" reason. It fixes only the "Socket error" reason.
My pseudo-fix fixes both.
I sometimes get "Layer6 invalid response, info: SSL handshake failure (various errors here)" even with my pseudo-fix. However, these might be real - they are very infrequent and the way I reproduce the situation loads the CPU of the VM haproxy is running on. I am running haproxy itself and 10 concurrent curl's accessing a backend through haproxy at localhost.
wtarreau commentedon Sep 27, 2022
Interesting. That's stranger then because it would indicate that either the error is set at another point, and/or that there are incorrect checks at some point. The problem with clearing the error is that it will be lost and may seriously result in stuck connections or such situations where an error event is lost. Errors should never be cleared. I'll have a check at sock_conn_check(), maybe it sets the error while there are still pending data and it should not do it.
gbrdead commentedon Sep 27, 2022
I think I can explain this situation, too. Mind you, I haven't got the time to check the behaviour at runtime, I just analyzed the source. Here is a modification of the original description for the "Layer7 invalid response" case:
Unlike in the original description, let's assume that we receive only the following event:
EPOLLIN|EPOLLRDHUP
(and that we do not receive the one with EPOLLHUP).
As a result of EPOLLRDHUP, _do_poll() in ev_epoll.c calls fd_update_events() with FD_EV_SHUT_R set and FD_EV_SHUT_W unset. In the latter, FD_POLL_HUP gets set in fdtab[fd].state but FD_POLL_ERR stays unset.
In sock_conn_check() in sock.c we reach the comment:
/* error present, fall through common error check path */
(i.e., just like in the original description - we go to wait only if both FD_POLL_HUP and FD_POLL_ERR are unset).
This explains why my pseudo-fix works for this case, too.
...
fdtab[conn->handle.fd].state & (FD_POLL_ERR|FD_POLL_HUP)) == FD_POLL_HUP is true upon entry to the function (unlike in the original description) and we go to read0. The response from the backend is never read and haproxy assumes an application level error.
[-]EPOLLHUP during connection establishment leads to a wrong decision that a backend is down[/-][+]EPOLLHUP or EPOLLRDHUP during connection establishment leads to a wrong decision that a backend is down[/+]56 remaining items
wtarreau commentedon Apr 19, 2024
I forgot that there's this dirty special case for HUP as well, so it's not just a matter of disabling RDHUP support :-( For this one I'm not sure how to proceed since it will affect all platforms, some more thinking is deserved. Maybe we'll end up with a configurable mask but I find this ugly :-/
capflam commentedon Nov 5, 2024
A gentle ping to make the issue more visible :)
BUG/MEDIUM: sock: Remove FD_POLL_HUP during connect() if FD_POLL_ERR …
capflam commentedon Nov 27, 2024
FYI, A fix was finally pushed. It was backported as far as 2.8 for now. But it should be backported as far as 2.4.
BUG/MEDIUM: sock: Remove FD_POLL_HUP during connect() if FD_POLL_ERR …
BUG/MEDIUM: sock: Remove FD_POLL_HUP during connect() if FD_POLL_ERR …
BUG/MEDIUM: sock: Remove FD_POLL_HUP during connect() if FD_POLL_ERR …
BUG/MEDIUM: sock: Remove FD_POLL_HUP during connect() if FD_POLL_ERR …