Skip to content
This repository has been archived by the owner on May 30, 2024. It is now read-only.

Add debugger launch test #989

Merged
merged 1 commit into from
Jan 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.2"
ruby-version: "3.3"

- name: 📦 Install dependencies
run: yarn --frozen-lockfile
Expand Down
93 changes: 69 additions & 24 deletions src/debugger.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,37 @@
import path from "path";
import fs from "fs";
import { ChildProcessWithoutNullStreams, spawn, execSync } from "child_process";
import net from "net";
import os from "os";
import { ChildProcessWithoutNullStreams, spawn } from "child_process";

import * as vscode from "vscode";

import { LOG_CHANNEL } from "./common";
import { LOG_CHANNEL, asyncExec } from "./common";
import { Workspace } from "./workspace";

class TerminalLogger {
Copy link
Member Author

Choose a reason for hiding this comment

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

This terminal logger is so that we can see logs from the debug server when a test fails

append(message: string) {
// eslint-disable-next-line no-console
console.log(message);
}

appendLine(value: string): void {
// eslint-disable-next-line no-console
console.log(value);
}
}

export class Debugger
implements
vscode.DebugAdapterDescriptorFactory,
vscode.DebugConfigurationProvider
{
private debugProcess?: ChildProcessWithoutNullStreams;
private readonly console = vscode.debug.activeDebugConsole;
// eslint-disable-next-line no-process-env
private readonly console = process.env.CI
? new TerminalLogger()
: vscode.debug.activeDebugConsole;

private readonly workspaceResolver: (
uri: vscode.Uri | undefined,
) => Workspace | undefined;
Expand Down Expand Up @@ -132,19 +150,17 @@ export class Debugger
}
}

private getSockets(session: vscode.DebugSession): string[] {
const cmd = "bundle exec rdbg --util=list-socks";
const workspaceFolder = session.workspaceFolder;
if (!workspaceFolder) {
throw new Error("Debugging requires a workspace folder to be opened");
}
private async getSockets(session: vscode.DebugSession) {
const configuration = session.configuration;
let sockets: string[] = [];

try {
sockets = execSync(cmd, {
cwd: workspaceFolder.uri.fsPath,
const result = await asyncExec("bundle exec rdbg --util=list-socks", {
Copy link
Member Author

Choose a reason for hiding this comment

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

Just switching to asyncExec instead of synchronous.

cwd: session.workspaceFolder?.uri.fsPath,
env: configuration.env,
})
});

sockets = result.stdout
.toString()
.split("\n")
.filter((socket) => socket.length > 0);
Expand All @@ -159,7 +175,8 @@ export class Debugger
): Promise<vscode.DebugAdapterDescriptor> {
// When using attach, a process will be launched using Ruby debug and it will create a socket automatically. We have
// to find the available sockets and ask the user which one they want to attach to
const sockets = this.getSockets(session);
const sockets = await this.getSockets(session);

if (sockets.length === 0) {
throw new Error(`No debuggee processes found. Is the process running?`);
}
Expand All @@ -183,7 +200,7 @@ export class Debugger
return new vscode.DebugAdapterNamedPipeServer(selectedSocketPath);
}

private spawnDebuggeeForLaunch(
private async spawnDebuggeeForLaunch(
session: vscode.DebugSession,
): Promise<vscode.DebugAdapterDescriptor | undefined> {
let initialMessage = "";
Expand All @@ -192,16 +209,18 @@ export class Debugger
const configuration = session.configuration;
const workspaceFolder = configuration.targetFolder;
const cwd = workspaceFolder.path;
const port =
Copy link
Member Author

Choose a reason for hiding this comment

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

Using UNIX sockets doesn't work on Windows, but we can easily spawn the debugger with a port.

os.platform() === "win32" ? await this.availablePort() : undefined;

return new Promise((resolve, reject) => {
const args = [
"exec",
"rdbg",
"--open",
"--command",
"--",
configuration.program,
];
const args = ["exec", "rdbg"];

// On Windows, we spawn the debugger with any available port. On Linux and macOS, we spawn it with a UNIX socket
if (port) {
args.push("--port", port.toString());
}

args.push("--open", "--command", "--", configuration.program);

LOG_CHANNEL.info(`Spawning debugger in directory ${cwd}`);
LOG_CHANNEL.info(` Command bundle ${args.join(" ")}`);
Expand All @@ -228,10 +247,14 @@ export class Debugger
initialMessage.includes("DEBUGGER: wait for debugger connection...")
) {
initialized = true;

const regex =
/DEBUGGER: Debugger can attach via UNIX domain socket \((.*)\)/;
const sockPath = RegExp(regex).exec(initialMessage);
if (sockPath && sockPath.length === 2) {

if (port) {
resolve(new vscode.DebugAdapterServer(port));
} else if (sockPath && sockPath.length === 2) {
resolve(new vscode.DebugAdapterNamedPipeServer(sockPath[1]));
} else {
reject(new Error("Debugger not found on UNIX socket"));
Expand All @@ -253,7 +276,7 @@ export class Debugger
// If the Ruby debug exits with an exit code > 1, then an error might've occurred. The reason we don't use only
// code zero here is because debug actually exits with 1 if the user cancels the debug session, which is not
// actually an error
this.debugProcess.on("exit", (code) => {
this.debugProcess.on("close", (code) => {
Copy link
Member Author

Choose a reason for hiding this comment

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

The exit event actually happens before the stdin, stdout and stderr pipes are closed. If we raise an error on exit, we never capture the backtrace of the server printed to stderr.

But if we use the close event, then everything gets printed and it's significantly easier to understand why the debug server failed to start.

if (code) {
const message = `Debugger exited with status ${code}. Check the output channel for more information.`;
this.console.append(message);
Expand All @@ -263,4 +286,26 @@ export class Debugger
});
});
}

// Find an available port for the debug server to listen on
private async availablePort(): Promise<number | undefined> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();

server.on("error", reject);

// By listening on port 0, the system will assign an available port automatically. We close the server and return
// the port that was assigned
server.listen(0, () => {
const address = server.address();
const port =
typeof address === "string" ? Number(address) : address?.port;

server.close(() => {
resolve(port);
});
});
});
}
}
2 changes: 1 addition & 1 deletion src/test/suite/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ suite("Client", () => {
name: path.basename(tmpPath),
index: 0,
};
fs.writeFileSync(path.join(tmpPath, ".ruby-version"), "3.2.2");
fs.writeFileSync(path.join(tmpPath, ".ruby-version"), "3.3.0");

const context = {
extensionMode: vscode.ExtensionMode.Test,
Expand Down
85 changes: 84 additions & 1 deletion src/test/suite/debugger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import * as os from "os";
import * as vscode from "vscode";

import { Debugger } from "../../debugger";
import { Ruby } from "../../ruby";
import { Ruby, VersionManager } from "../../ruby";
import { Workspace } from "../../workspace";
import { WorkspaceChannel } from "../../workspaceChannel";
import { LOG_CHANNEL, asyncExec } from "../../common";

suite("Debugger", () => {
test("Provide debug configurations returns the default configs", () => {
Expand Down Expand Up @@ -134,4 +136,85 @@ suite("Debugger", () => {
context.subscriptions.forEach((subscription) => subscription.dispose());
fs.rmSync(tmpPath, { recursive: true, force: true });
});

test("Launching the debugger", async () => {
// eslint-disable-next-line no-process-env
if (process.env.CI) {
await vscode.workspace
.getConfiguration("rubyLsp")
.update("rubyVersionManager", VersionManager.None, true, true);
}

// By default, VS Code always saves all open files when launching a debugging session. This is a problem for tests
// because it attempts to save an untitled test file and then we get stuck in the save file dialog with no way of
Copy link
Contributor

Choose a reason for hiding this comment

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

Which file is the untitled one here?

Copy link
Member Author

Choose a reason for hiding this comment

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

It's a new untitled file that gets created when we run tests.

// closing it. We have to disable that before running this test
const currentSaveBeforeStart = await vscode.workspace
.getConfiguration("debug")
.get("saveBeforeStart");
await vscode.workspace
.getConfiguration("debug")
.update("saveBeforeStart", "none", true, true);

const tmpPath = fs.mkdtempSync(
path.join(os.tmpdir(), "ruby-lsp-test-debugger"),
);
fs.writeFileSync(path.join(tmpPath, "test.rb"), "1 + 1");
fs.writeFileSync(path.join(tmpPath, ".ruby-version"), "3.3.0");
fs.writeFileSync(
path.join(tmpPath, "Gemfile"),
'source "https://rubygems.org"\ngem "debug"',
);

const context = { subscriptions: [] } as unknown as vscode.ExtensionContext;
const outputChannel = new WorkspaceChannel("fake", LOG_CHANNEL);
const workspaceFolder: vscode.WorkspaceFolder = {
uri: vscode.Uri.from({ scheme: "file", path: tmpPath }),
name: path.basename(tmpPath),
index: 0,
};
const ruby = new Ruby(context, workspaceFolder, outputChannel);
await ruby.activateRuby();

try {
await asyncExec("gem install debug", { env: ruby.env, cwd: tmpPath });
await asyncExec("bundle install", { env: ruby.env, cwd: tmpPath });
} catch (error: any) {
assert.fail(`Failed to bundle install: ${error.message}`);
}

assert.ok(fs.existsSync(path.join(tmpPath, "Gemfile.lock")));
assert.match(
fs.readFileSync(path.join(tmpPath, "Gemfile.lock")).toString(),
/debug/,
);

const debug = new Debugger(context, () => {
return {
ruby,
workspaceFolder,
} as Workspace;
});

try {
await vscode.debug.startDebugging(workspaceFolder, {
type: "ruby_lsp",
name: "Debug",
request: "launch",
program: `ruby ${path.join(tmpPath, "test.rb")}`,
});
} catch (error: any) {
assert.fail(`Failed to launch debugger: ${error.message}`);
}

// The debugger might take a bit of time to disconnect from the editor. We need to perform cleanup when we receive
// the termination callback or else we try to dispose of the debugger client too early
vscode.debug.onDidTerminateDebugSession(async (_session) => {
debug.dispose();
context.subscriptions.forEach((subscription) => subscription.dispose());
fs.rmSync(tmpPath, { recursive: true, force: true });
await vscode.workspace
.getConfiguration("debug")
.update("saveBeforeStart", currentSaveBeforeStart, true, true);
});
}).timeout(45000);
});
2 changes: 1 addition & 1 deletion src/test/suite/ruby.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ suite("Ruby environment activation", () => {
}

const tmpPath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-"));
fs.writeFileSync(path.join(tmpPath, ".ruby-version"), "3.2.2");
fs.writeFileSync(path.join(tmpPath, ".ruby-version"), "3.3.0");

const context = {
extensionMode: vscode.ExtensionMode.Test,
Expand Down
Loading