Skip to content

"onTerminate": "KeyboardInterrupt" is ignored on attach sessions #2074

Description

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:

  1. Check out bff9402, which is current main.
  2. Save the script below as repro_on_terminate.py.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions