Skip to content

Commit 57ec4c4

Browse files
committed
Fix #125: Stop Debugging in a "noDebug" session doesn't kill subprocesses
On Windows, run the debuggee in a separate Win32 job, and terminate the job when launcher exits. On POSIX, run the debuggee in a separate process group (PGID), and kill the entire group when launcher exits. Improve process tree autokill tests to actually check whether the child process has exited.
1 parent 2c524fa commit 57ec4c4

5 files changed

Lines changed: 233 additions & 10 deletions

File tree

src/debugpy/adapter/launchers.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,6 @@ def on_launcher_connected(sock):
172172
except messaging.MessageHandlingError as exc:
173173
exc.propagate(start_request)
174174

175-
if session.no_debug:
176-
return
177-
178175
if not session.wait_for(
179176
lambda: session.launcher.pid is not None,
180177
timeout=common.PROCESS_SPAWN_TIMEOUT,
@@ -183,6 +180,9 @@ def on_launcher_connected(sock):
183180
'Timed out waiting for "process" event from launcher'
184181
)
185182

183+
if session.no_debug:
184+
return
185+
186186
# Wait for the first incoming connection regardless of the PID - it won't
187187
# necessarily match due to the use of stubs like py.exe or "conda run".
188188
conn = servers.wait_for_connection(

src/debugpy/launcher/debuggee.py

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
from __future__ import absolute_import, division, print_function, unicode_literals
66

77
import atexit
8+
import ctypes
89
import os
10+
import signal
911
import struct
1012
import subprocess
1113
import sys
@@ -15,10 +17,16 @@
1517
from debugpy.common import fmt, log, messaging, compat
1618
from debugpy.launcher import output
1719

20+
if sys.platform == "win32":
21+
from debugpy.launcher import winapi
22+
1823

1924
process = None
2025
"""subprocess.Popen instance for the debuggee process."""
2126

27+
job_handle = None
28+
"""On Windows, the handle for the job object to which the debuggee is assigned."""
29+
2230
wait_on_exit_predicates = []
2331
"""List of functions that determine whether to pause after debuggee process exits.
2432
@@ -52,6 +60,11 @@ def spawn(process_name, cmdline, env, redirect_output):
5260
else:
5361
kwargs = {}
5462

63+
if sys.platform != "win32":
64+
# Start the debuggee in a new process group, so that the launcher can kill
65+
# the entire process tree later.
66+
kwargs.update(preexec_fn=os.setpgrp)
67+
5568
try:
5669
global process
5770
process = subprocess.Popen(cmdline, env=env, bufsize=0, **kwargs)
@@ -61,7 +74,45 @@ def spawn(process_name, cmdline, env, redirect_output):
6174
)
6275

6376
log.info("Spawned {0}.", describe())
77+
78+
if sys.platform == "win32":
79+
# Assign the debuggee to a new job object, so that the launcher can kill
80+
# the entire process tree later.
81+
try:
82+
global job_handle
83+
job_handle = winapi.kernel32.CreateJobObjectA(None, None)
84+
85+
job_info = winapi.JOBOBJECT_EXTENDED_LIMIT_INFORMATION()
86+
job_info_size = winapi.DWORD(ctypes.sizeof(job_info))
87+
winapi.kernel32.QueryInformationJobObject(
88+
job_handle,
89+
winapi.JobObjectExtendedLimitInformation,
90+
ctypes.pointer(job_info),
91+
job_info_size,
92+
ctypes.pointer(job_info_size),
93+
)
94+
95+
# Setting this flag ensures that the job will be terminated by the OS once the
96+
# launcher exits, even if it doesn't terminate the job explicitly.
97+
job_info.BasicLimitInformation.LimitFlags |= winapi.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
98+
winapi.kernel32.SetInformationJobObject(
99+
job_handle,
100+
winapi.JobObjectExtendedLimitInformation,
101+
ctypes.pointer(job_info),
102+
job_info_size,
103+
)
104+
105+
process_handle = winapi.kernel32.OpenProcess(
106+
winapi.PROCESS_TERMINATE | winapi.PROCESS_SET_QUOTA, False, process.pid
107+
)
108+
109+
winapi.kernel32.AssignProcessToJobObject(job_handle, process_handle)
110+
111+
except Exception:
112+
log.swallow_exception("Failed to set up job object", level="warning")
113+
64114
atexit.register(kill)
115+
65116
launcher.channel.send_event(
66117
"process",
67118
{
@@ -90,16 +141,23 @@ def spawn(process_name, cmdline, env, redirect_output):
90141
try:
91142
os.close(fd)
92143
except Exception:
93-
log.swallow_exception()
144+
log.swallow_exception(level="warning")
94145

95146

96147
def kill():
97148
if process is None:
98149
return
150+
99151
try:
100152
if process.poll() is None:
101153
log.info("Killing {0}", describe())
102-
process.kill()
154+
# Clean up the process tree
155+
if sys.platform == "win32":
156+
# On Windows, kill the job object.
157+
winapi.kernel32.TerminateJobObject(job_handle, 0)
158+
else:
159+
# On POSIX, kill the debuggee's process group.
160+
os.killpg(process.pid, signal.SIGKILL)
103161
except Exception:
104162
log.swallow_exception("Failed to kill {0}", describe())
105163

@@ -114,7 +172,7 @@ def wait_for_exit():
114172
# taking the lowest 8 bits of that negative returncode.
115173
code &= 0xFF
116174
except Exception:
117-
log.swallow_exception("Couldn't determine process exit code:")
175+
log.swallow_exception("Couldn't determine process exit code")
118176
code = -1
119177

120178
log.info("{0} exited with code {1}", describe(), code)

src/debugpy/launcher/handlers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def property_or_debug_option(prop_name, flag_name):
6464
adapter_access_token = request("adapterAccessToken", unicode, optional=True)
6565
if adapter_access_token != ():
6666
cmdline += ["--adapter-access-token", compat.filename(adapter_access_token)]
67+
6768
debugpy_args = request("debugpyArgs", json.array(unicode))
6869
cmdline += debugpy_args
6970

src/debugpy/launcher/winapi.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License. See LICENSE in the project root
3+
# for license information.
4+
5+
from __future__ import absolute_import, division, print_function, unicode_literals
6+
7+
import ctypes
8+
from ctypes.wintypes import BOOL, DWORD, HANDLE, LARGE_INTEGER, LPCSTR, UINT
9+
10+
from debugpy.common import log
11+
12+
13+
JOBOBJECTCLASS = ctypes.c_int
14+
LPDWORD = ctypes.POINTER(DWORD)
15+
LPVOID = ctypes.c_void_p
16+
SIZE_T = ctypes.c_size_t
17+
ULONGLONG = ctypes.c_ulonglong
18+
19+
20+
class IO_COUNTERS(ctypes.Structure):
21+
_fields_ = [
22+
("ReadOperationCount", ULONGLONG),
23+
("WriteOperationCount", ULONGLONG),
24+
("OtherOperationCount", ULONGLONG),
25+
("ReadTransferCount", ULONGLONG),
26+
("WriteTransferCount", ULONGLONG),
27+
("OtherTransferCount", ULONGLONG),
28+
]
29+
30+
31+
class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
32+
_fields_ = [
33+
("PerProcessUserTimeLimit", LARGE_INTEGER),
34+
("PerJobUserTimeLimit", LARGE_INTEGER),
35+
("LimitFlags", DWORD),
36+
("MinimumWorkingSetSize", SIZE_T),
37+
("MaximumWorkingSetSize", SIZE_T),
38+
("ActiveProcessLimit", DWORD),
39+
("Affinity", SIZE_T),
40+
("PriorityClass", DWORD),
41+
("SchedulingClass", DWORD),
42+
]
43+
44+
45+
class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure):
46+
_fields_ = [
47+
("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION),
48+
("IoInfo", IO_COUNTERS),
49+
("ProcessMemoryLimit", SIZE_T),
50+
("JobMemoryLimit", SIZE_T),
51+
("PeakProcessMemoryUsed", SIZE_T),
52+
("PeakJobMemoryUsed", SIZE_T),
53+
]
54+
55+
56+
JobObjectExtendedLimitInformation = JOBOBJECTCLASS(9)
57+
58+
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
59+
60+
PROCESS_TERMINATE = 0x0001
61+
PROCESS_SET_QUOTA = 0x0100
62+
63+
64+
def _errcheck(is_error_result=(lambda result: not result)):
65+
def impl(result, func, args):
66+
if is_error_result(result):
67+
log.debug("{0} returned {1}", func.__name__, result)
68+
raise ctypes.WinError()
69+
else:
70+
return result
71+
72+
return impl
73+
74+
75+
kernel32 = ctypes.windll.kernel32
76+
77+
kernel32.AssignProcessToJobObject.errcheck = _errcheck()
78+
kernel32.AssignProcessToJobObject.restype = BOOL
79+
kernel32.AssignProcessToJobObject.argtypes = (HANDLE, HANDLE)
80+
81+
kernel32.CreateJobObjectA.errcheck = _errcheck(lambda result: result == 0)
82+
kernel32.CreateJobObjectA.restype = HANDLE
83+
kernel32.CreateJobObjectA.argtypes = (LPVOID, LPCSTR)
84+
85+
kernel32.OpenProcess.errcheck = _errcheck(lambda result: result == 0)
86+
kernel32.OpenProcess.restype = HANDLE
87+
kernel32.OpenProcess.argtypes = (DWORD, BOOL, DWORD)
88+
89+
kernel32.QueryInformationJobObject.errcheck = _errcheck()
90+
kernel32.QueryInformationJobObject.restype = BOOL
91+
kernel32.QueryInformationJobObject.argtypes = (
92+
HANDLE,
93+
JOBOBJECTCLASS,
94+
LPVOID,
95+
DWORD,
96+
LPDWORD,
97+
)
98+
99+
kernel32.SetInformationJobObject.errcheck = _errcheck()
100+
kernel32.SetInformationJobObject.restype = BOOL
101+
kernel32.SetInformationJobObject.argtypes = (HANDLE, JOBOBJECTCLASS, LPVOID, DWORD)
102+
103+
kernel32.TerminateJobObject.errcheck = _errcheck()
104+
kernel32.TerminateJobObject.restype = BOOL
105+
kernel32.TerminateJobObject.argtypes = (HANDLE, UINT)

tests/debugpy/test_multiproc.py

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44

55
from __future__ import absolute_import, division, print_function, unicode_literals
66

7+
import psutil
78
import pytest
89
import sys
910

1011
import debugpy
1112
import tests
12-
from tests import debug
13+
from tests import debug, log
1314
from tests.debug import runners
1415
from tests.patterns import some
1516

@@ -218,18 +219,25 @@ def parent():
218219
assert child_argv == [child, "--arg1", "--arg2", "--arg3"]
219220

220221

221-
def test_autokill(pyfile, target):
222+
@pytest.mark.parametrize("run", runners.all_launch)
223+
def test_autokill(daemon, pyfile, target, run):
222224
@pyfile
223225
def child():
226+
import os
227+
from debuggee import backchannel
228+
229+
backchannel.send(os.getpid())
224230
while True:
225231
pass
226232

227233
@pyfile
228234
def parent():
235+
import debuggee
229236
import os
230237
import subprocess
231238
import sys
232239

240+
debuggee.setup()
233241
argv = [sys.executable, sys.argv[1]]
234242
env = os.environ.copy()
235243
subprocess.Popen(
@@ -242,8 +250,9 @@ def parent():
242250

243251
with debug.Session() as parent_session:
244252
parent_session.expected_exit_code = some.int
245-
246-
with parent_session.launch(target(parent, args=[child])):
253+
254+
backchannel = parent_session.open_backchannel()
255+
with run(parent_session, target(parent, args=[child])):
247256
pass
248257

249258
child_config = parent_session.wait_for_next_event("debugpyAttach")
@@ -253,9 +262,59 @@ def parent():
253262
with child_session.start():
254263
pass
255264

265+
child_pid = backchannel.receive()
266+
assert child_config["subProcessId"] == child_pid
267+
child_process = psutil.Process(child_pid)
268+
256269
parent_session.request("terminate")
257270
child_session.wait_for_exit()
258271

272+
log.info("Waiting for child process...")
273+
child_process.wait()
274+
275+
276+
@pytest.mark.parametrize("run", runners.all_launch)
277+
def test_autokill_nodebug(daemon, pyfile, target, run):
278+
@pyfile
279+
def child():
280+
import os
281+
from debuggee import backchannel
282+
283+
backchannel.send(os.getpid())
284+
while True:
285+
pass
286+
287+
@pyfile
288+
def parent():
289+
import os
290+
import subprocess
291+
import sys
292+
293+
argv = [sys.executable, sys.argv[1]]
294+
env = os.environ.copy()
295+
subprocess.Popen(
296+
argv,
297+
env=env,
298+
stdin=subprocess.PIPE,
299+
stdout=subprocess.PIPE,
300+
stderr=subprocess.PIPE,
301+
).wait()
302+
303+
with debug.Session() as session:
304+
session.expected_exit_code = some.int
305+
session.config["noDebug"] = True
306+
307+
backchannel = session.open_backchannel()
308+
run(session, target(parent, args=[child]))
309+
310+
child_pid = backchannel.receive()
311+
child_process = psutil.Process(child_pid)
312+
313+
session.request("terminate")
314+
315+
log.info("Waiting for child process...")
316+
child_process.wait()
317+
259318

260319
def test_argv_quoting(pyfile, target, run):
261320
@pyfile

0 commit comments

Comments
 (0)