1616from unittest .mock import MagicMock , patch
1717
1818from . 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
2020from .ws_client import websocket_proxycare
2121from .ws_client import STDOUT_CHANNEL
2222from kubernetes .client .configuration import Configuration
2323import os
24+ import select
2425import socket
2526import threading
2627import pytest
@@ -128,6 +129,139 @@ def test_websocket_proxycare(self):
128129 assert dictval (connect_opts , 'http_no_proxy' ) == expect_noproxy
129130
130131
132+ class WSClientMultiFrameReadTest (unittest .TestCase ):
133+ """Tests that reads spanning multiple websocket frames are not
134+ truncated at a frame boundary (issue #2226)"""
135+
136+ def setUp (self ):
137+ # Mock configuration to avoid real connections in WSClient.__init__
138+ self .config_mock = MagicMock ()
139+ self .config_mock .assert_hostname = False
140+ self .config_mock .api_key = {}
141+ self .config_mock .proxy = None
142+ self .config_mock .ssl_ca_cert = None
143+ self .config_mock .cert_file = None
144+ self .config_mock .key_file = None
145+ self .config_mock .verify_ssl = True
146+
147+ def _make_client (self , mock_ws ):
148+ with patch .object (ws_client_module , 'create_websocket' ) as mock_create :
149+ mock_create .return_value = mock_ws
150+ return WSClient (self .config_mock , "wss://test" , headers = None ,
151+ capture_all = True )
152+
153+ def test_read_stdout_returns_all_available_frames (self ):
154+ """Verify a single peek/read returns output spanning several frames.
155+
156+ The server sends long exec stdout as a sequence of websocket
157+ messages (32768 bytes each). Reading only one frame per update()
158+ truncated the output at a frame boundary for callers using the
159+ peek_stdout()/read_stdout() pattern."""
160+ frame_payload = 32768
161+ total = 70000
162+ payload = b'x' * total
163+
164+ mock_ws = MagicMock ()
165+ mock_ws .subprotocol = V5_CHANNEL_PROTOCOL
166+ mock_ws .connected = True
167+ client = self ._make_client (mock_ws )
168+
169+ server_sock , client_sock = socket .socketpair ()
170+ try :
171+ # Make sure the whole simulated server output fits in the
172+ # socket buffers so sendall() below cannot block.
173+ client_sock .setsockopt (socket .SOL_SOCKET , socket .SO_RCVBUF ,
174+ 4 * frame_payload )
175+ server_sock .setsockopt (socket .SOL_SOCKET , socket .SO_SNDBUF ,
176+ 4 * frame_payload )
177+
178+ ws = websocket .WebSocket ()
179+ ws .sock = client_sock
180+ ws .connected = True
181+ client .sock = ws
182+
183+ # The stdout messages (server frames are unmasked), followed by
184+ # a close frame, just like an exec of `cat <long file>`.
185+ frames = b''
186+ for offset in range (0 , total , frame_payload ):
187+ chunk = (bytes ([STDOUT_CHANNEL ])
188+ + payload [offset :offset + frame_payload ])
189+ frames += websocket .ABNF (
190+ 1 , 0 , 0 , 0 , websocket .ABNF .OPCODE_BINARY , 0 ,
191+ chunk ).format ()
192+ frames += websocket .ABNF (
193+ 1 , 0 , 0 , 0 , websocket .ABNF .OPCODE_CLOSE , 0 ,
194+ b'\x03 \xe8 ' ).format ()
195+ server_sock .sendall (frames )
196+
197+ out = ''
198+ if client .peek_stdout (timeout = 5 ):
199+ out = client .read_stdout ()
200+
201+ self .assertEqual (len (out ), total )
202+ # The close frame was consumed as well
203+ self .assertFalse (client .is_open ())
204+ finally :
205+ server_sock .close ()
206+ client_sock .close ()
207+
208+ def test_update_consumes_all_immediately_available_frames (self ):
209+ """Verify update() drains every frame that is already readable"""
210+ with patch ('select.poll' ) as mock_poll , \
211+ patch ('select.select' ) as mock_select :
212+ # The socket reports readable twice, then has no more data.
213+ mock_poll .return_value .poll .side_effect = [
214+ [(10 , select .POLLIN )], [(10 , select .POLLIN )], []]
215+ mock_select .side_effect = [
216+ ([10 ], [], []), ([10 ], [], []), ([], [], [])]
217+
218+ frame1 = MagicMock ()
219+ frame1 .data = bytes ([STDOUT_CHANNEL ]) + b'first'
220+ frame2 = MagicMock ()
221+ frame2 .data = bytes ([STDOUT_CHANNEL ]) + b'second'
222+
223+ mock_ws = MagicMock ()
224+ mock_ws .subprotocol = V5_CHANNEL_PROTOCOL
225+ mock_ws .connected = True
226+ mock_ws .recv_data_frame .side_effect = [
227+ (websocket .ABNF .OPCODE_BINARY , frame1 ),
228+ (websocket .ABNF .OPCODE_BINARY , frame2 )]
229+
230+ client = self ._make_client (mock_ws )
231+ client .update (timeout = 0 )
232+
233+ self .assertEqual (mock_ws .recv_data_frame .call_count , 2 )
234+ self .assertEqual (client ._channels .get (STDOUT_CHANNEL ),
235+ 'firstsecond' )
236+
237+ def test_update_stops_draining_on_close_frame (self ):
238+ """Verify the drain loop terminates when a close frame arrives"""
239+ with patch ('select.poll' ) as mock_poll , \
240+ patch ('select.select' ) as mock_select :
241+ # The socket always reports readable.
242+ mock_poll .return_value .poll .return_value = [(10 , select .POLLIN )]
243+ mock_select .return_value = ([10 ], [], [])
244+
245+ frame1 = MagicMock ()
246+ frame1 .data = bytes ([STDOUT_CHANNEL ]) + b'output'
247+ close_frame = MagicMock ()
248+ close_frame .data = b'\x03 \xe8 '
249+
250+ mock_ws = MagicMock ()
251+ mock_ws .subprotocol = V5_CHANNEL_PROTOCOL
252+ mock_ws .connected = True
253+ mock_ws .recv_data_frame .side_effect = [
254+ (websocket .ABNF .OPCODE_BINARY , frame1 ),
255+ (websocket .ABNF .OPCODE_CLOSE , close_frame )]
256+
257+ client = self ._make_client (mock_ws )
258+ client .update (timeout = 0 )
259+
260+ self .assertEqual (mock_ws .recv_data_frame .call_count , 2 )
261+ self .assertEqual (client ._channels .get (STDOUT_CHANNEL ), 'output' )
262+ self .assertFalse (client .is_open ())
263+
264+
131265class WSClientProtocolTest (unittest .TestCase ):
132266 """Tests for WSClient V5 protocol handling"""
133267
@@ -213,6 +347,7 @@ def test_update_receives_close_v5(self):
213347 def test_update_ignores_close_signal_v4 (self ):
214348 """Verify update treats 0xFF as regular data (or ignores signal interpretation) when v4"""
215349 with patch .object (ws_client_module , 'create_websocket' ) as mock_create , \
350+ patch ('select.poll' ) as mock_poll , \
216351 patch ('select.select' ) as mock_select :
217352
218353 mock_ws = MagicMock ()
@@ -227,7 +362,11 @@ def test_update_ignores_close_signal_v4(self):
227362 mock_ws .recv_data_frame .return_value = (websocket .ABNF .OPCODE_BINARY , frame )
228363
229364 mock_create .return_value = mock_ws
230- mock_select .return_value = ([mock_ws .sock ], [], [])
365+ # The frame is readable once, then there is no more data.
366+ mock_poll .return_value .poll .side_effect = [
367+ [(10 , select .POLLIN )], []]
368+ mock_select .side_effect = [
369+ ([mock_ws .sock ], [], []), ([], [], [])]
231370
232371 client = WSClient (self .config_mock , "ws://test" , headers = None , capture_all = True , binary = True ) # binary=True to avoid decode errors
233372 client .update (timeout = 0 )
0 commit comments