Skip to content

Do not leak TLSUpgradeProto.on_data when an SSL connect is cancelled - #1406

Draft
jmeiss wants to merge 1 commit into
MagicStack:masterfrom
jmeiss:fix-cancelled-ssl-upgrade
Draft

jmeiss wants to merge 1 commit into
MagicStack:masterfrom
jmeiss:fix-cancelled-ssl-upgrade

Conversation

@jmeiss

@jmeiss jmeiss commented Nov 11, 2026

Copy link
Copy Markdown

When a connect using sslnegotiation=postgres (the default with an ssl context) is cancelled while loop.create_connection() waits for its transport, the transport is closed and TLSUpgradeProto.connection_lost() then sets on_data with ConnectionError('unexpected connection_lost() call'). Nobody awaits that future, so asyncio logs:

Future exception was never retrieved
future: <Future finished exception=ConnectionError('unexpected connection_lost() call')>

The cancellation can come from the connect's own timeout or from a caller's asyncio.wait_for. We see it in production from a readiness probe whose connect to a slow server outlives its timeout. The same leak happens when the cancellation arrives after on_data already got its exception (the task is cancelled before it reads the result).

__connect_addr() already handles this for its connected future. This change does the same for the SSL upgrade:

  • _create_ssl_connection() keeps a reference to the TLSUpgradeProto it creates, and discards on_data when create_connection() fails or is cancelled.
  • It also discards on_data when awaiting it fails, so an exception set in the same loop iteration as the cancellation is retrieved.
  • A small _discard_future() helper (cancel if pending, otherwise retrieve the exception) is shared with the existing connected handling.

The new test TestConnectionCleanup.test_cancelled_ssl_upgrade_leaves_no_orphan_future needs no cluster. It cancels a connect to a local server that never answers at each event-loop step, with direct_tls off and on, and asserts that the loop's exception handler was never called. Without the fix it fails with the message above; with it, it passes. tests/test__sourcecode.py (flake8, mypy) passes.

🤖 Generated with Claude Code

When a connect with sslnegotiation=postgres is cancelled (for example
by the connect timeout) while loop.create_connection() waits for its
transport, the transport is closed and connection_lost() later sets
TLSUpgradeProto.on_data with ConnectionError('unexpected connection_lost()
call').  Nobody awaits that future, so asyncio logs "Future exception
was never retrieved".  The same happens when the cancellation arrives
after on_data already got its exception.

Keep a reference to the upgrade protocol and discard its future on
failure, the way __connect_addr() already does for 'connected'.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant