@jupyterlab/debugger
Version:
JupyterLab - Debugger Extension
1,047 lines (953 loc) • 28.4 kB
text/typescript
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
import { KernelSpec, Session } from '@jupyterlab/services';
import {
ITranslator,
nullTranslator,
TranslationBundle
} from '@jupyterlab/translation';
import { IDisposable } from '@lumino/disposable';
import { ISignal, Signal } from '@lumino/signaling';
import { DebugProtocol } from '@vscode/debugprotocol';
import { Debugger } from './debugger';
import { VariablesModel } from './panels/variables/model';
import { IDebugger } from './tokens';
/**
* A concrete implementation of the IDebugger interface.
*/
export class DebuggerService implements IDebugger, IDisposable {
/**
* Instantiate a new DebuggerService.
*
* @param options The instantiation options for a DebuggerService.
*/
constructor(options: DebuggerService.IOptions) {
this._config = options.config;
// Avoids setting session with invalid client
// session should be set only when a notebook or
// a console get the focus.
// TODO: also checks that the notebook or console
// runs a kernel with debugging ability
this._session = null;
this._specsManager = options.specsManager ?? null;
this._model = new Debugger.Model();
this._debuggerSources = options.debuggerSources ?? null;
this._trans = (options.translator || nullTranslator).load('jupyterlab');
}
/**
* Signal emitted for debug event messages.
*/
get eventMessage(): ISignal<IDebugger, IDebugger.ISession.Event> {
return this._eventMessage;
}
/**
* Get debugger config.
*/
get config(): IDebugger.IConfig {
return this._config;
}
/**
* Whether the debug service is disposed.
*/
get isDisposed(): boolean {
return this._isDisposed;
}
/**
* Whether the current debugger is started.
*/
get isStarted(): boolean {
return this._session?.isStarted ?? false;
}
/**
* A signal emitted when the pause on exception filter changes.
*/
get pauseOnExceptionChanged(): Signal<IDebugger, void> {
return this._pauseOnExceptionChanged;
}
/**
* Returns the debugger service's model.
*/
get model(): IDebugger.Model.IService {
return this._model;
}
/**
* Returns the current debug session.
*/
get session(): IDebugger.ISession | null {
return this._session;
}
/**
* Sets the current debug session to the given parameter.
*
* @param session - the new debugger session.
*/
set session(session: IDebugger.ISession | null) {
if (this._session === session) {
return;
}
if (this._session) {
this._session.dispose();
}
this._session = session;
this._session?.eventMessage.connect((_, event) => {
if (event.event === 'stopped') {
this._model.stoppedThreads.clear();
this._model.stoppedThreads.add(event.body.threadId);
void this._getAllFrames();
} else if (event.event === 'continued') {
this._model.stoppedThreads.delete(event.body.threadId);
this._clearModel();
this._clearSignals();
}
this._eventMessage.emit(event);
});
this._sessionChanged.emit(session);
}
/**
* Signal emitted upon session changed.
*/
get sessionChanged(): ISignal<IDebugger, IDebugger.ISession | null> {
return this._sessionChanged;
}
/**
* Dispose the debug service.
*/
dispose(): void {
if (this.isDisposed) {
return;
}
this._isDisposed = true;
Signal.clearData(this);
}
/**
* Computes an id based on the given code.
*
* @param code The source code.
*/
getCodeId(code: string): string {
try {
return this._config.getCodeId(
code,
this.session?.connection?.kernel?.name ?? ''
);
} catch {
return '';
}
}
/**
* Whether there exists a thread in stopped state.
*/
hasStoppedThreads(): boolean {
return this._model?.stoppedThreads.size > 0 ?? false;
}
/**
* Request whether debugging is available for the session connection.
*
* @param connection The session connection.
*/
async isAvailable(connection: Session.ISessionConnection): Promise<boolean> {
if (!this._specsManager) {
return true;
}
await this._specsManager.ready;
const kernel = connection?.kernel;
if (!kernel) {
return false;
}
const name = kernel.name;
if (!this._specsManager.specs?.kernelspecs[name]) {
return true;
}
return !!(
this._specsManager.specs.kernelspecs[name]?.metadata?.['debugger'] ??
false
);
}
/**
* Clear all the breakpoints for the current session.
*/
async clearBreakpoints(): Promise<void> {
if (this.session?.isStarted !== true) {
return;
}
this._model.breakpoints.breakpoints.forEach((_, path, map) => {
void this._setBreakpoints([], path);
});
let bpMap = new Map<string, IDebugger.IBreakpoint[]>();
this._model.breakpoints.restoreBreakpoints(bpMap);
}
/**
* Continues the execution of the current thread.
*/
async continue(): Promise<void> {
try {
if (!this.session) {
throw new Error('No active debugger session');
}
await this.session.sendRequest('continue', {
threadId: this._currentThread()
});
this._model.stoppedThreads.delete(this._currentThread());
this._clearModel();
this._clearSignals();
} catch (err) {
console.error('Error:', err.message);
}
}
/**
* Retrieve the content of a source file.
*
* @param source The source object containing the path to the file.
*/
async getSource(source: DebugProtocol.Source): Promise<IDebugger.Source> {
if (!this.session) {
throw new Error('No active debugger session');
}
const reply = await this.session.sendRequest('source', {
source,
sourceReference: source.sourceReference ?? 0
});
return { ...reply.body, path: source.path ?? '' };
}
/**
* Evaluate an expression.
*
* @param expression The expression to evaluate as a string.
*/
async evaluate(
expression: string
): Promise<DebugProtocol.EvaluateResponse['body'] | null> {
if (!this.session) {
throw new Error('No active debugger session');
}
const frameId = this.model.callstack.frame?.id;
const reply = await this.session.sendRequest('evaluate', {
context: 'repl',
expression,
frameId
});
if (!reply.success) {
return null;
}
// get the frames to retrieve the latest state of the variables
this._clearModel();
await this._getAllFrames();
return reply.body;
}
/**
* Makes the current thread run again for one step.
*/
async next(): Promise<void> {
try {
if (!this.session) {
throw new Error('No active debugger session');
}
await this.session.sendRequest('next', {
threadId: this._currentThread()
});
} catch (err) {
console.error('Error:', err.message);
}
}
/**
* Request rich representation of a variable.
*
* @param variableName The variable name to request
* @param frameId The current frame id in which to request the variable
* @returns The mime renderer data model
*/
async inspectRichVariable(
variableName: string,
frameId?: number
): Promise<IDebugger.IRichVariable> {
if (!this.session) {
throw new Error('No active debugger session');
}
const reply = await this.session.sendRequest('richInspectVariables', {
variableName,
frameId
});
if (reply.success) {
return reply.body;
} else {
throw new Error(reply.message);
}
}
/**
* Request variables for a given variable reference.
*
* @param variablesReference The variable reference to request.
*/
async inspectVariable(
variablesReference: number
): Promise<DebugProtocol.Variable[]> {
if (!this.session) {
throw new Error('No active debugger session');
}
const reply = await this.session.sendRequest('variables', {
variablesReference
});
if (reply.success) {
return reply.body.variables;
} else {
throw new Error(reply.message);
}
}
/**
* Request to set a variable in the global scope.
*
* @param name The name of the variable.
*/
async copyToGlobals(name: string): Promise<void> {
if (!this.session) {
throw new Error('No active debugger session');
}
if (!this.model.supportCopyToGlobals) {
throw new Error(
'The "copyToGlobals" request is not supported by the kernel'
);
}
const frames = this.model.callstack.frames;
this.session
.sendRequest('copyToGlobals', {
srcVariableName: name,
dstVariableName: name,
srcFrameId: frames[0].id
})
.then(async () => {
const scopes = await this._getScopes(frames[0]);
const variables = await Promise.all(
scopes.map(scope => this._getVariables(scope))
);
const variableScopes = this._convertScopes(scopes, variables);
this._model.variables.scopes = variableScopes;
})
.catch(reason => {
console.error(reason);
});
}
/**
* Requests all the defined variables and display them in the
* table view.
*/
async displayDefinedVariables(): Promise<void> {
if (!this.session) {
throw new Error('No active debugger session');
}
const inspectReply = await this.session.sendRequest('inspectVariables', {});
const variables = inspectReply.body.variables;
const variableScopes = [
{
name: this._trans.__('Globals'),
variables: variables
}
];
this._model.variables.scopes = variableScopes;
}
async displayModules(): Promise<void> {
if (!this.session) {
throw new Error('No active debugger session');
}
const modules = await this.session.sendRequest('modules', {});
this._model.kernelSources.kernelSources = modules.body.modules.map(
module => {
return {
name: module.name as string,
path: module.path as string
};
}
);
}
/**
* Restart the debugger.
*/
async restart(): Promise<void> {
const { breakpoints } = this._model.breakpoints;
await this.stop();
await this.start();
await this._restoreBreakpoints(breakpoints);
}
/**
* Restore the state of a debug session.
*
* @param autoStart - If true, starts the debugger if it has not been started.
*/
async restoreState(autoStart: boolean): Promise<void> {
if (!this.model || !this.session) {
return;
}
const reply = await this.session.restoreState();
const { body } = reply;
const breakpoints = this._mapBreakpoints(body.breakpoints);
const stoppedThreads = new Set(body.stoppedThreads);
this._model.hasRichVariableRendering = body.richRendering === true;
this._model.supportCopyToGlobals = body.copyToGlobals === true;
this._config.setHashParams({
kernel: this.session?.connection?.kernel?.name ?? '',
method: body.hashMethod,
seed: body.hashSeed
});
this._config.setTmpFileParams({
kernel: this.session?.connection?.kernel?.name ?? '',
prefix: body.tmpFilePrefix,
suffix: body.tmpFileSuffix
});
this._model.stoppedThreads = stoppedThreads;
if (!this.isStarted && (autoStart || stoppedThreads.size !== 0)) {
await this.start();
}
if (this.isStarted || autoStart) {
this._model.title = this.isStarted
? this.session?.connection?.name || '-'
: '-';
}
if (this._debuggerSources) {
const filtered = this._filterBreakpoints(breakpoints);
this._model.breakpoints.restoreBreakpoints(filtered);
} else {
this._model.breakpoints.restoreBreakpoints(breakpoints);
}
if (stoppedThreads.size !== 0) {
await this._getAllFrames();
} else if (this.isStarted) {
this._clearModel();
this._clearSignals();
}
// Send the currentExceptionFilters to debugger.
if (this.session.currentExceptionFilters) {
await this.pauseOnExceptions(this.session.currentExceptionFilters);
}
}
/**
* Starts a debugger.
* Precondition: !isStarted
*/
start(): Promise<void> {
if (!this.session) {
throw new Error('No active debugger session');
}
return this.session.start();
}
/**
* Makes the current thread pause if possible.
*/
async pause(): Promise<void> {
try {
if (!this.session) {
throw new Error('No active debugger session');
}
await this.session.sendRequest('pause', {
threadId: this._currentThread()
});
} catch (err) {
console.error('Error:', err.message);
}
}
/**
* Makes the current thread step in a function / method if possible.
*/
async stepIn(): Promise<void> {
try {
if (!this.session) {
throw new Error('No active debugger session');
}
await this.session.sendRequest('stepIn', {
threadId: this._currentThread()
});
} catch (err) {
console.error('Error:', err.message);
}
}
/**
* Makes the current thread step out a function / method if possible.
*/
async stepOut(): Promise<void> {
try {
if (!this.session) {
throw new Error('No active debugger session');
}
await this.session.sendRequest('stepOut', {
threadId: this._currentThread()
});
} catch (err) {
console.error('Error:', err.message);
}
}
/**
* Stops the debugger.
* Precondition: isStarted
*/
async stop(): Promise<void> {
if (!this.session) {
throw new Error('No active debugger session');
}
await this.session.stop();
if (this._model) {
this._model.clear();
}
}
/**
* Update all breakpoints at once.
*
* @param code - The code in the cell where the breakpoints are set.
* @param breakpoints - The list of breakpoints to set.
* @param path - Optional path to the file where to set the breakpoints.
*/
async updateBreakpoints(
code: string,
breakpoints: IDebugger.IBreakpoint[],
path?: string
): Promise<void> {
if (!this.session?.isStarted) {
return;
}
if (!path) {
path = (await this._dumpCell(code)).body.sourcePath;
}
const state = await this.session.restoreState();
const localBreakpoints = breakpoints
.filter(({ line }) => typeof line === 'number')
.map(({ line }) => ({ line: line! }));
const remoteBreakpoints = this._mapBreakpoints(state.body.breakpoints);
// Set the local copy of breakpoints to reflect only editors that exist.
if (this._debuggerSources) {
const filtered = this._filterBreakpoints(remoteBreakpoints);
this._model.breakpoints.restoreBreakpoints(filtered);
} else {
this._model.breakpoints.restoreBreakpoints(remoteBreakpoints);
}
// Removes duplicated breakpoints. It is better to do it here than
// in the editor, because the kernel can change the line of a
// breakpoint (when you attempt to set a breakpoint on an empty
// line for instance).
let addedLines = new Set<number>();
// Set the kernel's breakpoints for this path.
const reply = await this._setBreakpoints(localBreakpoints, path);
const updatedBreakpoints = reply.body.breakpoints.filter((val, _, arr) => {
const cond1 = arr.findIndex(el => el.line === val.line) > -1;
const cond2 = !addedLines.has(val.line!);
addedLines.add(val.line!);
return cond1 && cond2;
});
// Update the local model and finish kernel configuration.
this._model.breakpoints.setBreakpoints(path, updatedBreakpoints);
await this.session.sendRequest('configurationDone', {});
}
/**
* Determines if pausing on exceptions is supported by the kernel
*/
pauseOnExceptionsIsValid(): boolean {
if (this.isStarted) {
if (this.session?.exceptionBreakpointFilters?.length !== 0) {
return true;
}
}
return false;
}
/**
* Add or remove a filter from the current used filters.
*
* @param exceptionFilter - The filter to add or remove from current filters.
*/
async pauseOnExceptionsFilter(exceptionFilter: string): Promise<void> {
if (!this.session?.isStarted) {
return;
}
let exceptionFilters = this.session.currentExceptionFilters;
if (this.session.isPausingOnException(exceptionFilter)) {
const index = exceptionFilters.indexOf(exceptionFilter);
exceptionFilters.splice(index, 1);
} else {
exceptionFilters?.push(exceptionFilter);
}
await this.pauseOnExceptions(exceptionFilters);
}
/**
* Enable or disable pausing on exceptions.
*
* @param exceptionFilters - The filters to use for the current debugging session.
*/
async pauseOnExceptions(exceptionFilters: string[]): Promise<void> {
if (!this.session?.isStarted) {
return;
}
const exceptionBreakpointFilters =
this.session.exceptionBreakpointFilters?.map(e => e.filter) || [];
let options: DebugProtocol.SetExceptionBreakpointsArguments = {
filters: []
};
exceptionFilters.forEach(filter => {
if (exceptionBreakpointFilters.includes(filter)) {
options.filters.push(filter);
}
});
this.session.currentExceptionFilters = options.filters;
await this.session.sendRequest('setExceptionBreakpoints', options);
this._pauseOnExceptionChanged.emit();
}
/**
* Get the debugger state
*
* @returns Debugger state
*/
getDebuggerState(): IDebugger.State {
const breakpoints = this._model.breakpoints.breakpoints;
let cells: string[] = [];
if (this._debuggerSources) {
for (const id of breakpoints.keys()) {
const editorList = this._debuggerSources.find({
focus: false,
kernel: this.session?.connection?.kernel?.name ?? '',
path: this._session?.connection?.path ?? '',
source: id
});
const tmpCells = editorList.map(e => e.src.getSource());
cells = cells.concat(tmpCells);
}
}
return { cells, breakpoints };
}
/**
* Restore the debugger state
*
* @param state Debugger state
* @returns Whether the state has been restored successfully or not
*/
async restoreDebuggerState(state: IDebugger.State): Promise<boolean> {
await this.start();
for (const cell of state.cells) {
await this._dumpCell(cell);
}
const breakpoints = new Map<string, IDebugger.IBreakpoint[]>();
const kernel = this.session?.connection?.kernel?.name ?? '';
const { prefix, suffix } = this._config.getTmpFileParams(kernel);
for (const item of state.breakpoints) {
const [id, list] = item;
const unsuffixedId = id.substr(0, id.length - suffix.length);
const codeHash = unsuffixedId.substr(unsuffixedId.lastIndexOf('/') + 1);
const newId = prefix.concat(codeHash).concat(suffix);
breakpoints.set(newId, list);
}
await this._restoreBreakpoints(breakpoints);
const config = await this.session!.sendRequest('configurationDone', {});
await this.restoreState(false);
return config.success;
}
/**
* Clear the current model.
*/
private _clearModel(): void {
this._model.callstack.frames = [];
this._model.variables.scopes = [];
}
/**
* Clear the signals set on the model.
*/
private _clearSignals(): void {
this._model.callstack.currentFrameChanged.disconnect(
this._onCurrentFrameChanged,
this
);
this._model.variables.variableExpanded.disconnect(
this._onVariableExpanded,
this
);
}
/**
* Map a list of scopes to a list of variables.
*
* @param scopes The list of scopes.
* @param variables The list of variables.
*/
private _convertScopes(
scopes: DebugProtocol.Scope[],
variables: DebugProtocol.Variable[][]
): IDebugger.IScope[] {
if (!variables || !scopes) {
return [];
}
return scopes.map((scope, i) => {
return {
name: scope.name,
variables: variables[i].map(variable => {
return { ...variable };
})
};
});
}
/**
* Get the current thread from the model.
*/
private _currentThread(): number {
return this._model.stoppedThreads.values().next().value ?? 1;
}
/**
* Dump the content of a cell.
*
* @param code The source code to dump.
*/
private async _dumpCell(
code: string
): Promise<IDebugger.ISession.IDumpCellResponse> {
if (!this.session) {
throw new Error('No active debugger session');
}
return this.session.sendRequest('dumpCell', { code });
}
/**
* Filter breakpoints and only return those associated with a known editor.
*
* @param breakpoints - Map of breakpoints.
*
*/
private _filterBreakpoints(
breakpoints: Map<string, IDebugger.IBreakpoint[]>
): Map<string, IDebugger.IBreakpoint[]> {
if (!this._debuggerSources) {
return breakpoints;
}
let bpMapForRestore = new Map<string, IDebugger.IBreakpoint[]>();
for (const collection of breakpoints) {
const [id, list] = collection;
list.forEach(() => {
this._debuggerSources!.find({
focus: false,
kernel: this.session?.connection?.kernel?.name ?? '',
path: this._session?.connection?.path ?? '',
source: id
}).forEach(() => {
if (list.length > 0) {
bpMapForRestore.set(id, list);
}
});
});
}
return bpMapForRestore;
}
/**
* Get all the frames from the kernel.
*/
private async _getAllFrames(): Promise<void> {
this._model.callstack.currentFrameChanged.connect(
this._onCurrentFrameChanged,
this
);
this._model.variables.variableExpanded.connect(
this._onVariableExpanded,
this
);
const stackFrames = await this._getFrames(this._currentThread());
this._model.callstack.frames = stackFrames;
}
/**
* Get all the frames for the given thread id.
*
* @param threadId The thread id.
*/
private async _getFrames(
threadId: number
): Promise<DebugProtocol.StackFrame[]> {
if (!this.session) {
throw new Error('No active debugger session');
}
const reply = await this.session.sendRequest('stackTrace', {
threadId
});
const stackFrames = reply.body.stackFrames;
return stackFrames;
}
/**
* Get all the scopes for the given frame.
*
* @param frame The frame.
*/
private async _getScopes(
frame: DebugProtocol.StackFrame
): Promise<DebugProtocol.Scope[]> {
if (!this.session) {
throw new Error('No active debugger session');
}
if (!frame) {
return [];
}
const reply = await this.session.sendRequest('scopes', {
frameId: frame.id
});
return reply.body.scopes;
}
/**
* Get the variables for a given scope.
*
* @param scope The scope to get variables for.
*/
private async _getVariables(
scope: DebugProtocol.Scope
): Promise<DebugProtocol.Variable[]> {
if (!this.session) {
throw new Error('No active debugger session');
}
if (!scope) {
return [];
}
const reply = await this.session.sendRequest('variables', {
variablesReference: scope.variablesReference
});
return reply.body.variables;
}
/**
* Process the list of breakpoints from the server and return as a map.
*
* @param breakpoints - The list of breakpoints from the kernel.
*
*/
private _mapBreakpoints(
breakpoints: IDebugger.ISession.IDebugInfoBreakpoints[]
): Map<string, IDebugger.IBreakpoint[]> {
if (!breakpoints.length) {
return new Map<string, IDebugger.IBreakpoint[]>();
}
return breakpoints.reduce(
(
map: Map<string, IDebugger.IBreakpoint[]>,
val: IDebugger.ISession.IDebugInfoBreakpoints
) => {
const { breakpoints, source } = val;
map.set(
source,
breakpoints.map(point => ({
...point,
source: { path: source },
verified: true
}))
);
return map;
},
new Map<string, IDebugger.IBreakpoint[]>()
);
}
/**
* Handle a change of the current active frame.
*
* @param _ The callstack model
* @param frame The frame.
*/
private async _onCurrentFrameChanged(
_: IDebugger.Model.ICallstack,
frame: IDebugger.IStackFrame
): Promise<void> {
if (!frame) {
return;
}
const scopes = await this._getScopes(frame);
const variables = await Promise.all(
scopes.map(scope => this._getVariables(scope))
);
const variableScopes = this._convertScopes(scopes, variables);
this._model.variables.scopes = variableScopes;
}
/**
* Handle a variable expanded event and request variables from the kernel.
*
* @param _ The variables model.
* @param variable The expanded variable.
*/
private async _onVariableExpanded(
_: VariablesModel,
variable: DebugProtocol.Variable
): Promise<DebugProtocol.Variable[]> {
if (!this.session) {
throw new Error('No active debugger session');
}
const reply = await this.session.sendRequest('variables', {
variablesReference: variable.variablesReference
});
let newVariable = { ...variable, expanded: true };
reply.body.variables.forEach((variable: DebugProtocol.Variable) => {
newVariable = { [variable.name]: variable, ...newVariable };
});
const newScopes = this._model.variables.scopes.map(scope => {
const findIndex = scope.variables.findIndex(
ele => ele.variablesReference === variable.variablesReference
);
scope.variables[findIndex] = newVariable;
return { ...scope };
});
this._model.variables.scopes = [...newScopes];
return reply.body.variables;
}
/**
* Set the breakpoints for a given file.
*
* @param breakpoints The list of breakpoints to set.
* @param path The path to where to set the breakpoints.
*/
private async _setBreakpoints(
breakpoints: DebugProtocol.SourceBreakpoint[],
path: string
): Promise<DebugProtocol.SetBreakpointsResponse> {
if (!this.session) {
throw new Error('No active debugger session');
}
return await this.session.sendRequest('setBreakpoints', {
breakpoints: breakpoints,
source: { path },
sourceModified: false
});
}
/**
* Re-send the breakpoints to the kernel and update the model.
*
* @param breakpoints The map of breakpoints to send
*/
private async _restoreBreakpoints(
breakpoints: Map<string, IDebugger.IBreakpoint[]>
): Promise<void> {
for (const [source, points] of breakpoints) {
await this._setBreakpoints(
points
.filter(({ line }) => typeof line === 'number')
.map(({ line }) => ({ line: line! })),
source
);
}
this._model.breakpoints.restoreBreakpoints(breakpoints);
}
private _config: IDebugger.IConfig;
private _debuggerSources: IDebugger.ISources | null;
private _eventMessage = new Signal<IDebugger, IDebugger.ISession.Event>(this);
private _isDisposed = false;
private _model: IDebugger.Model.IService;
private _session: IDebugger.ISession | null;
private _sessionChanged = new Signal<IDebugger, IDebugger.ISession | null>(
this
);
private _specsManager: KernelSpec.IManager | null;
private _trans: TranslationBundle;
private _pauseOnExceptionChanged = new Signal<IDebugger, void>(this);
}
/**
* A namespace for `DebuggerService` statics.
*/
export namespace DebuggerService {
/**
* Instantiation options for a `DebuggerService`.
*/
export interface IOptions {
/**
* The configuration instance with hash method.
*/
config: IDebugger.IConfig;
/**
* The optional debugger sources instance.
*/
debuggerSources?: IDebugger.ISources | null;
/**
* The optional kernel specs manager.
*/
specsManager?: KernelSpec.IManager | null;
/**
* The application language translator.
*/
translator?: ITranslator | null;
}
}