diff options
author | 2023-08-27 09:03:15 -0700 | |
---|---|---|
committer | 2023-08-27 09:03:15 -0700 | |
commit | ed5dc5bbf922777acd3f637125a1978b6f2c391a (patch) | |
tree | e1e6ab8157ba441cbe612c7fd73348269a73c71e /packages/bun-debug-adapter-protocol/src/debugger/adapter.ts | |
parent | 36f9d2291c71d151dceff6d7d40adbbb2c3b3809 (diff) | |
download | bun-ed5dc5bbf922777acd3f637125a1978b6f2c391a.tar.gz bun-ed5dc5bbf922777acd3f637125a1978b6f2c391a.tar.zst bun-ed5dc5bbf922777acd3f637125a1978b6f2c391a.zip |
@electroid's dap changes (#4367)
* Rework terminal, launch is still WIP
* Use the proper Terminal profile API
* More changes
* progress
---------
Co-authored-by: Ashcon Partovi <ashcon@partovi.net>
Diffstat (limited to 'packages/bun-debug-adapter-protocol/src/debugger/adapter.ts')
-rw-r--r-- | packages/bun-debug-adapter-protocol/src/debugger/adapter.ts | 929 |
1 files changed, 490 insertions, 439 deletions
diff --git a/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts b/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts index 33555dbb0..1996863e6 100644 --- a/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts +++ b/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts @@ -1,12 +1,15 @@ import type { DAP } from "../protocol"; +import type { JSC } from "../../../bun-inspector-protocol/src/protocol"; +import type { InspectorEventMap } from "../../../bun-inspector-protocol/src/inspector"; // @ts-ignore -import type { JSC, InspectorListener, WebSocketInspectorOptions } from "../../../bun-inspector-protocol"; -import { UnixWebSocketInspector, remoteObjectToString } from "../../../bun-inspector-protocol/index"; +import { WebSocketInspector, remoteObjectToString } from "../../../bun-inspector-protocol/index"; import type { ChildProcess } from "node:child_process"; import { spawn, spawnSync } from "node:child_process"; import capabilities from "./capabilities"; import { Location, SourceMap } from "./sourcemap"; import { compare, parse } from "semver"; +import { EventEmitter } from "node:events"; +import { UnixSignal, randomUnixPath } from "./signal"; type InitializeRequest = DAP.InitializeRequest & { supportsConfigurationDoneRequest?: boolean; @@ -14,17 +17,22 @@ type InitializeRequest = DAP.InitializeRequest & { type LaunchRequest = DAP.LaunchRequest & { runtime?: string; + runtimeArgs?: string[]; program?: string; - cwd?: string; args?: string[]; + cwd?: string; env?: Record<string, string>; - inheritEnv?: boolean; + strictEnv?: boolean; + stopOnEntry?: boolean; + noDebug?: boolean; watch?: boolean | "hot"; - debug?: boolean; }; type AttachRequest = DAP.AttachRequest & { url?: string; + hostname?: string; + port?: number; + restart?: boolean; }; type Source = DAP.Source & { @@ -46,6 +54,7 @@ type Source = DAP.Source & { type Breakpoint = DAP.Breakpoint & { id: number; breakpointId: string; + generatedLocation: JSC.Debugger.Location; source: Source; }; @@ -67,6 +76,7 @@ type Scope = DAP.Scope & { type Variable = DAP.Variable & { objectId?: string; + objectGroup?: string; type: JSC.Runtime.RemoteObject["type"] | JSC.Runtime.RemoteObject["subtype"]; }; @@ -78,24 +88,28 @@ type IDebugAdapter = { ) => void | DAP.ResponseMap[R] | Promise<DAP.ResponseMap[R]> | Promise<void>; }; -export type DebugAdapterOptions = WebSocketInspectorOptions & { - url: string | URL; - send(message: DAP.Request | DAP.Response | DAP.Event): Promise<void>; - stdout?(message: string): void; - stderr?(message: string): void; +export type DebugAdapterEventMap = InspectorEventMap & { + [E in keyof DAP.EventMap as E extends string ? `Adapter.${E}` : never]: [DAP.EventMap[E]]; +} & { + "Adapter.request": [DAP.Request]; + "Adapter.response": [DAP.Response]; + "Adapter.event": [DAP.Event]; + "Adapter.error": [Error]; +} & { + "Process.requested": [unknown]; + "Process.spawned": [ChildProcess]; + "Process.exited": [number | Error | null, string | null]; + "Process.stdout": [string]; + "Process.stderr": [string]; }; -// This adapter only support single-threaded debugging, -// which means that there is only one thread at a time. -const threadId = 1; +let threadId = 1; +let isDebug = process.env.NODE_ENV === "development"; -// @ts-ignore -export class DebugAdapter implements IDebugAdapter, InspectorListener { - #url: URL; - #sendToAdapter: DebugAdapterOptions["send"]; - #stdout?: DebugAdapterOptions["stdout"]; - #stderr?: DebugAdapterOptions["stderr"]; - #inspector: UnixWebSocketInspector; +export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements IDebugAdapter { + #threadId: number; + #inspector: WebSocketInspector; + #process?: ChildProcess; #sourceId: number; #pendingSources: Map<string, ((source: Source) => void)[]>; #sources: Map<string | number, Source>; @@ -104,19 +118,22 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { #breakpointId: number; #breakpoints: Breakpoint[]; #functionBreakpoints: Map<string, FunctionBreakpoint>; - #variables: (Variable | Variable[])[]; - #process?: ChildProcess; + #variableId: number; + #variables: Map<number, Variable>; #initialized?: InitializeRequest; #launched?: LaunchRequest; - #connected?: boolean; - - constructor({ url, send, stdout, stderr, ...options }: DebugAdapterOptions) { - this.#url = new URL(url); - // @ts-ignore - this.#inspector = new UnixWebSocketInspector({ ...options, url, listener: this }); - this.#stdout = stdout; - this.#stderr = stderr; - this.#sendToAdapter = send; + + constructor(url?: string | URL) { + super(); + this.#threadId = threadId++; + this.#inspector = new WebSocketInspector(url); + 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)); + return sent; + }; this.#sourceId = 1; this.#pendingSources = new Map(); this.#sources = new Map(); @@ -125,87 +142,155 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { this.#breakpointId = 1; this.#breakpoints = []; this.#functionBreakpoints = new Map(); - this.#variables = [{ name: "", value: "", type: undefined, variablesReference: 0 }]; + this.#variableId = 1; + this.#variables = new Map(); + } + + /** + * Gets the inspector url. + */ + get url(): string { + return this.#inspector.url; + } + + /** + * Starts the inspector. + * @param url the inspector url + * @returns if the inspector was able to connect + */ + start(url?: string): Promise<boolean> { + return this.#inspector.start(url); + } + + /** + * Sends a request to the JavaScript inspector. + * @param method the method name + * @param params the method parameters + * @returns the response + * @example + * const { result, wasThrown } = await adapter.send("Runtime.evaluate", { + * expression: "1 + 1", + * }); + * 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); } - get inspector(): UnixWebSocketInspector { - return this.#inspector; - } + /** + * Emits an event. For the adapter to work, you must: + * - emit `Adapter.request` when the client sends a request to the adapter. + * - listen to `Adapter.response` to receive responses from the adapter. + * - listen to `Adapter.event` to receive events from the adapter. + * @param event the event name + * @param args the event arguments + * @returns if the event was sent to a listener + */ + emit<E extends keyof DebugAdapterEventMap>(event: E, ...args: DebugAdapterEventMap[E] | []): boolean { + if (isDebug && event !== "Adapter.event" && event !== "Inspector.event") { + console.log(this.#threadId, event, ...args); + } - async accept(message: DAP.Request | DAP.Response | DAP.Event): Promise<void> { - const { type } = message; + let sent = super.emit(event, ...(args as any)); + + if (!(event in this)) { + return sent; + } - switch (type) { - case "request": - return this.#acceptRequest(message); + let result: unknown; + try { + // @ts-ignore + result = this[event](...args); + } catch (cause) { + sent ||= this.emit("Adapter.error", unknownToError(cause)); + return sent; + } + + if (result instanceof Promise) { + result.catch(cause => { + this.emit("Adapter.error", unknownToError(cause)); + }); } - throw new Error(`Not supported: ${type}`); + return sent; } - async #acceptRequest(request: DAP.Request): Promise<void> { - const { seq, command, arguments: args } = request; + #emit<E extends keyof DAP.EventMap>(event: E, body?: DAP.EventMap[E]): void { + this.emit("Adapter.event", { + type: "event", + seq: 0, + event, + body, + }); + } + + #reverseSend<R extends keyof DAP.RequestMap>(name: R, request: DAP.RequestMap[R]): void { + this.emit("Adapter.request", { + type: "request", + seq: 0, + command: name, + arguments: request, + }); + } - let response; + async ["Adapter.request"](request: DAP.Request): Promise<void> { + const { command, arguments: args } = request; + + if (!(command in this)) { + return; + } + + let result: unknown; try { - if (!(command! in this)) { - throw new Error(`Not supported: ${command}`); - } - response = await this[command as keyof this](args); - } catch (error) { - const { message } = unknownToError(error); - return this.#sendToAdapter({ + // @ts-ignore + result = await this[command](args); + } catch (cause) { + const error = unknownToError(cause); + this.emit("Adapter.error", error); + + const { message } = error; + this.emit("Adapter.response", { type: "response", + command, success: false, message, - request_seq: seq, + request_seq: request.seq, seq: 0, - command, }); + return; } - return this.#sendToAdapter({ + this.emit("Adapter.response", { type: "response", + command, success: true, - request_seq: seq, + request_seq: request.seq, seq: 0, - command, - body: response, + body: result, }); } - async #send<M extends keyof JSC.RequestMap & keyof JSC.ResponseMap>( - method: M, - params?: JSC.RequestMap[M], - ): Promise<JSC.ResponseMap[M]> { - return this.#inspector.send(method, params); - } - - async #emit<E extends keyof DAP.EventMap>(name: E, body?: DAP.EventMap[E]): Promise<void> { - await this.#sendToAdapter({ - type: "event", - seq: 0, - event: name, - body, - }); + ["Adapter.event"](event: DAP.Event): void { + const { event: name, body } = event; + this.emit(`Adapter.${name}` as keyof DebugAdapterEventMap, body); } initialize(request: InitializeRequest): DAP.InitializeResponse { const { clientID, supportsConfigurationDoneRequest } = (this.#initialized = request); - this.#send("Inspector.enable"); - this.#send("Runtime.enable"); - this.#send("Console.enable"); - this.#send("Debugger.enable"); - this.#send("Debugger.setAsyncStackTraceDepth", { depth: 200 }); - this.#send("Debugger.setPauseOnDebuggerStatements", { enabled: true }); - this.#send("Debugger.setBlackboxBreakpointEvaluations", { blackboxBreakpointEvaluations: true }); - this.#send("Debugger.setBreakpointsActive", { active: true }); + this.send("Inspector.enable"); + this.send("Runtime.enable"); + this.send("Console.enable"); + this.send("Debugger.enable"); + this.send("Debugger.setAsyncStackTraceDepth", { depth: 200 }); + this.send("Debugger.setPauseOnDebuggerStatements", { enabled: true }); + this.send("Debugger.setBlackboxBreakpointEvaluations", { blackboxBreakpointEvaluations: true }); + this.send("Debugger.setBreakpointsActive", { active: true }); // If the client will not send a `configurationDone` request, then we need to // tell the debugger that everything is ready. if (!supportsConfigurationDoneRequest && clientID !== "vscode") { - this.#send("Inspector.initialized"); + this.send("Inspector.initialized"); } // Tell the client what capabilities this adapter supports. @@ -216,16 +301,16 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { // If the client requested that `noDebug` mode be enabled, // then we need to disable all breakpoints and pause on statements. if (this.#launched?.noDebug) { - this.#send("Debugger.setBreakpointsActive", { active: false }); - this.#send("Debugger.setPauseOnExceptions", { state: "none" }); - this.#send("Debugger.setPauseOnDebuggerStatements", { enabled: false }); - this.#send("Debugger.setPauseOnMicrotasks", { enabled: false }); - this.#send("Debugger.setPauseForInternalScripts", { shouldPause: false }); - this.#send("Debugger.setPauseOnAssertions", { enabled: false }); + this.send("Debugger.setBreakpointsActive", { active: false }); + this.send("Debugger.setPauseOnExceptions", { state: "none" }); + this.send("Debugger.setPauseOnDebuggerStatements", { enabled: false }); + this.send("Debugger.setPauseOnMicrotasks", { enabled: false }); + this.send("Debugger.setPauseForInternalScripts", { shouldPause: false }); + this.send("Debugger.setPauseOnAssertions", { enabled: false }); } // Tell the debugger that everything is ready. - this.#send("Inspector.initialized"); + this.send("Inspector.initialized"); } async launch(request: DAP.LaunchRequest): Promise<void> { @@ -241,16 +326,24 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { category: "stderr", output: `Failed to start debugger.\n${message}`, }); - this.#emit("terminated"); + this.terminate(); } } async #launch(request: LaunchRequest): Promise<void> { - if (this.#process?.exitCode === null) { - throw new Error("Another program is already running. Did you terminate the last session?"); - } + const { + runtime = "bun", + runtimeArgs = [], + program, + args = [], + cwd, + env = {}, + strictEnv = false, + stopOnEntry = false, + noDebug = false, + watch = false, + } = request; - const { program, runtime = "bun", args = [], cwd, env = {}, inheritEnv = true, watch = false } = request; if (!program) { throw new Error("No program specified. Did you set the 'program' property in your launch.json?"); } @@ -259,119 +352,58 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { throw new Error("Program must be a JavaScript or TypeScript file."); } - const finalArgs = [...args]; - const isTest = isTestJavaScript(program); - if (isTest) { - finalArgs.unshift("test"); + const processArgs = [...runtimeArgs, program, ...args]; + + if (isTestJavaScript(program) && !runtimeArgs.includes("test")) { + processArgs.unshift("test"); } - if (watch) { - finalArgs.push(watch === "hot" ? "--hot" : "--watch"); + if (watch && !runtimeArgs.includes("--watch") && !runtimeArgs.includes("--hot")) { + processArgs.unshift(watch === "hot" ? "--hot" : "--watch"); } - const finalEnv = inheritEnv + const processEnv = strictEnv ? { - ...process.env, ...env, } : { + ...process.env, ...env, }; - finalEnv["BUN_INSPECT"] = `1${this.#url}`; - finalEnv["BUN_INSPECT_NOTIFY"] = `unix://${this.#inspector.unix}`; + const url = `ws+unix://${randomUnixPath()}`; + const signal = new UnixSignal(); - if (isTest) { - finalEnv["FORCE_COLOR"] = "1"; - } else { - // https://github.com/microsoft/vscode/issues/571 - finalEnv["NO_COLOR"] = "1"; - } + const i = stopOnEntry ? "2" : "1"; + processEnv["BUN_INSPECT"] = `${i}${url}`; + processEnv["BUN_INSPECT_NOTIFY"] = signal.url; + processEnv["FORCE_COLOR"] = "1"; - const subprocess = spawn(runtime, [...finalArgs, program], { - stdio: ["ignore", "pipe", "pipe"], + const started = await this.#spawn({ + command: runtime, + args: processArgs, + env: processEnv, cwd, - env: finalEnv, + isDebugee: true, }); - subprocess.on("spawn", () => { - this.#process = subprocess; - this.#emit("process", { - name: program, - systemProcessId: subprocess.pid, - isLocalProcess: true, - startMethod: "launch", - }); - }); + if (!started) { + throw new Error("Program could not be started."); + } - subprocess.on("exit", (code, signal) => { - this.#emit("exited", { - exitCode: code ?? -1, + await new Promise<void>(resolve => { + signal.on("Signal.received", () => { + resolve(); }); - this.#process = undefined; - }); - - subprocess.stdout!.on("data", data => { - const text = data.toString(); - this.#stdout?.(text); - - if (isTest) { - this.#emit("output", { - category: "stdout", - output: text, - source: { - path: program, - }, - }); - } + setTimeout(resolve, 5000); }); - subprocess.stderr!.on("data", data => { - const text = data.toString(); - this.#stderr?.(text); - - if (isTest) { - this.#emit("output", { - category: "stdout", // Not stderr, since VSCode will highlight it as red. - output: text, - source: { - path: program, - }, - }); - } - }); - - const start = new Promise<undefined>(resolve => { - subprocess.on("spawn", () => resolve(undefined)); - }); + const attached = await this.#attach(url); - const exitOrError = new Promise<number | string | Error>(resolve => { - subprocess.on("exit", (code, signal) => resolve(code ?? signal ?? -1)); - subprocess.on("error", resolve); - }); - - const reason = await Promise.race([start, exitOrError]); - - if (reason instanceof Error) { - const { message } = reason; - throw new Error(`Program could not be started.\n${message}`); - } - - if (reason !== undefined) { - throw new Error(`Program exited with code ${reason} before the debugger could attached.`); - } - - if (await this.#start()) { + if (attached) { return; } - if (subprocess.exitCode === null && !subprocess.kill() && !subprocess.kill("SIGKILL")) { - this.#emit("output", { - category: "debug console", - output: `Failed to kill process ${subprocess.pid}\n`, - }); - } - const { stdout: version } = spawnSync(runtime, ["--version"], { stdio: "pipe", encoding: "utf-8" }); const minVersion = "0.8.2"; @@ -382,26 +414,73 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { throw new Error("Program started, but the debugger could not be attached."); } - async #start(url?: string | URL): Promise<boolean> { - if (url) { - this.#url = new URL(url); + 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; } - for (let i = 0; i < 5; i++) { - const ok = await this.#inspector.start(url); - if (ok) { - return true; + 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", + }); } + }); - await new Promise(resolve => setTimeout(resolve, 100 * i)); - } + subprocess.on("exit", (code, signal) => { + this.emit("Process.exited", code, signal); - return false; + if (isDebugee) { + this.#process = undefined; + this.#emit("exited", { + exitCode: code ?? -1, + }); + } + }); + + 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: DAP.AttachRequest): Promise<void> { + async attach(request: AttachRequest): Promise<void> { + const { url } = request; + try { - await this.#attach(request); + await this.#attach(url); } 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. @@ -410,33 +489,24 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { category: "stderr", output: `Failed to start debugger.\n${message}`, }); - this.#emit("terminated"); + this.terminate(); } } - async #attach(request: AttachRequest): Promise<void> { - const { url } = request; - - if (this.#url.href === url) { - this.#emit("output", { - category: "debug console", - output: "Debugger attached.\n", - }); - - this.configurationDone(); - return; - } - - if (await this.#start(url)) { - this.configurationDone(); - return; + async #attach(url?: string | URL): Promise<boolean> { + for (let i = 0; i < 5; i++) { + const ok = await this.#inspector.start(url); + if (ok) { + return true; + } + await new Promise(resolve => setTimeout(resolve, 100 * i)); } - - throw new Error("Failed to attach to program."); + return false; } terminate(): void { this.#process?.kill(); + this.#emit("terminated"); } disconnect(request: DAP.DisconnectRequest): void { @@ -453,18 +523,18 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { const { source } = request; const { scriptId } = await this.#getSource(sourceToId(source)); - const { scriptSource } = await this.#send("Debugger.getScriptSource", { scriptId }); + const { scriptSource } = await this.send("Debugger.getScriptSource", { scriptId }); return { content: scriptSource, }; } - async threads(request: DAP.ThreadsRequest): Promise<DAP.ThreadsResponse> { + async threads(): Promise<DAP.ThreadsResponse> { return { threads: [ { - id: threadId, + id: this.#threadId, name: "Main Thread", }, ], @@ -472,27 +542,27 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { } async pause(): Promise<void> { - await this.#send("Debugger.pause"); + await this.send("Debugger.pause"); this.#stopped = "pause"; } async continue(): Promise<void> { - await this.#send("Debugger.resume"); + await this.send("Debugger.resume"); this.#stopped = undefined; } async next(): Promise<void> { - await this.#send("Debugger.stepNext"); + await this.send("Debugger.stepNext"); this.#stopped = "step"; } async stepIn(): Promise<void> { - await this.#send("Debugger.stepInto"); + await this.send("Debugger.stepInto"); this.#stopped = "step"; } async stepOut(): Promise<void> { - await this.#send("Debugger.stepOut"); + await this.send("Debugger.stepOut"); this.#stopped = "step"; } @@ -505,7 +575,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { this.#generatedLocation(source, endLine ?? line + 1, endColumn), ]); - const { locations } = await this.#send("Debugger.getBreakpointLocations", { + const { locations } = await this.send("Debugger.getBreakpointLocations", { start, end, }); @@ -590,17 +660,19 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { const source = await this.#getSource(sourceId); const oldBreakpoints = this.#getBreakpoints(sourceId); - const breakpoints = await Promise.all( requests!.map(async ({ line, column, ...options }) => { - const breakpoint = this.#getBreakpoint(sourceId, line, column); - if (breakpoint) { - return breakpoint; + const location = this.#generatedLocation(source, line, column); + + for (const breakpoint of oldBreakpoints) { + const { generatedLocation } = breakpoint; + if (locationIsSame(generatedLocation, location)) { + return breakpoint; + } } - const location = this.#generatedLocation(source, line, column); try { - const { breakpointId, actualLocation } = await this.#send("Debugger.setBreakpoint", { + const { breakpointId, actualLocation } = await this.send("Debugger.setBreakpoint", { location, options: breakpointOptions(options), }); @@ -611,6 +683,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { breakpointId, source, verified: true, + generatedLocation: location, ...originalLocation, }); } catch (error) { @@ -626,6 +699,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { source, verified: false, message, + generatedLocation: location, }); } }), @@ -635,7 +709,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { oldBreakpoints.map(async ({ breakpointId }) => { const isRemoved = !breakpoints.filter(({ breakpointId: id }) => breakpointId === id).length; if (isRemoved) { - await this.#send("Debugger.removeBreakpoint", { + await this.send("Debugger.removeBreakpoint", { breakpointId, }); this.#removeBreakpoint(breakpointId); @@ -661,18 +735,13 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { return breakpoints; } - #getBreakpoint(sourceId: string | number, line?: number, column?: number): Breakpoint | undefined { - for (const breakpoint of this.#getBreakpoints(sourceId)) { - if (isSameLocation(breakpoint, { line, column })) { - return breakpoint; - } - } - return undefined; - } - #addBreakpoint(breakpoint: Breakpoint): Breakpoint { this.#breakpoints.push(breakpoint); + // For now, remove the column from breakpoints because + // it can be inaccurate and causes weird rendering issues in VSCode. + breakpoint.column = this.#lineFrom0BasedLine(0); + this.#emit("breakpoint", { reason: "changed", breakpoint, @@ -700,7 +769,6 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { const { breakpoints: requests } = request; const oldBreakpoints = this.#getFunctionBreakpoints(); - const breakpoints = await Promise.all( requests.map(async ({ name, ...options }) => { const breakpoint = this.#getFunctionBreakpoint(name); @@ -709,7 +777,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { } try { - await this.#send("Debugger.addSymbolicBreakpoint", { + await this.send("Debugger.addSymbolicBreakpoint", { symbol: name, caseSensitive: true, isRegex: false, @@ -737,7 +805,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { oldBreakpoints.map(async ({ name }) => { const isRemoved = !breakpoints.filter(({ name: n }) => name === n).length; if (isRemoved) { - await this.#send("Debugger.removeSymbolicBreakpoint", { + await this.send("Debugger.removeSymbolicBreakpoint", { symbol: name, caseSensitive: true, isRegex: false, @@ -763,18 +831,22 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { #addFunctionBreakpoint(breakpoint: FunctionBreakpoint): FunctionBreakpoint { const { name } = breakpoint; this.#functionBreakpoints.set(name, breakpoint); + this.#emit("breakpoint", { reason: "changed", breakpoint, }); + return breakpoint; } #removeFunctionBreakpoint(name: string): void { const breakpoint = this.#functionBreakpoints.get(name); + if (!breakpoint || !this.#functionBreakpoints.delete(name)) { return; } + this.#emit("breakpoint", { reason: "removed", breakpoint, @@ -789,7 +861,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { filterIds.push(...filterOptions.map(({ filterId }) => filterId)); } - await this.#send("Debugger.setPauseOnExceptions", { + await this.send("Debugger.setPauseOnExceptions", { state: exceptionFiltersToPauseOnExceptionsState(filterIds), }); } @@ -797,8 +869,9 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { async evaluate(request: DAP.EvaluateRequest): Promise<DAP.EvaluateResponse> { const { expression, frameId, context } = request; const callFrameId = this.#getCallFrameId(frameId); + const objectGroup = callFrameId ? "debugger" : context; - const { result, wasThrown } = await this.#evaluate(expression, callFrameId); + const { result, wasThrown } = await this.#evaluate(expression, objectGroup, callFrameId); const { className } = result; if (context === "hover" && wasThrown && (className === "SyntaxError" || className === "ReferenceError")) { @@ -808,18 +881,23 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { }; } - const { name, value, ...variable } = this.#addVariable(result); + const { name, value, ...variable } = this.#addObject(result, { objectGroup }); return { ...variable, result: value, }; } - async #evaluate(expression: string, callFrameId?: string): Promise<JSC.Runtime.EvaluateResponse> { + async #evaluate( + expression: string, + objectGroup?: string, + callFrameId?: string, + ): Promise<JSC.Runtime.EvaluateResponse> { const method = callFrameId ? "Debugger.evaluateOnCallFrame" : "Runtime.evaluate"; - return this.#send(method, { + return this.send(method, { callFrameId, + objectGroup, expression: sanitizeExpression(expression), generatePreview: true, emulateUserGesture: true, @@ -839,13 +917,6 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { } ["Inspector.connected"](): void { - if (this.#connected) { - this.restart(); - return; - } - - this.#connected = true; - this.#emit("output", { category: "debug console", output: "Debugger attached.\n", @@ -855,19 +926,19 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { } async ["Inspector.disconnected"](error?: Error): Promise<void> { - if (this.#connected && this.#process?.exitCode === null && (await this.#start())) { - return; - } - - if (!this.#connected) { - return; - } - this.#emit("output", { category: "debug console", output: "Debugger detached.\n", }); + if (error) { + const { message } = error; + this.#emit("output", { + category: "stderr", + output: `${message}\n`, + }); + } + this.#emit("terminated"); this.#reset(); } @@ -915,6 +986,11 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { ["Debugger.scriptFailedToParse"](event: JSC.Debugger.ScriptFailedToParseEvent): void { const { url, errorMessage, errorLine } = event; + // If no url is present, the script is from a `evaluate` request. + if (!url) { + return; + } + this.#emit("output", { category: "stderr", output: errorMessage, @@ -928,14 +1004,14 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { ["Debugger.paused"](event: JSC.Debugger.PausedEvent): void { const { reason, callFrames, asyncStackTrace, data } = event; - if (reason === "PauseOnNextStatement") { - for (const { functionName } of callFrames) { - if (functionName === "module code") { - this.#send("Debugger.resume"); - return; - } - } - } + // if (reason === "PauseOnNextStatement") { + // for (const { functionName } of callFrames) { + // if (functionName === "module code") { + // this.send("Debugger.resume"); + // return; + // } + // } + // } this.#stackFrames.length = 0; this.#stopped ||= stoppedReason(reason); @@ -946,10 +1022,11 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { this.#addAsyncStackTrace(asyncStackTrace); } - let hitBreakpointIds: number[] | undefined; // Depending on the reason, the `data` property is set to the reason // why the execution was paused. For example, if the reason is "breakpoint", // the `data` property is set to the breakpoint ID. + let hitBreakpointIds: number[] | undefined; + if (data) { if (reason === "exception") { const remoteObject = data as JSC.Runtime.RemoteObject; @@ -976,7 +1053,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { } this.#emit("stopped", { - threadId, + threadId: this.#threadId, reason: this.#stopped, hitBreakpointIds, }); @@ -985,83 +1062,93 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { ["Debugger.resumed"](event: JSC.Debugger.ResumedEvent): void { this.#stackFrames.length = 0; this.#stopped = undefined; + console.log("VARIABLES BEFORE", this.#variables.size); + for (const { variablesReference, objectGroup } of this.#variables.values()) { + if (objectGroup === "debugger") { + this.#variables.delete(variablesReference); + } + } + console.log("VARIABLES AFTER", this.#variables.size); this.#emit("continued", { - threadId, + threadId: this.#threadId, }); } - ["Console.messageAdded"](event: JSC.Console.MessageAddedEvent): void { - const { message } = event; - const { type, level, text, parameters, line, column, stackTrace } = message; - - let output: string; - let variablesReference: number | undefined; - - if (parameters?.length) { - output = ""; - - const variables = parameters.map((parameter, i) => { - const variable = this.#addVariable(parameter, { name: `${i}` }); - - output += remoteObjectToString(parameter, true) + " "; - - return variable; - }); - - if (variables.length === 1) { - const [{ variablesReference: reference }] = variables; - variablesReference = reference; - } else { - variablesReference = this.#setVariable(variables); - } - } else { - output = text; - } - - if (!output.endsWith("\n")) { - output += "\n"; - } - - const color = consoleLevelToAnsiColor(level); - if (color) { - output = `${color}${output}`; - } - - if (variablesReference) { - variablesReference = this.#setVariable([ - { - name: "", - value: "", - type: undefined, - variablesReference, - }, - ]); - } - - let source: Source | undefined; - if (stackTrace) { - const { callFrames } = stackTrace; - if (callFrames.length) { - const { scriptId } = callFrames.at(-1)!; - source = this.#getSourceIfPresent(scriptId); - } - } - - let location: Location | {} = {}; - if (source) { - location = this.#originalLocation(source, line, column); - } + ["Process.stdout"](output: string): void { + this.#emit("output", { + category: "debug console", + output, + }); + } + ["Process.stderr"](output: string): void { this.#emit("output", { category: "debug console", - group: consoleMessageGroup(type), output, - variablesReference, - source, - ...location, }); } + ["Console.messageAdded"](event: JSC.Console.MessageAddedEvent): void { + // const { message } = event; + // const { type, level, text, parameters, line, column, stackTrace } = message; + // let output: string; + // let variablesReference: number | undefined; + // if (parameters?.length) { + // output = ""; + // const variables = parameters.map((parameter, i) => { + // const variable = this.#addObject(parameter, { name: `${i}`, objectGroup: "console" }); + // output += remoteObjectToString(parameter, true) + " "; + // return variable; + // }); + // if (variables.length === 1) { + // const [{ variablesReference: reference }] = variables; + // variablesReference = reference; + // } else { + // variablesReference = this.#variableId++; + // //this.#variables.set(variablesReference, variables); + // } + // } else { + // output = text; + // } + // if (!output.endsWith("\n")) { + // output += "\n"; + // } + // const color = consoleLevelToAnsiColor(level); + // if (color) { + // output = `${color}${output}`; + // } + // if (variablesReference) { + // const containerReference = this.#variableId++; + // this.#variables.set(containerReference, { + // name: "", + // value: "", + // type: undefined, + // variablesReference, + // }); + // variablesReference = containerReference; + // } + // let source: Source | undefined; + // if (stackTrace) { + // const { callFrames } = stackTrace; + // if (callFrames.length) { + // const { scriptId } = callFrames.at(-1)!; + // source = this.#getSourceIfPresent(scriptId); + // } + // } + // let location: Location | {} = {}; + // if (source) { + // location = this.#originalLocation(source, line, column); + // } + // this.#emit("output", { + // category: "debug console", + // group: consoleMessageGroup(type), + // output, + // variablesReference, + // source, + // ...location, + // }); + } + #addSource(source: Source): Source { const { sourceId, scriptId, path, sourceReference } = source; @@ -1228,7 +1315,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { continue; } - const { variablesReference } = this.#addVariable(object); + const { variablesReference } = this.#addObject(object, { objectGroup: "debugger" }); const presentationHint = scopePresentationHint(type); const title = presentationHint ? titleize(presentationHint) : "Unknown"; const displayName = name ? `${title}: ${name}` : title; @@ -1311,7 +1398,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { async variables(request: DAP.VariablesRequest): Promise<DAP.VariablesResponse> { const { variablesReference, start, count } = request; - const variable = this.#variables[variablesReference]; + const variable = this.#variables.get(variablesReference); let variables: Variable[]; if (!variable) { @@ -1319,7 +1406,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { } else if (Array.isArray(variable)) { variables = variable; } else { - variables = await this.#getVariables(variable, start, count); + variables = await this.#getProperties(variable, start, count); } return { @@ -1327,20 +1414,16 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { }; } - #setVariable(variable: Variable | Variable[]): number { - const variablesReference = this.#variables.length; - - this.#variables.push(variable); - - return variablesReference; - } - - #addVariable(remoteObject: JSC.Runtime.RemoteObject, propertyDescriptor?: JSC.Runtime.PropertyDescriptor): Variable { + #addObject( + remoteObject: JSC.Runtime.RemoteObject, + propertyDescriptor?: Partial<JSC.Runtime.PropertyDescriptor> & { objectGroup?: string }, + ): Variable { const { objectId, type, subtype, size } = remoteObject; - const variablesReference = objectId ? this.#variables.length : 0; + const variablesReference = objectId ? this.#variableId++ : 0; const variable: Variable = { objectId, + objectGroup: propertyDescriptor?.objectGroup, name: propertyDescriptorToName(propertyDescriptor), type: subtype || type, value: remoteObjectToString(remoteObject), @@ -1349,37 +1432,74 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { namedVariables: isNamedIndexed(subtype) ? size : undefined, presentationHint: remoteObjectToVariablePresentationHint(remoteObject, propertyDescriptor), }; - this.#setVariable(variable); + + if (variablesReference) { + this.#variables.set(variablesReference, variable); + console.log("addObject", variablesReference, variable); + } return variable; } - async #getVariables(variable: Variable, offset?: number, count?: number): Promise<Variable[]> { - const { objectId, type, indexedVariables, namedVariables } = variable; + #addProperty( + propertyDescriptor: + | JSC.Runtime.PropertyDescriptor + | (JSC.Runtime.InternalPropertyDescriptor & { objectGroup?: string }), + ): Variable[] { + const { value, get, set, symbol } = propertyDescriptor as JSC.Runtime.PropertyDescriptor; + const variables: Variable[] = []; + + if (value) { + variables.push(this.#addObject(value, propertyDescriptor)); + } + + if (get) { + const { type } = get; + if (type !== "undefined") { + variables.push(this.#addObject(get, propertyDescriptor)); + } + } + + if (set) { + const { type } = set; + if (type !== "undefined") { + variables.push(this.#addObject(set, propertyDescriptor)); + } + } + + if (symbol) { + variables.push(this.#addObject(symbol, propertyDescriptor)); + } + + return variables; + } + + async #getProperties(variable: Variable, offset?: number, count?: number): Promise<Variable[]> { + const { objectId, objectGroup, type, indexedVariables, namedVariables } = variable; + const variables: Variable[] = []; if (!objectId || type === "symbol") { - return []; + return variables; } - const { properties, internalProperties } = await this.#send("Runtime.getDisplayableProperties", { + const { properties, internalProperties } = await this.send("Runtime.getDisplayableProperties", { objectId, generatePreview: true, }); - const variables: Variable[] = []; for (const property of properties) { - variables.push(...this.#getVariable(property)); + variables.push(...this.#addProperty({ ...property, objectGroup })); } if (internalProperties) { for (const property of internalProperties) { - variables.push(...this.#getVariable({ ...property, configurable: false })); + variables.push(...this.#addProperty({ ...property, objectGroup, configurable: false })); } } const hasEntries = type !== "array" && (indexedVariables || namedVariables); if (hasEntries) { - const { entries } = await this.#send("Runtime.getCollectionEntries", { + const { entries } = await this.send("Runtime.getCollectionEntries", { objectId, fetchStart: offset, fetchCount: count, @@ -1392,48 +1512,16 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { const { value, description } = key; name = String(value ?? description); } - variables.push(this.#addVariable(value, { name })); + variables.push(this.#addObject(value, { name, objectGroup })); } } return variables; } - #getVariable( - propertyDescriptor: JSC.Runtime.PropertyDescriptor | JSC.Runtime.InternalPropertyDescriptor, - ): Variable[] { - const { value, get, set, symbol } = propertyDescriptor as JSC.Runtime.PropertyDescriptor; - const variables: Variable[] = []; - - if (value) { - variables.push(this.#addVariable(value, propertyDescriptor)); - } - - if (get) { - const { type } = get; - if (type !== "undefined") { - variables.push(this.#addVariable(get, propertyDescriptor)); - } - } - - if (set) { - const { type } = set; - if (type !== "undefined") { - variables.push(this.#addVariable(set, propertyDescriptor)); - } - } - - if (symbol) { - variables.push(this.#addVariable(symbol, propertyDescriptor)); - } - - return variables; - } - close(): void { this.#process?.kill(); this.#inspector.close(); - this.#reset(); } #reset(): void { @@ -1444,10 +1532,9 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener { this.#breakpointId = 1; this.#breakpoints.length = 0; this.#functionBreakpoints.clear(); - this.#variables.length = 1; + this.#variables.clear(); this.#launched = undefined; this.#initialized = undefined; - this.#connected = undefined; } } @@ -1555,14 +1642,6 @@ function consoleMessageGroup(type: JSC.Console.ConsoleMessage["type"]): DAP.Outp return undefined; } -function sourceToPath(source?: DAP.Source): string { - const { path } = source ?? {}; - if (!path) { - throw new Error("No source found."); - } - return path; -} - function sourceToId(source?: DAP.Source): string | number { const { path, sourceReference } = source ?? {}; if (path) { @@ -1595,17 +1674,19 @@ function sanitizeExpression(expression: string): string { function remoteObjectToVariablePresentationHint( remoteObject: JSC.Runtime.RemoteObject, - propertyDescriptor?: JSC.Runtime.PropertyDescriptor, + propertyDescriptor?: Partial<JSC.Runtime.PropertyDescriptor>, ): DAP.VariablePresentationHint { const { type, subtype } = remoteObject; const { name, configurable, writable, isPrivate, symbol, get, set, wasThrown } = propertyDescriptor ?? {}; const hasGetter = get?.type === "function"; const hasSetter = set?.type === "function"; const hasSymbol = symbol?.type === "symbol"; + let kind: string | undefined; let visibility: string | undefined; let lazy: boolean | undefined; let attributes: string[] = []; + if (type === "function") { kind = "method"; } @@ -1625,6 +1706,7 @@ function remoteObjectToVariablePresentationHint( lazy = true; attributes.push("hasSideEffects"); } + return { kind, visibility, @@ -1633,7 +1715,7 @@ function remoteObjectToVariablePresentationHint( }; } -function propertyDescriptorToName(propertyDescriptor?: JSC.Runtime.PropertyDescriptor): string { +function propertyDescriptorToName(propertyDescriptor?: Partial<JSC.Runtime.PropertyDescriptor>): string { if (!propertyDescriptor) { return ""; } @@ -1641,7 +1723,7 @@ function propertyDescriptorToName(propertyDescriptor?: JSC.Runtime.PropertyDescr if (name === "__proto__") { return "[[Prototype]]"; } - return name; + return name ?? ""; } function unknownToError(input: unknown): Error { @@ -1659,41 +1741,6 @@ function isTestJavaScript(path: string): boolean { return /\.(test|spec)\.(c|m)?(j|t)sx?$/.test(path); } -function parseUrl(hostname?: string, port?: number): URL { - hostname ||= "localhost"; - port ||= 6499; - let url: URL; - try { - if (hostname.includes("://")) { - url = new URL(hostname); - } else if (hostname.includes(":") && !hostname.startsWith("[")) { - url = new URL(`ws://[${hostname}]:${port}/`); - } else { - url = new URL(`ws://${hostname}:${port}/`); - } - } catch { - throw new Error(`Invalid URL or hostname/port: ${hostname}`); - } - // HACK: Bun sometimes has issues connecting through "127.0.0.1" - if (url.hostname === "localhost" || url.hostname === "127.0.0.1") { - url.hostname = "[::1]"; - } - return url; -} - -function parseUrlMaybe(string: string): URL | undefined { - const match = /(wss?:\/\/.*)/im.exec(string); - if (!match) { - return undefined; - } - const [_, href] = match; - try { - return parseUrl(href); - } catch { - return undefined; - } -} - function variablesSortBy(a: DAP.Variable, b: DAP.Variable): number { const visibility = (variable: DAP.Variable): number => { const { presentationHint } = variable; @@ -1749,3 +1796,7 @@ function consoleLevelToAnsiColor(level: JSC.Console.ConsoleMessage["level"]): st function numberIsValid(number?: number): number is number { return typeof number === "number" && isFinite(number) && number >= 0; } + +function locationIsSame(a: JSC.Debugger.Location, b: JSC.Debugger.Location): boolean { + return a.scriptId === b.scriptId && a.lineNumber === b.lineNumber && a.columnNumber === b.columnNumber; +} |