Skip to content

Commit bc0b96f

Browse files
committed
Fix WSClient.update dropping frames buffered in the SSL socket
WSClient.update() waits for the underlying socket to become readable via poll()/select() before reading a frame. SSL sockets decrypt an entire TLS record at a time, so when several websocket frames arrive in one TLS record, the first recv_data_frame() call consumes the whole record from the socket and the remaining frames sit decrypted inside the SSLSocket's internal buffer, where poll()/select() cannot see them. Those frames are only delivered once new data arrives on the connection and are lost if it never does, which randomly truncates large outputs read through the stream API. Check SSLSocket.pending() before polling, mirroring what PortForward._proxy() already does, so buffered frames are consumed without waiting for socket readability. The OPCODE_CONT handling suggested in the issue is not needed: websocket-client reassembles continuation frames inside recv_data_frame() and returns the opcode of the initial frame, so update() never observes OPCODE_CONT. A regression test documents that fragmented messages are delivered in full. Signed-off-by: Jojin <[email protected]>
1 parent 5583fb5 commit bc0b96f

2 files changed

Lines changed: 115 additions & 2 deletions

File tree

kubernetes/base/stream/ws_client.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,16 @@ def update(self, timeout=0):
205205
self._connected = False
206206
return
207207

208+
# SSL sockets decrypt an entire TLS record at a time, so a previous
209+
# recv_data_frame() call may have pulled the bytes of the next frames
210+
# off the socket already: those frames sit decrypted in the
211+
# SSLSocket's internal buffer, invisible to select()/poll() on the
212+
# underlying socket. Without this check the buffered frames would
213+
# only be delivered once new data arrives on the socket, and would be
214+
# lost if it never does.
215+
if (isinstance(self.sock.sock, ssl.SSLSocket)
216+
and self.sock.sock.pending()):
217+
r = True
208218
# The options here are:
209219
# select.select() - this will work on most OS, however, it has a
210220
# limitation of only able to read fd numbers up to 1024.
@@ -214,7 +224,7 @@ def update(self, timeout=0):
214224
# efficient as epoll. Will work for fd numbers above 1024.
215225
# select.epoll() - newest and most efficient way of polling.
216226
# However, only works on linux.
217-
if hasattr(select, "poll"):
227+
elif hasattr(select, "poll"):
218228
poll = select.poll()
219229
poll.register(self.sock.sock, select.POLLIN)
220230
if timeout is not None and timeout != float("inf"):

kubernetes/base/stream/ws_client_test.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@
1616
from unittest.mock import MagicMock, patch
1717

1818
from . import ws_client as ws_client_module
19-
from .ws_client import get_websocket_url, WSClient, V5_CHANNEL_PROTOCOL, V4_CHANNEL_PROTOCOL, CLOSE_CHANNEL, STDIN_CHANNEL
19+
from .ws_client import get_websocket_url, WSClient, V5_CHANNEL_PROTOCOL, V4_CHANNEL_PROTOCOL, CLOSE_CHANNEL, STDIN_CHANNEL, STDOUT_CHANNEL
2020
from .ws_client import websocket_proxycare
2121
from kubernetes.client.configuration import Configuration
2222
import os
2323
import socket
24+
import ssl
2425
import threading
2526
import pytest
2627
from kubernetes import stream, client, config
@@ -385,6 +386,108 @@ def test_readline_channel_returns_empty_bytes_on_expired_timeout(self):
385386
self.assertEqual(line, b"")
386387

387388

389+
class WSClientUpdateTest(unittest.TestCase):
390+
"""Tests for WSClient.update() frame consumption (issue #2375)"""
391+
392+
def setUp(self):
393+
# Mock configuration to avoid real connections in WSClient.__init__
394+
self.config_mock = MagicMock()
395+
self.config_mock.assert_hostname = False
396+
self.config_mock.api_key = {}
397+
self.config_mock.proxy = None
398+
self.config_mock.ssl_ca_cert = None
399+
self.config_mock.cert_file = None
400+
self.config_mock.key_file = None
401+
self.config_mock.verify_ssl = True
402+
403+
def _make_client(self, mock_ws):
404+
with patch.object(ws_client_module, 'create_websocket') as mock_create:
405+
mock_create.return_value = mock_ws
406+
return WSClient(self.config_mock, "wss://test", headers=None,
407+
capture_all=True, binary=True)
408+
409+
def test_update_reads_frames_pending_in_ssl_buffer(self):
410+
"""Verify update reads a frame buffered inside the SSL socket even
411+
when the underlying socket does not report as readable.
412+
413+
SSL sockets decrypt a whole TLS record at a time, so frames that
414+
share a TLS record with a previously read frame sit decrypted in
415+
the SSLSocket's buffer where select()/poll() cannot see them."""
416+
with patch('select.poll') as mock_poll, \
417+
patch('select.select') as mock_select:
418+
# Nothing is readable on the underlying socket.
419+
mock_poll.return_value.poll.return_value = []
420+
mock_select.return_value = ([], [], [])
421+
422+
mock_ws = MagicMock()
423+
mock_ws.subprotocol = V4_CHANNEL_PROTOCOL
424+
mock_ws.connected = True
425+
# A decrypted frame is waiting inside the SSL socket.
426+
mock_ws.sock = MagicMock(spec=ssl.SSLSocket)
427+
mock_ws.sock.pending.return_value = 6
428+
frame = MagicMock()
429+
frame.data = bytes([STDOUT_CHANNEL]) + b'hello'
430+
mock_ws.recv_data_frame.return_value = (websocket.ABNF.OPCODE_BINARY, frame)
431+
432+
client = self._make_client(mock_ws)
433+
client.update(timeout=0)
434+
435+
self.assertEqual(client._channels.get(STDOUT_CHANNEL), b'hello')
436+
437+
def test_update_polls_when_no_ssl_data_pending(self):
438+
"""Verify update falls back to poll/select when the SSL socket has no
439+
buffered data"""
440+
with patch('select.poll') as mock_poll, \
441+
patch('select.select') as mock_select:
442+
mock_poll.return_value.poll.return_value = []
443+
mock_select.return_value = ([], [], [])
444+
445+
mock_ws = MagicMock()
446+
mock_ws.subprotocol = V4_CHANNEL_PROTOCOL
447+
mock_ws.connected = True
448+
mock_ws.sock = MagicMock(spec=ssl.SSLSocket)
449+
mock_ws.sock.pending.return_value = 0
450+
451+
client = self._make_client(mock_ws)
452+
client.update(timeout=0)
453+
454+
mock_ws.recv_data_frame.assert_not_called()
455+
456+
def test_update_receives_fragmented_message(self):
457+
"""Verify a message fragmented into continuation frames is delivered
458+
in full.
459+
460+
websocket-client reassembles continuation (OPCODE_CONT) frames inside
461+
recv_data_frame() and returns the opcode of the initial frame, so
462+
update() must buffer the complete message."""
463+
mock_ws = MagicMock()
464+
mock_ws.subprotocol = V4_CHANNEL_PROTOCOL
465+
mock_ws.connected = True
466+
client = self._make_client(mock_ws)
467+
468+
server_sock, client_sock = socket.socketpair()
469+
try:
470+
ws = websocket.WebSocket()
471+
ws.sock = client_sock
472+
ws.connected = True
473+
client.sock = ws
474+
475+
# A stdout message fragmented into an initial data frame (fin=0)
476+
# and a continuation frame (fin=1); server frames are unmasked.
477+
initial = websocket.ABNF(0, 0, 0, 0, websocket.ABNF.OPCODE_BINARY,
478+
0, bytes([STDOUT_CHANNEL]) + b'A' * 10)
479+
cont = websocket.ABNF(1, 0, 0, 0, websocket.ABNF.OPCODE_CONT,
480+
0, b'B' * 10)
481+
server_sock.sendall(initial.format() + cont.format())
482+
483+
client.update(timeout=5)
484+
485+
self.assertEqual(client._channels.get(STDOUT_CHANNEL),
486+
b'A' * 10 + b'B' * 10)
487+
finally:
488+
server_sock.close()
489+
client_sock.close()
490+
388491

389492
@pytest.fixture(scope="module")
390493
def dummy_proxy():

0 commit comments

Comments
 (0)