Debugging python via remote client connection sometimes hangs permanently after hitting a breakpoint

Where does the bug appear (feature/product)?

Cursor IDE

Describe the Bug

Sometimes when I’m debugging my Python apps in Cursor, the debugger behaves as if it hit a breakpoint, but doesn’t make any of the UI changes that allow me to control the program flow. This leaves my app in a permanently hung state until I stop the debug server in Cursor.

Steps to Reproduce

I debug my python WSGI apps via a middleware I wrote which connects to the debugpy server running in my Cursor app when a request starts, and disconnects after the request ends. The WSGI app is running in gunicorn inside a Docker container running on my Mac, and it connects to the debug server in Cursor via host.docker.internal:6765.

To reproduce the bug:

  1. Place a breakpoint in one of my views and visit that view’s URL via my browser.
  2. The breakpoint is hit as normal, and I can debug the code.
  3. Press Continue, allowing the request to complete.
  4. Refresh the page in my browser.
  5. The gunicorn worker hangs right when the breakpoint should have been hit. Print statements before the breakpoint get printed to my docker logs, but nothing after.

Expected Behavior

I expect the debugger hitting a breakpoint to consistently give me control of the debugging process, rather than hanging.

Operating System

MacOS

Version Information

Version: 3.23.12 (Universal)
VS Code Extension API: 1.128.0
Commit: 2d29876d567da1607532b23bbf2cd5ddbca496f0
Date: 2026-10-01T04:41:16.627Z
Layout: IDE
Build Type: Stable
Release Track: Default
Electron: 42.10.0
Chromium: 148.0.7778.280
Node.js: 24.18.1
V8: 14.8.178.38-electron.0
xterm.js: 6.1.0-beta.291
OS: Darwin arm64 25.6.0

Additional Information

If I perform Steps 1-3, then remove the breakpoint, and then do Step 4, the refresh works fine and the request completes as normal.

If I do Steps 1-3, then restart the debug server in Cursor, then do Step 4, the breakpoint gets hit as normal.

If I trigger Step 5, then, while gunicorn is hung, I click Disconnect in Cursor to stop the debug server, gunicorn un-hangs and the request completes as normal. If I then turn the debug server back on and refresh the page again, I get a normal breakpoint hit, with control of the debug process.

So the problem is related to hitting a breakpoint, and then hitting another breakpoint after the debug client has disconnected and reconnected, but without stopping and restarting the debug server.

I can get the same behavior if I instead configure my gunicorn server to connect to the debug server at boot time, staying connected across all requests. If I hit a breakpoint, then change my code such that gunicorn restarts itself to reload the new code, a subsequent request for a breakpointed view will trigger the hang.

Here’s my debugging middleware:

class DebugpyClientWSGIMiddleware:"""Enables client-style debugging, connecting to a remote debug server runninginside VS Code (usually). Only activates if REMOTE_DEBUG_ENABLED=True."""

def __init__(self, app):
    self.app = app
    self.enabled = env.bool("REMOTE_DEBUG_ENABLED", default=False)
    self.host: str = env("REMOTE_DEBUG_HOST", default="host.docker.internal")
    self.port: int = env.int("REMOTE_DEBUG_PORT", default=6765)
    if self.enabled:
        print("REQUEST DEBUGGING IS ENABLED.", flush=True)
        print(f"Ensure a python debug server is running at {self.host}:{self.port}.", flush=True)

def __call__(self, wsgi_environ, start_response):
    """
    Connect to the debug server at the start of every request.
    """
    connected = False
    if self.enabled:
        try:
            debugpy.connect((self.host, self.port))
        except ConnectionRefusedError:
            print(f"No debug server is running at {self.host}:{self.port}.", flush=True)
        else:
            connected = True
            print(f"Connected to debug server at {self.host}:{self.port}.", flush=True)

    response = self.app(wsgi_environ, start_response)

    if connected:
        # We can ignore this "invalid import" warning from BasedPyright. It just doesn't
        # know about the vendored pydevd that debugpy installs. Though it only _does_
        # that after you've called at least one debugpy API, which is why we do the
        # import here.
        import pydevd
        # Disconnect from the debug server after the request is over.
        pydevd.stoptrace()

    return response

Note that commenting out the if connected: block has no effect on the hang.

I wasn’t experiencing this hanging behavior before yesterday, Oct 1st, 2026.

Does this stop you from using Cursor

No - Cursor works, but with this issue

Hey, thanks for the detailed report and the middleware code. It makes it much easier to debug.

It looks like this comes from how the Python Debugger debugpy behaves when the same process connects to a listen session more than once. After the first request finishes, the session on Cursor’s side is already closed, and when the same gunicorn worker calls debugpy.connect() again, debugpy rejects the reconnect and leaves the worker in a paused state. That matches what you’re seeing: right after a fresh debug server start everything works, and on the next breakpoint hit it hangs.

A setup that avoids this is to flip the direction: let each worker listen once, and you attach from Cursor when needed. Then you can connect and disconnect as much as you want, and the worker never has to reconnect.

In gunicorn.conf.py keep workers = 1 while debugging and expose port 5678 from the container:

def post_worker_init(worker):
    import debugpy
    debugpy.listen(("0.0.0.0", 5678))

In launch.json:

{
  "name": "Attach to gunicorn",
  "type": "debugpy",
  "request": "attach",
  "connect": { "host": "localhost", "port": 5678 },
  "pathMappings": [{ "localRoot": "${workspaceFolder}", "remoteRoot": "/app" }]
}

Update remoteRoot to match the code path in your container, and remove the per request middleware.

To narrow down where it’s getting stuck, can you share:

  • The Python Debugger extension version (Extensions panel) and the debugpy version installed in the container.
  • Whether the same middleware setup behaves the same in plain VS Code with the same extension version.

Let me know how it goes.

Well, the reason I do debugging with gunicorn being the client and Cursor being the server is that when done the other way around, I have to remember to reconnect the debugger every time I make a code change, since Gunicorn will automatically restart when it detects changes (which kills the connection from Cursor).

By going from Gunicorn → Cursor, each Gunicorn restart (or each new request, when using my middleware) reconnects to the debugger automatically. Though I suppose that’s exactly what’s causing this problem… but why did it suddenly start being an issue now? I’ve been doing my debugging like this for years.

I tried your suggested approach with post_worker_init(), and it does seem to work (though as expected, the debugger disconnects when I make code changes). I hadn’t previously been aware of the post_worker_init() function in gunicorn’s config, which is very cool!

I tried using that as an alternative to my previous methods of connecting gunicorn to the debugger as a client, and it sortof helps. Since the connection now stays active for the life of the worker thread, I don’t get freezes after a single breakpointed request. But as soon as I make a code change and gunicorn restarts its worker, I start getting freezes again.

To answer your questions at the end:

  • Python Debugger extension is version 2026.6.0 (newest version on OpenVSX)
  • debugpy package version is 1.8.22 (newest version on pypi)

As for using plain VS Code, I still had it installed, but I haven’t touched plain VS Code in quite a while, so I doubt it’s a proper one-to-one comparison… but I do not get freezes in VS Code v1.140.0. Neither when using my middleware’s “reconnect to debug server with each request” method nor with the gunicorn post_worker_init() method I just developed.

VS Code and Cursor both seems to be using version 2026.6.0 of the Python Debugger extension.

Here’s my post_worker_init() code:

def post_worker_init(worker):
    if env.bool("DEBUG_SERVER_ENABLED", default=False):
        import debugpy
        port = env.int("DEBUG_SERVER_PORT", default=5678)
        debugpy.listen(("0.0.0.0", port))  # noqa: S104
        worker.log.info(f"Debugpy listening on port {port}...")
    elif env.bool("DEBUG_CLIENT_ENABLED", default=False):
        import debugpy
        # Attach to a remote python debug server at REMOTE_DEBUG_HOST:REMOTE_DEBUG_PORT.
        host = env("REMOTE_DEBUG_HOST", default="host.docker.internal")
        port = env.int("REMOTE_DEBUG_PORT", default=6765)

        # Check if the debug server is up before attempting to connect to it with debugpy, because
        # debugpy doesn't cleanly close its socket after raising ConnectionRefusedError.
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as check_sock:
            # Don't hang for longer than 1 second.
            check_sock.settimeout(1.0)
            try:
                check_sock.connect((host, port))
            except ConnectionRefusedError:
                worker.log.warning(f"No debug server is running at {host}:{port}.")
                return
            # We need to let the socket close itself via the context manager before we reconnect via debugpy.

        debugpy.connect((host, port))
        worker.log.info(f"Connected to debug server at {host}:{port}.")

Thanks for sharing the versions and the VS Code comparison, that helped narrow it down.

I tried to reproduce this on the current build and couldn’t. Setup: Cursor 3.24.11, Python Debugger 2026.6.0, debugpy 1.8.22, Python 3.14, macOS. I tested three variants, all using a "request": "attach" / "listen" session on 6765:

  1. One process: debugpy.connect() → breakpoint → Continue → pydevd.stoptrace() → connect() again.
  2. gunicorn with your DebugpyClientWSGIMiddleware exactly as posted, with connect per request and stoptrace() after.
  3. gunicorn with debugpy.connect() in post_worker_init and --reload, then a code change so the worker restarts.

In every case, as soon as the worker disconnects, whether by clean stoptrace(), worker exit on reload, or the worker being killed, the listen session on Cursor’s side ends and port 6765 stops listening. The next connect() gets ConnectionRefusedError, the middleware logs “No debug server is running”, and the request finishes without the debugger. No hang. VS Code 1.111 with the same extension and debugpy behaves the same way.

Your report needs the opposite state: the server still listening after the first request, so the worker can reconnect and then stall on the breakpoint. That matches what you described, where Disconnect unblocks it and restarting the server fixes it, but I can’t get into that state on 3.24.x. You’re on 3.23.12, built on Oct 1, which matches “started Oct 1”. So this may have been fixed between 3.23.12 and 3.24.11, or it may be specific to the Docker path with host.docker.internal and the container PID namespace.

Could you:

  • Update to the latest 3.24.x and retry the middleware flow.
  • If it still hangs, right after the first request completes and before the refresh, run lsof -nP -iTCP:6765 on the Mac and tell me if a Python process is still listening.
  • Add "logToFile": true to the listen config in launch.json, reproduce it once, and attach the debugpy.adapter-*.log from the Python Debugger extension folder. That log should show what the adapter did when the worker reconnected.

One unrelated thing I hit while testing: gunicorn’s sync worker has timeout = 30 by default, so if a worker sits on a breakpoint for more than 30 seconds, the master kills it with WORKER TIMEOUT and the request returns 500. It’s worth setting timeout = 0 while debugging so that doesn’t muddy the picture.

I’m now on Cursor 3.24.12, and I’m still able to reproduce the freeze with #2 and #3.

For #2, I noticed something interesting, tho. Before I start the debug server in Cursor, the UI looks like this:

After I start it, the UI looks like this:

With an animated line running across the panel border under the green and red buttons.

And when I connect the first time and hit the breakpoint, it turns into this:

After the request is complete and the client disconnects, the UI goes back to the first image (not the second one, as you’d expect it do), but when I refresh the page again (with the breakpoint still enabled) and the freeze occurs, the UI turns into this:

Maybe that’s somehow relevant? That last UI image is what it looks like most of the time when using the #3 method, since the debug client running in gunicorn remains connected all the time, but there’s nothing actively being debugged until a request is made. But that 4th image only appeared while using DebugpyClientWSGIMiddlewareas soon as I made a second breakpointed request.

Also, maybe this message that I sometimes get from gunicorn is relevant?

2026/10/13 16:09:40 [info] 12#12: *6 epoll_wait() reported that client prematurely closed connection, so upstream connection is closed too while sending request to upstream, client: 172.18.0.1, server: localhost, request: "GET / HTTP/1.1", upstream: "``http://unix``:/tmp/runtime/gunicorn.sock:/", host: "``sourceforge.net``"

It doesn’t happen every time I trigger a freeze, tho, so it may not be relevant.

As for reproducing the freeze in #3, I even tried adding a worker_exit implementation, to manually call pydevd.stoptrace() when gunicorn kills the worker during a restart. I confirmed that it’s doing that successfully by adding some logging messages, which are being printed, yet I still get the freeze if I attempt to debug a view with a breakpoint after the gunicorn reload. If I don’t have a breakpoint, tho, no freeze (same as it was before).

I ran the terminal command you suggested, after the first request with the middleware, but before the second request, and got this:

$ lsof -nP -iTCP:6765
COMMAND   PID     USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
Python  48525 REDACTED    7u  IPv4 0x7f3ac79620382524      0t0  TCP 127.0.0.1:6765 (LISTEN)

So it looks like Cursor is, in fact, still listening? Also note: the first UI image I shared above is how Cursor looks while that terminal command is outputting that info. I have no way to tell it to stop the debug server, because Cursor doesn’t seem to think its currently running. Note: I’ve had this particular UI problem with the Python debugger for years, and it affects VS Code, too, so that’s probably not related?

As for the logs, it looks like debugpy created two of them, and I’m not sure which is which, so I’ve included both.

debugger.vscode_92d39825-d3e1-42a6-824e-4d9bfebde8b9.log (12.6 KB)

debugger.vscode_27653306-172a-4aff-a016-05133f9259ae.log (5.7 KB)

As for the gunicorn timeout, I noticed that problem a long time ago, and have been using timeout = 9999 in my gunicorn_config.py files, but I didn’t know about timeout = 0. I’ll start using that.

Oh, and in case its relevant, here’s my workspace’s entire launch config (in my directory.code-workspace file):

    "launch": {
        // Debugger configuration for Python
        "version": "0.2.0",
        "configurations": [
            {
                // This config listens on port 6765 for a remote debugger to attach to it.
                // It works for sites, www, and tests, as long as only one of them are trying
                // to connect to it at the same time.
                "name": "Debug as Server",
                "type": "debugpy",
                "request": "attach",
                "restart": true,
                // "logToFile": true,
                "listen": {
                    "host": "localhost",
                    "port": 6765
                },
                "justMyCode": false,
                "debugStdLib": true,
                "django": true,
                "jinja": true,
                "pathMappings": [
                    // Map the packages in local libraries to their installed paths in the container.
                    {
                        "localRoot": "${workspaceFolder:django-djunk}/djunk",
                        "remoteRoot": "/ve/lib/python3.13/site-packages/djunk",
                    },
                    // Map the host venv into the /ve folder. This must go AFTER local library mappings but BEFORE
                    // the /app mapping, to ensure that the mappings override each other appropriately.
                    {
                        "localRoot": "${workspaceFolder:directory}/.venv",
                        "remoteRoot": "/ve"
                    },
                    // Map the root of the directory repo to the /app directory in the container.
                    {
                        "localRoot": "${workspaceFolder:directory}",
                        "remoteRoot": "/app"
                    },
                ],
                "variablePresentation": {
                    // Valid settings are "class", "protected", "function", and "special".
                    // Valid values are "inline", "group", and "hide".
                    // To activate changes, you must stop and start the debugger. Restart does not work.
                    // "all" sets the default for everything. Any of the following that are unset will be inlined,
                    // which is how instance attributes are always displayed.
                    "all": "inline",
                    // Group attributes prefixed with a single underscore into a "protected variables" group.
                    "protected": "group",
                    // Hide the "function variables" group, which contains all of an object's functions.
                    "function": "group",
                    // Hide the "special variables" group, which contains all of an object's dunders (e.g. __class__).
                    "special": "group",
                },
                "showReturnValue": false,
            },
        ]
    },

My Python Debugger extension settings have both checkboxes (“Debug Just My Code” and “Show Python Inline Values”) disabled.