🔍

VSCodeからNetBSD6(i386)をSSH経由でgdbアタッチデバッグする

2024/05/04に公開

TL;DR

  • 拡張機能Native Debugを修正してNetBSD 6.0.1固有問題に対応させる
  • NetBSD向けのgdbは非同期モード不可のためgdbコマンド実行直前にkill -INTでデバッグ対象のプロセスに割り込みシグナルを送信する
  • アタッチ対象のプロセスIDをMS公式C/C++拡張機能のように動的選択できる機能を実装("target": "${command:selectAttachProcess}")
  • 変更差分
    1. プロセスID動的選択の変更差分
    2. 割り込み実装の変更差分
      ※1を適用してから2を適用する

背景

NetBSD(i386)でVSCode(MS公式C/C++拡張機能)からアタッチデバッグできない

  • BSD系が対象OSに含まれていない 無理やりOSチェックのパスを通してもプロセス一覧が出ないなど、解析困難のため使用断念。

Native Debugからのアタッチデバッグ挙動がおかしい

MS公式C/C++拡張機能でのgdbデバッグの代替拡張機能としてNative Debugがあるが、こちらでやろうとしてもいくつか問題が発生する。

  • 事前にブレークポイントを設定していないとデバッグモードに入れない
    デバッグ開始前にブレークポイントを設定しておき、デバッグ開始後に止まってデバッグモードのままであればブレークポイントを更新等することはできるが、継続後running状態の間はコマンドが送信されない。
    そのためブレークポイントを1つも設定しないままアタッチをすると、後からブレークポイントを設定できずデバッグモードに入る手段がない。※kill -INTで割り込んでデバッグモードにすることは可能。
    この根本的な原因としては、gdb側で「非同期モード」が有効になっていないためかと思われる。 この機能を有効にしておくとデバッグ対象のプログラムがrunning状態であってもgdbコマンドを送信することができるようになるので、デバッガのVSCode拡張機能がこの機能を有効にするようにgdbへコマンドを送っていれば基本的には問題なく動作する。
    だがNetBSD6.0.1についてはそもそもgdb側で対応できていなさそうで、おそらく機能が未実装でビルド時に無効化されていると思われる。
    gdb-7.3.1/gdb/target.c
    /* Controls if async mode is permitted.  */
    int target_async_permitted = 0;
    
    /* The set command writes to this variable.  If the inferior is
       executing, linux_nat_async_permitted is *not* updated.  */
    static int target_async_permitted_1 = 0;
    
    static void
    set_maintenance_target_async_permitted (char *args, int from_tty,
    					struct cmd_list_element *c)
    {
      if (have_live_inferiors ())
        {
          target_async_permitted_1 = target_async_permitted;
          error (_("Cannot change this setting while the inferior is running."));
        }
    
      target_async_permitted = target_async_permitted_1;
    }
    
  • デタッチするとプロセスが落ちる
    おそらくrunning中にコマンド送信ができないことが原因で、target-detachが処理されないままgdbが切られるなど想定していない動きをするせいではないかと思われる。
    src/backend/mi2/mi2.ts
    detach() {
      const proc = this.process;
      const to = setTimeout(() => {
      	process.kill(-proc.pid);
      }, 1000);
      this.process.on("exit", function (code) {
      	clearTimeout(to);
      });
      this.sendRaw("-target-detach");
    }
    
  • gdb-8.1.1ではアタッチ開始できない
    gdb-7.3.1では問題ないが自前ビルドのgdb-8.1.1を用いると、上記とは別でアタッチ時に以下エラーが表示されてデバッグが開始できないという問題が発生する。
    Failed to SSH: Couldn't get registers: Device busy. (from target-attach 711)
    
    直接リモートマシンでターミナルからgdbを叩いても全く同じエラーが出るが、デバッガ自体は継続している。
    /usr/local/bin/gdb
    
    GNU gdb (GDB) 8.1.1
    Copyright (C) 2018 Free Software Foundation, Inc.
    License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
    This is free software: you are free to change and redistribute it.
    There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
    and "show warranty" for details.
    This GDB was configured as "i486--netbsdelf".
    Type "show configuration" for configuration details.
    For bug reporting instructions, please see:
    <http://www.gnu.org/software/gdb/bugs/>.
    Find the GDB manual and other documentation resources online at:
    <http://www.gnu.org/software/gdb/documentation/>.
    For help, type "help".
    Type "apropos word" to search for commands related to "word".
    (gdb) attach 328
    Attaching to process 328
    stat 4 flag 1
    Couldn't get registers: Device busy.
    (gdb) Reading symbols from /root/sample/sample...done.
    Reading symbols from /usr/lib/libc.so.12...(no debugging symbols found)...done.
    Reading symbols from /usr/libexec/ld.elf_so...(no debugging symbols found)...done.
    0xbbafb2c7 in _sys___nanosleep50 () from /usr/lib/libc.so.12
    
    このエラーが出力される原因は未確認だが、その後もブレークポイントを設定できるなど動作上問題が発生することは確認できなかった。
    つまり、このエラーを拡張機能側で無視すれば一応デバッグが始められると推測する。

事前準備

開発環境の準備

VSCodeでのコンテナで開発する。以下を参考。

.devcontainer/devcontainer.json
{
  "name": "native-debug-dev",
  "dockerComposeFile": "./docker-compose.yml",
  "service": "native-debug-dev",
  "workspaceFolder": "/root/work",
  "onCreateCommand": "npm clean-install --omit=optional",
  "customizations": {
    "vscode": {
      "extensions": [
        "dbaeumer.vscode-eslint",
        "streetsidesoftware.code-spell-checker",
        "mhutchie.git-graph",
        "bradzacher.vscode-copy-filename"
      ]
    }
  }
}
.devcontainer/docker-compose.yml
version: "3.5"
services:
  native-debug-dev:
    image: node:16.20.2-bookworm
    tty: true
    environment:
      - TZ=Asia/Tokyo
    volumes:
      - "../code-debug:/root/work"
      - "../sample:/root/sample"
    working_dir: "/root/work"

.devcontainerのある階層でNative Debugをクローン

command
git clone https://github.com/WebFreak001/code-debug

sampleディレクトリはデバッグ動作確認用なので任意。

MS公式C/C++拡張機能のアタッチプロセスID動的選択相当の機能を実装

本題とは直接関係はないがアタッチ機能を頻繁に使用する都合上、本件に対応する。

Native Debugのやや使いづらい点としてrequestがattachの場合にlaunch.jsonでtargetにプロセスIDを直書きしなければならないことがある。
これだと毎回psコマンドで確認しなければならず、またソースコード管理もしにくくなるのであまり扱いやすいとは言えない。
一方でMS公式C/C++拡張機能では${command:pickProcess}あるいはリモートなら${command:pickRemoteProcess}を指定することでアタッチ対象のプロセスIDを動的に選択できる。

Extension/src/Debugger/attachToProcess.ts
export class RemoteAttachPicker {
    constructor() {
        this._channel = vscode.window.createOutputChannel('remote-attach');
    }

    private _channel: vscode.OutputChannel;

    public async ShowAttachEntries(config: any): Promise<string | undefined> {
        this._channel.clear();
        let processes: AttachItem[];

上記実装を参考に、MS公式C/C++拡張機能のプロセスID動的選択相当の機能をNative Debugに実装する。

プロセスID動的選択の変更差分

変更差分
pid.diff
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -39,6 +39,10 @@
 			{
 				"command": "code-debug.examineMemoryLocation",
 				"title": "Code-Debug: Examine memory location"
+			},
+			{
+				"command": "code-debug.selectProcess",
+				"title": "Code-Debug: Select target attach process"
 			}
 		],
 		"breakpoints": [
@@ -137,7 +141,8 @@
 				],
 				"variables": {
 					"FileBasenameNoExt": "code-debug.getFileBasenameNoExt",
-					"FileNameNoExt": "code-debug.getFileNameNoExt"
+					"FileNameNoExt": "code-debug.getFileNameNoExt",
+					"selectAttachProcess": "code-debug.selectProcess"
 				},
 				"configurationAttributes": {
 					"launch": {
@@ -292,6 +297,11 @@
 									"bootstrap": {
 										"type": "string",
 										"description": "Content will be executed on the SSH host before the debugger call."
+									},
+									"forceIPAddressFamily": {
+										"type": "string",
+										"description": "Only connect via resolved IP address family for host",
+										"default": "IPv4"
 									}
 								}
 							}
@@ -305,7 +315,8 @@
 						"properties": {
 							"target": {
 								"type": "string",
-								"description": "PID of running program or program name or connection arguments (eg :2345) if remote is true"
+								"description": "PID of running program or program name or connection arguments (eg :2345) if remote is true",
+								"default": "${command:selectAttachProcess}"
 							},
 							"remote": {
 								"type": "boolean",
@@ -456,6 +467,11 @@
 									"bootstrap": {
 										"type": "string",
 										"description": "Content will be executed on the SSH host before the debugger call."
+									},
+									"forceIPAddressFamily": {
+										"type": "string",
+										"description": "Only connect via resolved IP address family for host",
+										"default": "IPv4"
 									}
 								}
 							}
@@ -492,7 +508,7 @@
 							"type": "gdb",
 							"request": "attach",
 							"name": "${2:Attach to PID}",
-							"target": "${1:[PID]}",
+							"target": "^\"\\${command:selectAttachProcess}\"",
 							"cwd": "^\"\\${workspaceRoot}\"",
 							"valuesFormatting": "parseText"
 						}
@@ -532,6 +548,27 @@
 							"valuesFormatting": "parseText"
 						}
 					},
+					{
+						"label": "GDB: Attach over SSH",
+						"description": "Remotely attaches to a running program pid using gdb",
+						"body": {
+							"type": "gdb",
+							"request": "attach",
+							"name": "${6:Attach Program (SSH)}",
+							"target": "^\"\\${command:selectAttachProcess}\"",
+							"cwd": "^\"\\${workspaceRoot}\"",
+							"ssh": {
+								"host": "${2:127.0.0.1}",
+								"cwd": "${3:/tmp/working}",
+								"keyfile": "${4:/home/my_user/.ssh/id_rsa}",
+								"user": "${5:remote_user}",
+								"sourceFileMap": {
+									"${6:/home/remote_user/project/}": "^\"\\${workspaceRoot}\""
+								}
+							},
+							"valuesFormatting": "parseText"
+						}
+					},
 					{
 						"label": "GDB: Launch GUI over SSH with X11 forwarding",
 						"description": "Remotely starts the program using gdb with X11 forwarding",
@@ -604,7 +641,8 @@
 				],
 				"variables": {
 					"FileBasenameNoExt": "code-debug.getFileBasenameNoExt",
-					"FileNameNoExt": "code-debug.getFileNameNoExt"
+					"FileNameNoExt": "code-debug.getFileNameNoExt",
+					"selectAttachProcess": "code-debug.selectProcess"
 				},
 				"configurationAttributes": {
 					"launch": {
@@ -754,6 +792,11 @@
 									"bootstrap": {
 										"type": "string",
 										"description": "Content will be executed on the SSH host before the debugger call."
+									},
+									"forceIPAddressFamily": {
+										"type": "string",
+										"description": "Only connect via resolved IP address family for host",
+										"default": "IPv4"
 									}
 								}
 							}
@@ -766,7 +809,8 @@
 						"properties": {
 							"target": {
 								"type": "string",
-								"description": "PID of running program or program name"
+								"description": "PID of running program or program name",
+								"default": "${command:selectAttachProcess}"
 							},
 							"valuesFormatting": {
 								"type": "string",
@@ -870,7 +914,7 @@
 							"type": "lldb-mi",
 							"request": "attach",
 							"name": "${2:Attach to PID}",
-							"target": "${1:[PID]}",
+							"target": "^\"\\${command:selectAttachProcess}\"",
 							"cwd": "^\"\\${workspaceRoot}\"",
 							"valuesFormatting": "parseText"
 						}
@@ -923,7 +967,8 @@
 				"label": "Mago-MI",
 				"variables": {
 					"FileBasenameNoExt": "code-debug.getFileBasenameNoExt",
-					"FileNameNoExt": "code-debug.getFileNameNoExt"
+					"FileNameNoExt": "code-debug.getFileNameNoExt",
+					"selectAttachProcess": "code-debug.selectProcess"
 				},
 				"configurationAttributes": {
 					"launch": {
@@ -992,7 +1037,8 @@
 						"properties": {
 							"target": {
 								"type": "string",
-								"description": "PID of running program or program name"
+								"description": "PID of running program or program name",
+								"default": "${command:selectAttachProcess}"
 							},
 							"valuesFormatting": {
 								"type": "string",
@@ -1081,7 +1127,7 @@
 							"type": "mago-mi",
 							"request": "attach",
 							"name": "${2:Attach to PID}",
-							"target": "${1:[PID]}",
+							"target": "^\"\\${command:selectAttachProcess}\"",
 							"cwd": "^\"\\${workspaceRoot}\"",
 							"valuesFormatting": "parseText"
 						}
diff --git a/src/backend/backend.ts b/src/backend/backend.ts
--- a/src/backend/backend.ts
+++ b/src/backend/backend.ts
@@ -52,6 +52,7 @@ export interface SSHArguments {
 	x11host: string;
 	bootstrap: string;
 	sourceFileMap: { [index: string]: string };
+	forceIPAddressFamily?: string;
 }
 
 export interface IBackend {
diff --git a/src/backend/mi2/mi2.ts b/src/backend/mi2/mi2.ts
--- a/src/backend/mi2/mi2.ts
+++ b/src/backend/mi2/mi2.ts
@@ -134,6 +134,13 @@ export class MI2 extends EventEmitter implements IBackend {
 			} else {
 				connectionArgs.password = args.password;
 			}
+			if (args.forceIPAddressFamily) {
+				if (!["IPv6"].includes(args.forceIPAddressFamily)) {
+					connectionArgs.forceIPv4 = true;
+				} else {
+					connectionArgs.forceIPv6 = true;
+				}
+			}
 
 			this.sshConn.on("ready", () => {
 				this.log("stdout", "Running " + this.application + " over ssh...");
diff --git a/src/frontend/extension.ts b/src/frontend/extension.ts
--- a/src/frontend/extension.ts
+++ b/src/frontend/extension.ts
@@ -3,6 +3,8 @@ import * as net from "net";
 import * as fs from "fs";
 import * as path from "path";
 import * as os from "os";
+import { supportedUnixOSCommand, supportedWinOSCommand, executeShellCommand, ExecCommandResult } from "../utils/process";
+import { SSHConnectionArgs, SSHClient } from "../utils/ssh";
 
 export function activate(context: vscode.ExtensionContext) {
 	context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider("debugmemory", new MemoryContentProvider()));
@@ -25,6 +27,7 @@ export function activate(context: vscode.ExtensionContext) {
 		const ext = path.extname(fileName);
 		return fileName.substring(0, fileName.length - ext.length);
 	}));
+	context.subscriptions.push(vscode.commands.registerCommand("code-debug.selectProcess", (settings) => selectProcess(settings)));
 }
 
 const memoryLocationRegex = /^0x[0-9a-f]+$/;
@@ -173,3 +176,180 @@ function center(str: string, width: number): string {
 	}
 	return str;
 }
+
+const selectProcessSupportedUnixOSList = ["Linux", "NetBSD", "Darwin", "Toybox"];
+const toyboxOSRegex = new RegExp(".*Toybox.*");
+const darwinOSRegex = new RegExp(".*Darwin.*");
+const checkUnixOSCommand = supportedUnixOSCommand(selectProcessSupportedUnixOSList);
+const generalUnixProcessCommand = "ps axww -o pid,comm,args";
+const darwinProcessCommand = "ps axww -o pid,comm,args -c";
+const toyboxProcessCommand = "ps -A -o pid,comm,args";
+const winProcessCommand = "powershell -NoProfile -Command \"Get-CimInstance Win32_Process"
+	+ " | Select-Object Name,ProcessId,CommandLine | ConvertTo-JSON -Compress\"";
+const unixProcessRegex: RegExp = new RegExp(`^\\s*([0-9]+)\\s+(.+)$`);
+
+interface ProcessPickItem extends vscode.QuickPickItem {
+	pid: string;
+}
+
+function showAndSelectProcess(psCmmandResult: ExecCommandResult, isWin?: boolean): Promise<String> {
+	return new Promise((resolve, reject) => {
+		if (psCmmandResult.code === 0) {
+			const processItems: ProcessPickItem[] = [];
+			if (!isWin) {
+				const stdOutLines: string[] = psCmmandResult.stdout.split("\n");
+				for (let i: number = 1; i < stdOutLines.length; i++) {
+					const matches: RegExpExecArray = unixProcessRegex.exec(
+						stdOutLines[i]
+					);
+					if (matches) {
+						const pid = matches[1].trim();
+						const args = matches[2].trim();
+						const name = args.split(" ")[0];
+						processItems.push({
+							label: name,
+							description: pid,
+							detail: args.substring(name.length).trim(),
+							pid: pid
+						});
+					}
+				}
+			} else {
+				JSON.parse(psCmmandResult.stdout).forEach((p: any) => {
+					processItems.push({
+						label: p.Name,
+						description: `${p.ProcessId}`,
+						detail: p.CommandLine || "",
+						pid: `${p.ProcessId}`
+					});
+				});
+			}
+			processItems.sort((a, b) => {
+				const labelA = (a.label || "").toLowerCase();
+				const labelB = (b.label || "").toLowerCase();
+				return labelA === labelB ? 0 : (labelA < labelB ? -1 : 1);
+			});
+			const procPick: vscode.QuickPick<ProcessPickItem> = vscode.window.createQuickPick<ProcessPickItem>();
+			procPick.title = "Select the debug target process";
+			procPick.canSelectMany = false;
+			procPick.matchOnDescription = true;
+			procPick.matchOnDetail = true;
+			procPick.placeholder = "Attach to...";
+			procPick.items = processItems;
+
+			procPick.onDidAccept(() => {
+				procPick.dispose();
+			});
+
+			procPick.onDidHide(() => {
+				if (procPick.selectedItems.length !== 1) {
+					procPick.dispose();
+					reject(new Error("no process selected"));
+					return;
+				}
+
+				const pid: string = procPick.selectedItems[0].pid;
+				procPick.dispose();
+				resolve(pid);
+			});
+			procPick.show();
+		} else {
+			reject(new Error(`Get process failed\ncode: ${psCmmandResult.code} stderr: ${psCmmandResult.stderr}`));
+		}
+	});
+}
+
+function getUnixProcessCommand(targetOS: string): string {
+	return toyboxOSRegex.exec(targetOS) ? toyboxProcessCommand :
+		(darwinOSRegex.exec(targetOS) ? darwinProcessCommand : generalUnixProcessCommand);
+}
+
+function selectProcess(settings?: any): Promise<String> {
+	return new Promise((resolve, reject) => {
+		const sshSettings = settings !== undefined ? settings.ssh : undefined;
+		if (sshSettings !== undefined) {
+			const sshArgs: SSHConnectionArgs = {
+				host: sshSettings.host,
+				port: sshSettings.port,
+				username: sshSettings.user
+			};
+
+			if (sshSettings.useAgent) {
+				sshArgs.agent = process.env.SSH_AUTH_SOCK;
+			} else if (sshSettings.keyfile) {
+				if (fs.existsSync(sshSettings.keyfile))
+					sshArgs.privateKey = fs.readFileSync(sshSettings.keyfile);
+				else {
+					reject(new Error("SSH key file does not exist!"));
+					return;
+				}
+			} else {
+				sshArgs.password = sshSettings.password;
+			}
+			if (sshSettings.forceIPAddressFamily) {
+				if (!["IPv6"].includes(sshSettings.forceIPAddressFamily)) {
+					sshArgs.forceIPv4 = true;
+				} else {
+					sshArgs.forceIPv6 = true;
+				}
+			}
+
+			const client: SSHClient = new SSHClient(sshArgs);
+			let succeededCheckOS = false;
+			client.connect(
+				() => {
+					client.executeCommand(checkUnixOSCommand).then((unixOSCommandResult: ExecCommandResult) => {
+						if (unixOSCommandResult.stdout) {
+							if (unixOSCommandResult.stderr) {
+								vscode.window.showWarningMessage(`Check unix OS failed over ssh! stderr:${unixOSCommandResult.stderr}`);
+							}
+							client.executeCommand(getUnixProcessCommand(unixOSCommandResult.stdout)).then((psCommandResult: ExecCommandResult) => {
+								succeededCheckOS = true;
+								client.end();
+								showAndSelectProcess(psCommandResult).then(pid => resolve(pid), reject);
+							});
+						} else {
+							client.executeCommand(supportedWinOSCommand).then((osWinCommandResult: ExecCommandResult) => {
+								if (osWinCommandResult.code === 0) {
+									client.executeCommand(winProcessCommand).then((psCommandResult: ExecCommandResult) => {
+										succeededCheckOS = true;
+										client.end();
+										showAndSelectProcess(psCommandResult, true).then(pid => resolve(pid), reject);
+									});
+								} else {
+									client.end();
+									reject(new Error(`Check OS failed over ssh!\ncode: ${osWinCommandResult.code} stderr:${osWinCommandResult.stderr}`));
+								}
+							});
+						}
+					}).catch((err: Error) => {
+						client.end();
+						reject(new Error(`Could not run (${checkUnixOSCommand}) over ssh!\n${err}`));
+					});
+				},
+				(err: Error) => {
+					if (!succeededCheckOS)
+						reject(new Error(`Error running over ssh!\n${err}`));
+				}
+			);
+		} else if (settings !== undefined) {
+			const isWin = process.platform === "win32";
+			executeShellCommand(!isWin ? checkUnixOSCommand : supportedWinOSCommand).then((osCommandResult: ExecCommandResult) => {
+				if (osCommandResult.stdout) {
+					if (osCommandResult.stderr) {
+						vscode.window.showWarningMessage(`Check OS failed on local! stderr: ${osCommandResult.stderr}`);
+					}
+					executeShellCommand(!isWin ? getUnixProcessCommand(osCommandResult.stdout) : winProcessCommand).then((psCommandResult: ExecCommandResult) => {
+						showAndSelectProcess(psCommandResult, isWin).then(pid => resolve(pid), reject);
+					});
+				} else {
+					reject(new Error(`Check OS failed on local!\ncode: ${osCommandResult.code} stderr: ${osCommandResult.stderr}`));
+				}
+			}).catch((err: Error) => {
+				reject(new Error(`Could not run (${checkUnixOSCommand}) on local!\n${err}`));
+			});
+		} else {
+			reject(new Error("You should call this command in launch.json"));
+		}
+	});
+}
diff --git a/src/utils/process.ts b/src/utils/process.ts
--- /dev/null
+++ b/src/utils/process.ts
@@ -0,0 +1,47 @@
+import { spawn } from "child_process";
+
+export interface ExecCommandResult {
+	command: string;
+	stdout?: string;
+	stderr?: string;
+	code: number;
+	signal?: string;
+}
+
+export function supportedUnixOSCommand(OSList: string[], existCodeWithError: number = 1): string {
+	return "sh -c 'echo $(uname) && if " + OSList.map(e => `[ "$(expr $(uname) : ".*${e}.*")" -eq 0 ]`).join(' && ') + '; then ' +
+	`echo "Unsupported OS: $(uname)\nSupported: ${OSList}" >&2 && exit ${existCodeWithError}; fi'`;
+}
+export const supportedWinOSCommand: string = "systeminfo";
+
+export function executeShellCommand(command: string): Promise<ExecCommandResult> {
+	return new Promise(resolve => {
+		const process = spawn(command, { shell: true });
+
+		let stdout: string = undefined;
+		process.stdout.on('data', (data: string) => {
+			if (stdout === undefined) {
+				stdout = "";
+			}
+			stdout += data.toString();
+		});
+
+		let stderr: string = undefined;
+		process.stderr.on('data', (data: string) => {
+			if (stderr === undefined) {
+				stderr = "";
+			}
+			stderr += data.toString();
+		});
+
+		process.on('close', (code: number, signal: string) => {
+			resolve({
+				command: command,
+				stdout: stdout !== undefined ? stdout.trim() : undefined,
+				stderr: stderr !== undefined ? stderr.trim() : undefined,
+				code: code,
+				signal: signal,
+			});
+		});
+	});
+}
diff --git a/src/utils/ssh.ts b/src/utils/ssh.ts
--- /dev/null
+++ b/src/utils/ssh.ts
@@ -0,0 +1,88 @@
+import { Client, ClientChannel } from "ssh2";
+import { ExecCommandResult } from "./process";
+
+export interface SSHConnectionArgs {
+	host: string;
+	port: number;
+	username: string;
+	password?: string;
+	privateKey?: Buffer;
+	agent?: string;
+	forceIPv4?: boolean;
+	forceIPv6?: boolean;
+}
+
+export class SSHClient {
+	private client: Client;
+	private args: SSHConnectionArgs;
+
+	public constructor(args: SSHConnectionArgs) {
+		this.args = args;
+	}
+
+	connect(onReady?: () => void, onError?: (error: Error) => void): void {
+		if (this.client !== undefined) {
+			return;
+		}
+		const client = new Client();
+		this.client = client
+			.on("ready", () => {
+				if (onReady !== undefined)
+					onReady();
+			})
+			.on("error", (error: Error) => {
+				if (onError !== undefined)
+					onError(new Error(`error occurred on connecting the ssh.\n${error}`));
+			}).connect(this.args);
+	}
+
+	end(): void {
+		this.client.end();
+		this.client = undefined;
+	}
+
+	executeCommand(command: string): Promise<ExecCommandResult> {
+		return new Promise((resolve, reject) => {
+			this.client.exec(command, (err: Error, stream: ClientChannel) => {
+				if (err) {
+					reject(new Error(`error occurred on executing the ssh command.\n${err}`));
+					return;
+				}
+
+				let stdout: string = undefined;
+				stream.on("data", (data: Buffer) => {
+					if (stdout === undefined) {
+						stdout = "";
+					}
+					try {
+						stdout += data.toString();
+					} catch (e) {
+						reject(new Error(`error occurred on reading the ssh stdout.\n${err}`));
+					}
+				});
+
+				let stderr: string = undefined;
+				stream.stderr.on("data", (data: Buffer) => {
+					if (stderr === undefined) {
+						stderr = "";
+					}
+					try {
+						stderr += data.toString();
+					} catch (e) {
+						reject(new Error(`error occurred on reading the ssh stderr.\n${err}`));
+					}
+				});
+
+				stream.on("close", (code: number, signal: string) => {
+					resolve({
+						command: command,
+						stdout: stdout !== undefined ? stdout.trim() : undefined,
+						stderr: stderr !== undefined ? stderr.trim() : undefined,
+						code: code,
+						signal: signal,
+					});
+				});
+			});
+		});
+	}
+}

追加の2ファイルは後述の割り込み実装で再利用するため共通化している。

SSH接続時localhost等ホスト名を指定した場合に、強制的にIPv4/IPv6での接続を指定できるようにしている(ssh: forceIPAddressFamily)。
※デバッグ対象がLinux(Ubuntu)、NetBSDのみで動作確認済み。その他Unixは未確認。
Windowsでも選択はできるが、元の実装時点で正常にデタッチできないのでそもそもアタッチ非推奨。

修正後ビルド

command
npx vsce package

ビルド後ルート直下にdebug-***.vsixというファイルができるので、直接VSIXからのインストールを実行する。

launch.jsonでの設定方法

targetに${command:selectAttachProcess}と設定することでプロセス一覧から選択できる。
ただしNetBSDの場合ps axww -o pid,comm,argsでのcomm列の出力結果が途中で切れるようなため、一覧も一部短縮された形となる。(そもそもLinuxと出力結果が異なる)

launch.json
    "configurations": [
        {
            "type": "gdb",
            "request": "attach",
            "name": "Attach Program (SSH)",
            "target": "${command:selectAttachProcess}",

kill -INTによる割り込みを用いたgdbコマンド送信の実装

方針

  • launch.jsonwithoutAsynctrueに設定することでgdb-set target-async onを設定する代わりに毎コマンド送信時にkill -INTで割り込む
    未設定かつOSがNetBSDなら強制的にtrueにする
  • kill -INT実行時デバッグモードでない場合、各コマンド実行後にexec-continueを実行する
  • launch時のkill -INT用の対象プロセスIDの取得は、gdbからの通知メッセージを解析する
  • デバッガ存在チェックに失敗することがある(SSH接続してもローカルのパスを見ている?)ので、launch.jsonskipCheckDebuggertrueに設定することでチェックを無視する
  • gdb-8.1.1だとtarget-attachでエラーになり落ちるので、launch.jsonsuppressAttachFailuretrueに設定することでエラーを無視する
  • traceフラグがハードコーディングされているので、launch.jsontraceを設定することで出力を切り替える(本題とは直接関係なし)

割り込み実装の変更差分

プロセスID動的選択の変更差分を適用してからの差分。

変更差分
sigint.diff
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -209,6 +209,11 @@
 								"description": "Prints all GDB responses to the console",
 								"default": false
 							},
+							"trace": {
+								"type": "boolean",
+								"description": "Trace debug log",
+								"default": false
+							},
 							"autorun": {
 								"type": "array",
 								"description": "GDB commands to run when starting to debug",
@@ -222,6 +227,24 @@
 								"description": "Whether debugger should stop at application entry point",
 								"default": false
 							},
+							"skipCheckDebugger": {
+								"type": "boolean",
+								"description": "Skip check debugger",
+								"default": false
+							},
+							"withoutAsync": {
+								"type": [
+									"boolean",
+									"null"
+								],
+								"description": "Experimental: Interrupt with kill -INT instead of set async in OS where it cannot be used",
+								"default": null
+							},
+							"suppressAttachFailure": {
+								"type": "boolean",
+								"description": "Experimental: Supress failure when attaching process",
+								"default": false
+							},
 							"ssh": {
 								"required": [
 									"host",
@@ -343,6 +366,11 @@
 								"description": "Prints all GDB responses to the console",
 								"default": false
 							},
+							"trace": {
+								"type": "boolean",
+								"description": "Trace debug log",
+								"default": false
+							},
 							"executable": {
 								"type": "string",
 								"description": "Path of executable for debugging symbols"
@@ -392,6 +420,24 @@
 								"description": "Whether debugger should stop at application entry point",
 								"default": false
 							},
+							"skipCheckDebugger": {
+								"type": "boolean",
+								"description": "Skip check debugger",
+								"default": false
+							},
+							"withoutAsync": {
+								"type": [
+									"boolean",
+									"null"
+								],
+								"description": "Experimental: Interrupt with kill -INT instead of set async in OS where it cannot be used",
+								"default": null
+							},
+							"suppressAttachFailure": {
+								"type": "boolean",
+								"description": "Experimental: Supress failure when attaching process",
+								"default": false
+							},
 							"ssh": {
 								"required": [
 									"host",
@@ -704,6 +750,11 @@
 								"description": "Prints all lldb responses to the console",
 								"default": false
 							},
+							"trace": {
+								"type": "boolean",
+								"description": "Trace debug log",
+								"default": false
+							},
 							"autorun": {
 								"type": "array",
 								"description": "lldb commands to run when starting to debug",
@@ -717,6 +768,24 @@
 								"description": "Whether debugger should stop at application entry point",
 								"default": false
 							},
+							"skipCheckDebugger": {
+								"type": "boolean",
+								"description": "Skip check debugger",
+								"default": false
+							},
+							"withoutAsync": {
+								"type": [
+									"boolean",
+									"null"
+								],
+								"description": "Experimental: Interrupt with kill -INT instead of set async in OS where it cannot be used",
+								"default": null
+							},
+							"suppressAttachFailure": {
+								"type": "boolean",
+								"description": "Experimental: Supress failure when attaching process",
+								"default": false
+							},
 							"ssh": {
 								"required": [
 									"host",
@@ -832,6 +901,11 @@
 								"description": "Prints all LLDB responses to the console",
 								"default": false
 							},
+							"trace": {
+								"type": "boolean",
+								"description": "Trace debug log",
+								"default": false
+							},
 							"executable": {
 								"type": "string",
 								"description": "Path of executable for debugging symbols"
@@ -880,6 +954,24 @@
 								],
 								"description": "Whether debugger should stop at application entry point",
 								"default": false
+							},
+							"skipCheckDebugger": {
+								"type": "boolean",
+								"description": "Skip check debugger",
+								"default": false
+							},
+							"withoutAsync": {
+								"type": [
+									"boolean",
+									"null"
+								],
+								"description": "Experimental: Interrupt with kill -INT instead of set async in OS where it cannot be used",
+								"default": null
+							},
+							"suppressAttachFailure": {
+								"type": "boolean",
+								"description": "Experimental: Supress failure when attaching process",
+								"default": false
 							}
 						}
 					}
@@ -1023,10 +1115,33 @@
 								"description": "Prints all mago responses to the console",
 								"default": false
 							},
+							"trace": {
+								"type": "boolean",
+								"description": "Trace debug log",
+								"default": false
+							},
 							"autorun": {
 								"type": "array",
 								"description": "mago commands to run when starting to debug",
 								"default": []
+							},
+							"skipCheckDebugger": {
+								"type": "boolean",
+								"description": "Skip check debugger",
+								"default": false
+							},
+							"withoutAsync": {
+								"type": [
+									"boolean",
+									"null"
+								],
+								"description": "Experimental: Interrupt with kill -INT instead of set async in OS where it cannot be used",
+								"default": null
+							},
+							"suppressAttachFailure": {
+								"type": "boolean",
+								"description": "Experimental: Supress failure when attaching process",
+								"default": false
 							}
 						}
 					},
@@ -1060,6 +1175,11 @@
 								"description": "Prints all mago responses to the console",
 								"default": false
 							},
+							"trace": {
+								"type": "boolean",
+								"description": "Trace debug log",
+								"default": false
+							},
 							"executable": {
 								"type": "string",
 								"description": "Path of executable for debugging symbols"
@@ -1093,6 +1213,24 @@
 								"type": "boolean",
 								"description": "Whether debugger should stop after connecting to target",
 								"default": false
+							},
+							"skipCheckDebugger": {
+								"type": "boolean",
+								"description": "Skip check debugger",
+								"default": false
+							},
+							"withoutAsync": {
+								"type": [
+									"boolean",
+									"null"
+								],
+								"description": "Experimental: Interrupt with kill -INT instead of set async in OS where it cannot be used",
+								"default": null
+							},
+							"suppressAttachFailure": {
+								"type": "boolean",
+								"description": "Experimental: Supress failure when attaching process",
+								"default": false
 							}
 						}
 					}
diff --git a/src/backend/backend.ts b/src/backend/backend.ts
--- a/src/backend/backend.ts
+++ b/src/backend/backend.ts
@@ -61,8 +61,8 @@ export interface IBackend {
 	attach(cwd: string, executable: string, target: string, autorun: string[]): Thenable<any>;
 	connect(cwd: string, executable: string, target: string, autorun: string[]): Thenable<any>;
 	start(runToStart: boolean): Thenable<boolean>;
-	stop(): void;
-	detach(): void;
+	stop(): Thenable<void>;
+	detach(): Thenable<void>;
 	interrupt(): Thenable<boolean>;
 	continue(): Thenable<boolean>;
 	next(): Thenable<boolean>;
diff --git a/src/backend/mi2/mi2.ts b/src/backend/mi2/mi2.ts
--- a/src/backend/mi2/mi2.ts
+++ b/src/backend/mi2/mi2.ts
@@ -7,6 +7,8 @@ import * as net from "net";
 import * as fs from "fs";
 import * as path from "path";
 import { Client, ClientChannel, ExecOptions } from "ssh2";
+import { supportedUnixOSCommand, executeShellCommand, ExecCommandResult } from "../../utils/process";
+import { SSHClient, SSHConnectionArgs } from "../../utils/ssh";
 
 export function escape(str: string) {
 	return str.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
@@ -16,14 +18,19 @@ const nonOutput = /^(?:\d*|undefined)[\*\+\=]|[\~\@\&\^]/;
 const gdbMatch = /(?:\d*|undefined)\(gdb\)/;
 const numRegex = /\d+/;
 
+const withoutAsyncSupportedUnixOSList = ["Linux", "NetBSD"];
+const withoutAsyncSupportedOSRegex = new RegExp(`.*(${withoutAsyncSupportedUnixOSList.join("|")}).*`);
+const withoutAsyncForcelyEnabledOSRegex = new RegExp(`.*(${["NetBSD"].join("|")}).*`);
+const pidRegex = /^id,i1,pid,(\d+)$/;
+const commandInterrupt = "exec-interrupt";
+const commandInterruptRegex = new RegExp(`^\\d+-${commandInterrupt}$`);
+
 function couldBeOutput(line: string) {
 	if (nonOutput.exec(line))
 		return false;
 	return true;
 }
 
-const trace = false;
-
 export class MI2 extends EventEmitter implements IBackend {
 	constructor(public application: string, public preargs: string[], public extraargs: string[], procEnv: any, public extraCommands: string[] = []) {
 		super();
@@ -114,17 +121,17 @@ export class MI2 extends EventEmitter implements IBackend {
 				});
 			}
 
-			const connectionArgs: any = {
+			this.sshArgs = {
 				host: args.host,
 				port: args.port,
 				username: args.user
 			};
 
 			if (args.useAgent) {
-				connectionArgs.agent = process.env.SSH_AUTH_SOCK;
+				this.sshArgs.agent = process.env.SSH_AUTH_SOCK;
 			} else if (args.keyfile) {
 				if (fs.existsSync(args.keyfile))
-					connectionArgs.privateKey = fs.readFileSync(args.keyfile);
+					this.sshArgs.privateKey = fs.readFileSync(args.keyfile);
 				else {
 					this.log("stderr", "SSH key file does not exist!");
 					this.emit("quit");
@@ -132,15 +139,16 @@ export class MI2 extends EventEmitter implements IBackend {
 					return;
 				}
 			} else {
-				connectionArgs.password = args.password;
+				this.sshArgs.password = args.password;
 			}
 			if (args.forceIPAddressFamily) {
 				if (!["IPv6"].includes(args.forceIPAddressFamily)) {
-					connectionArgs.forceIPv4 = true;
+					this.sshArgs.forceIPv4 = true;
 				} else {
-					connectionArgs.forceIPv6 = true;
+					this.sshArgs.forceIPv6 = true;
 				}
 			}
+			this.sshClient = new SSHClient(this.sshArgs);
 
 			this.sshConn.on("ready", () => {
 				this.log("stdout", "Running " + this.application + " over ssh...");
@@ -176,7 +184,7 @@ export class MI2 extends EventEmitter implements IBackend {
 					promises.push(this.sendCommand("environment-cd \"" + escape(cwd) + "\""));
 					if (attach) {
 						// Attach to local process
-						promises.push(this.sendCommand("target-attach " + target));
+						promises.push(this.sendCommand("target-attach " + target, this.suppressAttachFailure));
 					} else if (procArgs && procArgs.length)
 						promises.push(this.sendCommand("exec-arguments " + procArgs));
 					promises.push(...autorun.map(value => { return this.sendUserInput(value); }));
@@ -193,7 +201,7 @@ export class MI2 extends EventEmitter implements IBackend {
 				this.log("stderr", err.toString());
 				this.emit("quit");
 				reject();
-			}).connect(connectionArgs);
+			}).connect(this.sshArgs);
 		});
 	}
 
@@ -203,13 +211,64 @@ export class MI2 extends EventEmitter implements IBackend {
 		// Since the CWD is expected to be an absolute path in the debugger's environment, we can test
 		// that to determine the path type used by the debugger and use the result of that test to
 		// select the correct API to check whether the target path is an absolute path.
+		const pid = attach ? target : undefined;
 		const debuggerPath = path.posix.isAbsolute(cwd) ? path.posix : path.win32;
 
 		if (!debuggerPath.isAbsolute(target))
 			target = debuggerPath.join(cwd, target);
 
 		const cmds = [
-			this.sendCommand("gdb-set target-async on", true),
+			new Promise((resolve, reject) => {
+				const checkWithoutAsync = (unixOSCommandResult: ExecCommandResult) => {
+					if (unixOSCommandResult.code === 0) {
+						const unixOSName = unixOSCommandResult.stdout;
+						if (this.withoutAsync && !withoutAsyncSupportedOSRegex.exec(unixOSName)) {
+							this.log("stderr", `WARNING: ${unixOSName} is not supported for withoutAsync\nSupported: ${withoutAsyncSupportedUnixOSList}`);
+						} else if (this.withoutAsync === undefined && withoutAsyncForcelyEnabledOSRegex.exec(unixOSName)) {
+							this.withoutAsync = true;
+							this.log("stderr", `WARNING: withoutAsync is forcely enabled for ${unixOSName}`);
+						}
+					} else {
+						if (this.withoutAsync) {
+							this.log("stderr", `WARMING: check OS failed: ${unixOSCommandResult.code}\n${unixOSCommandResult.stderr}`);
+						}
+					}
+					this.withoutAsync = !!this.withoutAsync;
+					if (!this.withoutAsync) {
+						this.sendCommand("gdb-set target-async on", true).then(_ => {
+							resolve(undefined);
+						});
+					} else {
+						if (attach) {
+							this.sigIntCommand = `kill -INT ${pid}`;
+						}
+						resolve(undefined);
+					}
+				};
+
+				const checkUnixOSCommand = supportedUnixOSCommand(withoutAsyncSupportedUnixOSList, 0);
+				if (!this.isSSH) {
+					if (this.debugOutput) {
+						this.log("log", `execute shell command: ${checkUnixOSCommand}`);
+					}
+					executeShellCommand(checkUnixOSCommand)
+						.then((unixOSCommandResult: ExecCommandResult) => {
+							checkWithoutAsync(unixOSCommandResult);
+						});
+				} else {
+					this.sshClient.connect(() => {
+						if (this.debugOutput) {
+							this.log("log", `execute ssh command: ${checkUnixOSCommand}`);
+						}
+						this.sshClient.executeCommand(checkUnixOSCommand)
+							.then((unixOSCommandResult: ExecCommandResult) => {
+								checkWithoutAsync(unixOSCommandResult);
+							});
+					}, (error: Error) => {
+						reject(new Error(`ssh connection failed:\n${error}`));
+					});
+				}
+			}),
 			new Promise(resolve => {
 				this.sendCommand("list-features").then(done => {
 					this.features = done.result("features");
@@ -253,7 +312,7 @@ export class MI2 extends EventEmitter implements IBackend {
 				// Attach to local process
 				if (executable)
 					promises.push(this.sendCommand("file-exec-and-symbols \"" + escape(executable) + "\""));
-				promises.push(this.sendCommand("target-attach " + target));
+				promises.push(this.sendCommand("target-attach " + target, this.suppressAttachFailure));
 			}
 			promises.push(...autorun.map(value => { return this.sendUserInput(value); }));
 			Promise.all(promises).then(() => {
@@ -287,7 +346,7 @@ export class MI2 extends EventEmitter implements IBackend {
 	}
 
 	stdout(data: any) {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "stdout: " + data);
 		if (typeof data == "string")
 			this.buffer += data;
@@ -364,18 +423,20 @@ export class MI2 extends EventEmitter implements IBackend {
 						} else {
 							if (record.type == "exec") {
 								this.emit("exec-async-output", parsed);
-								if (record.asyncClass == "running")
+								if (record.asyncClass == "running") {
+									this.isStopping = false;
 									this.emit("running", parsed);
-								else if (record.asyncClass == "stopped") {
+								} else if (record.asyncClass == "stopped") {
+									this.isStopping = true;
 									const reason = parsed.record("reason");
 									if (reason === undefined) {
-										if (trace)
+										if (this.trace)
 											this.log("stderr", "stop (no reason given)");
 										// attaching to a process stops, but does not provide a reason
 										// also python generated interrupt seems to only produce this
 										this.emit("step-other", parsed);
 									} else {
-										if (trace)
+										if (this.trace)
 											this.log("stderr", "stop: " + reason);
 										switch (reason) {
 											case "breakpoint-hit":
@@ -427,10 +488,25 @@ export class MI2 extends EventEmitter implements IBackend {
 												this.emit("stopped", parsed);
 												break;
 										}
+										if (reason === "signal-received") {
+											const sigIntCounters = Object.keys(this.sigInthandlers);
+											if (0 < sigIntCounters.length) {
+												const minSigIntCouter = Math.min(...sigIntCounters.map(Number));
+												this.sigInthandlers[minSigIntCouter](parsed);
+												delete this.sigInthandlers[minSigIntCouter];
+											}
+										}
 									}
 								} else
 									this.log("log", JSON.stringify(parsed));
 							} else if (record.type == "notify") {
+								if (this.withoutAsync && this.sigIntCommand === undefined && 1 < (record.output || []).length) {
+									const output = [...record.output[0], ...record.output[1]].join(",");
+									const pidMatched = pidRegex.exec(output);
+									if (pidMatched) {
+										this.sigIntCommand = `kill -INT ${pidMatched[1]}`;
+									}
+								}
 								if (record.asyncClass == "thread-created") {
 									this.emit("thread-created", parsed);
 								} else if (record.asyncClass == "thread-exited") {
@@ -465,51 +541,164 @@ export class MI2 extends EventEmitter implements IBackend {
 		});
 	}
 
-	stop() {
-		if (this.isSSH) {
-			const proc = this.stream;
-			const to = setTimeout(() => {
-				proc.signal("KILL");
-			}, 1000);
-			this.stream.on("exit", function (code) {
-				clearTimeout(to);
-			});
-			this.sendRaw("-gdb-exit");
-		} else {
+	stop(): Thenable<void> {
+		return new Promise(resolve => {
+			const execStop = () => {
+				const sendExitCommand = () => this.sendCommand("gdb-exit");
+				if (!this.withoutAsync) {
+					sendExitCommand().then(_ => {
+						resolve();
+					});
+				} else {
+					this.sigInt().then(_ => {
+						sendExitCommand().then(_ => {
+							resolve();
+						});
+					});
+				}
+			};
+			if (this.isSSH) {
+				const proc = this.stream;
+				const to = setTimeout(() => {
+					if (proc !== undefined) {
+						proc.signal("KILL");
+					}
+					resolve();
+				}, 1000);
+				if (this.stream !== undefined) {
+					this.stream.on("exit", function (code) {
+						clearTimeout(to);
+						resolve();
+					});
+				}
+				execStop();
+			} else {
+				const proc = this.process;
+				const to = setTimeout(() => {
+					if (proc !== undefined) {
+						process.kill(-proc.pid);
+					}
+					resolve();
+				}, 1000);
+				if (this.process !== undefined) {
+					this.process.on("exit", function (code) {
+						clearTimeout(to);
+						resolve();
+					});
+				}
+				execStop();
+			}
+		});
+	}
+
+	detach(): Thenable<void> {
+		return new Promise(resolve => {
 			const proc = this.process;
 			const to = setTimeout(() => {
-				process.kill(-proc.pid);
+				if (proc !== undefined) {
+					process.kill(-proc.pid);
+				}
+				resolve();
 			}, 1000);
-			this.process.on("exit", function (code) {
-				clearTimeout(to);
-			});
-			this.sendRaw("-gdb-exit");
-		}
-	}
-
-	detach() {
-		const proc = this.process;
-		const to = setTimeout(() => {
-			process.kill(-proc.pid);
-		}, 1000);
-		this.process.on("exit", function (code) {
-			clearTimeout(to);
+			if (this.process !== undefined) {
+				this.process.on("exit", function (code) {
+					clearTimeout(to);
+					resolve();
+				});
+			}
+			const execDetach = () => {
+				this.sendCommand("target-detach").then(_ => {
+					resolve();
+				});
+			};
+			if (!this.withoutAsync) {
+				execDetach();
+			} else {
+				this.sigInt().then(_ => {
+					execDetach();
+				});
+			}
 		});
-		this.sendRaw("-target-detach");
 	}
 
 	interrupt(): Thenable<boolean> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "interrupt");
 		return new Promise((resolve, reject) => {
-			this.sendCommand("exec-interrupt").then((info) => {
-				resolve(info.resultRecords.resultClass == "done");
-			}, reject);
+			if (!this.withoutAsync) {
+				this.sendCommand("exec-interrupt").then((info) => {
+					resolve(info.resultRecords.resultClass == "done");
+				}, reject);
+			} else {
+				this.sigInt().then(([result, _]) => {
+					resolve(result);
+				}, reject);
+			}
+		});
+	}
+
+	sigInt(): Thenable<[boolean, boolean]> {
+		if (this.trace) {
+			this.log("stderr", "SIGINT");
+		}
+		const beforeStopping = this.isStopping;
+		return new Promise((resolve, reject) => {
+			if (beforeStopping) {
+				if (this.debugOutput) {
+					this.log("stderr", "SIGINT is skipped");
+				}
+				resolve([true, beforeStopping]);
+				return;
+			}
+			if (this.sigIntCommand === undefined) {
+				this.log("stderr", "SIGINT command is not initialized");
+				resolve([false, beforeStopping]);
+				return;
+			}
+			if (this.printCalls) {
+				this.log("log", this.sigIntCommand);
+			}
+			let sigIntSucceeded = false;
+			const counter = this.sigIntCounter++;
+			const sigIntTimer = setTimeout(() => {
+				if (sigIntSucceeded && this.sigInthandlers[counter]) {
+					this.log("stderr", `SIGINT handle timeout: ${counter}`);
+					this.sigInthandlers[counter](undefined);
+					delete this.sigInthandlers[counter];
+				}
+			}, 1000);
+			this.sigInthandlers[counter] = (_) => {
+				clearTimeout(sigIntTimer);
+				if (this.debugOutput) {
+					this.log("stderr", `SIGINT handled: ${counter}`);
+				}
+				resolve([true, beforeStopping]);
+			};
+			const checkSigIntResult = (result: ExecCommandResult) => {
+				if (result.code === 0) {
+					sigIntSucceeded = true;
+					if (this.debugOutput) {
+						this.log("stderr", `waiting for SIGINT handle: ${counter}`);
+					}
+				} else {
+					this.log("stderr", `SIGINT failed: ${result.code} ${result.stderr}`);
+					resolve([false, beforeStopping]);
+				}
+			};
+			if (!this.isSSH) {
+				executeShellCommand(this.sigIntCommand).then(result => {
+					checkSigIntResult(result);
+				}, reject);
+			} else {
+				this.sshClient.executeCommand(this.sigIntCommand).then(result => {
+					checkSigIntResult(result);
+				}, reject);
+			}
 		});
 	}
 
 	continue(reverse: boolean = false): Thenable<boolean> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "continue");
 		return new Promise((resolve, reject) => {
 			this.sendCommand("exec-continue" + (reverse ? " --reverse" : "")).then((info) => {
@@ -519,7 +708,7 @@ export class MI2 extends EventEmitter implements IBackend {
 	}
 
 	next(reverse: boolean = false): Thenable<boolean> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "next");
 		return new Promise((resolve, reject) => {
 			this.sendCommand("exec-next" + (reverse ? " --reverse" : "")).then((info) => {
@@ -529,7 +718,7 @@ export class MI2 extends EventEmitter implements IBackend {
 	}
 
 	step(reverse: boolean = false): Thenable<boolean> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "step");
 		return new Promise((resolve, reject) => {
 			this.sendCommand("exec-step" + (reverse ? " --reverse" : "")).then((info) => {
@@ -539,7 +728,7 @@ export class MI2 extends EventEmitter implements IBackend {
 	}
 
 	stepOut(reverse: boolean = false): Thenable<boolean> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "stepOut");
 		return new Promise((resolve, reject) => {
 			this.sendCommand("exec-finish" + (reverse ? " --reverse" : "")).then((info) => {
@@ -549,7 +738,7 @@ export class MI2 extends EventEmitter implements IBackend {
 	}
 
 	goto(filename: string, line: number): Thenable<Boolean> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "goto");
 		return new Promise((resolve, reject) => {
 			const target: string = '"' + (filename ? escape(filename) + ":" : "") + line + '"';
@@ -562,13 +751,13 @@ export class MI2 extends EventEmitter implements IBackend {
 	}
 
 	changeVariable(name: string, rawValue: string): Thenable<any> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "changeVariable");
 		return this.sendCommand("gdb-set var " + name + "=" + rawValue);
 	}
 
 	loadBreakPoints(breakpoints: Breakpoint[]): Thenable<[boolean, Breakpoint][]> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "loadBreakPoints");
 		const promisses: Thenable<[boolean, Breakpoint]>[] = [];
 		breakpoints.forEach(breakpoint => {
@@ -578,7 +767,7 @@ export class MI2 extends EventEmitter implements IBackend {
 	}
 
 	setBreakPointCondition(bkptNum: number, condition: string): Thenable<any> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "setBreakPointCondition");
 		return this.sendCommand("break-condition " + bkptNum + " " + condition);
 	}
@@ -588,7 +777,7 @@ export class MI2 extends EventEmitter implements IBackend {
 	}
 
 	addBreakPoint(breakpoint: Breakpoint): Thenable<[boolean, Breakpoint]> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "addBreakPoint");
 		return new Promise((resolve, reject) => {
 			if (this.breakpoints.has(breakpoint))
@@ -610,73 +799,128 @@ export class MI2 extends EventEmitter implements IBackend {
 				location += '"' + escape(breakpoint.raw) + '"';
 			else
 				location += '"' + escape(breakpoint.file) + ":" + breakpoint.line + '"';
-			this.sendCommand("break-insert -f " + location).then((result) => {
-				if (result.resultRecords.resultClass == "done") {
-					const bkptNum = parseInt(result.result("bkpt.number"));
-					const newBrk = {
-						file: breakpoint.file ? breakpoint.file : result.result("bkpt.file"),
-						raw: breakpoint.raw,
-						line: parseInt(result.result("bkpt.line")),
-						condition: breakpoint.condition
-					};
-					if (breakpoint.condition) {
-						this.setBreakPointCondition(bkptNum, breakpoint.condition).then((result) => {
-							if (result.resultRecords.resultClass == "done") {
-								this.breakpoints.set(newBrk, bkptNum);
-								resolve([true, newBrk]);
+			const execAddBreakpoint: () => Thenable<[boolean, Breakpoint]> = () => {
+				return new Promise((_resolve, _reject) => {
+					this.sendCommand("break-insert -f " + location).then((result) => {
+						if (result.resultRecords.resultClass == "done") {
+							const bkptNum = parseInt(result.result("bkpt.number"));
+							const newBrk = {
+								file: breakpoint.file ? breakpoint.file : result.result("bkpt.file"),
+								raw: breakpoint.raw,
+								line: parseInt(result.result("bkpt.line")),
+								condition: breakpoint.condition
+							};
+							if (breakpoint.condition) {
+								this.setBreakPointCondition(bkptNum, breakpoint.condition).then((result) => {
+									if (result.resultRecords.resultClass == "done") {
+										this.breakpoints.set(newBrk, bkptNum);
+										_resolve([true, newBrk]);
+									} else {
+										_resolve([false, undefined]);
+									}
+								}, _reject);
 							} else {
-								resolve([false, undefined]);
+								this.breakpoints.set(newBrk, bkptNum);
+								_resolve([true, newBrk]);
 							}
-						}, reject);
-					} else {
-						this.breakpoints.set(newBrk, bkptNum);
-						resolve([true, newBrk]);
-					}
-				} else {
-					reject(result);
-				}
-			}, reject);
+						} else {
+							_reject(result);
+						}
+					}, _reject);
+				});
+			};
+			if (!this.withoutAsync) {
+				execAddBreakpoint().then(resolve, reject);
+			} else {
+				this.sigInt().then(([result, before]) => {
+					execAddBreakpoint().then(res => {
+						if (result && !before) {
+							this.continue().then(_ => {
+								resolve(res);
+							});
+						} else {
+							resolve(res);
+						}
+					});
+				});
+			}
 		});
 	}
 
 	removeBreakPoint(breakpoint: Breakpoint): Thenable<boolean> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "removeBreakPoint");
 		return new Promise((resolve, reject) => {
 			if (!this.breakpoints.has(breakpoint))
 				return resolve(false);
-			this.sendCommand("break-delete " + this.breakpoints.get(breakpoint)).then((result) => {
-				if (result.resultRecords.resultClass == "done") {
-					this.breakpoints.delete(breakpoint);
-					resolve(true);
-				} else resolve(false);
-			});
+			const execRemoveBreakpoint = () => {
+				return new Promise(_resolve => {
+					this.sendCommand("break-delete " + this.breakpoints.get(breakpoint)).then((result) => {
+						if (result.resultRecords.resultClass == "done") {
+							this.breakpoints.delete(breakpoint);
+							_resolve(true);
+						} else _resolve(false);
+					});
+				});
+			};
+			if (!this.withoutAsync) {
+				execRemoveBreakpoint().then(resolve, reject);
+			} else {
+				this.sigInt().then(([result, before]) => {
+					execRemoveBreakpoint().then(_ => {
+						if (result && !before) {
+							this.continue().then(_ => {
+								resolve(true);
+							});
+						} else {
+							resolve(true);
+						}
+					});
+				});
+			}
 		});
 	}
 
 	clearBreakPoints(source?: string): Thenable<any> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "clearBreakPoints");
 		return new Promise((resolve, reject) => {
 			const promises: Thenable<void | MINode>[] = [];
 			const breakpoints = this.breakpoints;
 			this.breakpoints = new Map();
-			breakpoints.forEach((k, index) => {
-				if (index.file === source) {
-					promises.push(this.sendCommand("break-delete " + k).then((result) => {
-						if (result.resultRecords.resultClass == "done") resolve(true);
-						else resolve(false);
-					}));
-				} else {
-					this.breakpoints.set(index, k);
-				}
-			});
-			Promise.all(promises).then(resolve, reject);
+			const execClearBreakPoints = () => {
+				breakpoints.forEach((k, index) => {
+					if (index.file === source) {
+						promises.push(this.sendCommand("break-delete " + k).then((result) => {
+							if (result.resultRecords.resultClass == "done") resolve(true);
+							else resolve(false);
+						}));
+					} else {
+						this.breakpoints.set(index, k);
+					}
+				});
+				return Promise.all(promises);
+			};
+			if (!this.withoutAsync) {
+				execClearBreakPoints().then(resolve, reject);
+			} else {
+				this.sigInt().then(([result, before]) => {
+					execClearBreakPoints().then(_ => {
+						if (result && !before) {
+							this.continue().then(_ => {
+								resolve(true);
+							});
+						} else {
+							resolve(true);
+						}
+					});
+				});
+			}
 		});
 	}
 
 	async getThreads(): Promise<Thread[]> {
-		if (trace) this.log("stderr", "getThreads");
+		if (this.trace) this.log("stderr", "getThreads");
 
 		const command = "thread-info";
 		const result = await this.sendCommand(command);
@@ -697,7 +941,7 @@ export class MI2 extends EventEmitter implements IBackend {
 	}
 
 	async getStack(startFrame: number, maxLevels: number, thread: number): Promise<Stack[]> {
-		if (trace) this.log("stderr", "getStack");
+		if (this.trace) this.log("stderr", "getStack");
 
 		const options: string[] = [];
 
@@ -746,7 +990,7 @@ export class MI2 extends EventEmitter implements IBackend {
 	}
 
 	async getStackVariables(thread: number, frame: number): Promise<Variable[]> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "getStackVariables");
 
 		const result = await this.sendCommand(`stack-list-variables --thread ${thread} --frame ${frame} --simple-values`);
@@ -767,7 +1011,7 @@ export class MI2 extends EventEmitter implements IBackend {
 	}
 
 	async getRegisters(): Promise<Variable[]> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "getRegisters");
 
 		// Getting register names and values are separate GDB commands.
@@ -791,7 +1035,7 @@ export class MI2 extends EventEmitter implements IBackend {
 	}
 
 	async getRegisterNames(): Promise<string[]> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "getRegisterNames");
 		const result = await this.sendCommand("data-list-register-names");
 		const names = result.result('register-names');
@@ -802,7 +1046,7 @@ export class MI2 extends EventEmitter implements IBackend {
 	}
 
 	async getRegisterValues(): Promise<RegisterValue[]> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "getRegisterValues");
 		const result = await this.sendCommand("data-list-register-values N");
 		const nodes = result.result('register-values');
@@ -818,7 +1062,7 @@ export class MI2 extends EventEmitter implements IBackend {
 	}
 
 	examineMemory(from: number, length: number): Thenable<any> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "examineMemory");
 		return new Promise((resolve, reject) => {
 			this.sendCommand("data-read-memory-bytes 0x" + from.toString(16) + " " + length).then((result) => {
@@ -828,7 +1072,7 @@ export class MI2 extends EventEmitter implements IBackend {
 	}
 
 	async evalExpression(name: string, thread: number, frame: number): Promise<MINode> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "evalExpression");
 
 		let command = "data-evaluate-expression ";
@@ -841,7 +1085,7 @@ export class MI2 extends EventEmitter implements IBackend {
 	}
 
 	async varCreate(threadId: number, frameLevel: number, expression: string, name: string = "-", frame: string = "@"): Promise<VariableObject> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "varCreate");
 		let miCommand = "var-create ";
 		if (threadId != 0) {
@@ -852,13 +1096,13 @@ export class MI2 extends EventEmitter implements IBackend {
 	}
 
 	async varEvalExpression(name: string): Promise<MINode> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "varEvalExpression");
 		return this.sendCommand(`var-evaluate-expression ${this.quote(name)}`);
 	}
 
 	async varListChildren(name: string): Promise<VariableObject[]> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "varListChildren");
 		//TODO: add `from` and `to` arguments
 		const res = await this.sendCommand(`var-list-children --all-values ${this.quote(name)}`);
@@ -868,13 +1112,13 @@ export class MI2 extends EventEmitter implements IBackend {
 	}
 
 	async varUpdate(name: string = "*"): Promise<MINode> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "varUpdate");
 		return this.sendCommand(`var-update --all-values ${this.quote(name)}`);
 	}
 
 	async varAssign(name: string, rawValue: string): Promise<MINode> {
-		if (trace)
+		if (this.trace)
 			this.log("stderr", "varAssign");
 		return this.sendCommand(`var-assign ${this.quote(name)} ${rawValue}`);
 	}
@@ -889,7 +1133,27 @@ export class MI2 extends EventEmitter implements IBackend {
 
 	sendUserInput(command: string, threadId: number = 0, frameLevel: number = 0): Thenable<MINode> {
 		if (command.startsWith("-")) {
-			return this.sendCommand(command.substring(1));
+			const rawCommand = command.substring(1);
+			const execSendCommand = () => this.sendCommand(rawCommand);
+			if (!this.withoutAsync) {
+				return execSendCommand();
+			} else {
+				return new Promise((resolve, reject) => {
+					this.sigInt().then(([_, before]) => {
+						const onCommandHandled = (callback: (result: any) => void, result: any) => {
+							if (!before && rawCommand !== commandInterrupt) {
+								this.continue().then(_ => callback(result));
+							} else {
+								callback(result);
+							}
+						};
+						execSendCommand().then(
+							(out) => onCommandHandled(resolve, out),
+							(reason) => onCommandHandled(reject, reason)
+						);
+					}, reject);
+				});
+			}
 		} else {
 			return this.sendCliCommand(command, threadId, frameLevel);
 		}
@@ -905,12 +1169,31 @@ export class MI2 extends EventEmitter implements IBackend {
 	}
 
 	sendCliCommand(command: string, threadId: number = 0, frameLevel: number = 0): Thenable<MINode> {
-		let miCommand = "interpreter-exec ";
-		if (threadId != 0) {
-			miCommand += `--thread ${threadId} --frame ${frameLevel} `;
-		}
-		miCommand += `console "${command.replace(/[\\"']/g, '\\$&')}"`;
-		return this.sendCommand(miCommand);
+		return new Promise((resolve, reject) => {
+			let miCommand = "interpreter-exec ";
+			if (threadId != 0) {
+				miCommand += `--thread ${threadId} --frame ${frameLevel} `;
+			}
+			miCommand += `console "${command.replace(/[\\"']/g, '\\$&')}"`;
+			if (!this.withoutAsync || this.sigIntCommand === undefined) {
+				return this.sendCommand(miCommand).then((out) => {
+					resolve(out);
+				});
+			} else {
+				return this.sigInt().then(([_, before]) => {
+					const onCommandHandled = (callback: (result: any) => void, result: any) => {
+						if (!before && command !== commandInterrupt) {
+							this.continue().then(_ => callback(result));
+						} else {
+							callback(result);
+						}
+					};
+					this.sendCommand(miCommand, true).then(
+						(out) => onCommandHandled(resolve, out),
+						(reason) => onCommandHandled(reject, reason));
+				});
+			}
+		});
 	}
 
 	sendCommand(command: string, suppressFailure: boolean = false): Thenable<MINode> {
@@ -942,6 +1225,11 @@ export class MI2 extends EventEmitter implements IBackend {
 	prettyPrint: boolean = true;
 	printCalls: boolean;
 	debugOutput: boolean;
+
+	trace: boolean;
+	withoutAsync: boolean;
+	suppressAttachFailure: boolean;
+
 	features: string[];
 	public procEnv: any;
 	protected isSSH: boolean;
@@ -954,4 +1242,11 @@ export class MI2 extends EventEmitter implements IBackend {
 	protected process: ChildProcess.ChildProcess;
 	protected stream: ClientChannel;
 	protected sshConn: Client;
+
+	protected sshArgs: SSHConnectionArgs;
+	protected sshClient: SSHClient;
+	protected sigIntCommand: string;
+	protected sigIntCounter: number = 1;
+	protected sigInthandlers: { [index: number]: (info: MINode) => any } = {};
+	protected isStopping: boolean = true;
 }
diff --git a/src/frontend/extension.ts b/src/frontend/extension.ts
--- a/src/frontend/extension.ts
+++ b/src/frontend/extension.ts
@@ -177,16 +177,14 @@ function center(str: string, width: number): string {
 	return str;
 }
 
-const selectProcessSupportedUnixOSList = ["Linux", "NetBSD", "Darwin", "Toybox"];
-const toyboxOSRegex = new RegExp(".*Toybox.*");
-const darwinOSRegex = new RegExp(".*Darwin.*");
-const checkUnixOSCommand = supportedUnixOSCommand(selectProcessSupportedUnixOSList);
+const selectProcessSupportedUnixList = ["Linux", "NetBSD", "Darwin", "Toybox"];
+const checkUnixOSCommand = supportedUnixOSCommand(selectProcessSupportedUnixList);
 const generalUnixProcessCommand = "ps axww -o pid,comm,args";
 const darwinProcessCommand = "ps axww -o pid,comm,args -c";
 const toyboxProcessCommand = "ps -A -o pid,comm,args";
 const winProcessCommand = "powershell -NoProfile -Command \"Get-CimInstance Win32_Process"
 	+ " | Select-Object Name,ProcessId,CommandLine | ConvertTo-JSON -Compress\"";
-const unixProcessRegex: RegExp = new RegExp(`^\\s*([0-9]+)\\s+(.+)$`);
+const unixProcessRegExp: RegExp = new RegExp(`^\\s*([0-9]+)\\s+(.+)$`);
 
 interface ProcessPickItem extends vscode.QuickPickItem {
 	pid: string;
@@ -199,7 +197,7 @@ function showAndSelectProcess(psCmmandResult: ExecCommandResult, isWin?: boolean
 			if (!isWin) {
 				const stdOutLines: string[] = psCmmandResult.stdout.split("\n");
 				for (let i: number = 1; i < stdOutLines.length; i++) {
-					const matches: RegExpExecArray = unixProcessRegex.exec(
+					const matches: RegExpExecArray = unixProcessRegExp.exec(
 						stdOutLines[i]
 					);
 					if (matches) {
@@ -260,8 +258,8 @@ function showAndSelectProcess(psCmmandResult: ExecCommandResult, isWin?: boolean
 }
 
 function getUnixProcessCommand(targetOS: string): string {
-	return toyboxOSRegex.exec(targetOS) ? toyboxProcessCommand :
-		(darwinOSRegex.exec(targetOS) ? darwinProcessCommand : generalUnixProcessCommand);
+	return new RegExp(".*Toybox.*").exec(targetOS) ? toyboxProcessCommand :
+		(new RegExp(".*Darwin.*").exec(targetOS) ? darwinProcessCommand : generalUnixProcessCommand);
 }
 
 function selectProcess(settings?: any): Promise<String> {
diff --git a/src/gdb.ts b/src/gdb.ts
--- a/src/gdb.ts
+++ b/src/gdb.ts
@@ -19,6 +19,10 @@ export interface LaunchRequestArguments extends DebugProtocol.LaunchRequestArgum
 	valuesFormatting: ValuesFormattingMode;
 	printCalls: boolean;
 	showDevDebugOutput: boolean;
+	trace: boolean;
+	skipCheckDebugger: boolean;
+	withoutAsync: boolean;
+	suppressAttachFailure: boolean;
 }
 
 export interface AttachRequestArguments extends DebugProtocol.AttachRequestArguments {
@@ -37,6 +41,10 @@ export interface AttachRequestArguments extends DebugProtocol.AttachRequestArgum
 	valuesFormatting: ValuesFormattingMode;
 	printCalls: boolean;
 	showDevDebugOutput: boolean;
+	trace: boolean;
+	skipCheckDebugger: boolean;
+	withoutAsync: boolean;
+	suppressAttachFailure: boolean;
 }
 
 class GDBDebugSession extends MI2DebugSession {
@@ -54,7 +62,7 @@ class GDBDebugSession extends MI2DebugSession {
 
 	protected override launchRequest(response: DebugProtocol.LaunchResponse, args: LaunchRequestArguments): void {
 		const dbgCommand = args.gdbpath || "gdb";
-		if (this.checkCommand(dbgCommand)) {
+		if (!args.skipCheckDebugger && this.checkCommand(dbgCommand)) {
 			this.sendErrorResponse(response, 104, `Configured debugger ${dbgCommand} not found.`);
 			return;
 		}
@@ -70,7 +78,10 @@ class GDBDebugSession extends MI2DebugSession {
 		this.setValuesFormattingMode(args.valuesFormatting);
 		this.miDebugger.printCalls = !!args.printCalls;
 		this.miDebugger.debugOutput = !!args.showDevDebugOutput;
+		this.miDebugger.trace = !!args.trace;
 		this.stopAtEntry = args.stopAtEntry;
+		this.miDebugger.withoutAsync = args.withoutAsync;
+		this.miDebugger.suppressAttachFailure = false;
 		if (args.ssh !== undefined) {
 			if (args.ssh.forwardX11 === undefined)
 				args.ssh.forwardX11 = true;
@@ -100,7 +111,7 @@ class GDBDebugSession extends MI2DebugSession {
 
 	protected override attachRequest(response: DebugProtocol.AttachResponse, args: AttachRequestArguments): void {
 		const dbgCommand = args.gdbpath || "gdb";
-		if (this.checkCommand(dbgCommand)) {
+		if (!args.skipCheckDebugger && this.checkCommand(dbgCommand)) {
 			this.sendErrorResponse(response, 104, `Configured debugger ${dbgCommand} not found.`);
 			return;
 		}
@@ -114,7 +125,10 @@ class GDBDebugSession extends MI2DebugSession {
 		this.setValuesFormattingMode(args.valuesFormatting);
 		this.miDebugger.printCalls = !!args.printCalls;
 		this.miDebugger.debugOutput = !!args.showDevDebugOutput;
+		this.miDebugger.trace = !!args.trace;
 		this.stopAtEntry = args.stopAtEntry;
+		this.miDebugger.withoutAsync = args.withoutAsync;
+		this.miDebugger.suppressAttachFailure = !!args.suppressAttachFailure;
 		if (args.ssh !== undefined) {
 			if (args.ssh.forwardX11 === undefined)
 				args.ssh.forwardX11 = true;
diff --git a/src/lldb.ts b/src/lldb.ts
--- a/src/lldb.ts
+++ b/src/lldb.ts
@@ -18,6 +18,10 @@ export interface LaunchRequestArguments extends DebugProtocol.LaunchRequestArgum
 	valuesFormatting: ValuesFormattingMode;
 	printCalls: boolean;
 	showDevDebugOutput: boolean;
+	trace: boolean;
+	skipCheckDebugger: boolean;
+	withoutAsync: boolean;
+	suppressAttachFailure: boolean;
 }
 
 export interface AttachRequestArguments extends DebugProtocol.AttachRequestArguments {
@@ -34,6 +38,10 @@ export interface AttachRequestArguments extends DebugProtocol.AttachRequestArgum
 	valuesFormatting: ValuesFormattingMode;
 	printCalls: boolean;
 	showDevDebugOutput: boolean;
+	trace: boolean;
+	skipCheckDebugger: boolean;
+	withoutAsync: boolean;
+	suppressAttachFailure: boolean;
 }
 
 class LLDBDebugSession extends MI2DebugSession {
@@ -49,7 +57,7 @@ class LLDBDebugSession extends MI2DebugSession {
 
 	protected override launchRequest(response: DebugProtocol.LaunchResponse, args: LaunchRequestArguments): void {
 		const dbgCommand = args.lldbmipath || "lldb-mi";
-		if (this.checkCommand(dbgCommand)) {
+		if (!args.skipCheckDebugger && this.checkCommand(dbgCommand)) {
 			this.sendErrorResponse(response, 104, `Configured debugger ${dbgCommand} not found.`);
 			return;
 		}
@@ -65,7 +73,10 @@ class LLDBDebugSession extends MI2DebugSession {
 		this.setValuesFormattingMode(args.valuesFormatting);
 		this.miDebugger.printCalls = !!args.printCalls;
 		this.miDebugger.debugOutput = !!args.showDevDebugOutput;
+		this.miDebugger.trace = !!args.trace;
 		this.stopAtEntry = args.stopAtEntry;
+		this.miDebugger.withoutAsync = args.withoutAsync;
+		this.miDebugger.suppressAttachFailure = false;
 		if (args.ssh !== undefined) {
 			if (args.ssh.forwardX11 === undefined)
 				args.ssh.forwardX11 = true;
@@ -95,7 +106,7 @@ class LLDBDebugSession extends MI2DebugSession {
 
 	protected override attachRequest(response: DebugProtocol.AttachResponse, args: AttachRequestArguments): void {
 		const dbgCommand = args.lldbmipath || "lldb-mi";
-		if (this.checkCommand(dbgCommand)) {
+		if (!args.skipCheckDebugger && this.checkCommand(dbgCommand)) {
 			this.sendErrorResponse(response, 104, `Configured debugger ${dbgCommand} not found.`);
 			return;
 		}
@@ -109,7 +120,10 @@ class LLDBDebugSession extends MI2DebugSession {
 		this.setValuesFormattingMode(args.valuesFormatting);
 		this.miDebugger.printCalls = !!args.printCalls;
 		this.miDebugger.debugOutput = !!args.showDevDebugOutput;
+		this.miDebugger.trace = !!args.trace;
 		this.stopAtEntry = args.stopAtEntry;
+		this.miDebugger.withoutAsync = args.withoutAsync;
+		this.miDebugger.suppressAttachFailure = !!args.suppressAttachFailure;
 		this.miDebugger.attach(args.cwd, args.executable, args.target, args.autorun || []).then(() => {
 			this.sendResponse(response);
 		}, err => {
diff --git a/src/mago.ts b/src/mago.ts
--- a/src/mago.ts
+++ b/src/mago.ts
@@ -15,6 +15,10 @@ export interface LaunchRequestArguments extends DebugProtocol.LaunchRequestArgum
 	valuesFormatting: ValuesFormattingMode;
 	printCalls: boolean;
 	showDevDebugOutput: boolean;
+	trace: boolean;
+	skipCheckDebugger: boolean;
+	withoutAsync: boolean;
+	suppressAttachFailure: boolean;
 }
 
 export interface AttachRequestArguments extends DebugProtocol.AttachRequestArguments {
@@ -29,6 +33,10 @@ export interface AttachRequestArguments extends DebugProtocol.AttachRequestArgum
 	valuesFormatting: ValuesFormattingMode;
 	printCalls: boolean;
 	showDevDebugOutput: boolean;
+	trace: boolean;
+	skipCheckDebugger: boolean;
+	withoutAsync: boolean;
+	suppressAttachFailure: boolean;
 }
 
 class MagoDebugSession extends MI2DebugSession {
@@ -51,7 +59,7 @@ class MagoDebugSession extends MI2DebugSession {
 
 	protected override launchRequest(response: DebugProtocol.LaunchResponse, args: LaunchRequestArguments): void {
 		const dbgCommand = args.magomipath || "mago-mi";
-		if (this.checkCommand(dbgCommand)) {
+		if (!args.skipCheckDebugger && this.checkCommand(dbgCommand)) {
 			this.sendErrorResponse(response, 104, `Configured debugger ${dbgCommand} not found.`);
 			return;
 		}
@@ -66,6 +74,9 @@ class MagoDebugSession extends MI2DebugSession {
 		this.setValuesFormattingMode(args.valuesFormatting);
 		this.miDebugger.printCalls = !!args.printCalls;
 		this.miDebugger.debugOutput = !!args.showDevDebugOutput;
+		this.miDebugger.trace = !!args.trace;
+		this.miDebugger.withoutAsync = args.withoutAsync;
+		this.miDebugger.suppressAttachFailure = false;
 		this.miDebugger.load(args.cwd, args.target, args.arguments, undefined, args.autorun || []).then(() => {
 			this.sendResponse(response);
 		}, err => {
@@ -75,7 +86,7 @@ class MagoDebugSession extends MI2DebugSession {
 
 	protected override attachRequest(response: DebugProtocol.AttachResponse, args: AttachRequestArguments): void {
 		const dbgCommand = args.magomipath || "mago-mi";
-		if (this.checkCommand(dbgCommand)) {
+		if (!args.skipCheckDebugger && this.checkCommand(dbgCommand)) {
 			this.sendErrorResponse(response, 104, `Configured debugger ${dbgCommand} not found.`);
 			return;
 		}
@@ -88,6 +99,9 @@ class MagoDebugSession extends MI2DebugSession {
 		this.setValuesFormattingMode(args.valuesFormatting);
 		this.miDebugger.printCalls = !!args.printCalls;
 		this.miDebugger.debugOutput = !!args.showDevDebugOutput;
+		this.miDebugger.trace = !!args.trace;
+		this.miDebugger.withoutAsync = args.withoutAsync;
+		this.miDebugger.suppressAttachFailure = !!args.suppressAttachFailure;
 		this.miDebugger.attach(args.cwd, args.executable, args.target, args.autorun || []).then(() => {
 			this.sendResponse(response);
 		}, err => {
diff --git a/src/mibase.ts b/src/mibase.ts
--- a/src/mibase.ts
+++ b/src/mibase.ts
@@ -183,13 +183,20 @@ export class MI2DebugSession extends DebugSession {
 	}
 
 	protected override disconnectRequest(response: DebugProtocol.DisconnectResponse, args: DebugProtocol.DisconnectArguments): void {
+		const execDisconnect = () => {
+			this.commandServer.close();
+			this.commandServer = undefined;
+			this.sendResponse(response);
+		};
 		if (this.attached)
-			this.miDebugger.detach();
-		else
-			this.miDebugger.stop();
-		this.commandServer.close();
-		this.commandServer = undefined;
-		this.sendResponse(response);
+			this.miDebugger.detach().then(_ => {
+				execDisconnect();
+			});
+		else {
+			this.miDebugger.stop().then(_ => {
+				execDisconnect();
+			});
+		}
 	}
 
 	protected override async setVariableRequest(response: DebugProtocol.SetVariableResponse, args: DebugProtocol.SetVariableArguments): Promise<void> {

※デバッグ対象がLinux(Ubuntu)、NetBSDのみで動作確認済み。その他Unixは未確認。
 Windowsはkill -INT相当の標準コマンドがないため非対応。デタッチも正常にできなくなるためNative Debug自体非推奨。
更にgdb以外のデバッガも動作未確認。

修正後ビルド

command
npx vsce package

launch.jsonでの設定方法

以下のように記述する。

launch.json
    "configurations": [
        {
            "type": "gdb",
            "request": "attach",
            "name": "Attach Program (SSH)",
            "target": "${command:selectAttachProcess}",
            "cwd": "${workspaceRoot}",
            "showDevDebugOutput": false,
            "printCalls": true,
            "trace": false,
            "skipCheckDebugger": true,
            "withoutAsync": true,
            "suppressAttachFailure": true,
            "ssh": {
                "host": "localhost",
                "port": 22222,
                "cwd": "/root/sample",
                "user": "root",
                "password": "root",
                "forwardX11": false,
                "forceIPAddressFamily": "IPv4",
                "sourceFileMap": {
                    "ビルド時のパス": "ローカル側のパス"
                }
            },
            "valuesFormatting": "parseText",
            "gdbpath": "/usr/local/bin/gdb",
        },

アタッチの実行例

printCallstrueにすることでgdbコマンドがデバッグコンソールに出力されるようになる。

Running /usr/local/bin/gdb over ssh...
1-list-features
2-environment-directory "/root/sample"
3-environment-cd "/root/sample"
4-target-attach 722
5-thread-info
WARNING: Error executing command 'target-attach 722'
6-exec-continue
0xbbafb2c7 in _sys___nanosleep50 () from /usr/lib/libc.so.12
7-thread-info
8-thread-info
kill -INT 722

Program
 received signal SIGINT, Interrupt.
0xbbafb2c7 in _sys___nanosleep50 () from /usr/lib/libc.so.12
9-exec-continue
10-thread-info
kill -INT 722

Program
 received signal SIGINT, Interrupt.
11-stack-info-depth --thread 1
0xbbafb2c7 in _sys___nanosleep50 () from /usr/lib/libc.so.12
12-break-insert -f "/root/sample/sample.c:9"
13-thread-info
14-stack-info-depth --thread 1
15-stack-list-frames --thread 1 0 2
16-exec-continue
17-stack-list-frames --thread 1 0 2
18-stack-info-depth --thread 1
19-stack-info-depth --thread 1

Breakpoint 1, main () at sample.c:9
9	    	printf("Current value is not the same target value. target: %d current: %d\n", target_value, current_value);
20-thread-info
21-stack-list-frames --thread 1 0 0
22-stack-list-frames --thread 1 0 0
23-stack-info-depth --thread 1
24-stack-list-frames --thread 1 0 0
25-stack-info-depth --thread 1
26-stack-list-frames --thread 1 0 0
27-stack-list-variables --thread 1 --frame 0 --simple-values
28-exec-continue
kill -INT 722

Program
 received signal SIGINT, Interrupt.
0xbbafb2c7 in _sys___nanosleep50 () from /usr/lib/libc.so.12
29-target-detach
30-thread-info

WARNING: Error executing command 'target-attach 722'となっているが、これはアタッチ時のFailed to SSH: Couldn't get registers: Device busy. (from target-attach 722)等のエラーが発生した場合に、suppressAttachFailuretrueに設定していることでそのエラーを無視した代わりに出力されるログである。
当然本当にアタッチ出来ないエラーであっても無視されるのでその後の動作は保証しない。

12-break-insertはGUIからブレークポイントを追加したときのコマンドで、その直前にデバッグ対象のプロセスID(今回は722)に対してkill -INT 722で割り込みをしている。これによりgdbコマンドが実行できるようになる。
また、ブレークポイント追加時がrunning状態だと16-exec-continueのようにコマンド実行後に継続コマンドを実行させている。

29-target-detachはGUIからデタッチしたときのコマンドで、同様に直前にkill -INT 722で割り込みをしている。

アタッチ時に一瞬ブレークに入りかける/kill -INT直前がrunningの場合、コマンド実行後のcontinueのタイミング依存で稀にデバッグ状態のままになる/デバッグコンソールから-exec-interruptで止めると2回再開する必要がある等、やや不安定になることもあるが全くデバッグ状態に入れなくなることは回避できているので許容範囲。

参考

NetBSD2.0.2(i386)対応版(SSH接続方式に対応)

Discussion