Before creating a new issue, please check the FAQ to see if your question is answered there.
Environment data
- debugpy version: 1.8.22+1.gbff9402 (running from source at
bff9402)
- OS and version: macOS 26.6.2, build 25G83
- Python version (& distribution if applicable, e.g. Anaconda): 3.10.6, CPython from python.org
- Using VS Code or Visual Studio: neither. The repro below drives the adapter handler directly.
Actual behavior
"onTerminate": "KeyboardInterrupt" does nothing on an attach session. The adapter accepts the option and then kills the debuggee outright instead of interrupting it.
attach_request validates the option as bool, while launch_request validates it as str:
src/debugpy/adapter/clients.py:407 on_terminate = request("onTerminate", str, optional=True)
src/debugpy/adapter/clients.py:450 on_terminate = request("onTerminate", bool, optional=True)
Both are followed by the same comparison:
self._forward_terminate_request = on_terminate == "KeyboardInterrupt"
json.of_type(bool) doesn't reject a string, it coerces it. So on attach, "KeyboardInterrupt" arrives as True, and True == "KeyboardInterrupt" is False. _forward_terminate_request stays False on every attach session, which makes the if self._forward_terminate_request: branch in terminate_request (clients.py:688) unreachable from attach. No value of "onTerminate" turns the option on when you attach.
The option is a string everywhere else in the tree. The debug server reads it as one:
src/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_process_net_command_json.py:404
terminate_keyboard_interrupt = args.get("onTerminate", "kill") == "KeyboardInterrupt"
and pydevd's own coverage for it only goes through launch (tests_python/test_debugger_json.py:7161 calls write_launch(onTerminate=...)), so the attach side has never been exercised.
Who this hits: anyone attaching to a service that's already running and asking for a graceful stop. On launch the debuggee gets a Ctrl+C, so finally blocks, atexit handlers and buffered output all get their chance. On attach it gets session.finalize(..., terminate_debuggee=True) instead, which kills the process and its subprocesses. Nothing reports an error, so the option looks like it took effect.
Expected behavior
"onTerminate": "KeyboardInterrupt" on an attach configuration should make terminate_request delegate to the server, the same way it already does on launch.
Steps to reproduce:
- Check out
bff9402, which is current main.
- Save the script below as
repro_on_terminate.py.
- Run
PYTHONPATH=<clone>/src python3 repro_on_terminate.py.
"""Repro: "onTerminate": "KeyboardInterrupt" never takes effect on an attach session.
Parses a real DAP "attach" packet the way the adapter parses it, runs it through the
real Client.attach_request handler, and prints the flag that terminate_request reads.
No sockets are opened: the handler stops at the first argument check that follows the
"onTerminate" lines.
Exit 1 while the bug is present, 0 once it is fixed.
"""
import sys
from debugpy.common import log, messaging
from debugpy.adapter import clients
log.stderr.levels = () # quiet the adapter's own logging
class _Stream(object):
name = "memory"
def close(self):
pass
def write_json(self, value, encoder=None):
pass
class _Session(object):
launcher = None
server = None
def __enter__(self):
return self
def __exit__(self, *exc_info):
return False
def forward_terminate_flag(attach_json):
decoder = messaging.JsonIOStream.json_decoder_factory(
object_hook=lambda d: messaging.MessageDict(None, d)
)
channel = messaging.JsonMessageChannel(_Stream(), None)
client = clients.Client.__new__(clients.Client)
client.session = _Session()
client.channel = channel
client.start_request = None
client.has_started = False
client._forward_terminate_request = False
client._initialize_request = messaging.Request(
channel, 1, "initialize", messaging.MessageDict(None, {})
)
request = messaging.Request(channel, 2, "attach", decoder.decode(attach_json))
try:
clients.Client.attach_request(client, request)
except messaging.MessageHandlingError:
# "listen" and "connect" are mutually exclusive. That check is the first one
# after "onTerminate" is read, so the handler never reaches any socket.
pass
return client._forward_terminate_request
BOTH_ENDPOINTS = '"listen": {"port": 5678}, "connect": {"port": 5678}'
GRACEFUL = '{"onTerminate": "KeyboardInterrupt", %s}' % BOTH_ENDPOINTS
PLAIN = "{%s}" % BOTH_ENDPOINTS
def main():
graceful = forward_terminate_flag(GRACEFUL)
plain = forward_terminate_flag(PLAIN)
print('attach with "onTerminate": "KeyboardInterrupt" -> %r' % (graceful,))
print("attach with no onTerminate -> %r" % (plain,))
if plain is not False:
print("RESULT: UNEXPECTED, the default should be False")
return 2
if graceful is True:
print("RESULT: FIXED")
return 0
print("RESULT: BUG, terminate will hard kill the debuggee")
return 1
if __name__ == "__main__":
sys.exit(main())
What I get on bff9402:
attach with "onTerminate": "KeyboardInterrupt" -> False
attach with no onTerminate -> False
RESULT: BUG, terminate will hard kill the debuggee
Exit code 1.
What I expected: the first line to print True, since that's what the launch path produces for the same option.
I have a one word fix and a regression test ready, and I'm opening a PR with them now.
Before creating a new issue, please check the FAQ to see if your question is answered there.
Environment data
bff9402)Actual behavior
"onTerminate": "KeyboardInterrupt"does nothing on an attach session. The adapter accepts the option and then kills the debuggee outright instead of interrupting it.attach_requestvalidates the option asbool, whilelaunch_requestvalidates it asstr:Both are followed by the same comparison:
json.of_type(bool)doesn't reject a string, it coerces it. So on attach,"KeyboardInterrupt"arrives asTrue, andTrue == "KeyboardInterrupt"isFalse._forward_terminate_requeststaysFalseon every attach session, which makes theif self._forward_terminate_request:branch interminate_request(clients.py:688) unreachable from attach. No value of"onTerminate"turns the option on when you attach.The option is a string everywhere else in the tree. The debug server reads it as one:
and pydevd's own coverage for it only goes through launch (
tests_python/test_debugger_json.py:7161callswrite_launch(onTerminate=...)), so the attach side has never been exercised.Who this hits: anyone attaching to a service that's already running and asking for a graceful stop. On launch the debuggee gets a Ctrl+C, so
finallyblocks,atexithandlers and buffered output all get their chance. On attach it getssession.finalize(..., terminate_debuggee=True)instead, which kills the process and its subprocesses. Nothing reports an error, so the option looks like it took effect.Expected behavior
"onTerminate": "KeyboardInterrupt"on an attach configuration should maketerminate_requestdelegate to the server, the same way it already does on launch.Steps to reproduce:
bff9402, which is currentmain.repro_on_terminate.py.PYTHONPATH=<clone>/src python3 repro_on_terminate.py.What I get on
bff9402:Exit code 1.
What I expected: the first line to print
True, since that's what the launch path produces for the same option.I have a one word fix and a regression test ready, and I'm opening a PR with them now.