#14392·graal

[DAP] Server remains listening but does not accept subsequent connections after debugger disconnect

Author: DantelainCreated Sep 7, 2026Updated Sep 11, 2026
Labelsbugtools

[DAP] Server remains listening but does not accept subsequent connections after debugger disconnect

Summary

When debugging JavaScript embedded in a long-running Java service, the first GraalVM DAP connection works. After ending that debugger session while leaving the host JVM running, another DAP connection establishes TCP successfully but never becomes operational. The DAP listening socket remains open, although the threads that accepted and handled the original connection have exited.

Environment

  • Host JVM: Oracle GraalVM 25.3.4.1+1.1, Java 25.0.4.1 LTS, Java HotSpot(TM) 64-Bit Server VM, build 25.0.4.1+1-LTS-jvmci-25.3-b22.
  • Polyglot tools: org.graalvm.tools:dap-tool:25.3.4.1, brought in through org.graalvm.polyglot:dap:25.3.4.1.
  • OS: Linux x86_64 (Ubuntu).
  • Client: IntelliJ IDEA, a GraalVM DAP debug configuration connecting to localhost:4711.
  • Application: a long-running Java/Spring service embedding GraalJS, with a shared org.graalvm.polyglot.Engine.
  • Java debugging and the DAP client are separate debug sessions in IDEA.
  • JVM options relevant to debugging:
-Dpolyglot.dap=4711
-Dpolyglot.dap.Suspend=false
-Dpolyglot.inspect=9229
-Dpolyglot.inspect.Suspend=false

The Inspector options were also enabled during the observation. This has not yet been retested with Inspector disabled or against a newer snapshot.

The shared Engine is constructed as follows:

java
Engine engine = Engine.newBuilder()
        .allowExperimentalOptions(true)
        .build();

Steps to reproduce

These steps describe the observed application scenario; a standalone minimal reproducer has not yet been validated.

  1. Start a long-running Java application embedding GraalJS with a shared Engine and the DAP options above. Initialize the guest language so the DAP server is listening.
  2. Attach a DAP debugger to localhost:4711 and use the first session.
  3. End the DAP session in the IDE, leaving the host Java application and its Engine running.
  4. Start the same DAP attach configuration again, without restarting the application.

Expected behavior

For an attach/detach workflow, a subsequent debugger should be able to attach to the still-running application.

If reconnecting is intentionally unsupported, the server should at least close its listening socket when it stops accepting connections, so subsequent clients fail promptly rather than establishing TCP and waiting indefinitely.

Actual behavior and runtime evidence

  • The host service remains running.
  • The new IDE DAP session appears active but does not initialize successfully.
  • ss -ltnp shows localhost:4711 still in LISTEN, owned by the host JVM, with Recv-Q = 1 and backlog 50.
  • ss -tnp shows the new client connection in ESTAB; the server side has 370 unread bytes and is not associated with an accepted process file descriptor in the output. This is consistent with a connection waiting in the listener's accept queue.
  • A jcmd <pid> Thread.print dump contains neither DAP client connection thread nor DAP request handler.

The TCP observations and absence of DAP threads indicate that the new connection has reached the listening socket but no application thread is accepting it.

Source-level findings

Inspected the source JAR shipped with dap-tool:25.3.4.1, specifically com.oracle.truffle.tools.dap.server.DebugProtocolServerImpl:

  1. start(ServerSocket) (around lines 646–698) uses a OneTimeExecutor and invokes serverSocket.accept() exactly once. There is no accept loop. Once the first connection's listener finishes, the client socket is closed and the task returns, leaving the server socket open.
  2. The server socket is closed by a callback registered with onDispose(...).
  3. disconnect(...) (around lines 264–286) sets disposed = true, closes the debugger session, and disposes the execution context, but does not run those server-disposal callbacks.
  4. dispose() (around lines 288–303) immediately returns if disposed is already true. This appears to prevent later disposal from running the registered server-socket cleanup after a protocol disconnect.

These findings suggest two related lifecycle problems: accepting only one client and leaving the listener open after disconnect. A reconnect fix likely needs fresh per-session debugger state as well as another accept; simply adding an accept loop may not be sufficient.

The exact DAP disconnect payload from the IDE was not captured, so the disconnect() cleanup path above is source analysis rather than a verified protocol trace.

Impact

The DAP debugger can only be used once per application run in this scenario. Restarting the IDE's DAP configuration alone does not restore debugging. The open listener makes the failure look like a hung connection rather than an unavailable debugger.

Is a single DAP client connection per Engine intentional? If so, could the listener be closed reliably on disconnect, and is there a supported way to re-enable DAP for an existing long-running Engine?