Skip to content

fix(psycopg): prevent proxy deadlock on TLS 1.3 post-handshake messages - #1494

Merged
kgala2 merged 1 commit into
mainfrom
fix-psycopg-tls13-want-read
Oct 14, 2026
Merged

kgala2 merged 1 commit into
mainfrom
fix-psycopg-tls13-want-read

Conversation

@kgala2

@kgala2 kgala2 commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes intermittent psycopg.errors.ConnectionTimeout: connection timeout expired failures in CI (particularly on windows-latest and macos-latest, e.g. in test_lazy_psycopg_connection, test_system_psycopg_to_thread, and test_psycopg_connection).

Root Cause

In TLS 1.3 (RFC 8446 §4.6.1), SSLContext.wrap_socket() completes immediately after the client sends ClientFinished, and the Cloud SQL Server Proxy then asynchronously sends a post-handshake NewSessionTicket TLS record.

When that NewSessionTicket arrives in remote's OS socket receive buffer before psycopg writes its PostgreSQL StartupMessage to local (which happens frequently on Windows where _PsycopgProxy uses a local TCP loopback socket 127.0.0.1:<port>), sel.select() in _proxy() wakes up with EVENT_READ on remote and calls remote.recv(8192) on the blocking ssl.SSLSocket. OpenSSL consumes the NewSessionTicket record internally, sees 0 bytes of decrypted application data, and blocks indefinitely inside SSL_read waiting for PostgreSQL to send data first—deadlocking the single-threaded proxy loop so psycopg's StartupMessage on local is never forwarded.

Fix

  • Add nonblocking: bool = False to _forward_bytes() and pass nonblocking=True when reading from remote on sel.select() wakeups in _proxy().
  • Temporarily set src.setblocking(False) around src.recv(_BUFFER_SIZE) (restoring src.setblocking(True) in finally with contextlib.suppress(OSError)) and return True on (ssl.SSLWantReadError, BlockingIOError) so the selector loop continues without blocking or closing the connection.
  • Add unit test test_proxy_tls13_post_handshake_want_read covering this scenario.

In TLS 1.3 (RFC 8446 Section 4.6.1), the server sends a post-handshake NewSessionTicket record asynchronously after the handshake completes. When this record arrives on the remote SSLSocket before psycopg writes its PostgreSQL StartupMessage to the local socket (or during an idle window between queries), selectors.DefaultSelector() wakes up with EVENT_READ on remote. Calling recv() on a blocking SSLSocket consumes the NewSessionTicket internally and then blocks indefinitely inside SSL_read waiting for application data from the server, deadlocking the single-threaded proxy until psycopg's 30s connect_timeout expires.

Temporarily put the remote socket in non-blocking mode during selector-driven recv() calls and return True on SSLWantReadError/BlockingIOError so the selector loop continues without blocking.
@kgala2
kgala2 requested a review from a team as a code owner October 14, 2026 00:21
@kgala2
kgala2 merged commit d3eef24 into main Oct 14, 2026
24 of 26 checks passed
@kgala2
kgala2 deleted the fix-psycopg-tls13-want-read branch October 14, 2026 20:06
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.

3 participants