blob: 7a04b40c0662fc1973e36215afb2f4c90b06771b [file]
// 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,
);
}