Skip to content

Fail connectSSL() when the handshake times out - #5485

Open
luohaha wants to merge 1 commit into
pocoproject:mainfrom
luohaha:fix-connectssl-handshake-timeout
Open

luohaha wants to merge 1 commit into
pocoproject:mainfrom
luohaha:fix-connectssl-handshake-timeout

Conversation

@luohaha

@luohaha luohaha commented Sep 18, 2026

Copy link
Copy Markdown

Problem

SecureSocketImpl::connectSSL() is the only one of the four SSL_* call sites in this file that does not carry a deadline. It calls SSL_connect once and passes the result to handleError():

if (performHandshake && _pSocket->getBlocking())
{
    int ret = ::SSL_connect(_pSSL);
    handleError(ret);
    verifyPeerCertificate();
}

handleError() maps SSL_ERROR_WANT_READ to the ERR_SSL_WANT_READ return code rather than throwing, and connectSSL() discards that return value and proceeds to verifyPeerCertificate(). On a blocking socket an expired SO_RCVTIMEO surfaces as SSL_ERROR_WANT_READ — OpenSSL's socket BIO sets its retry flags on EAGAIN — so a handshake that consumed its entire timeout without receiving a ServerHello is reported to the caller as a successful connect().

The failure is then deferred into the first request on that session, which re-enters the handshake from SSL_write and spends a second timeout there. Against a peer that completes the TCP handshake and never answers — the half-open state a NAT leaves behind when it forgets a flow — one request costs connect_timeout + receive_timeout instead of connect_timeout.

sendBytes(), receiveBytes() and completeHandshake() were all given a tsStart.isElapsed(...) deadline; connectSSL() was missed.

Change

Give connectSSL() the same loop shape completeHandshake() already uses. The handshake in connectSSL() runs with the socket timeout temporarily set to the connect timeout by SecureSocketImpl::connect(address, timeout, performHandshake), so getReceiveTimeout() is the right bound here, exactly as in completeHandshake().

Non-blocking sockets are untouched: the branch is already guarded by _pSocket->getBlocking().

Measured

Built from this branch, HTTPSClientSession against a listener that accepts the connection and never writes, 1 s connect timeout and 5 s send/receive timeout:

one stalled connection
before 6.61 s
after 1.16 s

Both raise Poco::TimeoutException; the difference is that the handshake timeout is now recognised where it happens rather than being paid again by the first request.

Test

HTTPSClientSessionTest::testStalledPeerTimeout uses the test suite's own DialogServer, which accepts the connection and — with no response queued — never writes, so the handshake gets no ServerHello. It asserts Poco::TimeoutException and that the failure arrives inside connection_timeout + receive_timeout.

Verified both ways on Linux with NetSSL-testrunner HTTPSClientSessionTest.testStalledPeerTimeout: it passes with this change and fails without it, on the elapsed-time assertion — unpatched also raises TimeoutException, so the timing is what discriminates.

with:     HTTPSClientSessionTest::testStalledPeerTimeout:  OK (1 tests)
without:  HTTPSClientSessionTest::testStalledPeerTimeout:  FAILURE
          "tsStart.elapsed() < (connectTimeout + requestTimeout).totalMicroseconds()"

Unrelated observation

While measuring the above: Timestamp::isElapsed(interval) is diff >= interval, so isElapsed(0) is always true. In the deadline loops added to sendBytes/receiveBytes/completeHandshake, a socket whose timeout is zero — which usually means no timeout — therefore throws Poco::TimeoutException on the very first retry. Not addressed here, but it may be worth a look.

🤖 Generated with Claude Code

@matejk matejk added the bug label Sep 24, 2026
@matejk matejk added this to the Release 2.0 milestone Sep 24, 2026
@matejk
matejk force-pushed the fix-connectssl-handshake-timeout branch from d11d958 to 688b131 Compare September 24, 2026 10:45
connectSSL() is the only one of the four SSL call sites in SecureSocketImpl
without a deadline: it calls SSL_connect once and passes the result to
handleError(), which maps SSL_ERROR_WANT_READ to the ERR_SSL_WANT_READ return
code rather than throwing. connectSSL() discards that value and goes on to
verifyPeerCertificate(), so connect() reports success on a session whose
handshake never completed.

On a blocking socket an expired SO_RCVTIMEO surfaces as SSL_ERROR_WANT_READ,
because the socket BIO sets its retry flags on EAGAIN. Against a peer that
completes the TCP handshake and never answers, the handshake therefore spends
its whole timeout unnoticed and the first request on the session re-enters it
and spends another one: connect_timeout + receive_timeout to fail a request
the caller asked to bound at one of them.

Give connectSSL() the loop that completeHandshake() already uses. The
handshake here runs with the socket timeout set to the connection timeout by
SecureSocketImpl::connect(address, timeout, performHandshake), so
getReceiveTimeout() is the right bound, as it is in completeHandshake().
Non-blocking sockets are unaffected; the branch is already guarded by
getBlocking().

Measured with HTTPSClientSession against a listener that accepts and never
writes, 1 s connection timeout and 5 s send/receive timeout: 6.61 s before,
1.16 s after. testStalledPeerTimeout covers it and fails without the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@matejk
matejk force-pushed the fix-connectssl-handshake-timeout branch from 688b131 to 460e2ab Compare September 24, 2026 10:47

@matejk matejk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I rebased the branch onto main to resolve a conflict; please pull before further changes.

Please restore the send and receive timeouts in SecureSocketImpl::connect(address, timeout, performHandshake) when connectSSL() throws: with the new TimeoutException, the socket otherwise keeps the connect timeout.

}



Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two blank lines between functions, as elsewhere in the file.

Suggested change

}
assertTrue (tsStart.elapsed() < (connectTimeout + requestTimeout).totalMicroseconds());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

Suggested change

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants