Skip to content

Commit fb6449f

Browse files
committed
fix: refactor psycopg proxy to be single-threaded selectors-based, resolving SSLSocket thread-safety deadlocks
1 parent bc20b34 commit fb6449f

1 file changed

Lines changed: 78 additions & 39 deletions

File tree

‎google/cloud/sql/connector/psycopg.py‎

Lines changed: 78 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import logging
1616
import os
17+
import selectors
1718
import socket
1819
import ssl
1920
import tempfile
@@ -25,48 +26,86 @@
2526

2627
logger = logging.getLogger(name=__name__)
2728

28-
_CHUNK_SIZE = 8 * 1024 # bytes per recv() call inside the proxy forwarding loop
29-
3029

3130
def _proxy(local: socket.socket, remote: "ssl.SSLSocket") -> None:
32-
"""Bidirectionally proxy bytes between a local Unix socket and a remote
33-
SSL socket.
34-
35-
Spawns one daemon thread for the remote→local direction and runs the
36-
local→remote direction in the calling thread. Blocks until the calling
37-
thread's direction reaches EOF or a socket error, at which point both
38-
sockets are closed so the other thread also unblocks and exits.
31+
"""Single-threaded selectors-based proxy to avoid SSLSocket thread-safety issues."""
32+
sel = selectors.DefaultSelector()
33+
sel.register(local, selectors.EVENT_READ, data="local")
34+
sel.register(remote, selectors.EVENT_READ, data="remote")
35+
36+
def forward_pending() -> bool:
37+
"""Read any pending decrypted data from SSL buffer and forward it.
38+
Returns True if EOF was reached or error occurred (should exit).
39+
"""
40+
while remote.pending() > 0:
41+
try:
42+
data = remote.recv(8192)
43+
except OSError as e:
44+
logger.debug("psycopg proxy: remote recv pending error: %s", e)
45+
return True
46+
if not data:
47+
logger.debug("psycopg proxy: remote pending EOF")
48+
return True
49+
try:
50+
local.sendall(data)
51+
except OSError as e:
52+
logger.debug("psycopg proxy: local send pending error: %s", e)
53+
return True
54+
return False
3955

40-
Args:
41-
local: The Unix domain socket connected to the database driver.
42-
remote: The SSL socket connected to the Cloud SQL proxy server.
43-
"""
44-
def forward(src: Any, dst: Any) -> None:
45-
buf = bytearray(_CHUNK_SIZE)
46-
view = memoryview(buf)
47-
try:
48-
while True:
49-
n = src.recv_into(view)
50-
if n == 0:
51-
logger.debug("psycopg proxy: EOF on %s, closing both sockets", src)
52-
break
53-
dst.sendall(view[:n])
54-
except (OSError, ssl.SSLError) as e:
55-
logger.debug("psycopg proxy: socket error on %s: %s", src, e)
56-
finally:
57-
# Close both ends so the sibling thread also unblocks.
58-
for s in (local, remote):
59-
try:
60-
s.shutdown(socket.SHUT_RDWR)
61-
except OSError:
62-
pass
63-
try:
64-
s.close()
65-
except OSError:
66-
pass
67-
68-
threading.Thread(target=forward, args=(remote, local), daemon=True).start()
69-
forward(local, remote) # run in calling thread rather than spawning a third
56+
try:
57+
while True:
58+
# First check if there is any pending data in SSL buffer
59+
if forward_pending():
60+
break
61+
62+
events = sel.select(timeout=30)
63+
if not events:
64+
logger.debug("psycopg proxy: inactivity timeout (30s)")
65+
break
66+
67+
for key, mask in events:
68+
if key.data == "local":
69+
try:
70+
data = local.recv(8192)
71+
except OSError as e:
72+
logger.debug("psycopg proxy: local recv error: %s", e)
73+
return
74+
if not data:
75+
logger.debug("psycopg proxy: local EOF")
76+
return
77+
try:
78+
remote.sendall(data)
79+
except OSError as e:
80+
logger.debug("psycopg proxy: remote send error: %s", e)
81+
return
82+
elif key.data == "remote":
83+
try:
84+
data = remote.recv(8192)
85+
except OSError as e:
86+
logger.debug("psycopg proxy: remote recv error: %s", e)
87+
return
88+
if not data:
89+
logger.debug("psycopg proxy: remote EOF")
90+
return
91+
try:
92+
local.sendall(data)
93+
except OSError as e:
94+
logger.debug("psycopg proxy: local send error: %s", e)
95+
return
96+
except OSError as e:
97+
logger.debug("psycopg proxy: OSError in loop: %s", e)
98+
finally:
99+
sel.close()
100+
for s in (local, remote):
101+
try:
102+
s.shutdown(socket.SHUT_RDWR)
103+
except OSError:
104+
pass
105+
try:
106+
s.close()
107+
except OSError:
108+
pass
70109

71110

72111
def connect(

0 commit comments

Comments
 (0)