Skip to content

[lldb-dap] Adding server mode support to lldb-dap VSCode extension. #128957

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Feb 28, 2025

Conversation

ashgti
Copy link
Contributor

@ashgti ashgti commented Feb 26, 2025

This adds support for launching lldb-dap in server mode. The extension will start lldb-dap in server mode on-demand and retain the server until the VSCode window is closed (when the extension context is disposed). While running in server mode, launch performance for binaries is greatly improved by improving caching between debug sessions.

For example, on my local M1 Max laptop it takes ~5s to attach for the first attach to an iOS Simulator process and ~0.5s to attach each time after the first.

This adds support for launching lldb-dap in server mode. The extension will start lldb-dap in server mode on-demand and retain the server until the VSCode window is closed (when the extension context is disposed). While running in server mode, launch performance for binaries is greatly improved by improving caching between debug sessions.

For example, on my local M1 Max laptop it takes ~5s to attach for the first attach to an iOS Simulator process and ~0.5s to attach each time after the first.
@llvmbot
Copy link
Member

llvmbot commented Feb 26, 2025

@llvm/pr-subscribers-lldb

Author: John Harrison (ashgti)

Changes

This adds support for launching lldb-dap in server mode. The extension will start lldb-dap in server mode on-demand and retain the server until the VSCode window is closed (when the extension context is disposed). While running in server mode, launch performance for binaries is greatly improved by improving caching between debug sessions.

For example, on my local M1 Max laptop it takes ~5s to attach for the first attach to an iOS Simulator process and ~0.5s to attach each time after the first.


Full diff: https://github.com/llvm/llvm-project/pull/128957.diff

3 Files Affected:

  • (modified) lldb/tools/lldb-dap/package.json (+7-1)
  • (modified) lldb/tools/lldb-dap/src-ts/debug-adapter-factory.ts (+57-23)
  • (modified) lldb/tools/lldb-dap/src-ts/extension.ts (+4-5)
diff --git a/lldb/tools/lldb-dap/package.json b/lldb/tools/lldb-dap/package.json
index 31d808eda4c35..51f392e386b22 100644
--- a/lldb/tools/lldb-dap/package.json
+++ b/lldb/tools/lldb-dap/package.json
@@ -88,6 +88,12 @@
           "additionalProperties": {
             "type": "string"
           }
+        },
+        "lldb-dap.serverMode": {
+          "scope": "resource",
+          "type": "boolean",
+          "description": "Run lldb-dap in server mode. When enabled, lldb-dap will start a background server that will be reused between debug sessions. This can improve launch performance and caching of debug symbols between debug sessions.",
+          "default": false
         }
       }
     },
@@ -543,4 +549,4 @@
       }
     ]
   }
-}
+}
\ No newline at end of file
diff --git a/lldb/tools/lldb-dap/src-ts/debug-adapter-factory.ts b/lldb/tools/lldb-dap/src-ts/debug-adapter-factory.ts
index 36107336ebc4d..89a76d7e6f40c 100644
--- a/lldb/tools/lldb-dap/src-ts/debug-adapter-factory.ts
+++ b/lldb/tools/lldb-dap/src-ts/debug-adapter-factory.ts
@@ -4,6 +4,8 @@ import * as vscode from "vscode";
 import * as child_process from "child_process";
 import * as fs from "node:fs/promises";
 
+const exec = util.promisify(child_process.execFile);
+
 export async function isExecutable(path: string): Promise<Boolean> {
   try {
     await fs.access(path, fs.constants.X_OK);
@@ -16,7 +18,6 @@ export async function isExecutable(path: string): Promise<Boolean> {
 async function findWithXcrun(executable: string): Promise<string | undefined> {
   if (process.platform === "darwin") {
     try {
-      const exec = util.promisify(child_process.execFile);
       let { stdout, stderr } = await exec("/usr/bin/xcrun", [
         "-find",
         executable,
@@ -24,7 +25,7 @@ async function findWithXcrun(executable: string): Promise<string | undefined> {
       if (stdout) {
         return stdout.toString().trimEnd();
       }
-    } catch (error) {}
+    } catch (error) { }
   }
   return undefined;
 }
@@ -97,8 +98,15 @@ async function getDAPExecutable(
  * depending on the session configuration.
  */
 export class LLDBDapDescriptorFactory
-  implements vscode.DebugAdapterDescriptorFactory
-{
+  implements vscode.DebugAdapterDescriptorFactory, vscode.Disposable {
+  private server?: Promise<{ process: child_process.ChildProcess, host: string, port: number }>;
+
+  dispose() {
+    this.server?.then(({ process }) => {
+      process.kill();
+    });
+  }
+
   async createDebugAdapterDescriptor(
     session: vscode.DebugSession,
     executable: vscode.DebugAdapterExecutable | undefined,
@@ -115,7 +123,18 @@ export class LLDBDapDescriptorFactory
     }
     const configEnvironment =
       config.get<{ [key: string]: string }>("environment") || {};
-    const dapPath = await getDAPExecutable(session);
+    const dapPath = (await getDAPExecutable(session)) ?? executable?.command;
+
+    if (!dapPath) {
+      LLDBDapDescriptorFactory.showLLDBDapNotFoundMessage();
+      return undefined;
+    }
+
+    if (!(await isExecutable(dapPath))) {
+      LLDBDapDescriptorFactory.showLLDBDapNotFoundMessage(dapPath);
+      return;
+    }
+
     const dbgOptions = {
       env: {
         ...executable?.options?.env,
@@ -123,30 +142,45 @@ export class LLDBDapDescriptorFactory
         ...env,
       },
     };
-    if (dapPath) {
-      if (!(await isExecutable(dapPath))) {
-        LLDBDapDescriptorFactory.showLLDBDapNotFoundMessage(dapPath);
-        return undefined;
-      }
-      return new vscode.DebugAdapterExecutable(dapPath, [], dbgOptions);
-    } else if (executable) {
-      if (!(await isExecutable(executable.command))) {
-        LLDBDapDescriptorFactory.showLLDBDapNotFoundMessage(executable.command);
-        return undefined;
-      }
-      return new vscode.DebugAdapterExecutable(
-        executable.command,
-        executable.args,
-        dbgOptions,
-      );
+    const dbgArgs = executable?.args ?? [];
+
+    const serverMode = config.get<boolean>('serverMode', false);
+    if (serverMode) {
+      const { host, port } = await this.startServer(dapPath, dbgArgs, dbgOptions);
+      return new vscode.DebugAdapterServer(port, host);
     }
-    return undefined;
+
+    return new vscode.DebugAdapterExecutable(dapPath, dbgArgs, dbgOptions);
+  }
+
+  startServer(dapPath: string, args: string[], options: child_process.CommonSpawnOptions): Promise<{ host: string, port: number }> {
+    if (this.server) return this.server;
+
+    this.server = new Promise(resolve => {
+      args.push(
+        '--connection',
+        'connect://localhost:0'
+      );
+      const server = child_process.spawn(dapPath, args, options);
+      server.stdout!.setEncoding('utf8').on('data', (data: string) => {
+        const connection = /connection:\/\/\[([^\]]+)\]:(\d+)/.exec(data);
+        if (connection) {
+          const host = connection[1];
+          const port = Number(connection[2]);
+          resolve({ process: server, host, port });
+        }
+      });
+      server.on('exit', () => {
+        this.server = undefined;
+      })
+    });
+    return this.server;
   }
 
   /**
    * Shows a message box when the debug adapter's path is not found
    */
-  static async showLLDBDapNotFoundMessage(path: string) {
+  static async showLLDBDapNotFoundMessage(path?: string) {
     const openSettingsAction = "Open Settings";
     const callbackValue = await vscode.window.showErrorMessage(
       `Debug adapter path: ${path} is not a valid file`,
diff --git a/lldb/tools/lldb-dap/src-ts/extension.ts b/lldb/tools/lldb-dap/src-ts/extension.ts
index 71fd48298f8f5..a07bcdebcb68b 100644
--- a/lldb/tools/lldb-dap/src-ts/extension.ts
+++ b/lldb/tools/lldb-dap/src-ts/extension.ts
@@ -1,5 +1,3 @@
-import * as path from "path";
-import * as util from "util";
 import * as vscode from "vscode";
 
 import {
@@ -15,13 +13,14 @@ import { DisposableContext } from "./disposable-context";
 export class LLDBDapExtension extends DisposableContext {
   constructor() {
     super();
+    const factory = new LLDBDapDescriptorFactory();
+    this.pushSubscription(factory);
     this.pushSubscription(
       vscode.debug.registerDebugAdapterDescriptorFactory(
         "lldb-dap",
-        new LLDBDapDescriptorFactory(),
-      ),
+        factory,
+      )
     );
-
     this.pushSubscription(
       vscode.workspace.onDidChangeConfiguration(async (event) => {
         if (event.affectsConfiguration("lldb-dap.executable-path")) {

Copy link
Member

@JDevlieghere JDevlieghere left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Random question: what happens when the server crashes? Do you get a popup in the UI telling you?

"lldb-dap.serverMode": {
"scope": "resource",
"type": "boolean",
"description": "Run lldb-dap in server mode. When enabled, lldb-dap will start a background server that will be reused between debug sessions. This can improve launch performance and caching of debug symbols between debug sessions.",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can make this a stronger statement. Maybe something like: "This allows caching of things like debug symbols and should improve launch performance".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the wording, let me know what you think.

…he handler to collect the connection parameter to only run once.
@ashgti
Copy link
Contributor Author

ashgti commented Feb 28, 2025

Random question: what happens when the server crashes? Do you get a popup in the UI telling you?

If the server is killed while the debug session is running then the debug session stops. This is the same behavior as today when running not in server mode.

If the server crashes between debug sessions a new one will be spawned on demand. I don't currently alert the user if it shuts down or is killed and I don't have an idle timeout or anything. The server is running under the extension host process and VS Code uses one extension host process per window. That means the server will be around until the window is closed or the user quits VS Code.

As far as server management goes, I don't have any explicit logic for that at the moment, but we could add it in the future.

Some thoughts:

  • The server could shut itself down if the process gets a low memory warning and there are no active clients.
  • The server could have an idle timeout and shutdown if there are not active clients after some configurable period.
  • The extension could alert the user to unexpected crashes or exits.
  • The extension could have commands to manually start / stop the server.
  • We could run one server per host instead of the default behavior of one per-window. Its possible to spawn the server in a different process group than the extension host process and advertise the connection parameters in a different way (e.g. the server can use a unix socket and place the socket in ~/.lldb-dap-sock then if the socket file exists we could use it across extension hosts).

The main downside to the background server is the memory usage between debug sessions. For example, having the server attach to an iOS application then site between debug sessions results in a process consuming 1.3GB of memory for my machine.

At the moment though, I am keeping this relatively simple.

Copy link
Member

@JDevlieghere JDevlieghere left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@@ -15,13 +13,14 @@ import { DisposableContext } from "./disposable-context";
export class LLDBDapExtension extends DisposableContext {
constructor() {
super();
const factory = new LLDBDapDescriptorFactory();
this.pushSubscription(factory);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this do?

Copy link
Contributor Author

@ashgti ashgti Feb 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made the factory itself implement vscode.Disposable so if the extension is deactivated it will stop the server, if its running.

@JDevlieghere
Copy link
Member

Some thoughts:

  • The server could shut itself down if the process gets a low memory warning and there are no active clients.

There is SBDebugger::MemoryPressureDetected that removes modules with a zero refcount. That might be a good API to wire up first.

  • The server could have an idle timeout and shutdown if there are not active clients after some configurable period.
  • The extension could alert the user to unexpected crashes or exits.

This sounds like a nice to have and I think should be fairly straightforward to hook up.

  • The extension could have commands to manually start / stop the server.
  • We could run one server per host instead of the default behavior of one per-window. Its possible to spawn the server in a different process group than the extension host process and advertise the connection parameters in a different way (e.g. the server can use a unix socket and place the socket in ~/.lldb-dap-sock then if the socket file exists we could use it across extension hosts).

That's what Xcode does with the lldb-rpc-server and that's definitely going to be the most efficient, but also the most complicated to hook up.

Thanks for putting this list together.I think all the options you mentioned make sense.

The main downside to the background server is the memory usage between debug sessions. For example, having the server attach to an iOS application then site between debug sessions results in a process consuming 1.3GB of memory for my machine.

Yup, we see the same thing with the lldb-rpc-server. I wonder how much calling MemoryPressureDetected would improve things. That said, we definitely have things that will only grow over time (like the ConstString pool).

At the moment though, I am keeping this relatively simple.

👍

@ashgti
Copy link
Contributor Author

ashgti commented Feb 28, 2025

I filed #129283 to track some of the improvements discussed in the comments so they're not lost.

@ashgti ashgti merged commit a3ac1f2 into llvm:main Feb 28, 2025
6 of 9 checks passed
cheezeburglar pushed a commit to cheezeburglar/llvm-project that referenced this pull request Feb 28, 2025
…lvm#128957)

This adds support for launching lldb-dap in server mode. The extension
will start lldb-dap in server mode on-demand and retain the server until
the VSCode window is closed (when the extension context is disposed).
While running in server mode, launch performance for binaries is greatly
improved by improving caching between debug sessions.

For example, on my local M1 Max laptop it takes ~5s to attach for the
first attach to an iOS Simulator process and ~0.5s to attach each time
after the first.
Copy link
Member

@vogelsgesang vogelsgesang left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your persistence to get this across the finish line! 🎉

A couple of (late) review comments below. Sorry for the late review :/

}

startServer(dapPath: string, args: string[], options: child_process.CommonSpawnOptions): Promise<{ host: string, port: number }> {
if (this.server) return this.server;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

afaict, this does not work correctly in combination with #126803. We might end up reusing the wrong lldb-dap binary

Not sure what this means for us, though... We also can't just kill the old server when we receive a different lldb-dap path, because we could have two separate debugging sessions running at the same time, each one using a different server binary. I guess the most robust solution would be to use a Map to associate each lldb-dap path with its own Promise<{ process: child_process.ChildProcess, host: string, port: number }>?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a simple solution could be to just ask the user if we can kill the running server or if they want to reuse the existing server. I don't expect that users will be changing lldb-dap paths often, I hope.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discussion continued in #129262 (comment)

'connect://localhost:0'
);
const server = child_process.spawn(dapPath, args, options);
server.stdout!.setEncoding('utf8').once('data', (data: string) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this actually "race-free" (for lack of a better term)?

Looking at https://nodejs.org/api/stream.html#event-data I am not sure if we have a guarantee that the complete stdout content will be delivered in a single data event or if the connection://... string could be split across multiple data events?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A single 'data' event usually corresponds to a single 'write' event which

llvm::outs() << "Listening for: " << address << "\n";
should correspond to one call.

"scope": "resource",
"type": "boolean",
"markdownDescription": "Run lldb-dap in server mode.\n\nWhen enabled, lldb-dap will start a background server that will be reused between debug sessions. This allows caching of debug symbols between sessions and improves launch performance.",
"default": false
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we have plans to enable this by default in the future? I think we should. In my experience, users rarely fiddle with the default settings, and to allow the widest possible user base to benefit from your great work, I think we should aim for making server-mode the default. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should have more testing of the setting first. I know the unit tests are failing on Windows right now and I'm not sure why at the moment. Right now this is fairly basic, I have #129283 filed to look into improving server management operations in the extension code.

We may be able to enable it on macOS and linux sooner than Windows, we'd just want to do some more testing before making it the default.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 sounds good to me

SquallATF pushed a commit to SquallATF/llvm-project that referenced this pull request Mar 10, 2025
…lvm#128957)

This adds support for launching lldb-dap in server mode. The extension
will start lldb-dap in server mode on-demand and retain the server until
the VSCode window is closed (when the extension context is disposed).
While running in server mode, launch performance for binaries is greatly
improved by improving caching between debug sessions.

For example, on my local M1 Max laptop it takes ~5s to attach for the
first attach to an iOS Simulator process and ~0.5s to attach each time
after the first.
SquallATF pushed a commit to SquallATF/llvm-project that referenced this pull request Mar 20, 2025
…lvm#128957)

This adds support for launching lldb-dap in server mode. The extension
will start lldb-dap in server mode on-demand and retain the server until
the VSCode window is closed (when the extension context is disposed).
While running in server mode, launch performance for binaries is greatly
improved by improving caching between debug sessions.

For example, on my local M1 Max laptop it takes ~5s to attach for the
first attach to an iOS Simulator process and ~0.5s to attach each time
after the first.
SquallATF pushed a commit to SquallATF/llvm-project that referenced this pull request Apr 2, 2025
…lvm#128957)

This adds support for launching lldb-dap in server mode. The extension
will start lldb-dap in server mode on-demand and retain the server until
the VSCode window is closed (when the extension context is disposed).
While running in server mode, launch performance for binaries is greatly
improved by improving caching between debug sessions.

For example, on my local M1 Max laptop it takes ~5s to attach for the
first attach to an iOS Simulator process and ~0.5s to attach each time
after the first.
SquallATF pushed a commit to SquallATF/llvm-project that referenced this pull request Apr 17, 2025
…lvm#128957)

This adds support for launching lldb-dap in server mode. The extension
will start lldb-dap in server mode on-demand and retain the server until
the VSCode window is closed (when the extension context is disposed).
While running in server mode, launch performance for binaries is greatly
improved by improving caching between debug sessions.

For example, on my local M1 Max laptop it takes ~5s to attach for the
first attach to an iOS Simulator process and ~0.5s to attach each time
after the first.
SquallATF pushed a commit to SquallATF/llvm-project that referenced this pull request Apr 30, 2025
…lvm#128957)

This adds support for launching lldb-dap in server mode. The extension
will start lldb-dap in server mode on-demand and retain the server until
the VSCode window is closed (when the extension context is disposed).
While running in server mode, launch performance for binaries is greatly
improved by improving caching between debug sessions.

For example, on my local M1 Max laptop it takes ~5s to attach for the
first attach to an iOS Simulator process and ~0.5s to attach each time
after the first.
SquallATF pushed a commit to SquallATF/llvm-project that referenced this pull request May 15, 2025
…lvm#128957)

This adds support for launching lldb-dap in server mode. The extension
will start lldb-dap in server mode on-demand and retain the server until
the VSCode window is closed (when the extension context is disposed).
While running in server mode, launch performance for binaries is greatly
improved by improving caching between debug sessions.

For example, on my local M1 Max laptop it takes ~5s to attach for the
first attach to an iOS Simulator process and ~0.5s to attach each time
after the first.
SquallATF pushed a commit to SquallATF/llvm-project that referenced this pull request May 29, 2025
…lvm#128957)

This adds support for launching lldb-dap in server mode. The extension
will start lldb-dap in server mode on-demand and retain the server until
the VSCode window is closed (when the extension context is disposed).
While running in server mode, launch performance for binaries is greatly
improved by improving caching between debug sessions.

For example, on my local M1 Max laptop it takes ~5s to attach for the
first attach to an iOS Simulator process and ~0.5s to attach each time
after the first.
SquallATF pushed a commit to SquallATF/llvm-project that referenced this pull request Jun 13, 2025
…lvm#128957)

This adds support for launching lldb-dap in server mode. The extension
will start lldb-dap in server mode on-demand and retain the server until
the VSCode window is closed (when the extension context is disposed).
While running in server mode, launch performance for binaries is greatly
improved by improving caching between debug sessions.

For example, on my local M1 Max laptop it takes ~5s to attach for the
first attach to an iOS Simulator process and ~0.5s to attach each time
after the first.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants