Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/actions/build-vsix/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ runs:
node-version: ${{ inputs.node_version }}
cache: 'npm'

# Minimum supported version is Python 3.9
- name: Use Python 3.9
# Minimum supported version is Python 3.10 (debugpy no longer supports Python 3.9,
# and pip 26.1+ requires Python >= 3.10)
- name: Use Python 3.10
uses: actions/setup-python@v5
with:
python-version: 3.9
python-version: '3.10'

- name: Pip cache
uses: actions/cache@v4
Expand Down
9 changes: 5 additions & 4 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python: ['3.9', '3.10', '3.11', '3.12', '3.13']
python: ['3.10', '3.11', '3.12', '3.13']

steps:
- name: Checkout
Expand All @@ -60,11 +60,12 @@ jobs:
path: ${{ env.special-working-directory-relative }}
persist-credentials: false

# Install bundled libs using 3.9 even though you test it on other versions.
- name: Use Python 3.9
# Install bundled libs using 3.10 (minimum supported; debugpy dropped 3.9 support,
# and pip 26.1+ requires Python >= 3.10).
- name: Use Python 3.10
uses: actions/setup-python@v5
with:
python-version: '3.9'
python-version: '3.10'

- name: Update pip, install pipx and install wheel
run: python -m pip install -U pip pipx wheel
Expand Down
9 changes: 5 additions & 4 deletions .github/workflows/push-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python: ['3.9', '3.10', '3.11', '3.12', '3.13']
python: ['3.10', '3.11', '3.12', '3.13']

steps:
- name: Checkout
Expand All @@ -65,11 +65,12 @@ jobs:
path: ${{ env.special-working-directory-relative }}
persist-credentials: false

# Install bundled libs using 3.9 even though you test it on other versions.
- name: Use Python 3.9
# Install bundled libs using 3.10 (minimum supported; debugpy dropped 3.9 support,
# and pip 26.1+ requires Python >= 3.10).
- name: Use Python 3.10
uses: actions/setup-python@v5
with:
python-version: '3.9'
python-version: '3.10'

- name: Update pip, install pipx and install wheel
run: python -m pip install -U pip pipx wheel
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 12 additions & 1 deletion src/extension/debugger/hooks/childProcessAttachService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,18 @@ import { traceLog } from '../../common/log/logging';
export class ChildProcessAttachService implements IChildProcessAttachService {
@captureTelemetry(EventName.DEBUGGER_ATTACH_TO_CHILD_PROCESS)
public async attach(data: AttachRequestArguments & DebugConfiguration, parentSession: DebugSession): Promise<void> {
const debugConfig: AttachRequestArguments & DebugConfiguration = data;
const debugConfig: AttachRequestArguments & DebugConfiguration = { ...data };

// Remove the 'purpose' field from the child process debug configuration.
// The child session inherits the parent's configuration (including 'purpose')
// via debugpy's notify_of_subprocess. If the parent is a test debug session
// (purpose: ['debug-test']), the child would also appear to be a test session.
// This can cause the Python extension's test adapter to incorrectly treat the
// child process session termination as the end of the test run, which results
// in premature disconnection of the parent (test runner) debug session.
// See: https://github.com/microsoft/vscode-python-debugger/issues/548
delete debugConfig.purpose;

const debugSessionOption: DebugSessionOptions = {
parentSession: parentSession,
lifecycleManagedByParent: true,
Expand Down
35 changes: 35 additions & 0 deletions src/test/.vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "launch a file",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"args": [],
"console": "integratedTerminal",
"justMyCode": false
},
{
"name": "attach to a local port",
"type": "debugpy",
"request": "attach",
"port": 5678,
"host": "localhost",
"pathMappings": [
{
"localRoot": "${workspaceFolder}",
"remoteRoot": "."
}
],
"justMyCode": false
},
{
"name": "attach to a local PID",
"type": "debugpy",
"request": "attach",
"processId": "${command:pickProcess}",
"justMyCode": false
}
]
}
57 changes: 57 additions & 0 deletions src/test/pythonFiles/debugging/test_multiproc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""
Pytest test file demonstrating the multiprocessing issue when debugging.

When debugging pytest tests, creating a child process via multiprocessing.Process
and then waiting for it via process.join() can cause premature exit of the debug
session. Specifically, the debugger terminates immediately after the child process
terminates (i.e., at the point where process.join() returns), before reaching
subsequent statements in the test body.

This file contains tests that reproduce the issue described in:
https://github.com/microsoft/vscode-python-debugger/issues/548

Workaround: Use a launch.json with "-s" (--capture=no) in pytest args, or
launch pytest via a custom launch.json with "module": "pytest".
"""
import multiprocessing as mp
import time


def _child_main() -> None:
"""Simple child process that sleeps briefly then exits."""
time.sleep(0.25)


def test_multiprocessing_join_in_test_body() -> None:
"""
Regression test: process.join() in a pytest test body should not cause
premature debugger exit.

When debugging this test, the debugger should reach print("after!") and
the assertion, not exit immediately after process.join() returns.
"""
proc = mp.Process(
target=_child_main,
name="debug_multiprocessing_test_body_child",
)
proc.daemon = True
proc.start()
print("before!")
proc.join(10)
print("after!")
assert proc.exitcode == 0


def test_multiprocessing_nondaemon_join() -> None:
"""
Variant: non-daemon child process with join().

Should behave the same as the daemon variant when debugging.
"""
proc = mp.Process(
target=_child_main,
name="debug_multiprocessing_nondaemon_child",
)
proc.start()
proc.join(10)
assert proc.exitcode == 0
40 changes: 40 additions & 0 deletions src/test/pythonFiles/debugging/wait_for_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""
Wait for a file to appear, then print 'done!' and exit.

This script is used to keep a debug session alive (by pausing the script
until the test harness is ready to terminate it), and to produce a known
output (useful for verifying that output was captured correctly).

Usage:
python wait_for_file.py <done_file> [output_file]
"""
import os
import sys
import time


def wait_for_file(path: str) -> None:
while not os.path.exists(path):
time.sleep(0.1)


def main() -> None:
args = sys.argv[1:]
if not args:
print("usage: wait_for_file.py <done_file> [output_file]", file=sys.stderr)
sys.exit(1)

done_file = args[0]
output_file = args[1] if len(args) > 1 else None

wait_for_file(done_file)

if output_file:
with open(output_file, "w") as f:
f.write("done!\n")
else:
print("done!")


if __name__ == "__main__":
main()
46 changes: 46 additions & 0 deletions src/test/unittest/hooks/childProcessAttachService.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,4 +189,50 @@ suite('Debug - Attach to Child Process', () => {
expect(thirdArg).to.deep.equal({ parentSession: session, lifecycleManagedByParent: true });
sinon.assert.notCalled(showErrorMessageStub);
});
test('Child process debug config should not inherit purpose from parent session', async () => {
// When the parent session is a test debug session (purpose: ['debug-test']),
// the child process config inherits 'purpose' via debugpy's notify_of_subprocess.
// We must strip 'purpose' from the child config so that VS Code's test adapter
// does not treat child process session termination as test run completion,
// which would cause premature disconnection of the parent debug session.
// Regression test for: https://github.com/microsoft/vscode-python-debugger/issues/548
const data: AttachRequestArguments = {
request: 'attach',
type: debuggerTypeName,
name: 'Attach',
port: 1234,
subProcessId: 2,
purpose: ['debug-test'],
};

const session: any = {};
getWorkspaceFoldersStub.returns(undefined);
startDebuggingStub.resolves(true);

await attachService.attach(data, session);

sinon.assert.calledOnce(startDebuggingStub);
const [, secondArg] = startDebuggingStub.args[0];
expect(secondArg).to.not.have.property('purpose');
sinon.assert.notCalled(showErrorMessageStub);
});
test('Attaching to child process does not mutate the original data object', async () => {
const data: AttachRequestArguments = {
request: 'attach',
type: debuggerTypeName,
name: 'Attach',
port: 1234,
subProcessId: 2,
purpose: ['debug-test'],
};

const session: any = {};
getWorkspaceFoldersStub.returns(undefined);
startDebuggingStub.resolves(true);

await attachService.attach(data, session);

// The original data object must not be mutated.
expect(data).to.have.property('purpose').deep.equal(['debug-test']);
});
});