Skip to content

Commit 5970384

Browse files
committed
Fix python_server.py infinite loop on EOF (fixes #25620)
When VS Code closes, the STDIN stream closes and readline() returns empty bytes (b''). Previously this was incorrectly treated as an empty line separator, causing an infinite loop with 100% CPU usage. This fix: - Detects EOF in get_headers() by checking for b'' and raising EOFError - Handles EOFError in all three places that call get_headers(): - The main loop - handle_response() - custom_input() - Exits gracefully with sys.exit(0) when EOF is detected The key insight is distinguishing between: - EOF: readline() returns b'' (empty bytes) - Empty line: readline() returns b'\r\n' or b'\n' (newline bytes) Also added comprehensive unit tests to verify the fix.
1 parent c8b6f50 commit 5970384

File tree

2 files changed

+180
-7
lines changed

2 files changed

+180
-7
lines changed

python_files/python_server.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ def custom_input(prompt=""):
6464
message_text = STDIN.buffer.read(content_length).decode()
6565
message_json = json.loads(message_text)
6666
return message_json["result"]["userInput"]
67+
except EOFError:
68+
# Input stream closed, exit gracefully
69+
sys.exit(0)
6770
except Exception:
6871
print_log(traceback.format_exc())
6972

@@ -74,7 +77,7 @@ def custom_input(prompt=""):
7477

7578

7679
def handle_response(request_id):
77-
while not STDIN.closed:
80+
while True:
7881
try:
7982
headers = get_headers()
8083
# Content-Length is the data size in bytes.
@@ -88,8 +91,10 @@ def handle_response(request_id):
8891
send_response(our_user_input, message_json["id"])
8992
elif message_json["method"] == "exit":
9093
sys.exit(0)
91-
92-
except Exception: # noqa: PERF203
94+
except EOFError: # noqa: PERF203
95+
# Input stream closed, exit gracefully
96+
sys.exit(0)
97+
except Exception:
9398
print_log(traceback.format_exc())
9499

95100

@@ -164,7 +169,11 @@ def get_value(self) -> str:
164169
def get_headers():
165170
headers = {}
166171
while True:
167-
line = STDIN.buffer.readline().decode().strip()
172+
raw = STDIN.buffer.readline()
173+
# Detect EOF: readline() returns empty bytes when input stream is closed
174+
if raw == b"":
175+
raise EOFError("EOF reached while reading headers")
176+
line = raw.decode().strip()
168177
if not line:
169178
break
170179
name, value = line.split(":", 1)
@@ -183,7 +192,7 @@ def get_headers():
183192
while "" in sys.path:
184193
sys.path.remove("")
185194
sys.path.insert(0, "")
186-
while not STDIN.closed:
195+
while True:
187196
try:
188197
headers = get_headers()
189198
# Content-Length is the data size in bytes.
@@ -198,6 +207,8 @@ def get_headers():
198207
check_valid_command(request_json)
199208
elif request_json["method"] == "exit":
200209
sys.exit(0)
201-
202-
except Exception: # noqa: PERF203
210+
except EOFError: # noqa: PERF203
211+
# Input stream closed (VS Code terminated), exit gracefully
212+
sys.exit(0)
213+
except Exception:
203214
print_log(traceback.format_exc())
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
"""Tests for python_server.py, specifically EOF handling to prevent infinite loops."""
5+
6+
import io
7+
from unittest import mock
8+
9+
import pytest
10+
11+
12+
class TestGetHeaders:
13+
"""Tests for the get_headers function."""
14+
15+
def test_get_headers_normal(self):
16+
"""Test get_headers with valid headers."""
17+
# Arrange: Import the module
18+
import python_server
19+
20+
# Create a mock stdin with valid headers
21+
mock_input = b"Content-Length: 100\r\nContent-Type: application/json\r\n\r\n"
22+
mock_stdin = io.BytesIO(mock_input)
23+
24+
# Act
25+
with mock.patch.object(python_server, "STDIN", mock.Mock(buffer=mock_stdin)):
26+
headers = python_server.get_headers()
27+
28+
# Assert
29+
assert headers == {"Content-Length": "100", "Content-Type": "application/json"}
30+
31+
def test_get_headers_eof_raises_error(self):
32+
"""Test that get_headers raises EOFError when stdin is closed (EOF)."""
33+
# Arrange: Import the module
34+
import python_server
35+
36+
# Create a mock stdin that returns empty bytes (EOF)
37+
mock_stdin = io.BytesIO(b"")
38+
39+
# Act & Assert
40+
with mock.patch.object(python_server, "STDIN", mock.Mock(buffer=mock_stdin)), pytest.raises(
41+
EOFError, match="EOF reached while reading headers"
42+
):
43+
python_server.get_headers()
44+
45+
def test_get_headers_eof_mid_headers_raises_error(self):
46+
"""Test that get_headers raises EOFError when EOF occurs mid-headers."""
47+
# Arrange: Import the module
48+
import python_server
49+
50+
# Create a mock stdin with partial headers then EOF
51+
mock_input = b"Content-Length: 100\r\n" # No terminating empty line
52+
mock_stdin = io.BytesIO(mock_input)
53+
54+
# Act & Assert
55+
with mock.patch.object(python_server, "STDIN", mock.Mock(buffer=mock_stdin)), pytest.raises(
56+
EOFError, match="EOF reached while reading headers"
57+
):
58+
python_server.get_headers()
59+
60+
def test_get_headers_empty_line_terminates(self):
61+
"""Test that an empty line (not EOF) properly terminates header reading."""
62+
# Arrange: Import the module
63+
import python_server
64+
65+
# Create a mock stdin with headers followed by empty line
66+
mock_input = b"Content-Length: 50\r\n\r\nsome body content"
67+
mock_stdin = io.BytesIO(mock_input)
68+
69+
# Act
70+
with mock.patch.object(python_server, "STDIN", mock.Mock(buffer=mock_stdin)):
71+
headers = python_server.get_headers()
72+
73+
# Assert
74+
assert headers == {"Content-Length": "50"}
75+
76+
77+
class TestEOFHandling:
78+
"""Tests for EOF handling in various functions that use get_headers."""
79+
80+
def test_custom_input_exits_on_eof(self):
81+
"""Test that custom_input exits gracefully on EOF."""
82+
# Arrange: Import the module
83+
import python_server
84+
85+
# Create a mock stdin that returns empty bytes (EOF)
86+
mock_stdin = io.BytesIO(b"")
87+
mock_stdout = io.BytesIO()
88+
89+
# Act & Assert
90+
with mock.patch.object(
91+
python_server, "STDIN", mock.Mock(buffer=mock_stdin)
92+
), mock.patch.object(python_server, "STDOUT", mock.Mock(buffer=mock_stdout)), pytest.raises(
93+
SystemExit
94+
) as exc_info:
95+
python_server.custom_input("prompt> ")
96+
97+
# Should exit with code 0 (graceful exit)
98+
assert exc_info.value.code == 0
99+
100+
def test_handle_response_exits_on_eof(self):
101+
"""Test that handle_response exits gracefully on EOF."""
102+
# Arrange: Import the module
103+
import python_server
104+
105+
# Create a mock stdin that returns empty bytes (EOF)
106+
mock_stdin = io.BytesIO(b"")
107+
108+
# Act & Assert
109+
with mock.patch.object(python_server, "STDIN", mock.Mock(buffer=mock_stdin)), pytest.raises(
110+
SystemExit
111+
) as exc_info:
112+
python_server.handle_response("test-request-id")
113+
114+
# Should exit with code 0 (graceful exit)
115+
assert exc_info.value.code == 0
116+
117+
118+
class TestMainLoopEOFHandling:
119+
"""Tests that simulate the main loop EOF scenario."""
120+
121+
def test_main_loop_exits_on_eof(self):
122+
"""Test that the main loop pattern exits gracefully on EOF.
123+
124+
This test verifies the fix for GitHub issue #25620 where the server
125+
would spin at 100% CPU instead of exiting when VS Code closes.
126+
"""
127+
# Arrange: Import the module
128+
import python_server
129+
130+
# Create a mock stdin that returns empty bytes (EOF)
131+
mock_stdin = io.BytesIO(b"")
132+
133+
# Simulate what happens in the main loop
134+
with mock.patch.object(python_server, "STDIN", mock.Mock(buffer=mock_stdin)):
135+
try:
136+
python_server.get_headers()
137+
# If we get here without raising EOFError, the fix isn't working
138+
pytest.fail("Expected EOFError to be raised on EOF")
139+
except EOFError:
140+
# This is the expected behavior - the fix is working
141+
pass
142+
143+
def test_readline_eof_vs_empty_line(self):
144+
"""Test that we correctly distinguish between EOF and empty line.
145+
146+
EOF: readline() returns b'' (empty bytes)
147+
Empty line: readline() returns b'\\r\\n' or b'\\n' (newline bytes)
148+
"""
149+
# Test EOF case
150+
eof_stream = io.BytesIO(b"")
151+
result = eof_stream.readline()
152+
assert result == b"", "EOF should return empty bytes"
153+
154+
# Test empty line case
155+
empty_line_stream = io.BytesIO(b"\r\n")
156+
result = empty_line_stream.readline()
157+
assert result == b"\r\n", "Empty line should return newline bytes"
158+
159+
# Test empty line with just newline
160+
empty_line_stream2 = io.BytesIO(b"\n")
161+
result = empty_line_stream2.readline()
162+
assert result == b"\n", "Empty line should return newline bytes"

0 commit comments

Comments
 (0)