Skip to content

Commit aa9c8e2

Browse files
Merge pull request #2651 from jojinkb/fix-ws-stdout-truncation
Fix long exec stdout truncated at websocket frame boundaries
2 parents 4cf2c85 + 0613ebb commit aa9c8e2

2 files changed

Lines changed: 165 additions & 4 deletions

File tree

kubernetes/base/stream/ws_client.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,23 @@ def write_stdin(self, data):
197197
"""The same as write_channel with channel=0."""
198198
self.write_channel(STDIN_CHANNEL, data)
199199

200+
def _frames_immediately_available(self):
201+
"""Return True if at least one more frame is already waiting on
202+
the socket and can be read without blocking."""
203+
# Prefer poll over select for the same reasons as in update().
204+
if hasattr(select, "poll"):
205+
poll = select.poll()
206+
poll.register(self.sock.sock, select.POLLIN)
207+
r = poll.poll(0)
208+
poll.unregister(self.sock.sock)
209+
else:
210+
r, _, _ = select.select((self.sock.sock, ), (), (), 0)
211+
return bool(r)
212+
200213
def update(self, timeout=0):
201-
"""Update channel buffers with at most one complete frame of input."""
214+
"""Update channel buffers with all complete frames of input that
215+
are available, waiting at most `timeout` seconds for the first
216+
one."""
202217
if not self.is_open():
203218
return
204219
if not self.sock.connected:
@@ -231,7 +246,7 @@ def update(self, timeout=0):
231246
r, _, _ = select.select(
232247
(self.sock.sock, ), (), (), timeout)
233248

234-
if r:
249+
while r:
235250
op_code, frame = self.sock.recv_data_frame(True)
236251
if op_code == ABNF.OPCODE_CLOSE:
237252
self._connected = False
@@ -266,6 +281,13 @@ def update(self, timeout=0):
266281
else:
267282
self._channels[channel] += data
268283

284+
# Output larger than the websocket frame size (e.g. long exec
285+
# stdout) arrives as a sequence of frames. Consume every frame
286+
# that is already available before returning, otherwise callers
287+
# reading a channel after a single update() would see the
288+
# output truncated at a frame boundary.
289+
r = self._frames_immediately_available()
290+
269291
def run_forever(self, timeout=None):
270292
"""Wait till connection is closed or timeout reached. Buffer any input
271293
received during this time."""

kubernetes/base/stream/ws_client_test.py

Lines changed: 141 additions & 2 deletions
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 .ws_client import STDOUT_CHANNEL
2222
from kubernetes.client.configuration import Configuration
2323
import os
24+
import select
2425
import socket
2526
import threading
2627
import 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+
131265
class 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

Comments
 (0)