Skip to content

Commit 802f93b

Browse files
committed
Merge branch 'test-project-support' into instant-eagle-unittest-discovery
2 parents 07d1883 + 5cc9aac commit 802f93b

File tree

7 files changed

+194
-65
lines changed

7 files changed

+194
-65
lines changed

package-lock.json

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1509,7 +1509,7 @@
15091509
"name": "get_python_environment_details",
15101510
"displayName": "Get Python Environment Info",
15111511
"userDescription": "%python.languageModelTools.get_python_environment_details.userDescription%",
1512-
"modelDescription": "This tool will retrieve the details of the Python Environment for the specified file or workspace. The details returned include the 1. Type of Python Environment (conda, venv, etec), 2. Version of Python, 3. List of all installed Python packages with their versions. ALWAYS call configure_python_environment before using this tool.",
1512+
"modelDescription": "This tool will retrieve the details of the Python Environment for the specified file or workspace. The details returned include the 1. Type of Python Environment (conda, venv, etc), 2. Version of Python, 3. List of all installed Python packages with their versions. ALWAYS call configure_python_environment before using this tool. IMPORTANT: This tool is only for Python environments (venv, virtualenv, conda, pipenv, poetry, pyenv, pixi, or any other Python environment manager). Do not use this tool for npm packages, system packages, Ruby gems, or any other non-Python dependencies.",
15131513
"toolReferenceName": "getPythonEnvironmentInfo",
15141514
"tags": [
15151515
"python",
@@ -1534,7 +1534,7 @@
15341534
"name": "get_python_executable_details",
15351535
"displayName": "Get Python Executable",
15361536
"userDescription": "%python.languageModelTools.get_python_executable_details.userDescription%",
1537-
"modelDescription": "This tool will retrieve the details of the Python Environment for the specified file or workspace. ALWAYS use this tool before executing any Python command in the terminal. This tool returns the details of how to construct the fully qualified path and or command including details such as arguments required to run Python in a terminal. Note: Instead of executing `python --version` or `python -c 'import sys; print(sys.executable)'`, use this tool to get the Python executable path to replace the `python` command. E.g. instead of using `python -c 'import sys; print(sys.executable)'`, use this tool to build the command `conda run -n <env_name> -c 'import sys; print(sys.executable)'`. ALWAYS call configure_python_environment before using this tool.",
1537+
"modelDescription": "This tool will retrieve the details of the Python Environment for the specified file or workspace. ALWAYS use this tool before executing any Python command in the terminal. This tool returns the details of how to construct the fully qualified path and or command including details such as arguments required to run Python in a terminal. Note: Instead of executing `python --version` or `python -c 'import sys; print(sys.executable)'`, use this tool to get the Python executable path to replace the `python` command. E.g. instead of using `python -c 'import sys; print(sys.executable)'`, use this tool to build the command `conda run -n <env_name> -c 'import sys; print(sys.executable)'`. ALWAYS call configure_python_environment before using this tool. IMPORTANT: This tool is only for Python environments (venv, virtualenv, conda, pipenv, poetry, pyenv, pixi, or any other Python environment manager). Do not use this tool for npm packages, system packages, Ruby gems, or any other non-Python dependencies.",
15381538
"toolReferenceName": "getPythonExecutableCommand",
15391539
"tags": [
15401540
"python",
@@ -1559,7 +1559,7 @@
15591559
"name": "install_python_packages",
15601560
"displayName": "Install Python Package",
15611561
"userDescription": "%python.languageModelTools.install_python_packages.userDescription%",
1562-
"modelDescription": "Installs Python packages in the given workspace. Use this tool to install Python packages in the user's chosen Python environment. ALWAYS call configure_python_environment before using this tool.",
1562+
"modelDescription": "Installs Python packages in the given workspace. Use this tool to install Python packages in the user's chosen Python environment. ALWAYS call configure_python_environment before using this tool. IMPORTANT: This tool should only be used to install Python packages using package managers like pip or conda (works with any Python environment: venv, virtualenv, pipenv, poetry, pyenv, pixi, conda, etc.). Do not use this tool to install npm packages, system packages (apt/brew/yum), Ruby gems, or any other non-Python dependencies.",
15631563
"toolReferenceName": "installPythonPackage",
15641564
"tags": [
15651565
"python",
@@ -1593,7 +1593,7 @@
15931593
{
15941594
"name": "configure_python_environment",
15951595
"displayName": "Configure Python Environment",
1596-
"modelDescription": "This tool configures a Python environment in the given workspace. ALWAYS Use this tool to set up the user's chosen environment and ALWAYS call this tool before using any other Python related tools or running any Python command in the terminal.",
1596+
"modelDescription": "This tool configures a Python environment in the given workspace. ALWAYS Use this tool to set up the user's chosen environment and ALWAYS call this tool before using any other Python related tools or running any Python command in the terminal. IMPORTANT: This tool is only for Python environments (venv, virtualenv, conda, pipenv, poetry, pyenv, pixi, or any other Python environment manager). Do not use this tool for npm packages, system packages, Ruby gems, or any other non-Python dependencies.",
15971597
"userDescription": "%python.languageModelTools.configure_python_environment.userDescription%",
15981598
"toolReferenceName": "configurePythonEnvironment",
15991599
"tags": [
@@ -1713,7 +1713,7 @@
17131713
"iconv-lite": "^0.6.3",
17141714
"inversify": "^6.0.2",
17151715
"jsonc-parser": "^3.0.0",
1716-
"lodash": "^4.17.21",
1716+
"lodash": "^4.17.23",
17171717
"minimatch": "^5.0.1",
17181718
"named-js-regexp": "^1.3.3",
17191719
"node-stream-zip": "^1.6.0",

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"

src/client/envExt/api.internal.ts

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
import { EventEmitter, Terminal, Uri, Disposable, ConfigurationTarget } from 'vscode';
4+
import { EventEmitter, Terminal, Uri, Disposable } from 'vscode';
55
import { getExtension } from '../common/vscodeApis/extensionsApi';
66
import {
77
GetEnvironmentScope,
@@ -13,7 +13,6 @@ import {
1313
DidChangeEnvironmentEventArgs,
1414
} from './types';
1515
import { executeCommand } from '../common/vscodeApis/commandApis';
16-
import { IInterpreterPathService } from '../common/types';
1716
import { getConfiguration } from '../common/vscodeApis/workspaceApis';
1817

1918
export const ENVS_EXTENSION_ID = 'ms-python.vscode-python-envs';
@@ -128,32 +127,3 @@ export async function clearCache(): Promise<void> {
128127
await executeCommand('python-envs.clearCache');
129128
}
130129
}
131-
132-
export function registerEnvExtFeatures(
133-
disposables: Disposable[],
134-
interpreterPathService: IInterpreterPathService,
135-
): void {
136-
if (useEnvExtension()) {
137-
disposables.push(
138-
onDidChangeEnvironmentEnvExt(async (e: DidChangeEnvironmentEventArgs) => {
139-
const previousPath = interpreterPathService.get(e.uri);
140-
141-
if (previousPath !== e.new?.environmentPath.fsPath) {
142-
if (e.uri) {
143-
await interpreterPathService.update(
144-
e.uri,
145-
ConfigurationTarget.WorkspaceFolder,
146-
e.new?.environmentPath.fsPath,
147-
);
148-
} else {
149-
await interpreterPathService.update(
150-
undefined,
151-
ConfigurationTarget.Global,
152-
e.new?.environmentPath.fsPath,
153-
);
154-
}
155-
}
156-
}),
157-
);
158-
}
159-
}

src/client/extensionActivation.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,7 @@ import { IApplicationEnvironment, ICommandManager, IWorkspaceService } from './c
1313
import { Commands, PYTHON_LANGUAGE, UseProposedApi } from './common/constants';
1414
import { registerTypes as installerRegisterTypes } from './common/installer/serviceRegistry';
1515
import { IFileSystem } from './common/platform/types';
16-
import {
17-
IConfigurationService,
18-
IDisposableRegistry,
19-
IExtensions,
20-
IInterpreterPathService,
21-
ILogOutputChannel,
22-
IPathUtils,
23-
} from './common/types';
16+
import { IConfigurationService, IDisposableRegistry, IExtensions, ILogOutputChannel, IPathUtils } from './common/types';
2417
import { noop } from './common/utils/misc';
2518
import { registerTypes as debugConfigurationRegisterTypes } from './debugger/extension/serviceRegistry';
2619
import { IDebugConfigurationService } from './debugger/extension/types';
@@ -55,7 +48,6 @@ import { registerTriggerForTerminalREPL } from './terminals/codeExecution/termin
5548
import { registerPythonStartup } from './terminals/pythonStartup';
5649
import { registerPixiFeatures } from './pythonEnvironments/common/environmentManagers/pixi';
5750
import { registerCustomTerminalLinkProvider } from './terminals/pythonStartupLinkProvider';
58-
import { registerEnvExtFeatures } from './envExt/api.internal';
5951

6052
export async function activateComponents(
6153
// `ext` is passed to any extra activation funcs.
@@ -95,13 +87,9 @@ export function activateFeatures(ext: ExtensionState, _components: Components):
9587
const interpreterQuickPick: IInterpreterQuickPick = ext.legacyIOC.serviceContainer.get<IInterpreterQuickPick>(
9688
IInterpreterQuickPick,
9789
);
98-
const interpreterPathService: IInterpreterPathService = ext.legacyIOC.serviceContainer.get<IInterpreterPathService>(
99-
IInterpreterPathService,
100-
);
10190
const interpreterService: IInterpreterService = ext.legacyIOC.serviceContainer.get<IInterpreterService>(
10291
IInterpreterService,
10392
);
104-
registerEnvExtFeatures(ext.disposables, interpreterPathService);
10593
const pathUtils = ext.legacyIOC.serviceContainer.get<IPathUtils>(IPathUtils);
10694
registerPixiFeatures(ext.disposables);
10795
registerAllCreateEnvironmentFeatures(

0 commit comments

Comments
 (0)