diff options
Diffstat (limited to 'packages/bun-debug-adapter-protocol/src/debugger/adapter.ts')
-rw-r--r-- | packages/bun-debug-adapter-protocol/src/debugger/adapter.ts | 568 |
1 files changed, 327 insertions, 241 deletions
diff --git a/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts b/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts index f3a60c793..94aa7e292 100644 --- a/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts +++ b/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts @@ -133,10 +133,15 @@ type Source = DAP.Source & { type Breakpoint = DAP.Breakpoint & { id: number; breakpointId: string; - generatedLocation?: JSC.Debugger.Location; + request?: DAP.SourceBreakpoint; source?: Source; }; +type FutureBreakpoint = { + url: string; + breakpoint: DAP.SourceBreakpoint; +}; + type Target = (DAP.GotoTarget | DAP.StepInTarget) & { source: Source; }; @@ -201,8 +206,8 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements #stackFrames: StackFrame[]; #stopped?: DAP.StoppedEvent["reason"]; #exception?: Variable; - #breakpointId: number; - #breakpoints: Map<string, Breakpoint>; + #breakpoints: Map<string, Breakpoint[]>; + #futureBreakpoints: Map<string, FutureBreakpoint[]>; #functionBreakpoints: Map<string, FunctionBreakpoint>; #targets: Map<number, Target>; #variableId: number; @@ -225,9 +230,9 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements this.#pendingSources = new Map(); this.#sources = new Map(); this.#stackFrames = []; - this.#stopped = "start"; - this.#breakpointId = 1; + this.#stopped = undefined; this.#breakpoints = new Map(); + this.#futureBreakpoints = new Map(); this.#functionBreakpoints = new Map(); this.#targets = new Map(); this.#variableId = 1; @@ -397,9 +402,6 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements } }); this.send("Debugger.setAsyncStackTraceDepth", { depth: 200 }); - this.send("Debugger.setBreakpointsActive", { active: true }); - this.send("Debugger.pause"); - this.send("Inspector.initialized"); const { clientID, supportsConfigurationDoneRequest } = request; if (!supportsConfigurationDoneRequest && clientID !== "vscode") { @@ -413,17 +415,11 @@ 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. - if (this.#options?.noDebug) { - this.send("Debugger.setBreakpointsActive", { active: false }); - this.setExceptionBreakpoints({ filters: [] }); - } + const active = !this.#options?.noDebug; + this.send("Debugger.setBreakpointsActive", { active }); - if (this.#options?.stopOnEntry) { - this.send("Debugger.pause"); - } else { - // TODO: Check that the current location is not on a breakpoint before resuming. - this.send("Debugger.resume"); - } + // Tell the debugger that its ready to start execution. + this.send("Inspector.initialized"); } async launch(request: DAP.LaunchRequest): Promise<void> { @@ -763,147 +759,131 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements } async setBreakpoints(request: DAP.SetBreakpointsRequest): Promise<DAP.SetBreakpointsResponse> { - const { source, breakpoints } = request; - - if (!breakpoints?.length) { - return { - breakpoints: [], - }; - } - - const { path } = source; - const verifiedSource = this.#getSourceIfPresent(sourceToId(source)); - - let results: Breakpoint[]; - if (verifiedSource) { - results = await this.#setBreakpointsById(verifiedSource, breakpoints); - } else if (path) { - results = await this.#setBreakpointsByUrl(path, breakpoints); - } else { - results = []; + const { source, breakpoints: requests = [] } = request; + const { path, sourceReference } = source; + + let breakpoints: Breakpoint[] | undefined; + if (path) { + breakpoints = await this.#setBreakpointsByUrl(path, requests, true); + } else if (sourceReference) { + const source = this.#getSourceIfPresent(sourceReference); + if (source) { + const { scriptId } = source; + breakpoints = await this.#setBreakpointsById(scriptId, requests, true); + } } return { - breakpoints: results, + breakpoints: breakpoints ?? [], }; } - async #setBreakpointsByUrl(url: string, requests: DAP.SourceBreakpoint[]): Promise<Breakpoint[]> { - const source = await this.#getSourceByUrl(url); + async #setBreakpointsByUrl(url: string, requests: DAP.SourceBreakpoint[], unsetOld?: boolean): Promise<Breakpoint[]> { + const source = this.#getSourceIfPresent(url); - // If there is no source, this is likely a breakpoint from an unrelated - // file that can be ignored. Return an unverified breakpoint for each request. + // If the source is not loaded, set a placeholder breakpoint at the start of the file. + // If the breakpoint is resolved in the future, a `Debugger.breakpointResolved` event + // will be emitted and each of these breakpoint requests can be retried. if (!source) { - return requests.map(() => { - const breakpointId = this.#breakpointId++; - return this.#addBreakpoint({ - id: breakpointId, - breakpointId: `${breakpointId}`, - verified: false, + let result; + try { + result = await this.send("Debugger.setBreakpointByUrl", { + url, + lineNumber: 0, }); - }); - } + } catch (error) { + return requests.map(() => invalidBreakpoint(error)); + } - const sourceId = sourceToId(source); - const oldBreakpoints = this.#getBreakpoints(sourceId); + const { breakpointId, locations } = result; + if (locations.length) { + // TODO: Source was loaded while the breakpoint was being set? + } - const breakpoints = await Promise.all( - requests.map(async ({ line, column, ...options }) => { - const location = this.#generatedLocation(source, line, column); + return requests.map(request => + this.#addFutureBreakpoint({ + breakpointId, + url, + breakpoint: request, + }), + ); + } - for (const breakpoint of oldBreakpoints) { - const { generatedLocation } = breakpoint; - if (locationIsSame(generatedLocation, location)) { - return breakpoint; - } + const oldBreakpoints = this.#getBreakpoints(sourceToId(source)); + const breakpoints = await Promise.all( + requests.map(async request => { + const oldBreakpoint = this.#getBreakpointByLocation(source, request); + if (oldBreakpoint) { + return oldBreakpoint; } + const { line, column, ...options } = request; + const location = this.#generatedLocation(source, line, column); + let result; try { result = await this.send("Debugger.setBreakpointByUrl", { url, - options: breakpointOptions(options), ...location, + options: breakpointOptions(options), }); } catch (error) { - // If there was an error setting the breakpoint, - // mark it as unverified and add a message. - const { message } = unknownToError(error); - const breakpointId = this.#breakpointId++; - return this.#addBreakpoint({ - id: breakpointId, - breakpointId: `${breakpointId}`, - line, - column, - source, - verified: false, - message, - generatedLocation: location, - }); + return invalidBreakpoint(error); } const { breakpointId, locations } = result; - if (!locations.length) { - return this.#addBreakpoint({ - id: this.#breakpointId++, + + const breakpoints = locations.map((location, i) => + this.#addBreakpoint({ breakpointId, - line, - column, + location, source, - verified: false, - generatedLocation: location, - }); - } - - const originalLocation = this.#originalLocation(source, locations[0]); - return this.#addBreakpoint({ - id: this.#breakpointId++, - breakpointId, - source, - verified: true, - generatedLocation: location, - ...originalLocation, - }); - }), - ); + request, + // It is theoretically possible for a breakpoint to resolve to multiple locations. + // In that case, send a seperate `breakpoint` event for each one, excluding the first. + notify: i > 0, + }), + ); - await Promise.all( - oldBreakpoints.map(async ({ breakpointId }) => { - const isRemoved = !breakpoints.filter(({ breakpointId: id }) => breakpointId === id).length; - if (isRemoved) { - await this.send("Debugger.removeBreakpoint", { - breakpointId, - }); - this.#removeBreakpoint(breakpointId); - } + // Each breakpoint request can only be mapped to one breakpoint. + return breakpoints[0]; }), ); - const duplicateBreakpoints = breakpoints.filter( - ({ message }) => message === "Breakpoint for given location already exists", - ); - for (const { breakpointId } of duplicateBreakpoints) { - this.#removeBreakpoint(breakpointId); + if (unsetOld) { + await Promise.all( + oldBreakpoints.map(({ breakpointId }) => { + if (!breakpoints.some(({ breakpointId: id }) => breakpointId === id)) { + return this.#unsetBreakpoint(breakpointId); + } + }), + ); } return breakpoints; } - async #setBreakpointsById(source: Source, requests: DAP.SourceBreakpoint[]): Promise<Breakpoint[]> { - const { sourceId } = source; - const oldBreakpoints = this.#getBreakpoints(sourceId); + async #setBreakpointsById( + scriptId: string, + requests: DAP.SourceBreakpoint[], + unsetOld?: boolean, + ): Promise<Breakpoint[]> { + const source = await this.#getSourceById(scriptId); + if (!source) { + return requests.map(() => invalidBreakpoint()); + } + const oldBreakpoints = this.#getBreakpoints(sourceToId(source)); const breakpoints = await Promise.all( - requests!.map(async ({ line, column, ...options }) => { - const location = this.#generatedLocation(source, line, column); - - for (const breakpoint of oldBreakpoints) { - const { generatedLocation } = breakpoint; - if (locationIsSame(generatedLocation, location)) { - return breakpoint; - } + requests.map(async request => { + const oldBreakpoint = this.#getBreakpointByLocation(source, request); + if (oldBreakpoint) { + return oldBreakpoint; } + const { line, column, ...options } = request; + const location = this.#generatedLocation(source, line, column); + let result; try { result = await this.send("Debugger.setBreakpoint", { @@ -911,92 +891,147 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements options: breakpointOptions(options), }); } catch (error) { - // If there was an error setting the breakpoint, - // mark it as unverified and add a message. - const { message } = unknownToError(error); - const breakpointId = this.#breakpointId++; - return this.#addBreakpoint({ - id: breakpointId, - breakpointId: `${breakpointId}`, - line, - column, - source, - verified: false, - message, - generatedLocation: location, - }); + return invalidBreakpoint(error); } const { breakpointId, actualLocation } = result; - const originalLocation = this.#originalLocation(source, actualLocation); - return this.#addBreakpoint({ - id: this.#breakpointId++, breakpointId, + location: actualLocation, + request, source, - verified: true, - generatedLocation: location, - ...originalLocation, }); }), ); - await Promise.all( - oldBreakpoints.map(async ({ breakpointId }) => { - const isRemoved = !breakpoints.filter(({ breakpointId: id }) => breakpointId === id).length; - if (isRemoved) { - await this.send("Debugger.removeBreakpoint", { - breakpointId, - }); - this.#removeBreakpoint(breakpointId); - } - }), - ); - - const duplicateBreakpoints = breakpoints.filter( - ({ message }) => message === "Breakpoint for given location already exists", - ); - for (const { breakpointId } of duplicateBreakpoints) { - this.#removeBreakpoint(breakpointId); + if (unsetOld) { + await Promise.all( + oldBreakpoints.map(({ breakpointId }) => { + if (!breakpoints.some(({ breakpointId: id }) => breakpointId === id)) { + return this.#unsetBreakpoint(breakpointId); + } + }), + ); } return breakpoints; } - #getBreakpoints(sourceId?: string | number): Breakpoint[] { - const breakpoints: Breakpoint[] = []; - - if (!sourceId) { - return breakpoints; + async #unsetBreakpoint(breakpointId: string): Promise<void> { + try { + await this.send("Debugger.removeBreakpoint", { breakpointId }); + } catch { + // Ignore any errors. } - for (const breakpoint of this.#breakpoints.values()) { - const { source } = breakpoint; - if (source && sourceId === sourceToId(source)) { - breakpoints.push(breakpoint); - } + this.#removeBreakpoint(breakpointId); + this.#removeFutureBreakpoint(breakpointId); + } + + #addBreakpoint(options: { + breakpointId: string; + location?: JSC.Debugger.Location; + request?: DAP.SourceBreakpoint; + source?: Source; + notify?: boolean; + }): Breakpoint { + const { breakpointId, location, source, request, notify } = options; + + let originalLocation; + if (source) { + originalLocation = this.#originalLocation(source, location); + } else { + originalLocation = {}; } - return breakpoints; - } + const breakpoints = this.#getBreakpointsById(breakpointId); + const breakpoint: Breakpoint = { + id: nextId(), + breakpointId, + source, + request, + ...originalLocation, + verified: !!source, + }; - #addBreakpoint(breakpoint: Breakpoint): Breakpoint { - const { breakpointId } = breakpoint; - this.#breakpoints.set(breakpointId, breakpoint); + breakpoints.push(breakpoint); return breakpoint; } - #removeBreakpoint(breakpointId: string): void { - const breakpoint = this.#breakpoints.get(breakpointId); + #addFutureBreakpoint(options: { breakpointId: string; url: string; breakpoint: DAP.SourceBreakpoint }): Breakpoint { + const { breakpointId, url, breakpoint } = options; + + const breakpoints = this.#getFutureBreakpoints(breakpointId); + breakpoints.push({ + url, + breakpoint, + }); + + return this.#addBreakpoint({ + breakpointId, + request: breakpoint, + }); + } + + #removeBreakpoint(breakpointId: string, notify?: boolean): void { + const breakpoints = this.#breakpoints.get(breakpointId); - if (!breakpoint || !this.#breakpoints.delete(breakpointId)) { + if (!breakpoints || !this.#breakpoints.delete(breakpointId) || !notify) { return; } - this.#emitAfterResponse("breakpoint", { - reason: "removed", - breakpoint, + for (const breakpoint of breakpoints) { + this.#emit("breakpoint", { + reason: "removed", + breakpoint, + }); + } + } + + #removeFutureBreakpoint(breakpointId: string, notify?: boolean): void { + const breakpoint = this.#futureBreakpoints.get(breakpointId); + + if (!breakpoint || !this.#futureBreakpoints.delete(breakpointId)) { + return; + } + + this.#removeBreakpoint(breakpointId, notify); + } + + #getBreakpointsById(breakpointId: string): Breakpoint[] { + let breakpoints = this.#breakpoints.get(breakpointId); + if (!breakpoints) { + this.#breakpoints.set(breakpointId, (breakpoints = [])); + } + return breakpoints; + } + + #getBreakpointByLocation(source: Source, location: DAP.SourceBreakpoint): Breakpoint | undefined { + console.log("getBreakpointByLocation", { + source: sourceToId(source), + location, + ids: this.#getBreakpoints(sourceToId(source)).map(({ id }) => id), + breakpointIds: this.#getBreakpoints(sourceToId(source)).map(({ breakpointId }) => breakpointId), + lines: this.#getBreakpoints(sourceToId(source)).map(({ line }) => line), + columns: this.#getBreakpoints(sourceToId(source)).map(({ column }) => column), }); + const sourceId = sourceToId(source); + const [breakpoint] = this.#getBreakpoints(sourceId).filter( + ({ source, request }) => source && sourceToId(source) === sourceId && request?.line === location.line, + ); + return breakpoint; + } + + #getBreakpoints(sourceId: string | number): Breakpoint[] { + return [...this.#breakpoints.values()].flat().filter(({ source }) => source && sourceToId(source) === sourceId); + } + + #getFutureBreakpoints(breakpointId: string): FutureBreakpoint[] { + let breakpoints = this.#futureBreakpoints.get(breakpointId); + if (!breakpoints) { + this.#futureBreakpoints.set(breakpointId, (breakpoints = [])); + } + return breakpoints; } async setFunctionBreakpoints( @@ -1022,7 +1057,7 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements } catch (error) { const { message } = unknownToError(error); return this.#addFunctionBreakpoint({ - id: this.#breakpointId++, + id: nextId(), name, verified: false, message, @@ -1030,7 +1065,7 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements } return this.#addFunctionBreakpoint({ - id: this.#breakpointId++, + id: nextId(), name, verified: true, }); @@ -1329,28 +1364,62 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements }); } - ["Debugger.paused"](event: JSC.Debugger.PausedEvent): void { - const { reason, callFrames, asyncStackTrace, data } = event; + async ["Debugger.breakpointResolved"](event: JSC.Debugger.BreakpointResolvedEvent): Promise<void> { + const { breakpointId, location } = event; - if (reason === "PauseOnNextStatement") { - if (this.#stopped === "start" && !this.#options?.stopOnEntry) { - this.#stopped = undefined; - return; + const futureBreakpoints = this.#getFutureBreakpoints(breakpointId); + + // If the breakpoint resolves to a placeholder breakpoint, go through + // each breakpoint request and attempt to set them again. + if (futureBreakpoints?.length) { + const [{ url }] = futureBreakpoints; + const requests = futureBreakpoints.map(({ breakpoint }) => breakpoint); + + const oldBreakpoints = this.#getBreakpointsById(breakpointId); + const breakpoints = await this.#setBreakpointsByUrl(url, requests); + + for (let i = 0; i < breakpoints.length; i++) { + const breakpoint = breakpoints[i]; + const oldBreakpoint = oldBreakpoints[i]; + + this.#emit("breakpoint", { + reason: "changed", + breakpoint: { + ...breakpoint, + id: oldBreakpoint.id, + }, + }); } + + // Finally, remove the placeholder breakpoint. + await this.#unsetBreakpoint(breakpointId); + return; } - if (reason === "DebuggerStatement") { - // FIXME: This is a hacky fix for the `Debugger.paused` event being fired - // when the debugger is started in hot mode. - for (const { functionName } of callFrames) { - // @ts-ignore - if (functionName === "module code" && this.#options?.watchMode === "hot") { - this.send("Debugger.resume"); - return; - } - } + const breakpoints = this.#getBreakpointsById(breakpointId); + + // This is a new breakpoint, which was likely created by another client + // connected to the same debugger. + if (!breakpoints.length) { + const { scriptId } = location; + const [url] = breakpointId.split(":"); + const source = await this.#getSourceById(scriptId, url); + + this.#addBreakpoint({ + breakpointId, + location, + source, + notify: true, + }); + return; } + // TODO: update breakpoints? + } + + ["Debugger.paused"](event: JSC.Debugger.PausedEvent): void { + const { reason, callFrames, asyncStackTrace, data } = event; + this.#stackFrames.length = 0; this.#stopped ||= stoppedReason(reason); for (const callFrame of callFrames) { @@ -1381,12 +1450,17 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements } if (reason === "Breakpoint") { - const { breakpointId: hitBreakpointId } = data as { breakpointId: string }; - for (const { id, breakpointId } of this.#breakpoints.values()) { - if (breakpointId === hitBreakpointId) { - hitBreakpointIds = [id]; - break; - } + const { breakpointId } = data as JSC.Debugger.BreakpointPauseReason; + + const futureBreakpoints = this.#getFutureBreakpoints(breakpointId); + if (futureBreakpoints.length) { + this.send("Debugger.resume"); + return; + } + + const breakpoints = this.#getBreakpointsById(breakpointId); + if (breakpoints.length) { + hitBreakpointIds = breakpoints.map(({ id }) => id); } } } @@ -1579,59 +1653,41 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements }); } - async #getSourceByUrl(url: string): Promise<Source | undefined> { - const source = this.#getSourceIfPresent(url); + async #getSourceById(scriptId: string, url?: string): Promise<Source | undefined> { + const source = this.#getSourceIfPresent(scriptId); if (source) { return source; } - // If the source is not present, the debugger did not send the `Debugger.scriptParsed` event. - // Since there is no request to retrieve a script by its url, the hacky solution is to set - // a no-op breakpoint by url, then immediately remove it. let result; try { - result = await this.send("Debugger.setBreakpointByUrl", { - url, - lineNumber: 0, - options: { - autoContinue: true, - }, - }); + result = await this.send("Debugger.getScriptSource", { scriptId }); } catch { - // If there was an error setting the breakpoint, - // the source probably does not exist. return undefined; } - const { breakpointId, locations } = result; - await this.send("Debugger.removeBreakpoint", { - breakpointId, - }); - - if (!locations.length) { - return undefined; - } + const { scriptSource } = result; + const sourceMap = SourceMap(scriptSource); + const presentationHint = sourcePresentationHint(url); - // It is possible that the source was loaded between the time it took - // to set and remove the breakpoint, so check again. - const [{ scriptId }] = locations; - const recentSource = this.#getSourceIfPresent(scriptId) || this.#getSourceIfPresent(url); - if (recentSource) { - return recentSource; + if (url) { + return this.#addSource({ + scriptId, + sourceId: url, + name: sourceName(url), + path: url, + sourceMap, + presentationHint, + }); } - // Otherwise, retrieve the source and source map url and add the source. - const { scriptSource } = await this.send("Debugger.getScriptSource", { - scriptId, - }); - + const sourceReference = this.#sourceId++; return this.#addSource({ scriptId, - sourceId: url, - name: sourceName(url), - path: url, - sourceMap: SourceMap(scriptSource), - presentationHint: sourcePresentationHint(url), + sourceId: sourceReference, + sourceReference, + sourceMap, + presentationHint, }); } @@ -2051,9 +2107,10 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements this.#pendingSources.clear(); this.#sources.clear(); this.#stackFrames.length = 0; - this.#stopped = "start"; + this.#stopped = undefined; this.#exception = undefined; this.#breakpoints.clear(); + this.#futureBreakpoints.clear(); this.#functionBreakpoints.clear(); this.#targets.clear(); this.#variables.clear(); @@ -2231,6 +2288,19 @@ function sourceToId(source?: DAP.Source): string | number { throw new Error("No source found."); } +function sourceToPath(source?: DAP.Source | string): string { + if (typeof source === "string") { + return source; + } + if (source) { + const { path } = source; + if (path) { + return path; + } + } + throw new Error("No source found."); +} + function callFrameToId(callFrame: JSC.Console.CallFrame): string { const { url, lineNumber, columnNumber } = callFrame; return `${url}:${lineNumber}:${columnNumber}`; @@ -2471,4 +2541,20 @@ function stripAnsi(string: string): string { return string.replace(/\u001b\[\d+m/g, ""); } +function invalidBreakpoint(error?: unknown): Breakpoint { + const { message } = error ? unknownToError(error) : { message: undefined }; + return { + id: nextId(), + breakpointId: "", + verified: false, + message, + }; +} + const Cancel = Symbol("Cancel"); + +let sequence = 1; + +function nextId(): number { + return sequence++; +} |