213 lines · typescript
1import { FSWatcher, watch as chokidarWatch } from 'chokidar';2import * as child_process from "node:child_process";3import * as path from "path";4import { isDeepStrictEqual } from "util";5import * as vscode from "vscode";6 7/**8 * Represents a running lldb-dap process that is accepting connections (i.e. in "server mode").9 *10 * Handles startup of the process if it isn't running already as well as prompting the user11 * to restart when arguments have changed.12 */13export class LLDBDapServer implements vscode.Disposable {14 private serverProcess?: child_process.ChildProcessWithoutNullStreams;15 private serverInfo?: Promise<{ host: string; port: number }>;16 private serverSpawnInfo?: string[];17 // Detects changes to the lldb-dap executable file since the server's startup.18 private serverFileWatcher?: FSWatcher;19 // Indicates whether the lldb-dap executable file has changed since the server's startup.20 private serverFileChanged?: boolean;21 22 constructor() {23 vscode.commands.registerCommand(24 "lldb-dap.getServerProcess",25 () => this.serverProcess,26 );27 }28 29 /**30 * Starts the server with the provided options. The server will be restarted or reused as31 * necessary.32 *33 * @param dapPath the path to the debug adapter executable34 * @param args the list of arguments to provide to the debug adapter35 * @param options the options to provide to the debug adapter process36 * @returns a promise that resolves with the host and port information or `undefined` if unable to launch the server.37 */38 async start(39 dapPath: string,40 args: string[],41 options?: child_process.SpawnOptionsWithoutStdio,42 connectionTimeoutSeconds?: number,43 ): Promise<{ host: string; port: number } | undefined> {44 // Both the --connection and --connection-timeout arguments are subject to the shouldContinueStartup() check.45 const connectionTimeoutArgs =46 connectionTimeoutSeconds && connectionTimeoutSeconds > 047 ? ["--connection-timeout", `${connectionTimeoutSeconds}`]48 : [];49 const dapArgs = [50 ...args,51 "--connection",52 "listen://localhost:0",53 ...connectionTimeoutArgs,54 ];55 if (!(await this.shouldContinueStartup(dapPath, dapArgs, options?.env))) {56 return undefined;57 }58 59 if (this.serverInfo) {60 return this.serverInfo;61 }62 63 this.serverInfo = new Promise((resolve, reject) => {64 const process = child_process.spawn(dapPath, dapArgs, options);65 process.on("error", (error) => {66 reject(error);67 this.cleanUp(process);68 });69 process.on("exit", (code, signal) => {70 let errorMessage = "Server process exited early";71 if (code !== undefined) {72 errorMessage += ` with code ${code}`;73 } else if (signal !== undefined) {74 errorMessage += ` due to signal ${signal}`;75 }76 reject(new Error(errorMessage));77 this.cleanUp(process);78 });79 process.stdout.setEncoding("utf8").on("data", (data) => {80 const connection = /connection:\/\/\[([^\]]+)\]:(\d+)/.exec(81 data.toString(),82 );83 if (connection) {84 const host = connection[1];85 const port = Number(connection[2]);86 resolve({ host, port });87 process.stdout.removeAllListeners();88 }89 });90 this.serverProcess = process;91 this.serverSpawnInfo = this.getSpawnInfo(dapPath, dapArgs, options?.env);92 this.serverFileChanged = false;93 this.serverFileWatcher = chokidarWatch(dapPath);94 this.serverFileWatcher95 .on('change', () => this.serverFileChanged = true)96 .on('unlink', () => this.serverFileChanged = true);97 });98 return this.serverInfo;99 }100 101 /**102 * Checks to see if the server needs to be restarted. If so, it will prompt the user103 * to ask if they wish to restart.104 *105 * @param dapPath the path to the debug adapter106 * @param args the arguments for the debug adapter107 * @returns whether or not startup should continue depending on user input108 */109 private async shouldContinueStartup(110 dapPath: string,111 args: string[],112 env: NodeJS.ProcessEnv | { [key: string]: string } | undefined,113 ): Promise<boolean> {114 if (115 !this.serverProcess ||116 !this.serverInfo ||117 !this.serverSpawnInfo ||118 !this.serverFileWatcher ||119 this.serverFileChanged === undefined120 ) {121 return true;122 }123 124 const changeTLDR = [];125 const changeDetails = [];126 127 if (this.serverFileChanged) {128 changeTLDR.push("an old binary");129 }130 131 const newSpawnInfo = this.getSpawnInfo(dapPath, args, env);132 if (!isDeepStrictEqual(this.serverSpawnInfo, newSpawnInfo)) {133 changeTLDR.push("different arguments");134 changeDetails.push(`135The previous lldb-dap server was started with:136 137${this.serverSpawnInfo.join(" ")}138 139The new lldb-dap server will be started with:140 141${newSpawnInfo.join(" ")}142`143 );144 }145 146 // If the server hasn't changed, continue startup without killing it.147 if (changeTLDR.length === 0) {148 return true;149 }150 151 // The server has changed. Prompt the user to restart it.152 const userInput = await vscode.window.showInformationMessage(153 "The lldb-dap server has changed. Would you like to restart the server?",154 {155 modal: true,156 detail: `An existing lldb-dap server (${this.serverProcess.pid}) is running with ${changeTLDR.map(s => `*${s}*`).join(" and ")}.157${changeDetails.join("\n")}158Restarting the server will interrupt any existing debug sessions and start a new server.`,159 },160 "Restart",161 "Use Existing",162 );163 switch (userInput) {164 case "Restart":165 this.dispose();166 return true;167 case "Use Existing":168 return true;169 case undefined:170 return false;171 }172 }173 174 dispose() {175 if (!this.serverProcess) {176 return;177 }178 this.serverProcess.kill();179 this.cleanUp(this.serverProcess);180 }181 182 cleanUp(process: child_process.ChildProcessWithoutNullStreams) {183 // If the following don't equal, then the fields have already been updated184 // (either a new process has started, or the fields were already cleaned185 // up), and so the cleanup should be skipped.186 if (this.serverProcess === process) {187 this.serverProcess = undefined;188 this.serverInfo = undefined;189 this.serverSpawnInfo = undefined;190 this.serverFileWatcher?.close();191 this.serverFileWatcher = undefined;192 this.serverFileChanged = undefined;193 }194 }195 196 getSpawnInfo(197 path: string,198 args: string[],199 env: NodeJS.ProcessEnv | { [key: string]: string } | undefined,200 ): string[] {201 return [202 path,203 ...args,204 ...Object.entries(env ?? {})205 // Filter and sort to avoid restarting the server just because the206 // order of env changed or the log path changed.207 .filter((entry) => String(entry[0]) !== "LLDBDAP_LOG")208 .sort()209 .map((entry) => String(entry[0]) + "=" + String(entry[1])),210 ];211 }212}213