// ***************************************************************************** // Copyright (C) 2018 Red Hat, Inc. and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { DisposableCollection, Emitter, Event, MessageService, nls, ProgressService, WaitUntilEvent } from '@theia/core'; import { ApplicationShell, ConfirmDialog } from '@theia/core/lib/browser'; import { ContextKey, ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { WindowService } from '@theia/core/lib/browser/window/window-service'; import URI from '@theia/core/lib/common/uri'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import * as monaco from '@theia/monaco-editor-core'; import { QuickOpenTask } from '@theia/task/lib/browser/quick-open-task'; import { TaskEndedInfo, TaskEndedTypes, TaskService } from '@theia/task/lib/browser/task-service'; import { TaskIdentifier } from '@theia/task/lib/common'; import { VariableResolverService } from '@theia/variable-resolver/lib/browser'; import { WorkspaceTrustService } from '@theia/workspace/lib/browser'; import { DebugConfiguration } from '../common/debug-common'; import { DebugPreferences } from '../common/debug-preferences'; import { DebugError, DebugService } from '../common/debug-service'; import { BreakpointManager } from './breakpoint/breakpoint-manager'; import { DebugVariable } from './console/debug-console-items'; import { DebugConfigurationManager } from './debug-configuration-manager'; import { DebugSession, DebugState, debugStateContextValue } from './debug-session'; import { DebugSessionConfigurationLabelProvider } from './debug-session-configuration-label-provider'; import { DebugSessionContributionRegistry, DebugSessionFactory } from './debug-session-contribution'; import { DebugCompoundRoot, DebugCompoundSessionOptions, DebugConfigurationSessionOptions, DebugSessionOptions, InternalDebugSessionOptions } from './debug-session-options'; import { DebugStackFrame } from './model/debug-stack-frame'; import { DebugThread } from './model/debug-thread'; export interface WillStartDebugSession extends WaitUntilEvent { } export interface WillResolveDebugConfiguration extends WaitUntilEvent { debugType: string } export interface DidChangeActiveDebugSession { previous: DebugSession | undefined current: DebugSession | undefined } export interface DidChangeBreakpointsEvent { session?: DebugSession uri: URI } export interface DidResolveLazyVariableEvent { readonly session: DebugSession readonly variable: DebugVariable } export interface DebugSessionCustomEvent { readonly body?: any // eslint-disable-line @typescript-eslint/no-explicit-any readonly event: string readonly session: DebugSession } @injectable() export class DebugSessionManager { protected readonly _sessions = new Map(); protected readonly onWillStartDebugSessionEmitter = new Emitter(); readonly onWillStartDebugSession: Event = this.onWillStartDebugSessionEmitter.event; protected readonly onWillResolveDebugConfigurationEmitter = new Emitter(); readonly onWillResolveDebugConfiguration: Event = this.onWillResolveDebugConfigurationEmitter.event; protected readonly onDidCreateDebugSessionEmitter = new Emitter(); readonly onDidCreateDebugSession: Event = this.onDidCreateDebugSessionEmitter.event; protected readonly onDidStartDebugSessionEmitter = new Emitter(); readonly onDidStartDebugSession: Event = this.onDidStartDebugSessionEmitter.event; protected readonly onDidStopDebugSessionEmitter = new Emitter(); readonly onDidStopDebugSession: Event = this.onDidStopDebugSessionEmitter.event; protected readonly onDidChangeActiveDebugSessionEmitter = new Emitter(); readonly onDidChangeActiveDebugSession: Event = this.onDidChangeActiveDebugSessionEmitter.event; protected readonly onDidDestroyDebugSessionEmitter = new Emitter(); readonly onDidDestroyDebugSession: Event = this.onDidDestroyDebugSessionEmitter.event; protected readonly onDidReceiveDebugSessionCustomEventEmitter = new Emitter(); readonly onDidReceiveDebugSessionCustomEvent: Event = this.onDidReceiveDebugSessionCustomEventEmitter.event; protected readonly onDidFocusStackFrameEmitter = new Emitter(); readonly onDidFocusStackFrame = this.onDidFocusStackFrameEmitter.event; protected readonly onDidFocusThreadEmitter = new Emitter(); readonly onDidFocusThread = this.onDidFocusThreadEmitter.event; protected readonly onDidChangeEmitter = new Emitter(); readonly onDidChange: Event = this.onDidChangeEmitter.event; protected fireDidChange(current: DebugSession | undefined): void { this.debugTypeKey.set(current?.configuration.type); this.inDebugModeKey.set(this.inDebugMode); this.debugStateKey.set(debugStateContextValue(this.state)); this.onDidChangeEmitter.fire(current); } protected readonly onDidResolveLazyVariableEmitter = new Emitter(); readonly onDidResolveLazyVariable: Event = this.onDidResolveLazyVariableEmitter.event; @inject(DebugSessionFactory) protected readonly debugSessionFactory: DebugSessionFactory; @inject(DebugService) protected readonly debug: DebugService; @inject(BreakpointManager) protected readonly breakpoints: BreakpointManager; @inject(VariableResolverService) protected readonly variableResolver: VariableResolverService; @inject(DebugSessionContributionRegistry) protected readonly sessionContributionRegistry: DebugSessionContributionRegistry; @inject(MessageService) protected readonly messageService: MessageService; @inject(ProgressService) protected readonly progressService: ProgressService; @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; @inject(TaskService) protected readonly taskService: TaskService; @inject(DebugConfigurationManager) protected readonly debugConfigurationManager: DebugConfigurationManager; @inject(QuickOpenTask) protected readonly quickOpenTask: QuickOpenTask; @inject(ApplicationShell) protected readonly shell: ApplicationShell; @inject(DebugSessionConfigurationLabelProvider) protected readonly sessionConfigurationLabelProvider: DebugSessionConfigurationLabelProvider; @inject(WorkspaceTrustService) protected readonly workspaceTrustService: WorkspaceTrustService; @inject(DebugPreferences) protected readonly debugPreferences: DebugPreferences; @inject(WindowService) protected readonly windowService: WindowService; protected debugTypeKey: ContextKey; protected inDebugModeKey: ContextKey; protected debugStateKey: ContextKey; @postConstruct() protected init(): void { this.debugTypeKey = this.contextKeyService.createKey('debugType', undefined); this.inDebugModeKey = this.contextKeyService.createKey('inDebugMode', this.inDebugMode); this.debugStateKey = this.contextKeyService.createKey('debugState', debugStateContextValue(this.state)); } get inDebugMode(): boolean { return this.state > DebugState.Inactive; } isCurrentEditorFrame(uri: URI | string | monaco.Uri): boolean { return this.currentFrame?.source?.uri.toString() === (uri instanceof URI ? uri : new URI(uri.toString())).toString(); } protected async saveAll(): Promise { if (!this.shell.canSaveAll()) { return true; // Nothing to save. } try { await this.shell.saveAll(); return true; } catch (error) { console.error('saveAll failed:', error); return false; } } async start(options: DebugCompoundSessionOptions): Promise; async start(options: DebugConfigurationSessionOptions): Promise; async start(options: DebugSessionOptions): Promise; async start(name: string): Promise; async start(optionsOrName: DebugSessionOptions | string): Promise { if (typeof optionsOrName === 'string') { const options = this.debugConfigurationManager.find(optionsOrName); return !!options && this.start(options); } return optionsOrName.configuration ? this.startConfiguration(optionsOrName) : this.startCompound(optionsOrName); } protected async startConfiguration(options: DebugConfigurationSessionOptions): Promise { // Check workspace trust before starting debug session const trust = await this.workspaceTrustService.requestWorkspaceTrust(); if (!trust) { return undefined; } return this.progressService.withProgress(nls.localizeByDefault('Starting...'), 'debug', async () => { try { // If a parent session is available saving should be handled by the parent if (!options.configuration.parentSessionId && !options.configuration.suppressSaveBeforeStart && !await this.saveAll()) { return undefined; } await this.fireWillStartDebugSession(); const resolved = await this.resolveConfiguration(options); if (!resolved || !resolved.configuration) { // As per vscode API: https://code.visualstudio.com/api/references/vscode-api#DebugConfigurationProvider // "Returning the value 'undefined' prevents the debug session from starting. // Returning the value 'null' prevents the debug session from starting and opens the // underlying debug configuration instead." // eslint-disable-next-line no-null/no-null if (resolved === null) { this.debugConfigurationManager.openConfiguration(); } return undefined; } const sessionConfigurationLabel = this.sessionConfigurationLabelProvider.getLabel(resolved); if (options?.startedByUser && options.configuration.suppressMultipleSessionWarning !== true && this.sessions.some(s => this.sessionConfigurationLabelProvider.getLabel(s.options) === sessionConfigurationLabel) ) { const yes = await new ConfirmDialog({ title: nls.localizeByDefault('Debug'), msg: nls.localizeByDefault("'{0}' is already running. Do you want to start another instance?", sessionConfigurationLabel) }).open(); if (!yes) { return undefined; } } // preLaunchTask isn't run in case of auto restart as well as postDebugTask if (!options.configuration.__restart) { const taskRun = await this.runTask(options.workspaceFolderUri, resolved.configuration.preLaunchTask, true); if (!taskRun) { return undefined; } } const sessionId = await this.debug.createDebugSession(resolved.configuration, options.workspaceFolderUri); return this.doStart(sessionId, resolved); } catch (e) { if (DebugError.NotFound.is(e)) { this.messageService.error(nls.localize('theia/debug/debugSessionTypeNotSupported', 'The debug session type "{0}" is not supported.', e.data.type)); return undefined; } this.messageService.error(nls.localize('theia/debug/errorStartingDebugSession', 'There was an error starting the debug session, check the logs for more details.')); console.error('Error starting the debug session', e); throw e; } }); } protected async startCompound(options: DebugCompoundSessionOptions): Promise { // Check workspace trust before starting compound debug session const trust = await this.workspaceTrustService.requestWorkspaceTrust(); if (!trust) { return false; } let configurations: DebugConfigurationSessionOptions[] = []; const compoundRoot = options.compound.stopAll ? new DebugCompoundRoot() : undefined; try { configurations = this.getCompoundConfigurations(options, compoundRoot); } catch (error) { this.messageService.error(error.message); return; } if (options.compound.preLaunchTask) { const taskRun = await this.runTask(options.workspaceFolderUri, options.compound.preLaunchTask, true); if (!taskRun) { return undefined; } } // Compound launch is a success only if each configuration launched successfully const values = await Promise.all(configurations.map(async configuration => { const newSession = await this.startConfiguration(configuration); if (newSession) { compoundRoot?.onDidSessionStop(() => newSession.stop(false, () => this.debug.terminateDebugSession(newSession.id))); } return newSession; })); const result = values.every(success => !!success); return result; } protected getCompoundConfigurations(options: DebugCompoundSessionOptions, compoundRoot: DebugCompoundRoot | undefined): DebugConfigurationSessionOptions[] { const compound = options.compound; if (!compound.configurations) { throw new Error(nls.localizeByDefault('Compound must have "configurations" attribute set in order to start multiple configurations.')); } const configurations: DebugConfigurationSessionOptions[] = []; for (const configData of compound.configurations) { const name = typeof configData === 'string' ? configData : configData.name; if (name === compound.name) { throw new Error(nls.localize('theia/debug/compound-cycle', "Launch configuration '{0}' contains a cycle with itself", name)); } const workspaceFolderUri = typeof configData === 'string' ? options.workspaceFolderUri : configData.folder; const matchingOptions = [...this.debugConfigurationManager.all] .filter(option => option.name === name && !!option.configuration && option.workspaceFolderUri === workspaceFolderUri); if (matchingOptions.length === 1) { const match = matchingOptions[0]; if (DebugSessionOptions.isConfiguration(match)) { configurations.push({ ...match, compoundRoot, configuration: { ...match.configuration, noDebug: options.noDebug } }); } else { throw new Error(nls.localizeByDefault("Could not find launch configuration '{0}' in the workspace.", name)); } } else { throw new Error(matchingOptions.length === 0 ? workspaceFolderUri ? nls.localizeByDefault("Can not find folder with name '{0}' for configuration '{1}' in compound '{2}'.", workspaceFolderUri, name, compound.name) : nls.localizeByDefault("Could not find launch configuration '{0}' in the workspace.", name) : nls.localizeByDefault("There are multiple launch configurations '{0}' in the workspace. Use folder name to qualify the configuration.", name)); } } return configurations; } protected async fireWillStartDebugSession(): Promise { await WaitUntilEvent.fire(this.onWillStartDebugSessionEmitter, {}); } protected configurationIds = new Map(); protected async resolveConfiguration( options: Readonly ): Promise { if (InternalDebugSessionOptions.is(options)) { return options; } const { workspaceFolderUri } = options; let configuration = await this.resolveDebugConfiguration(options.configuration, workspaceFolderUri); if (configuration) { // Resolve command variables provided by the debugger const commandIdVariables = await this.debug.provideDebuggerVariables(configuration.type); configuration = await this.variableResolver.resolve(configuration, { context: options.workspaceFolderUri ? new URI(options.workspaceFolderUri) : undefined, configurationSection: 'launch', commandIdVariables, configuration }); if (configuration) { configuration = await this.resolveDebugConfigurationWithSubstitutedVariables( configuration, workspaceFolderUri ); } } if (!configuration) { return configuration; } const key = configuration.name + workspaceFolderUri; const id = this.configurationIds.has(key) ? this.configurationIds.get(key)! + 1 : 0; this.configurationIds.set(key, id); return { id, ...options, name: configuration.name, configuration }; } protected async resolveDebugConfiguration( configuration: DebugConfiguration, workspaceFolderUri: string | undefined ): Promise { await this.fireWillResolveDebugConfiguration(configuration.type); return this.debug.resolveDebugConfiguration(configuration, workspaceFolderUri); } protected async fireWillResolveDebugConfiguration(debugType: string): Promise { await WaitUntilEvent.fire(this.onWillResolveDebugConfigurationEmitter, { debugType }); } protected async resolveDebugConfigurationWithSubstitutedVariables( configuration: DebugConfiguration, workspaceFolderUri: string | undefined ): Promise { return this.debug.resolveDebugConfigurationWithSubstitutedVariables(configuration, workspaceFolderUri); } protected async doStart(sessionId: string, options: DebugConfigurationSessionOptions): Promise { const parentSession = options.configuration.parentSessionId ? this._sessions.get(options.configuration.parentSessionId) : undefined; const contrib = this.sessionContributionRegistry.get(options.configuration.type); const sessionFactory = contrib ? contrib.debugSessionFactory() : this.debugSessionFactory; const session = sessionFactory.get(this, sessionId, options, parentSession); this._sessions.set(sessionId, session); this.debugTypeKey.set(session.configuration.type); this.onDidCreateDebugSessionEmitter.fire(session); let state = DebugState.Inactive; session.onDidChange(() => { if (state !== session.state) { state = session.state; if (state === DebugState.Stopped) { this.onDidStopDebugSessionEmitter.fire(session); // Only switch to this session if a thread actually stopped (not just state change) if (session.currentThread && session.currentThread.stopped) { this.updateCurrentSession(session); } } } // Always fire change event to update views (threads, variables, etc.) // The selection logic in widgets will handle not jumping to non-stopped threads this.fireDidChange(session); }); session.on('terminated', async event => { const restart = event.body && event.body.restart; if (restart) { // postDebugTask isn't run in case of auto restart as well as preLaunchTask this.doRestart(session, !!restart); } else { await session.disconnect(false, () => this.debug.terminateDebugSession(session.id)); await this.runTask(session.options.workspaceFolderUri, session.configuration.postDebugTask); } }); session.on('exited', async event => { await session.disconnect(false, () => this.debug.terminateDebugSession(session.id)); }); session.onDispose(() => this.cleanup(session)); session.start().then(() => { this.onDidStartDebugSessionEmitter.fire(session); // Set as current session if no current session exists // This ensures the UI shows the running session and buttons are enabled if (!this.currentSession) { this.updateCurrentSession(session); } }).catch(e => { session.stop(false, () => { this.debug.terminateDebugSession(session.id); }); }); session.onDidCustomEvent(({ event, body }) => this.onDidReceiveDebugSessionCustomEventEmitter.fire({ event, body, session }) ); return session; } protected cleanup(session: DebugSession): void { // Data breakpoints that can't persist should be removed when a session ends. const currentDataBreakpoints = this.breakpoints.getDataBreakpoints(); const toRetain = currentDataBreakpoints.filter(candidate => candidate.origin.info.canPersist); if (currentDataBreakpoints.length !== toRetain.length) { this.breakpoints.setDataBreakpoints(toRetain.map(bp => bp.origin)); } if (this.remove(session.id)) { this.onDidDestroyDebugSessionEmitter.fire(session); this.breakpoints.updateSessionData(session.id, session.capabilities); this.breakpoints.clearExceptionSessionEnablement(session.id); } } protected async doRestart(session: DebugSession, isRestart: boolean): Promise { if (session.canRestart()) { await session.restart(); return session; } const { options, configuration } = session; session.stop(isRestart, () => this.debug.terminateDebugSession(session.id)); configuration.__restart = isRestart; return this.start(options); } async terminateSession(session?: DebugSession): Promise { if (!session) { this.updateCurrentSession(this._currentSession); session = this._currentSession; } if (session) { if (session.options.compoundRoot) { session.options.compoundRoot.stopSession(); } else if (session.parentSession && session.configuration.lifecycleManagedByParent) { this.terminateSession(session.parentSession); } else { session.stop(false, () => this.debug.terminateDebugSession(session!.id)); } } } async restartSession(session?: DebugSession): Promise { if (!session) { this.updateCurrentSession(this._currentSession); session = this._currentSession; } if (session) { if (session.parentSession && session.configuration.lifecycleManagedByParent) { return this.restartSession(session.parentSession); } else { return this.doRestart(session, true); } } } protected remove(sessionId: string): boolean { const existed = this._sessions.delete(sessionId); const { currentSession } = this; if (currentSession && currentSession.id === sessionId) { this.updateCurrentSession(undefined); } return existed; } getSession(sessionId: string): DebugSession | undefined { return this._sessions.get(sessionId); } get sessions(): DebugSession[] { return Array.from(this._sessions.values()).filter(session => session.state > DebugState.Inactive); } protected _currentSession: DebugSession | undefined; protected readonly disposeOnCurrentSessionChanged = new DisposableCollection(); get currentSession(): DebugSession | undefined { return this._currentSession; } set currentSession(current: DebugSession | undefined) { if (this._currentSession === current) { return; } this.disposeOnCurrentSessionChanged.dispose(); const previous = this.currentSession; this._currentSession = current; this.onDidChangeActiveDebugSessionEmitter.fire({ previous, current }); if (current) { this.disposeOnCurrentSessionChanged.push(current.onDidChange(() => { if (this.currentFrame === this.topFrame) { this.open('auto'); } this.fireDidChange(current); })); this.disposeOnCurrentSessionChanged.push(current.onDidResolveLazyVariable(variable => this.onDidResolveLazyVariableEmitter.fire({ session: current, variable }))); this.disposeOnCurrentSessionChanged.push(current.onDidFocusStackFrame(frame => this.onDidFocusStackFrameEmitter.fire(frame))); this.disposeOnCurrentSessionChanged.push(current.onDidFocusThread(thread => this.onDidFocusThreadEmitter.fire(thread))); const { currentThread } = current; this.onDidFocusThreadEmitter.fire(currentThread); } this.open(); this.fireDidChange(current); } open(revealOption: 'auto' | 'center' = 'center'): void { const { currentFrame } = this; if (currentFrame && currentFrame.thread.stopped) { const focusEditor = this.debugPreferences['debug.focusEditorOnBreak']; currentFrame.open({ revealOption, mode: focusEditor ? 'activate' : 'reveal', }); if (this.debugPreferences['debug.focusWindowOnBreak']) { this.windowService.focus(); } } } protected updateCurrentSession(session: DebugSession | undefined): void { this.currentSession = session || this.sessions[0]; } get currentThread(): DebugThread | undefined { const session = this.currentSession; return session && session.currentThread; } get state(): DebugState { const session = this.currentSession; return session ? session.state : DebugState.Inactive; } get currentFrame(): DebugStackFrame | undefined { const { currentThread } = this; return currentThread && currentThread.currentFrame; } get topFrame(): DebugStackFrame | undefined { const { currentThread } = this; return currentThread && currentThread.topFrame; } /** * Runs the given tasks. * @param taskName the task name to run, see [TaskNameResolver](#TaskNameResolver) * @return true if it allowed to continue debugging otherwise it returns false */ protected async runTask(workspaceFolderUri: string | undefined, taskName: string | TaskIdentifier | undefined, checkErrors?: boolean): Promise { if (!taskName) { return true; } const taskInfo = await this.taskService.runWorkspaceTask(this.taskService.startUserAction(), workspaceFolderUri, taskName); if (!checkErrors) { return true; } const taskLabel = typeof taskName === 'string' ? taskName : JSON.stringify(taskName); if (!taskInfo) { return this.doPostTaskAction(nls.localize('theia/debug/couldNotRunTask', "Could not run the task '{0}'.", taskLabel)); } const getExitCodePromise: Promise = this.taskService.getExitCode(taskInfo.taskId).then(result => ({ taskEndedType: TaskEndedTypes.TaskExited, value: result })); const isBackgroundTaskEndedPromise: Promise = this.taskService.isBackgroundTaskEnded(taskInfo.taskId).then(result => ({ taskEndedType: TaskEndedTypes.BackgroundTaskEnded, value: result })); // After start running the task, we wait for the task process to exit and if it is a background task, we also wait for a feedback // that a background task is active, as soon as one of the promises fulfills, we can continue and analyze the results. const taskEndedInfo: TaskEndedInfo = await Promise.race([getExitCodePromise, isBackgroundTaskEndedPromise]); if (taskEndedInfo.taskEndedType === TaskEndedTypes.BackgroundTaskEnded && taskEndedInfo.value) { return true; } if (taskEndedInfo.taskEndedType === TaskEndedTypes.TaskExited && taskEndedInfo.value === 0) { return true; } else if (taskEndedInfo.taskEndedType === TaskEndedTypes.TaskExited && taskEndedInfo.value !== undefined) { return this.doPostTaskAction(nls.localize('theia/debug/taskTerminatedWithExitCode', "Task '{0}' terminated with exit code {1}.", taskLabel, taskEndedInfo.value)); } else { const signal = await this.taskService.getTerminateSignal(taskInfo.taskId); if (signal !== undefined) { return this.doPostTaskAction(nls.localize('theia/debug/taskTerminatedBySignal', "Task '{0}' terminated by signal {1}.", taskLabel, signal)); } else { return this.doPostTaskAction(nls.localize('theia/debug/taskTerminatedForUnknownReason', "Task '{0}' terminated for unknown reason.", taskLabel)); } } } protected async doPostTaskAction(errorMessage: string): Promise { const actions = [ nls.localizeByDefault('Open {0}', 'launch.json'), nls.localizeByDefault('Cancel'), nls.localizeByDefault('Configure Task'), nls.localizeByDefault('Debug Anyway') ]; const result = await this.messageService.error(errorMessage, ...actions); switch (result) { case actions[0]: // open launch.json this.debugConfigurationManager.openConfiguration(); return false; case actions[1]: // cancel return false; case actions[2]: // configure tasks this.quickOpenTask.configure(); return false; default: // continue debugging return true; } } }