feat(vscode-extension) error reporting, qol (#15261)

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
Co-authored-by: Ashcon Partovi <ashcon@partovi.net>
Co-authored-by: Electroid <Electroid@users.noreply.github.com>
Co-authored-by: Meghan Denny <meghan@bun.sh>
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
This commit is contained in:
Alistair Smith
2024-11-22 02:55:21 -08:00
committed by GitHub
parent 5bcaf32ba3
commit 4117af6e46
62 changed files with 4736 additions and 3012 deletions

View File

@@ -1,6 +1,7 @@
{
"name": "bun-debug-adapter-protocol",
"version": "0.0.1",
"type": "module",
"dependencies": {
"semver": "^7.5.4",
"source-map-js": "^1.0.2"

View File

@@ -1,19 +1,19 @@
import type { InspectorEventMap } from "../../../bun-inspector-protocol/src/inspector";
import type { JSC } from "../../../bun-inspector-protocol/src/protocol";
import type { DAP } from "../protocol";
// @ts-ignore
import { ChildProcess, spawn } from "node:child_process";
import { EventEmitter } from "node:events";
import { AddressInfo, createServer } from "node:net";
import { AddressInfo, createServer, Socket } from "node:net";
import * as path from "node:path";
import { remoteObjectToString, WebSocketInspector } from "../../../bun-inspector-protocol/index";
import { randomUnixPath, TCPSocketSignal, UnixSignal } from "./signal";
import { Location, SourceMap } from "./sourcemap";
import { remoteObjectToString, WebSocketInspector } from "../../../bun-inspector-protocol/index.ts";
import type { Inspector, InspectorEventMap } from "../../../bun-inspector-protocol/src/inspector/index.d.ts";
import { NodeSocketInspector } from "../../../bun-inspector-protocol/src/inspector/node-socket.ts";
import type { JSC } from "../../../bun-inspector-protocol/src/protocol/index.d.ts";
import type { DAP } from "../protocol/index.d.ts";
import { randomUnixPath, TCPSocketSignal, UnixSignal } from "./signal.ts";
import { Location, SourceMap } from "./sourcemap.ts";
export async function getAvailablePort(): Promise<number> {
const server = createServer();
server.listen(0);
return new Promise((resolve, reject) => {
return new Promise(resolve => {
server.on("listening", () => {
const { port } = server.address() as AddressInfo;
server.close(() => {
@@ -105,7 +105,18 @@ const capabilities: DAP.Capabilities = {
type InitializeRequest = DAP.InitializeRequest & {
supportsConfigurationDoneRequest?: boolean;
};
enableControlFlowProfiler?: boolean;
enableDebugger?: boolean;
} & (
| {
enableLifecycleAgentReporter?: false;
sendImmediatePreventExit?: false;
}
| {
enableLifecycleAgentReporter: true;
sendImmediatePreventExit?: boolean;
}
);
type LaunchRequest = DAP.LaunchRequest & {
runtime?: string;
@@ -231,10 +242,14 @@ function normalizeSourcePath(sourcePath: string, untitledDocPath?: string, bunEv
return path.normalize(sourcePath);
}
export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements IDebugAdapter {
export abstract class BaseDebugAdapter<T extends Inspector = Inspector>
extends EventEmitter<DebugAdapterEventMap>
implements IDebugAdapter
{
protected readonly inspector: T;
protected options?: DebuggerOptions;
#threadId: number;
#inspector: WebSocketInspector;
#process?: ChildProcess;
#sourceId: number;
#pendingSources: Map<string, ((source: Source) => void)[]>;
#sources: Map<string | number, Source>;
@@ -247,20 +262,21 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
#targets: Map<number, Target>;
#variableId: number;
#variables: Map<number, Variable>;
#initialized?: InitializeRequest;
#options?: DebuggerOptions;
#untitledDocPath?: string;
#bunEvalPath?: string;
#initialized?: InitializeRequest;
constructor(url?: string | URL, untitledDocPath?: string, bunEvalPath?: string) {
protected constructor(inspector: T, untitledDocPath?: string, bunEvalPath?: string) {
super();
this.#untitledDocPath = untitledDocPath;
this.#bunEvalPath = bunEvalPath;
this.#threadId = threadId++;
this.#inspector = new WebSocketInspector(url);
const emit = this.#inspector.emit.bind(this.#inspector);
this.#inspector.emit = (event, ...args) => {
this.inspector = inspector;
const emit = this.inspector.emit.bind(this.inspector);
this.inspector.emit = (event, ...args) => {
let sent = false;
sent ||= emit(event, ...args);
sent ||= this.emit(event, ...(args as any));
sent ||= this.emit(event as keyof JSC.EventMap, ...(args as any));
return sent;
};
this.#sourceId = 1;
@@ -274,25 +290,22 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
this.#targets = new Map();
this.#variableId = 1;
this.#variables = new Map();
this.#untitledDocPath = untitledDocPath;
this.#bunEvalPath = bunEvalPath;
}
/**
* Gets the inspector url.
* Gets the inspector url. This is deprecated and exists for compat.
* @deprecated You should get the inspector directly, and if it's a WebSocketInspector you can access `.url` direclty.
*/
get url(): string {
return this.#inspector.url;
// This code has been migrated from a time when the inspector was always a WebSocketInspector.
if (this.inspector instanceof WebSocketInspector) {
return this.inspector.url;
}
throw new Error("Inspector does not offer a URL");
}
/**
* Starts the inspector.
* @param url the inspector url
* @returns if the inspector was able to connect
*/
start(url?: string): Promise<boolean> {
return this.#attach({ url });
}
abstract start(...args: unknown[]): Promise<boolean>;
/**
* Sends a request to the JavaScript inspector.
@@ -306,7 +319,7 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
* console.log(result.value); // 2
*/
async send<M extends keyof JSC.ResponseMap>(method: M, params?: JSC.RequestMap[M]): Promise<JSC.ResponseMap[M]> {
return this.#inspector.send(method, params);
return this.inspector.send(method, params);
}
/**
@@ -347,7 +360,7 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
return sent;
}
#emit<E extends keyof DAP.EventMap>(event: E, body?: DAP.EventMap[E]): void {
protected emitAdapterEvent<E extends keyof DAP.EventMap>(event: E, body?: DAP.EventMap[E]): void {
this.emit("Adapter.event", {
type: "event",
seq: 0,
@@ -359,7 +372,7 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
#emitAfterResponse<E extends keyof DAP.EventMap>(event: E, body?: DAP.EventMap[E]): void {
this.once("Adapter.response", () => {
process.nextTick(() => {
this.#emit(event, body);
this.emitAdapterEvent(event, body);
});
});
}
@@ -437,19 +450,37 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
this.emit(`Adapter.${name}` as keyof DebugAdapterEventMap, body);
}
initialize(request: InitializeRequest): DAP.InitializeResponse {
public initialize(request: InitializeRequest): DAP.InitializeResponse {
this.#initialized = request;
this.send("Inspector.enable");
this.send("Runtime.enable");
this.send("Console.enable");
this.send("Debugger.enable").catch(error => {
const { message } = unknownToError(error);
if (message !== "Debugger domain already enabled") {
throw error;
if (request.enableControlFlowProfiler) {
this.send("Runtime.enableControlFlowProfiler");
}
if (request.enableLifecycleAgentReporter) {
this.send("LifecycleReporter.enable");
if (request.sendImmediatePreventExit) {
this.send("LifecycleReporter.preventExit");
}
});
this.send("Debugger.setAsyncStackTraceDepth", { depth: 200 });
}
// use !== false because by default if unspecified we want to enable the debugger
// and this option didn't exist beforehand, so we can't make it non-optional
if (request.enableDebugger !== false) {
this.send("Debugger.enable").catch(error => {
const { message } = unknownToError(error);
if (message !== "Debugger domain already enabled") {
throw error;
}
});
this.send("Debugger.setAsyncStackTraceDepth", { depth: 200 });
}
const { clientID, supportsConfigurationDoneRequest } = request;
if (!supportsConfigurationDoneRequest && clientID !== "vscode") {
@@ -463,248 +494,20 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
configurationDone(): void {
// If the client requested that `noDebug` mode be enabled,
// then we need to disable all breakpoints and pause on statements.
const active = !this.#options?.noDebug;
const active = !this.options?.noDebug;
this.send("Debugger.setBreakpointsActive", { active });
// Tell the debugger that its ready to start execution.
this.send("Inspector.initialized");
}
async launch(request: DAP.LaunchRequest): Promise<void> {
this.#options = { ...request, type: "launch" };
try {
await this.#launch(request);
} catch (error) {
// Some clients, like VSCode, will show a system-level popup when a `launch` request fails.
// Instead, we want to show the error as a sidebar notification.
const { message } = unknownToError(error);
this.#emit("output", {
category: "stderr",
output: `Failed to start debugger.\n${message}`,
});
this.terminate();
}
}
async #launch(request: LaunchRequest): Promise<void> {
const {
runtime = "bun",
runtimeArgs = [],
program,
args = [],
cwd,
env = {},
strictEnv = false,
watchMode = false,
stopOnEntry = false,
__skipValidation = false,
stdin,
} = request;
if (!__skipValidation && !program) {
throw new Error("No program specified");
}
const processArgs = [...runtimeArgs];
if (program === "-" && stdin) {
processArgs.push("--eval", stdin);
} else if (program) {
processArgs.push(program);
}
processArgs.push(...args);
if (program && isTestJavaScript(program) && !runtimeArgs.includes("test")) {
processArgs.unshift("test");
}
if (watchMode && !runtimeArgs.includes("--watch") && !runtimeArgs.includes("--hot")) {
processArgs.unshift(watchMode === "hot" ? "--hot" : "--watch");
}
const processEnv = strictEnv
? {
...env,
}
: {
...process.env,
...env,
};
if (process.platform !== "win32") {
// we're on unix
const url = `ws+unix://${randomUnixPath()}`;
const signal = new UnixSignal();
signal.on("Signal.received", () => {
this.#attach({ url });
});
this.once("Adapter.terminated", () => {
signal.close();
});
const query = stopOnEntry ? "break=1" : "wait=1";
processEnv["BUN_INSPECT"] = `${url}?${query}`;
processEnv["BUN_INSPECT_NOTIFY"] = signal.url;
// This is probably not correct, but it's the best we can do for now.
processEnv["FORCE_COLOR"] = "1";
processEnv["BUN_QUIET_DEBUG_LOGS"] = "1";
processEnv["BUN_DEBUG_QUIET_LOGS"] = "1";
const started = await this.#spawn({
command: runtime,
args: processArgs,
env: processEnv,
cwd,
isDebugee: true,
});
if (!started) {
throw new Error("Program could not be started.");
}
} else {
// we're on windows
// Create TCPSocketSignal
const url = `ws://127.0.0.1:${await getAvailablePort()}/${getRandomId()}`; // 127.0.0.1 so it resolves correctly on windows
const signal = new TCPSocketSignal(await getAvailablePort());
signal.on("Signal.received", async () => {
this.#attach({ url });
});
this.once("Adapter.terminated", () => {
signal.close();
});
const query = stopOnEntry ? "break=1" : "wait=1";
processEnv["BUN_INSPECT"] = `${url}?${query}`;
processEnv["BUN_INSPECT_NOTIFY"] = signal.url; // 127.0.0.1 so it resolves correctly on windows
// This is probably not correct, but it's the best we can do for now.
processEnv["FORCE_COLOR"] = "1";
processEnv["BUN_QUIET_DEBUG_LOGS"] = "1";
processEnv["BUN_DEBUG_QUIET_LOGS"] = "1";
const started = await this.#spawn({
command: runtime,
args: processArgs,
env: processEnv,
cwd,
isDebugee: true,
});
if (!started) {
throw new Error("Program could not be started.");
}
}
}
async #spawn(options: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string | undefined>;
isDebugee?: boolean;
}): Promise<boolean> {
const { command, args = [], cwd, env, isDebugee } = options;
const request = { command, args, cwd, env };
this.emit("Process.requested", request);
let subprocess: ChildProcess;
try {
subprocess = spawn(command, args, {
...request,
stdio: ["ignore", "pipe", "pipe"],
});
} catch (cause) {
this.emit("Process.exited", new Error("Failed to spawn process", { cause }), null);
return false;
}
subprocess.on("spawn", () => {
this.emit("Process.spawned", subprocess);
if (isDebugee) {
this.#process = subprocess;
this.#emit("process", {
name: `${command} ${args.join(" ")}`,
systemProcessId: subprocess.pid,
isLocalProcess: true,
startMethod: "launch",
});
}
});
subprocess.on("exit", (code, signal) => {
this.emit("Process.exited", code, signal);
if (isDebugee) {
this.#process = undefined;
this.#emit("exited", {
exitCode: code ?? -1,
});
this.#emit("terminated");
}
});
subprocess.stdout?.on("data", data => {
this.emit("Process.stdout", data.toString());
});
subprocess.stderr?.on("data", data => {
this.emit("Process.stderr", data.toString());
});
return new Promise(resolve => {
subprocess.on("spawn", () => resolve(true));
subprocess.on("exit", () => resolve(false));
subprocess.on("error", () => resolve(false));
});
}
async attach(request: AttachRequest): Promise<void> {
this.#options = { ...request, type: "attach" };
try {
await this.#attach(request);
} catch (error) {
// Some clients, like VSCode, will show a system-level popup when a `launch` request fails.
// Instead, we want to show the error as a sidebar notification.
const { message } = unknownToError(error);
this.#emit("output", {
category: "stderr",
output: `Failed to start debugger.\n${message}`,
});
this.terminate();
}
}
async #attach(request: AttachRequest): Promise<boolean> {
const { url } = request;
for (let i = 0; i < 3; i++) {
const ok = await this.#inspector.start(url);
if (ok) {
return true;
}
await new Promise(resolve => setTimeout(resolve, 100 * i));
}
return false;
}
// Required so all implementations have a method that .terminate() always calls.
// This is useful because we don't want any implementors to forget
protected abstract exitJSProcess(): void;
terminate(): void {
if (!this.#process?.kill()) {
this.#evaluate({
expression: "process.exit(0)",
});
}
this.#emit("terminated");
this.exitJSProcess();
this.emitAdapterEvent("terminated");
}
disconnect(request: DAP.DisconnectRequest): void {
@@ -1077,7 +880,7 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
}
for (const breakpoint of breakpoints) {
this.#emit("breakpoint", {
this.emitAdapterEvent("breakpoint", {
reason: "removed",
breakpoint,
});
@@ -1316,7 +1119,7 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
const callFrameId = this.#getCallFrameId(frameId);
const objectGroup = callFrameId ? "debugger" : context;
const { result, wasThrown } = await this.#evaluate({
const { result, wasThrown } = await this.evaluateInternal({
expression,
objectGroup,
callFrameId,
@@ -1337,7 +1140,7 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
};
}
async #evaluate(options: {
protected async evaluateInternal(options: {
expression: string;
objectGroup?: string;
callFrameId?: string;
@@ -1361,7 +1164,7 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
const callFrameId = this.#getCallFrameId(frameId);
const { expression, hint } = completionToExpression(text);
const { result, wasThrown } = await this.#evaluate({
const { result, wasThrown } = await this.evaluateInternal({
expression: expression || "this",
callFrameId,
objectGroup: "repl",
@@ -1393,33 +1196,29 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
}
["Inspector.connected"](): void {
this.#emit("output", {
this.emitAdapterEvent("output", {
category: "debug console",
output: "Debugger attached.\n",
});
this.#emit("initialized");
this.emitAdapterEvent("initialized");
}
async ["Inspector.disconnected"](error?: Error): Promise<void> {
this.#emit("output", {
this.emitAdapterEvent("output", {
category: "debug console",
output: "Debugger detached.\n",
});
if (error) {
const { message } = error;
this.#emit("output", {
this.emitAdapterEvent("output", {
category: "stderr",
output: `${message}\n`,
});
}
this.#reset();
if (this.#process?.exitCode !== null) {
this.#emit("terminated");
}
this.resetInternal();
}
async ["Debugger.scriptParsed"](event: JSC.Debugger.ScriptParsedEvent): Promise<void> {
@@ -1470,7 +1269,7 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
return;
}
this.#emit("output", {
this.emitAdapterEvent("output", {
category: "stderr",
output: errorMessage,
line: this.#lineFrom0BasedLine(errorLine),
@@ -1498,7 +1297,7 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
const breakpoint = breakpoints[i];
const oldBreakpoint = oldBreakpoints[i];
this.#emit("breakpoint", {
this.emitAdapterEvent("breakpoint", {
reason: "changed",
breakpoint: {
...breakpoint,
@@ -1581,7 +1380,7 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
}
}
this.#emit("stopped", {
this.emitAdapterEvent("stopped", {
threadId: this.#threadId,
reason: this.#stopped,
hitBreakpointIds,
@@ -1598,20 +1397,20 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
}
}
this.#emit("continued", {
this.emitAdapterEvent("continued", {
threadId: this.#threadId,
});
}
["Process.stdout"](output: string): void {
this.#emit("output", {
this.emitAdapterEvent("output", {
category: "debug console",
output,
});
}
["Process.stderr"](output: string): void {
this.#emit("output", {
this.emitAdapterEvent("output", {
category: "debug console",
output,
});
@@ -1695,8 +1494,8 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
// If the path changed or the source has a source reference,
// the old source should be marked as removed.
if (path !== oldPath || sourceReference) {
this.#emit("loadedSource", {
if (path !== oldPath /*|| sourceReference*/) {
this.emitAdapterEvent("loadedSource", {
reason: "removed",
source: oldSource,
});
@@ -1706,7 +1505,7 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
this.#sources.set(sourceId, source);
this.#sources.set(scriptId, source);
this.#emit("loadedSource", {
this.emitAdapterEvent("loadedSource", {
// If the reason is "changed", the source will be retrieved using
// the `source` command, which is why it cannot be set when `path` is present.
reason: oldSource && !path ? "changed" : "new",
@@ -1762,9 +1561,9 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
}
// If the source is not present, it may not have been loaded yet.
let resolves = this.#pendingSources.get(sourceId);
let resolves = this.#pendingSources.get(sourceId.toString());
if (!resolves) {
this.#pendingSources.set(sourceId, (resolves = []));
this.#pendingSources.set(sourceId.toString(), (resolves = []));
}
return new Promise(resolve => {
@@ -2016,7 +1815,7 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
const callFrameId = this.#getCallFrameId(frameId);
const objectGroup = callFrameId ? "debugger" : "repl";
const { result, wasThrown } = await this.#evaluate({
const { result, wasThrown } = await this.evaluateInternal({
expression: `${expression} = (${value});`,
objectGroup: "repl",
callFrameId,
@@ -2216,12 +2015,11 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
}
close(): void {
this.#process?.kill();
this.#inspector.close();
this.#reset();
this.inspector.close();
this.resetInternal();
}
#reset(): void {
protected resetInternal(): void {
this.#pendingSources.clear();
this.#sources.clear();
this.#stackFrames.length = 0;
@@ -2232,7 +2030,304 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
this.#functionBreakpoints.clear();
this.#targets.clear();
this.#variables.clear();
this.#options = undefined;
this.options = undefined;
}
}
/**
* Create a debug adapter that connects over a unix/tcp socket. Usually
* in the case of a reverse connection. This is used by the vscode extension.
*
* @warning This will gracefully handle socket closure, you don't need to add extra handling.
*/
export class NodeSocketDebugAdapter extends BaseDebugAdapter<NodeSocketInspector> {
public constructor(socket: Socket, untitledDocPath?: string, bunEvalPath?: string) {
super(new NodeSocketInspector(socket), untitledDocPath, bunEvalPath);
socket.once("close", () => {
this.resetInternal();
});
}
protected exitJSProcess(): void {
this.evaluateInternal({
expression: "process.exit(0)",
});
}
public async start() {
const ok = await this.inspector.start();
return ok;
}
}
/**
* The default debug adapter. Connects via WebSocket
*/
export class DebugAdapter extends BaseDebugAdapter<WebSocketInspector> {
#process?: ChildProcess;
public constructor(url?: string | URL, untitledDocPath?: string, bunEvalPath?: string) {
super(new WebSocketInspector(url), untitledDocPath, bunEvalPath);
}
async ["Inspector.disconnected"](error?: Error): Promise<void> {
await super["Inspector.disconnected"](error);
if (this.#process?.exitCode !== null) {
this.emitAdapterEvent("terminated");
}
}
protected exitJSProcess() {
if (!this.#process?.kill()) {
this.evaluateInternal({
expression: "process.exit(0)",
});
}
}
/**
* Starts the inspector.
* @param url the inspector url, will default to the one provided in the constructor (if any). If none
* @returns if the inspector was able to connect
*/
start(url?: string): Promise<boolean> {
return this.#attach({ url });
}
close() {
this.#process?.kill();
super.close();
}
async launch(request: DAP.LaunchRequest): Promise<void> {
this.options = { ...request, type: "launch" };
try {
await this.#launch(request);
} catch (error) {
// Some clients, like VSCode, will show a system-level popup when a `launch` request fails.
// Instead, we want to show the error as a sidebar notification.
const { message } = unknownToError(error);
this.emitAdapterEvent("output", {
category: "stderr",
output: `Failed to start debugger.\n${message}`,
});
this.terminate();
}
}
async #launch(request: LaunchRequest): Promise<void> {
const {
runtime = "bun",
runtimeArgs = [],
program,
args = [],
cwd,
env = {},
strictEnv = false,
watchMode = false,
stopOnEntry = false,
__skipValidation = false,
stdin,
} = request;
if (!__skipValidation && !program) {
throw new Error("No program specified");
}
const processArgs = [...runtimeArgs];
if (program === "-" && stdin) {
processArgs.push("--eval", stdin);
} else if (program) {
processArgs.push(program);
}
processArgs.push(...args);
if (program && isTestJavaScript(program) && !runtimeArgs.includes("test")) {
processArgs.unshift("test");
}
if (watchMode && !runtimeArgs.includes("--watch") && !runtimeArgs.includes("--hot")) {
processArgs.unshift(watchMode === "hot" ? "--hot" : "--watch");
}
const processEnv = strictEnv
? {
...env,
}
: {
...process.env,
...env,
};
if (process.platform !== "win32") {
// we're on unix
const url = `ws+unix://${randomUnixPath()}`;
const signal = new UnixSignal();
signal.on("Signal.received", () => {
this.#attach({ url });
});
this.once("Adapter.terminated", () => {
signal.close();
});
const query = stopOnEntry ? "break=1" : "wait=1";
processEnv["BUN_INSPECT"] = `${url}?${query}`;
processEnv["BUN_INSPECT_NOTIFY"] = signal.url;
// This is probably not correct, but it's the best we can do for now.
processEnv["FORCE_COLOR"] = "1";
processEnv["BUN_QUIET_DEBUG_LOGS"] = "1";
processEnv["BUN_DEBUG_QUIET_LOGS"] = "1";
const started = await this.#spawn({
command: runtime,
args: processArgs,
env: processEnv,
cwd,
isDebugee: true,
});
if (!started) {
throw new Error("Program could not be started.");
}
} else {
// we're on windows
// Create TCPSocketSignal
const url = `ws://127.0.0.1:${await getAvailablePort()}/${getRandomId()}`; // 127.0.0.1 so it resolves correctly on windows
const signal = new TCPSocketSignal(await getAvailablePort());
signal.on("Signal.received", async () => {
this.#attach({ url });
});
this.once("Adapter.terminated", () => {
signal.close();
});
const query = stopOnEntry ? "break=1" : "wait=1";
processEnv["BUN_INSPECT"] = `${url}?${query}`;
processEnv["BUN_INSPECT_NOTIFY"] = signal.url; // 127.0.0.1 so it resolves correctly on windows
// This is probably not correct, but it's the best we can do for now.
processEnv["FORCE_COLOR"] = "1";
processEnv["BUN_QUIET_DEBUG_LOGS"] = "1";
processEnv["BUN_DEBUG_QUIET_LOGS"] = "1";
const started = await this.#spawn({
command: runtime,
args: processArgs,
env: processEnv,
cwd,
isDebugee: true,
});
if (!started) {
throw new Error("Program could not be started.");
}
}
}
async #spawn(options: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string | undefined>;
isDebugee?: boolean;
}): Promise<boolean> {
const { command, args = [], cwd, env, isDebugee } = options;
const request = { command, args, cwd, env };
this.emit("Process.requested", request);
let subprocess: ChildProcess;
try {
subprocess = spawn(command, args, {
...request,
stdio: ["ignore", "pipe", "pipe"],
});
} catch (cause) {
this.emit("Process.exited", new Error("Failed to spawn process", { cause }), null);
return false;
}
subprocess.on("spawn", () => {
this.emit("Process.spawned", subprocess);
if (isDebugee) {
this.#process = subprocess;
this.emitAdapterEvent("process", {
name: `${command} ${args.join(" ")}`,
systemProcessId: subprocess.pid,
isLocalProcess: true,
startMethod: "launch",
});
}
});
subprocess.on("exit", (code, signal) => {
this.emit("Process.exited", code, signal);
if (isDebugee) {
this.#process = undefined;
this.emitAdapterEvent("exited", {
exitCode: code ?? -1,
});
this.emitAdapterEvent("terminated");
}
});
subprocess.stdout?.on("data", data => {
this.emit("Process.stdout", data.toString());
});
subprocess.stderr?.on("data", data => {
this.emit("Process.stderr", data.toString());
});
return new Promise(resolve => {
subprocess.on("spawn", () => resolve(true));
subprocess.on("exit", () => resolve(false));
subprocess.on("error", () => resolve(false));
});
}
async attach(request: AttachRequest): Promise<void> {
this.options = { ...request, type: "attach" };
try {
await this.#attach(request);
} catch (error) {
// Some clients, like VSCode, will show a system-level popup when a `launch` request fails.
// Instead, we want to show the error as a sidebar notification.
const { message } = unknownToError(error);
this.emitAdapterEvent("output", {
category: "stderr",
output: `Failed to start debugger.\n${message}`,
});
this.terminate();
}
}
async #attach(request: AttachRequest): Promise<boolean> {
const { url } = request;
for (let i = 0; i < 3; i++) {
const ok = await this.inspector.start(url);
if (ok) {
return true;
}
await new Promise(resolve => setTimeout(resolve, 100 * i));
}
return false;
}
}

View File

@@ -0,0 +1,117 @@
import type { Socket } from "node:net";
const enum FramerState {
WaitingForLength,
WaitingForMessage,
}
let socketFramerMessageLengthBuffer: Buffer;
export class SocketFramer {
state: FramerState = FramerState.WaitingForLength;
pendingLength: number = 0;
sizeBuffer: Buffer = Buffer.alloc(4);
sizeBufferIndex: number = 0;
bufferedData: Buffer = Buffer.alloc(0);
socket: Socket;
private onMessage: (message: string | string[]) => void;
constructor(socket: Socket, onMessage: (message: string | string[]) => void) {
this.socket = socket;
this.onMessage = onMessage;
if (!socketFramerMessageLengthBuffer) {
socketFramerMessageLengthBuffer = Buffer.alloc(4);
}
this.reset();
}
reset(): void {
this.state = FramerState.WaitingForLength;
this.bufferedData = Buffer.alloc(0);
this.sizeBufferIndex = 0;
this.sizeBuffer = Buffer.alloc(4);
}
send(data: string): void {
socketFramerMessageLengthBuffer.writeUInt32BE(data.length, 0);
this.socket.write(socketFramerMessageLengthBuffer);
this.socket.write(data);
}
onData(data: Buffer): void {
this.bufferedData = this.bufferedData.length > 0 ? Buffer.concat([this.bufferedData, data]) : data;
let messagesToDeliver: string[] = [];
let position = 0;
while (position < this.bufferedData.length) {
// Need 4 bytes for the length
if (this.bufferedData.length - position < 4) {
break;
}
// Read the length prefix
const messageLength = this.bufferedData.readUInt32BE(position);
// Validate message length
if (messageLength <= 0 || messageLength > 1024 * 1024) {
// 1MB max
// Try to resync by looking for the next valid message
let newPosition = position + 1;
let found = false;
while (newPosition < this.bufferedData.length - 4) {
const testLength = this.bufferedData.readUInt32BE(newPosition);
if (testLength > 0 && testLength <= 1024 * 1024) {
// Verify we can read the full message
if (this.bufferedData.length - newPosition - 4 >= testLength) {
const testMessage = this.bufferedData.toString("utf-8", newPosition + 4, newPosition + 4 + testLength);
if (testMessage.startsWith('{"')) {
position = newPosition;
found = true;
break;
}
}
}
newPosition++;
}
if (!found) {
// Couldn't find a valid message, discard buffer up to this point
this.bufferedData = this.bufferedData.slice(position + 4);
return;
}
continue;
}
// Check if we have the complete message
if (this.bufferedData.length - position - 4 < messageLength) {
break;
}
const message = this.bufferedData.toString("utf-8", position + 4, position + 4 + messageLength);
if (message.startsWith('{"')) {
messagesToDeliver.push(message);
}
position += 4 + messageLength;
}
if (position > 0) {
this.bufferedData =
position < this.bufferedData.length ? this.bufferedData.slice(position) : SocketFramer.emptyBuffer;
}
if (messagesToDeliver.length === 1) {
this.onMessage(messagesToDeliver[0]);
} else if (messagesToDeliver.length > 1) {
this.onMessage(messagesToDeliver);
}
}
private static emptyBuffer = Buffer.from([]);
}

View File

@@ -11,6 +11,8 @@ export type UnixSignalEventMap = {
"Signal.error": [Error];
"Signal.received": [string];
"Signal.closed": [];
"Signal.Socket.closed": [socket: Socket];
"Signal.Socket.connect": [socket: Socket];
};
/**
@@ -21,7 +23,7 @@ export class UnixSignal extends EventEmitter<UnixSignalEventMap> {
#server: Server;
#ready: Promise<void>;
constructor(path?: string | URL) {
constructor(path?: string | URL | undefined) {
super();
this.#path = path ? parseUnixPath(path) : randomUnixPath();
this.#server = createServer();
@@ -29,9 +31,13 @@ export class UnixSignal extends EventEmitter<UnixSignalEventMap> {
this.#server.on("error", error => this.emit("Signal.error", error));
this.#server.on("close", () => this.emit("Signal.closed"));
this.#server.on("connection", socket => {
this.emit("Signal.Socket.connect", socket);
socket.on("data", data => {
this.emit("Signal.received", data.toString());
});
socket.on("close", () => {
this.emit("Signal.Socket.closed", socket);
});
});
this.#ready = new Promise((resolve, reject) => {
this.#server.on("listening", resolve);
@@ -45,7 +51,7 @@ export class UnixSignal extends EventEmitter<UnixSignalEventMap> {
console.log(event, ...args);
}
return super.emit(event, ...args);
return super.emit(event, ...(args as never));
}
/**
@@ -91,6 +97,8 @@ export type TCPSocketSignalEventMap = {
"Signal.error": [Error];
"Signal.closed": [];
"Signal.received": [string];
"Signal.Socket.closed": [socket: Socket];
"Signal.Socket.connect": [socket: Socket];
};
export class TCPSocketSignal extends EventEmitter {
@@ -103,6 +111,8 @@ export class TCPSocketSignal extends EventEmitter {
this.#port = port;
this.#server = createServer((socket: Socket) => {
this.emit("Signal.Socket.connect", socket);
socket.on("data", data => {
this.emit("Signal.received", data.toString());
});
@@ -112,10 +122,14 @@ export class TCPSocketSignal extends EventEmitter {
});
socket.on("close", () => {
this.emit("Signal.closed");
this.emit("Signal.Socket.closed", socket);
});
});
this.#server.on("close", () => {
this.emit("Signal.closed");
});
this.#ready = new Promise((resolve, reject) => {
this.#server.listen(this.#port, () => {
this.emit("Signal.listening");

View File

@@ -1,6 +1,6 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { SourceMap } from "./sourcemap";
import { SourceMap } from "./sourcemap.js";
test("works without source map", () => {
const sourceMap = getSourceMap("without-sourcemap.js");

View File

@@ -21,7 +21,15 @@ export type Location = {
);
export interface SourceMap {
/**
* Converts a location in the original source to a location in the generated source.
* @param request A request
*/
generatedLocation(request: LocationRequest): Location;
/**
* Converts a location in the generated source to a location in the original source.
* @param request A request
*/
originalLocation(request: LocationRequest): Location;
}

View File

@@ -1,13 +1,13 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"module": "esnext",
"module": "NodeNext",
"target": "esnext",
"moduleResolution": "nodenext",
"moduleDetection": "force",
"allowImportingTsExtensions": true,
"noEmit": true,
"composite": true,
// "composite": true,
"strict": true,
"downlevelIteration": true,
"skipLibCheck": true,
@@ -15,7 +15,7 @@
"forceConsistentCasingInFileNames": true,
"inlineSourceMap": true,
"allowJs": true,
"outDir": "dist",
"outDir": "dist"
},
"include": ["src", "scripts", "../bun-types/index.d.ts", "../bun-inspector-protocol/src"]
"include": ["src", "scripts", "../bun-types/index.d.ts", "../bun-inspector-protocol/**/*.ts"]
}