| // Copyright 2026 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import * as vscode from 'vscode'; |
| import * as path from 'path'; |
| import * as process from 'process'; |
| |
| import * as logger from '../logger'; |
| import { Setup } from '../extension'; |
| |
| const kSessionType = 'zxdb'; |
| const kEventName = 'vscode-fuchsia.updateAsyncBacktrace'; |
| export const kMaxLabelLength = 35; |
| |
| // This needs to match the `AsyncTaskNode` struct in the DAP server implementation: |
| // https://cs.opensource.google/search?q=%22struct%20AsyncTaskNode%22&ss=fuchsia |
| export interface AsyncTaskNodeData { |
| id?: string; |
| name: string; |
| file?: string; |
| line?: number; |
| children: AsyncTaskNodeData[]; |
| } |
| |
| // This needs to match the `AsyncBacktraceUpdate` struct in the DAP server implementation: |
| // https://cs.opensource.google/search?q=%22struct%20AsyncBacktraceUpdate%22&ss=fuchsia |
| export interface AsyncBacktraceUpdateData { |
| id: number; |
| name: string; |
| tasks?: AsyncTaskNodeData[]; // `tasks: undefined` acts as a tombstone. |
| } |
| |
| /** |
| * A common base class for `AsyncBacktrace` and `AsyncTask`. |
| */ |
| class AsyncObject { |
| // A deterministically generated value that can be used to track expansion/collapse state. |
| public readonly viewId: string; |
| public readonly name: string; |
| public readonly children: AsyncTask[]; |
| |
| constructor( |
| { viewId, name, children }: { |
| viewId: string, |
| name: string, |
| children: AsyncTask[] |
| } |
| ) { |
| this.viewId = viewId; |
| this.name = name; |
| this.children = children; |
| } |
| } |
| |
| export class AsyncTask extends AsyncObject { |
| public readonly file?: string; |
| public readonly line?: number; |
| |
| constructor( |
| { id, name, file, line, children }: AsyncTaskNodeData, |
| parentViewId: string, |
| index: number, |
| ) { |
| // TODO(https://fxbug.dev/494811949): Remove workaround once the DAP server consistently |
| // provides a stable unique id for each task. |
| const viewId = `${parentViewId}::${id ?? `${name}::${file ?? ''}::${index}`}`; |
| super({ |
| viewId, |
| name, |
| children: children?.map((child, i) => new AsyncTask(child, viewId, i)) ?? [], |
| }); |
| this.file = file; |
| this.line = line; |
| if (file && !path.isAbsolute(file)) { |
| logger.debug( |
| `File path for task ${name} is not absolute: ${file}. ` + |
| 'Navigating to this task\'s file might not work as expected.', |
| 'async_backtrace', |
| ); |
| } |
| } |
| }; |
| |
| export class AsyncBacktrace extends AsyncObject { |
| public readonly koid: number; |
| public readonly threadExists: boolean; |
| public readonly sessionId: string; |
| |
| constructor({ id, name, tasks }: AsyncBacktraceUpdateData, sessionId: string) { |
| const viewId = `${sessionId}::thread-${id}`; |
| super({ |
| viewId, |
| name, |
| children: tasks?.map((task, i) => new AsyncTask(task, viewId, i)) ?? [], |
| }); |
| this.koid = id; |
| this.threadExists = !!tasks; |
| this.sessionId = sessionId; |
| } |
| } |
| |
| export class AsyncBacktraceProvider implements |
| vscode.TreeDataProvider<AsyncObject>, |
| vscode.Disposable { |
| // Holds the state of the entire async-backtrace pane. |
| private asyncBacktraces: AsyncBacktrace[] = []; |
| private readonly expandedViewIds: Set<string> = new Set<string>(); |
| private readonly workspaceRoot: string; |
| |
| private _onDidChangeTreeData: vscode.EventEmitter<AsyncObject | void> = |
| new vscode.EventEmitter<AsyncObject | void>(); |
| readonly onDidChangeTreeData: vscode.Event<AsyncObject | void> = this._onDidChangeTreeData.event; |
| |
| /** |
| * Creates a new AsyncBacktraceProvider. |
| * @param workspaceRoot The root directory of the workspace. |
| * Used as a fallback to resolve relative `AsyncTask` file paths. |
| */ |
| constructor(workspaceRoot: string) { |
| this.workspaceRoot = path.resolve(workspaceRoot); |
| } |
| |
| /** |
| * Returns the tree item for the given async entry. |
| * |
| * @param element The async entry to return the tree item for. |
| * @returns The tree item for the given async entry. |
| */ |
| public getTreeItem(element: AsyncObject): vscode.TreeItem { |
| // Task names often come with verbose, less useful namespace prefixes, so truncate them to |
| // better fit the relevant part of that name. |
| const label = element instanceof AsyncTask && element.name.length > kMaxLabelLength |
| ? '…' + element.name.slice(-(kMaxLabelLength - 1)) |
| : element.name; |
| const item = new vscode.TreeItem(label); |
| item.id = element.viewId; |
| |
| if (element.children.length) { |
| item.collapsibleState = this.expandedViewIds.has(element.viewId) |
| ? vscode.TreeItemCollapsibleState.Expanded |
| : vscode.TreeItemCollapsibleState.Collapsed; |
| } else { |
| item.collapsibleState = vscode.TreeItemCollapsibleState.None; |
| } |
| |
| item.tooltip = element.name; |
| if (element instanceof AsyncBacktrace) { |
| item.tooltip += ` (${element.sessionId})`; |
| item.description = `koid:${element.koid}`; |
| } else if (element instanceof AsyncTask && element.file) { |
| item.description = `${path.basename(element.file)}${element.line ? `:${element.line}` : ''}`; |
| |
| const filePath = path.isAbsolute(element.file) |
| ? element.file |
| : path.resolve(this.workspaceRoot, element.file); |
| const args: unknown[] = [vscode.Uri.file(filePath)]; |
| if (element.line) { |
| args.push({ |
| selection: new vscode.Range(element.line - 1, 0, element.line - 1, 0) |
| }); |
| } |
| |
| item.command = { |
| title: 'Jump to definition', |
| command: 'vscode.open', |
| arguments: args, |
| }; |
| } |
| |
| return item; |
| } |
| |
| public getChildren(element?: AsyncObject): vscode.ProviderResult<AsyncObject[]> { |
| return element ? element.children : this.asyncBacktraces; |
| } |
| |
| /** |
| * Updates the async-backtrace pane with new async-backtrace information on a given thread. |
| * |
| * @param backtrace The thread's async-backtrace state to update. |
| */ |
| public updateAsyncBacktrace(backtrace: AsyncBacktrace) { |
| if (!backtrace.threadExists) { |
| throw new Error('AsyncBacktrace.threadExists must be true.'); |
| } |
| const existingIndex = this.asyncBacktraces.findIndex( |
| t => t.sessionId === backtrace.sessionId && t.koid === backtrace.koid |
| ); |
| // This `backtrace`'s thread is either new or has been updated. |
| // Either add it to the async-backtrace pane, or update the existing entry if it's already |
| // present. |
| if (existingIndex === -1) { |
| this.asyncBacktraces.push(backtrace); |
| } else { |
| this.asyncBacktraces[existingIndex] = backtrace; |
| // Don't prune existing `expandedViewIds` to preserve the expand/collapse state of async tasks |
| // across steps (async tasks will be empty while a thread's state is running). |
| } |
| this._onDidChangeTreeData.fire(); |
| } |
| |
| /** |
| * Removes the async-backtrace view item for the given `thread`. |
| * |
| * @param thread The thread to remove. |
| */ |
| public removeAsyncBacktrace(thread: AsyncBacktrace) { |
| if (thread.threadExists) { |
| throw new Error('AsyncBacktrace.threadExists must be false.'); |
| } |
| const existingIndex = this.asyncBacktraces.findIndex( |
| t => t.sessionId === thread.sessionId && t.koid === thread.koid |
| ); |
| if (existingIndex === -1) { |
| logger.error( |
| `Failed to remove async backtrace for thread ${thread.koid}: thread not found in view.`, |
| 'async_backtrace', |
| ); |
| return; |
| } |
| this.asyncBacktraces.splice(existingIndex, 1); |
| |
| // Prune `expandedViewIds`. |
| this.expandedViewIds.delete(thread.viewId); |
| this.expandedViewIds.forEach(id => { |
| if (id.startsWith(`${thread.viewId}::`)) { |
| this.expandedViewIds.delete(id); |
| } |
| }); |
| |
| this._onDidChangeTreeData.fire(); |
| } |
| |
| /** |
| * Records that the given element's tree item was either expanded or collapsed. |
| * |
| * @param element The element whose tree item was expanded or collapsed. |
| * @param expanded Whether the element's tree item was expanded. |
| */ |
| public updateTreeItemExpanded(element: AsyncObject, expanded: boolean) { |
| if (expanded) { |
| this.expandedViewIds.add(element.viewId); |
| } else { |
| this.expandedViewIds.delete(element.viewId); |
| } |
| } |
| |
| /** |
| * Resets the async-backtrace pane for a given session. |
| * |
| * @param sessionId The ID of the session to reset. |
| */ |
| public reset(sessionId: string) { |
| this.asyncBacktraces = this.asyncBacktraces.filter(t => t.sessionId !== sessionId); |
| this.expandedViewIds.forEach(id => { |
| if (id.startsWith(`${sessionId}::`)) { |
| this.expandedViewIds.delete(id); |
| } |
| }); |
| this._onDidChangeTreeData.fire(); |
| } |
| |
| public dispose() { |
| this._onDidChangeTreeData.dispose(); |
| } |
| } |
| |
| /** |
| * Initialize the zxdb async-backtrace pane. |
| */ |
| export function setUpAsyncBacktrace(ctx: vscode.ExtensionContext, setup: Setup) { |
| const provider = new AsyncBacktraceProvider( |
| setup.fx.fuchsiaDir |
| ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath |
| ?? process.cwd() |
| ); |
| const asyncBacktraceUpdateListener = vscode.debug.onDidReceiveDebugSessionCustomEvent(e => { |
| if (e.session.type !== kSessionType || e.event !== kEventName) { |
| return; |
| } |
| |
| // Assumes that the data from the DAP server conforms to the interface. |
| try { |
| const threadBacktrace = new AsyncBacktrace(e.body as AsyncBacktraceUpdateData, e.session.id); |
| if (threadBacktrace.threadExists) { |
| provider.updateAsyncBacktrace(threadBacktrace); |
| } else { |
| provider.removeAsyncBacktrace(threadBacktrace); |
| } |
| } catch (err) { |
| logger.error( |
| `Failed to update async backtrace pane from DAP server event: ${String(err)}`, |
| 'async_backtrace', |
| ); |
| logger.error(`Corresponding DAP server event: ${JSON.stringify(e.body)}`, 'async_backtrace'); |
| } |
| }); |
| |
| const treeView = vscode.window.createTreeView('vscode-fuchsia.asyncBacktraceView', { |
| treeDataProvider: provider, |
| }); |
| |
| const expandListener = treeView.onDidExpandElement( |
| (e: vscode.TreeViewExpansionEvent<AsyncObject>) => { |
| provider.updateTreeItemExpanded(e.element, true); |
| } |
| ); |
| const collapseListener = treeView.onDidCollapseElement( |
| (e: vscode.TreeViewExpansionEvent<AsyncObject>) => { |
| provider.updateTreeItemExpanded(e.element, false); |
| } |
| ); |
| |
| const debugSessionTerminateListener = vscode.debug.onDidTerminateDebugSession( |
| (session: vscode.DebugSession) => { |
| if (session.type === kSessionType) { |
| provider.reset(session.id); |
| } |
| } |
| ); |
| |
| ctx.subscriptions.push( |
| provider, |
| asyncBacktraceUpdateListener, |
| treeView, |
| expandListener, |
| collapseListener, |
| debugSessionTerminateListener, |
| ); |
| } |