diff --git a/examples/domainmodel/src/language-server/domain-model-scope.ts b/examples/domainmodel/src/language-server/domain-model-scope.ts index 9e067afab..279f7c800 100644 --- a/examples/domainmodel/src/language-server/domain-model-scope.ts +++ b/examples/domainmodel/src/language-server/domain-model-scope.ts @@ -8,7 +8,7 @@ import type { AstNode, AstNodeDescription, LangiumDocument, PrecomputedScopes } import type { DomainModelServices } from './domain-model-module.js'; import type { QualifiedNameProvider } from './domain-model-naming.js'; import type { Domainmodel, PackageDeclaration } from './generated/ast.js'; -import { AstUtils, Cancellation, DefaultScopeComputation, interruptAndCheck, MultiMap } from 'langium'; +import { AstUtils, DefaultScopeComputation, MultiMap, CancellationToken } from 'langium'; import { isType, isPackageDeclaration } from './generated/ast.js'; export class DomainModelScopeComputation extends DefaultScopeComputation { @@ -23,10 +23,10 @@ export class DomainModelScopeComputation extends DefaultScopeComputation { /** * Exports only types (`DataType or `Entity`) with their qualified names. */ - override async computeExports(document: LangiumDocument, cancelToken = Cancellation.CancellationToken.None): Promise { + override async computeExports(document: LangiumDocument, cancelToken = CancellationToken.None): Promise { const descr: AstNodeDescription[] = []; for (const modelNode of AstUtils.streamAllContents(document.parseResult.value)) { - await interruptAndCheck(cancelToken); + await cancelToken.check(); if (isType(modelNode)) { let name = this.nameProvider.getName(modelNode); if (name) { @@ -40,17 +40,17 @@ export class DomainModelScopeComputation extends DefaultScopeComputation { return descr; } - override async computeLocalScopes(document: LangiumDocument, cancelToken = Cancellation.CancellationToken.None): Promise { + override async computeLocalScopes(document: LangiumDocument, cancelToken = CancellationToken.None): Promise { const model = document.parseResult.value as Domainmodel; const scopes = new MultiMap(); await this.processContainer(model, scopes, document, cancelToken); return scopes; } - protected async processContainer(container: Domainmodel | PackageDeclaration, scopes: PrecomputedScopes, document: LangiumDocument, cancelToken: Cancellation.CancellationToken): Promise { + protected async processContainer(container: Domainmodel | PackageDeclaration, scopes: PrecomputedScopes, document: LangiumDocument, cancelToken: CancellationToken): Promise { const localDescriptions: AstNodeDescription[] = []; for (const element of container.elements) { - await interruptAndCheck(cancelToken); + await cancelToken.check(); if (isType(element) && element.name) { const description = this.descriptions.createDescription(element, element.name, document); localDescriptions.push(description); diff --git a/packages/langium-sprotty/src/diagram-server-manager.ts b/packages/langium-sprotty/src/diagram-server-manager.ts index 6ded563ae..8aca53605 100644 --- a/packages/langium-sprotty/src/diagram-server-manager.ts +++ b/packages/langium-sprotty/src/diagram-server-manager.ts @@ -4,13 +4,13 @@ * terms of the MIT License, which is available in the project root. ******************************************************************************/ -import type { CancellationToken, Connection } from 'vscode-languageserver'; +import type { Connection } from 'vscode-languageserver'; import type { ActionMessage, DiagramOptions, DiagramServer, RequestModelAction } from 'sprotty-protocol'; -import type { LangiumDocument, ServiceRegistry, URI } from 'langium'; +import type { CancellationToken, LangiumDocument, ServiceRegistry, URI } from 'langium'; import type { LangiumSprottyServices, LangiumSprottySharedServices } from './sprotty-services.js'; import type { LangiumDiagramGeneratorArguments } from './diagram-generator.js'; import { isRequestAction, RejectAction } from 'sprotty-protocol'; -import { DocumentState, UriUtils, interruptAndCheck, stream } from 'langium'; +import { DocumentState, UriUtils, stream } from 'langium'; import { DiagramActionNotification } from './lsp.js'; /** @@ -89,7 +89,7 @@ export class DefaultDiagramServerManager implements DiagramServerManager { protected async updateDiagrams(documents: Map, cancelToken: CancellationToken): Promise { while (documents.size > 0) { - await interruptAndCheck(cancelToken); + await cancelToken.check(); const [firstEntry] = documents; const [document, diagramServers] = firstEntry; const language = this.serviceRegistry.getServices(document.uri) as LangiumSprottyServices; diff --git a/packages/langium/src/lsp/inlay-hint-provider.ts b/packages/langium/src/lsp/inlay-hint-provider.ts index 9078abbec..37e8fac21 100644 --- a/packages/langium/src/lsp/inlay-hint-provider.ts +++ b/packages/langium/src/lsp/inlay-hint-provider.ts @@ -10,7 +10,6 @@ import { CancellationToken } from '../utils/cancellation.js'; import type { MaybePromise } from '../utils/promise-utils.js'; import type { LangiumDocument } from '../workspace/documents.js'; import { streamAst } from '../utils/ast-utils.js'; -import { interruptAndCheck } from '../utils/promise-utils.js'; export type InlayHintAcceptor = (inlayHint: InlayHint) => void; @@ -33,7 +32,7 @@ export abstract class AbstractInlayHintProvider implements InlayHintProvider { const inlayHints: InlayHint[] = []; const acceptor: InlayHintAcceptor = hint => inlayHints.push(hint); for (const node of streamAst(root, { range: params.range })) { - await interruptAndCheck(cancelToken); + await cancelToken.check(); this.computeInlayHint(node, acceptor); } return inlayHints; diff --git a/packages/langium/src/lsp/language-server.ts b/packages/langium/src/lsp/language-server.ts index 53b6f3208..731093b6e 100644 --- a/packages/langium/src/lsp/language-server.ts +++ b/packages/langium/src/lsp/language-server.ts @@ -7,7 +7,7 @@ import type { CallHierarchyIncomingCallsParams, CallHierarchyOutgoingCallsParams, - CancellationToken, + CancellationToken as LSPCancellationToken, Connection, Disposable, Event, @@ -31,7 +31,6 @@ import type { import { DidChangeConfigurationNotification, Emitter, LSPErrorCodes, ResponseError, TextDocumentSyncKind } from 'vscode-languageserver-protocol'; import { eagerLoad } from '../dependency-injection.js'; import type { LangiumCoreServices } from '../services.js'; -import { isOperationCancelled } from '../utils/promise-utils.js'; import { URI } from '../utils/uri-utils.js'; import type { ConfigurationInitializedParams } from '../workspace/configuration.js'; import { DocumentState, type LangiumDocument } from '../workspace/documents.js'; @@ -39,6 +38,8 @@ import { mergeCompletionProviderOptions } from './completion/completion-provider import type { LangiumSharedServices, PartialLangiumLSPServices } from './lsp-services.js'; import { DefaultSemanticTokenOptions } from './semantic-token-provider.js'; import { mergeSignatureHelpOptions } from './signature-help-provider.js'; +import { WorkspaceLockPriority } from '../workspace/workspace-lock.js'; +import { CancellationToken, isOperationCancelled } from '../utils/cancellation.js'; export interface LanguageServer { initialize(params: InitializeParams): Promise @@ -518,7 +519,7 @@ export function addExecuteCommandHandler(connection: Connection, services: Langi if (commandHandler) { connection.onExecuteCommand(async (params, token) => { try { - return await commandHandler.executeCommand(params.command, params.arguments ?? [], token); + return await commandHandler.executeCommand(params.command, params.arguments ?? [], CancellationToken.create(token)); } catch (err) { return responseError(err); } @@ -552,12 +553,15 @@ export function addCodeLensHandler(connection: Connection, services: LangiumShar export function addWorkspaceSymbolHandler(connection: Connection, services: LangiumSharedServices): void { const workspaceSymbolProvider = services.lsp.WorkspaceSymbolProvider; + const lock = services.workspace.WorkspaceLock; if (workspaceSymbolProvider) { const documentBuilder = services.workspace.DocumentBuilder; connection.onWorkspaceSymbol(async (params, token) => { try { - await documentBuilder.waitUntil(DocumentState.IndexedContent, token); - return await workspaceSymbolProvider.getSymbols(params, token); + const cancellationToken = CancellationToken.create(token); + await documentBuilder.waitUntil(DocumentState.IndexedContent, cancellationToken); + const result = await lock.read(() => workspaceSymbolProvider.getSymbols(params, cancellationToken), WorkspaceLockPriority.Immediate); + return result; } catch (err) { return responseError(err); } @@ -566,8 +570,10 @@ export function addWorkspaceSymbolHandler(connection: Connection, services: Lang if (resolveWorkspaceSymbol) { connection.onWorkspaceSymbolResolve(async (workspaceSymbol, token) => { try { - await documentBuilder.waitUntil(DocumentState.IndexedContent, token); - return await resolveWorkspaceSymbol(workspaceSymbol, token); + const cancellationToken = CancellationToken.create(token); + await documentBuilder.waitUntil(DocumentState.IndexedContent, cancellationToken); + const result = await lock.read(() => resolveWorkspaceSymbol(workspaceSymbol, cancellationToken), WorkspaceLockPriority.Immediate); + return result; } catch (err) { return responseError(err); } @@ -654,10 +660,12 @@ export function createHierarchyRequestHandler

HandlerResult, sharedServices: LangiumSharedServices, ): ServerRequestHandler { + const lock = sharedServices.workspace.WorkspaceLock; const serviceRegistry = sharedServices.ServiceRegistry; - return async (params: P, cancelToken: CancellationToken) => { + return async (params: P, cancelToken: LSPCancellationToken) => { const uri = URI.parse(params.item.uri); - const cancellationError = await waitUntilPhase(sharedServices, cancelToken, uri, DocumentState.IndexedReferences); + const token = CancellationToken.create(cancelToken); + const cancellationError = await waitUntilPhase(sharedServices, token, uri, DocumentState.IndexedReferences); if (cancellationError) { return cancellationError; } @@ -668,7 +676,9 @@ export function createHierarchyRequestHandler

await serviceCall(language, params, token), WorkspaceLockPriority.Immediate); + return result; } catch (err) { return responseError(err); } @@ -681,10 +691,12 @@ export function createServerRequestHandler

{ const documents = sharedServices.workspace.LangiumDocuments; + const lock = sharedServices.workspace.WorkspaceLock; const serviceRegistry = sharedServices.ServiceRegistry; - return async (params: P, cancelToken: CancellationToken) => { + return async (params: P, cancelToken: LSPCancellationToken) => { const uri = URI.parse(params.textDocument.uri); - const cancellationError = await waitUntilPhase(sharedServices, cancelToken, uri, targetState); + const token = CancellationToken.create(cancelToken); + const cancellationError = await waitUntilPhase(sharedServices, token, uri, targetState); if (cancellationError) { return cancellationError; } @@ -694,9 +706,16 @@ export function createServerRequestHandler

(new Error(errorText)); } const language = serviceRegistry.getServices(uri); + const document = documents.getDocument(uri); + if (!document) { + const errorText = `Could not find document for uri: '${uri}'`; + console.debug(errorText); + return responseError(new Error(errorText)); + } try { - const document = await documents.getOrCreateDocument(uri); - return await serviceCall(language, document, params, cancelToken); + // Give this priority, since we already waited until the target state + const result = await lock.read(async () => await serviceCall(language, document, params, token), WorkspaceLockPriority.Immediate); + return result; } catch (err) { return responseError(err); } @@ -709,10 +728,12 @@ export function createRequestHandler

{ const documents = sharedServices.workspace.LangiumDocuments; + const lock = sharedServices.workspace.WorkspaceLock; const serviceRegistry = sharedServices.ServiceRegistry; - return async (params: P, cancelToken: CancellationToken) => { + return async (params: P, cancelToken: LSPCancellationToken) => { const uri = URI.parse(params.textDocument.uri); - const cancellationError = await waitUntilPhase(sharedServices, cancelToken, uri, targetState); + const token = CancellationToken.create(cancelToken); + const cancellationError = await waitUntilPhase(sharedServices, token, uri, targetState); if (cancellationError) { return cancellationError; } @@ -721,9 +742,16 @@ export function createRequestHandler

(new Error(errorText)); + } try { - const document = await documents.getOrCreateDocument(uri); - return await serviceCall(language, document, params, cancelToken); + // Give this priority, since we already waited until the target state + const result = await lock.read(async () => await serviceCall(language, document, params, token), WorkspaceLockPriority.Immediate); + return result; } catch (err) { return responseError(err); } diff --git a/packages/langium/src/lsp/semantic-token-provider.ts b/packages/langium/src/lsp/semantic-token-provider.ts index cada94f27..6f9f3e215 100644 --- a/packages/langium/src/lsp/semantic-token-provider.ts +++ b/packages/langium/src/lsp/semantic-token-provider.ts @@ -14,7 +14,6 @@ import { streamAst } from '../utils/ast-utils.js'; import { inRange } from '../utils/cst-utils.js'; import { findNodeForKeyword, findNodeForProperty, findNodesForKeyword, findNodesForProperty } from '../utils/grammar-utils.js'; import type { MaybePromise } from '../utils/promise-utils.js'; -import { interruptAndCheck } from '../utils/promise-utils.js'; import type { LangiumDocument } from '../workspace/documents.js'; import type { LangiumServices } from './lsp-services.js'; @@ -287,7 +286,7 @@ export abstract class AbstractSemanticTokenProvider implements SemanticTokenProv do { result = treeIterator.next(); if (!result.done) { - await interruptAndCheck(cancelToken); + await cancelToken.check(); const node = result.value; if (this.highlightElement(node, acceptor) === 'prune') { treeIterator.prune(); diff --git a/packages/langium/src/lsp/workspace-symbol-provider.ts b/packages/langium/src/lsp/workspace-symbol-provider.ts index 01ca50b56..7bd22ad00 100644 --- a/packages/langium/src/lsp/workspace-symbol-provider.ts +++ b/packages/langium/src/lsp/workspace-symbol-provider.ts @@ -12,7 +12,6 @@ import type { AstNodeDescription } from '../syntax-tree.js'; import type { NodeKindProvider } from './node-kind-provider.js'; import type { FuzzyMatcher } from './fuzzy-matcher.js'; import { CancellationToken } from '../utils/cancellation.js'; -import { interruptAndCheck } from '../utils/promise-utils.js'; /** * Shared service for handling workspace symbols requests. @@ -58,7 +57,7 @@ export class DefaultWorkspaceSymbolProvider implements WorkspaceSymbolProvider { const workspaceSymbols: WorkspaceSymbol[] = []; const query = params.query.toLowerCase(); for (const description of this.indexManager.allElements()) { - await interruptAndCheck(cancelToken); + await cancelToken.check(); if (this.fuzzyMatcher.match(query, description.name)) { const symbol = this.getWorkspaceSymbol(description); if (symbol) { diff --git a/packages/langium/src/parser/async-parser.ts b/packages/langium/src/parser/async-parser.ts index 494b41fbc..7d1855564 100644 --- a/packages/langium/src/parser/async-parser.ts +++ b/packages/langium/src/parser/async-parser.ts @@ -4,13 +4,13 @@ * terms of the MIT License, which is available in the project root. ******************************************************************************/ -import type { CancellationToken } from '../utils/cancellation.js'; +import { OperationCancelled, type CancellationToken } from '../utils/cancellation.js'; import type { LangiumCoreServices } from '../services.js'; import type { AstNode } from '../syntax-tree.js'; import type { LangiumParser, ParseResult } from './langium-parser.js'; import type { Hydrator } from '../serializer/hydrator.js'; import type { Event } from '../utils/event.js'; -import { Deferred, OperationCancelled } from '../utils/promise-utils.js'; +import { Deferred } from '../utils/promise-utils.js'; import { Emitter } from '../utils/event.js'; /** diff --git a/packages/langium/src/references/linker.ts b/packages/langium/src/references/linker.ts index d5cb303a5..a9c6dcf65 100644 --- a/packages/langium/src/references/linker.ts +++ b/packages/langium/src/references/linker.ts @@ -12,7 +12,6 @@ import type { ScopeProvider } from './scope-provider.js'; import { CancellationToken } from '../utils/cancellation.js'; import { isAstNode, isAstNodeDescription, isLinkingError } from '../syntax-tree.js'; import { findRootNode, streamAst, streamReferences } from '../utils/ast-utils.js'; -import { interruptAndCheck } from '../utils/promise-utils.js'; import { DocumentState } from '../workspace/documents.js'; /** @@ -91,7 +90,7 @@ export class DefaultLinker implements Linker { async link(document: LangiumDocument, cancelToken = CancellationToken.None): Promise { for (const node of streamAst(document.parseResult.value)) { - await interruptAndCheck(cancelToken); + await cancelToken.check(); streamReferences(node).forEach(ref => this.doLink(ref, document)); } } diff --git a/packages/langium/src/references/scope-computation.ts b/packages/langium/src/references/scope-computation.ts index e95662b4f..af9d978c9 100644 --- a/packages/langium/src/references/scope-computation.ts +++ b/packages/langium/src/references/scope-computation.ts @@ -12,7 +12,6 @@ import type { NameProvider } from './name-provider.js'; import { CancellationToken } from '../utils/cancellation.js'; import { streamAllContents, streamContents } from '../utils/ast-utils.js'; import { MultiMap } from '../utils/collections.js'; -import { interruptAndCheck } from '../utils/promise-utils.js'; /** * Language-specific service for precomputing global and local scopes. The service methods are executed @@ -94,7 +93,7 @@ export class DefaultScopeComputation implements ScopeComputation { this.exportNode(parentNode, exports, document); for (const node of children(parentNode)) { - await interruptAndCheck(cancelToken); + await cancelToken.check(); this.exportNode(node, exports, document); } return exports; @@ -116,7 +115,7 @@ export class DefaultScopeComputation implements ScopeComputation { const scopes = new MultiMap(); // Here we navigate the full AST - local scopes shall be available in the whole document for (const node of streamAllContents(rootNode)) { - await interruptAndCheck(cancelToken); + await cancelToken.check(); this.processNode(node, document, scopes); } return scopes; diff --git a/packages/langium/src/utils/cancellation.ts b/packages/langium/src/utils/cancellation.ts index 73a9d5ea6..fe32e475f 100644 --- a/packages/langium/src/utils/cancellation.ts +++ b/packages/langium/src/utils/cancellation.ts @@ -4,5 +4,226 @@ * terms of the MIT License, which is available in the project root. ******************************************************************************/ -// eslint-disable-next-line no-restricted-imports -export * from 'vscode-jsonrpc/lib/common/cancellation.js'; +import type { Disposable } from './disposable.js'; +import { Emitter, Event } from './event.js'; +import { delayNextTick } from './promise-utils.js'; + +let lastTick = 0; +let globalInterruptionPeriod = 10; + +/** + * Reset the global interruption period and create a cancellation token source. + * + * @deprecated Use {@link CancellationTokenSource} directly instead. + */ +export function startCancelableOperation(): AbstractCancellationTokenSource { + lastTick = Date.now(); + return new CancellationTokenSource(); +} + +/** + * This symbol may be thrown in an asynchronous context by any Langium service that receives + * a `CancellationToken`. This means that the promise returned by such a service is rejected with + * this symbol as rejection reason. + */ +export const OperationCancelled = Symbol('OperationCancelled'); + +/** + * Use this in a `catch` block to check whether the thrown object indicates that the operation + * has been cancelled. + */ +export function isOperationCancelled(err: unknown): err is typeof OperationCancelled { + return err === OperationCancelled; +} + +/** + * Change the period duration for {@link CancellationToken#check} and {@link interruptAndCheck} to the given number of milliseconds. + * The default value is 10ms. + */ +export function setInterruptionPeriod(period: number): void { + globalInterruptionPeriod = period; +} + +/** + * This function does two things: + * 1. Check the elapsed time since the last call to this function or to {@link startCancelableOperation}. If the predefined + * period (configured with {@link setInterruptionPeriod}) is exceeded, execution is delayed with {@link delayNextTick}. + * 2. If the predefined period is not met yet or execution is resumed after an interruption, the given cancellation + * token is checked, and if cancellation is requested, {@link OperationCancelled} is thrown. + * + * All services in Langium that receive a `CancellationToken` may potentially call this function, so the + * {@link OperationCancelled} must be caught (with an `async` try-catch block or a `catch` callback attached to + * the promise) to avoid that event being exposed as an error. + * + * @deprecated Use {@link CancellationToken#check} instead. + */ +export async function interruptAndCheck(token: CancellationToken): Promise { + if (token === CancellationToken.None) { + // Early exit in case cancellation was disabled by the caller + return; + } + const current = Date.now(); + if (current - lastTick >= globalInterruptionPeriod) { + lastTick = current; + await delayNextTick(); + } + if (token.isCancellationRequested) { + throw OperationCancelled; + } +} + +export interface SimpleCancellationToken { + /** + * Is `true` when the token has been cancelled, `false` otherwise. + */ + readonly isCancellationRequested: boolean; + /** + * An {@link Event event} which fires upon cancellation. + */ + readonly onCancellationRequested: Event; +} + +/** + * Defines a CancellationToken. This interface is not + * intended to be implemented. A CancellationToken must + * be created via a CancellationTokenSource. + */ +export interface CancellationToken extends SimpleCancellationToken { + /** + * This function does two things: + * 1. Check the elapsed time since the last call to this function or the creation time of this token. If the predefined + * period (configured with {@link setInterruptionPeriod}) is exceeded, execution is delayed with {@link delayNextTick}. + * 2. If the predefined period is not met yet or execution is resumed after an interruption, the given cancellation + * token is checked, and if cancellation is requested, {@link OperationCancelled} is thrown. + * + * All services in Langium that receive a {@link CancellationToken} may potentially call this function, so the + * {@link OperationCancelled} must be caught (with an `async` try-catch block or a `catch` callback attached to + * the promise) to avoid that event being exposed as an error. + */ + check(): Promise; +} + +export namespace CancellationToken { + export const None: CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: Event.None, + check: () => Promise.resolve() + }; + export const Cancelled: CancellationToken = { + isCancellationRequested: true, + onCancellationRequested: Event.None, + check: () => { + throw OperationCancelled; + } + }; + export function is(value: unknown): value is CancellationToken { + return typeof value === 'object' && !!value + && 'isCancellationRequested' in value + && 'onCancellationRequested' in value; + } + export function create(other: SimpleCancellationToken): CancellationToken { + let time = performance.now(); + return { + get isCancellationRequested() { + return other.isCancellationRequested; + }, + onCancellationRequested: other.onCancellationRequested, + async check() { + const now = performance.now(); + if (time - now >= 10) { + time = now; + await delayNextTick(); + } + if (other.isCancellationRequested) { + throw OperationCancelled; + } + } + }; + } +} + +class MutableToken implements CancellationToken { + + private _tick = performance.now(); + private _isCancelled: boolean = false; + private _emitter: Emitter | undefined; + + public cancel() { + if (!this._isCancelled) { + this._isCancelled = true; + if (this._emitter) { + this._emitter.fire(undefined); + this.dispose(); + } + } + } + + get isCancellationRequested(): boolean { + return this._isCancelled; + } + + get onCancellationRequested(): Event { + if (!this._emitter) { + this._emitter = new Emitter(); + } + return this._emitter.event; + } + + async check(): Promise { + const now = performance.now(); + if (now - this._tick >= globalInterruptionPeriod) { + this._tick = now; + await delayNextTick(); + } + if (this.isCancellationRequested) { + throw OperationCancelled; + } + } + + public dispose(): void { + if (this._emitter) { + this._emitter.dispose(); + this._emitter = undefined; + } + } +} + +export interface AbstractCancellationTokenSource extends Disposable { + token: CancellationToken; + cancel(): void; +} + +export class CancellationTokenSource implements AbstractCancellationTokenSource { + + private _token: CancellationToken | undefined; + + get token(): CancellationToken { + if (!this._token) { + // be lazy and create the token only when + // actually needed + this._token = new MutableToken(); + } + return this._token; + } + + cancel(): void { + if (!this._token) { + // save an object by returning the default + // cancelled token when cancellation happens + // before someone asks for the token + this._token = CancellationToken.Cancelled; + } else { + (this._token).cancel(); + } + } + + dispose(): void { + if (!this._token) { + // ensure to initialize with an empty token if we had none + this._token = CancellationToken.None; + } else if (this._token instanceof MutableToken) { + // actually dispose + this._token.dispose(); + } + } +} \ No newline at end of file diff --git a/packages/langium/src/utils/index.ts b/packages/langium/src/utils/index.ts index 6ff74b422..203b27ad8 100644 --- a/packages/langium/src/utils/index.ts +++ b/packages/langium/src/utils/index.ts @@ -5,6 +5,7 @@ ******************************************************************************/ export * from './caching.js'; +export * from './cancellation.js'; export * from './event.js'; export * from './collections.js'; export * from './disposable.js'; @@ -15,8 +16,7 @@ export * from './stream.js'; export * from './uri-utils.js'; import * as AstUtils from './ast-utils.js'; -import * as Cancellation from './cancellation.js'; import * as CstUtils from './cst-utils.js'; import * as GrammarUtils from './grammar-utils.js'; import * as RegExpUtils from './regexp-utils.js'; -export { AstUtils, Cancellation, CstUtils, GrammarUtils, RegExpUtils }; +export { AstUtils, CstUtils, GrammarUtils, RegExpUtils }; diff --git a/packages/langium/src/utils/promise-utils.ts b/packages/langium/src/utils/promise-utils.ts index 9b730e3e1..506f73997 100644 --- a/packages/langium/src/utils/promise-utils.ts +++ b/packages/langium/src/utils/promise-utils.ts @@ -4,8 +4,6 @@ * terms of the MIT License, which is available in the project root. ******************************************************************************/ -import { CancellationToken, CancellationTokenSource, type AbstractCancellationTokenSource } from '../utils/cancellation.js'; - export type MaybePromise = T | Promise /** @@ -24,66 +22,6 @@ export function delayNextTick(): Promise { }); } -let lastTick = 0; -let globalInterruptionPeriod = 10; - -/** - * Reset the global interruption period and create a cancellation token source. - */ -export function startCancelableOperation(): AbstractCancellationTokenSource { - lastTick = Date.now(); - return new CancellationTokenSource(); -} - -/** - * Change the period duration for `interruptAndCheck` to the given number of milliseconds. - * The default value is 10ms. - */ -export function setInterruptionPeriod(period: number): void { - globalInterruptionPeriod = period; -} - -/** - * This symbol may be thrown in an asynchronous context by any Langium service that receives - * a `CancellationToken`. This means that the promise returned by such a service is rejected with - * this symbol as rejection reason. - */ -export const OperationCancelled = Symbol('OperationCancelled'); - -/** - * Use this in a `catch` block to check whether the thrown object indicates that the operation - * has been cancelled. - */ -export function isOperationCancelled(err: unknown): err is typeof OperationCancelled { - return err === OperationCancelled; -} - -/** - * This function does two things: - * 1. Check the elapsed time since the last call to this function or to `startCancelableOperation`. If the predefined - * period (configured with `setInterruptionPeriod`) is exceeded, execution is delayed with `delayNextTick`. - * 2. If the predefined period is not met yet or execution is resumed after an interruption, the given cancellation - * token is checked, and if cancellation is requested, `OperationCanceled` is thrown. - * - * All services in Langium that receive a `CancellationToken` may potentially call this function, so the - * `CancellationToken` must be caught (with an `async` try-catch block or a `catch` callback attached to - * the promise) to avoid that event being exposed as an error. - */ -export async function interruptAndCheck(token: CancellationToken): Promise { - if (token === CancellationToken.None) { - // Early exit in case cancellation was disabled by the caller - return; - } - const current = Date.now(); - if (current - lastTick >= globalInterruptionPeriod) { - lastTick = current; - await delayNextTick(); - } - if (token.isCancellationRequested) { - throw OperationCancelled; - } -} - /** * Simple implementation of the deferred pattern. * An object that exposes a promise and functions to resolve and reject it. diff --git a/packages/langium/src/validation/document-validator.ts b/packages/langium/src/validation/document-validator.ts index e5e1d56d5..931cc060e 100644 --- a/packages/langium/src/validation/document-validator.ts +++ b/packages/langium/src/validation/document-validator.ts @@ -12,11 +12,10 @@ import type { LangiumCoreServices } from '../services.js'; import type { AstNode, CstNode } from '../syntax-tree.js'; import type { LangiumDocument } from '../workspace/documents.js'; import type { DiagnosticData, DiagnosticInfo, ValidationAcceptor, ValidationCategory, ValidationRegistry } from './validation-registry.js'; -import { CancellationToken } from '../utils/cancellation.js'; +import { CancellationToken, isOperationCancelled } from '../utils/cancellation.js'; import { findNodeForKeyword, findNodeForProperty } from '../utils/grammar-utils.js'; import { streamAst } from '../utils/ast-utils.js'; import { tokenToRange } from '../utils/cst-utils.js'; -import { interruptAndCheck, isOperationCancelled } from '../utils/promise-utils.js'; import { diagnosticData } from './validation-registry.js'; export interface ValidationOptions { @@ -62,7 +61,7 @@ export class DefaultDocumentValidator implements DocumentValidator { const parseResult = document.parseResult; const diagnostics: Diagnostic[] = []; - await interruptAndCheck(cancelToken); + await cancelToken.check(); if (!options.categories || options.categories.includes('built-in')) { this.processLexingErrors(parseResult, diagnostics, options); @@ -91,7 +90,7 @@ export class DefaultDocumentValidator implements DocumentValidator { console.error('An error occurred during validation:', err); } - await interruptAndCheck(cancelToken); + await cancelToken.check(); return diagnostics; } @@ -182,7 +181,7 @@ export class DefaultDocumentValidator implements DocumentValidator { }; await Promise.all(streamAst(rootNode).map(async node => { - await interruptAndCheck(cancelToken); + await cancelToken.check(); const checks = this.validationRegistry.getChecks(node.$type, options.categories); for (const check of checks) { await check(node, acceptor, cancelToken); diff --git a/packages/langium/src/validation/validation-registry.ts b/packages/langium/src/validation/validation-registry.ts index e6ae570fa..5d2d38bd6 100644 --- a/packages/langium/src/validation/validation-registry.ts +++ b/packages/langium/src/validation/validation-registry.ts @@ -5,14 +5,13 @@ ******************************************************************************/ import type { CodeDescription, DiagnosticRelatedInformation, DiagnosticTag, integer, Range } from 'vscode-languageserver-types'; -import type { CancellationToken } from '../utils/cancellation.js'; +import { isOperationCancelled, type CancellationToken } from '../utils/cancellation.js'; import type { LangiumCoreServices } from '../services.js'; import type { AstNode, AstReflection, Properties } from '../syntax-tree.js'; import type { MaybePromise } from '../utils/promise-utils.js'; import type { Stream } from '../utils/stream.js'; import type { DocumentSegment } from '../workspace/documents.js'; import { MultiMap } from '../utils/collections.js'; -import { isOperationCancelled } from '../utils/promise-utils.js'; import { stream } from '../utils/stream.js'; export type DiagnosticInfo> = { diff --git a/packages/langium/src/workspace/ast-descriptions.ts b/packages/langium/src/workspace/ast-descriptions.ts index 46a3743d8..5a8fb790e 100644 --- a/packages/langium/src/workspace/ast-descriptions.ts +++ b/packages/langium/src/workspace/ast-descriptions.ts @@ -14,7 +14,6 @@ import { CancellationToken } from '../utils/cancellation.js'; import { isLinkingError } from '../syntax-tree.js'; import { getDocument, streamAst, streamReferences } from '../utils/ast-utils.js'; import { toDocumentSegment } from '../utils/cst-utils.js'; -import { interruptAndCheck } from '../utils/promise-utils.js'; import { UriUtils } from '../utils/uri-utils.js'; /** @@ -117,7 +116,7 @@ export class DefaultReferenceDescriptionProvider implements ReferenceDescription const descr: ReferenceDescription[] = []; const rootNode = document.parseResult.value; for (const astNode of streamAst(rootNode)) { - await interruptAndCheck(cancelToken); + await cancelToken.check(); streamReferences(astNode).filter(refInfo => !isLinkingError(refInfo)).forEach(refInfo => { // TODO: Consider logging a warning or throw an exception when DocumentState is < than Linked const description = this.createDescription(refInfo); diff --git a/packages/langium/src/workspace/document-builder.ts b/packages/langium/src/workspace/document-builder.ts index 0331febb9..a8637305d 100644 --- a/packages/langium/src/workspace/document-builder.ts +++ b/packages/langium/src/workspace/document-builder.ts @@ -4,7 +4,7 @@ * terms of the MIT License, which is available in the project root. ******************************************************************************/ -import { CancellationToken } from '../utils/cancellation.js'; +import { CancellationToken, isOperationCancelled, OperationCancelled } from '../utils/cancellation.js'; import { Disposable } from '../utils/disposable.js'; import type { ServiceRegistry } from '../service-registry.js'; import type { LangiumSharedCoreServices } from '../services.js'; @@ -15,7 +15,6 @@ import type { ValidationOptions } from '../validation/document-validator.js'; import type { IndexManager } from '../workspace/index-manager.js'; import type { LangiumDocument, LangiumDocuments, LangiumDocumentFactory, TextDocumentProvider } from './documents.js'; import { MultiMap } from '../utils/collections.js'; -import { OperationCancelled, interruptAndCheck, isOperationCancelled } from '../utils/promise-utils.js'; import { stream } from '../utils/stream.js'; import type { URI } from '../utils/uri-utils.js'; import { ValidationCategory } from '../validation/validation-registry.js'; @@ -223,7 +222,7 @@ export class DefaultDocumentBuilder implements DocumentBuilder { // Notify listeners of the update await this.emitUpdate(changed, deleted); // Only allow interrupting the execution after all state changes are done - await interruptAndCheck(cancelToken); + await cancelToken.check(); // Collect and sort all documents that we should rebuild const rebuildDocuments = this.sortDocuments( @@ -382,7 +381,7 @@ export class DefaultDocumentBuilder implements DocumentBuilder { callback: (document: LangiumDocument) => MaybePromise): Promise { const filtered = documents.filter(doc => doc.state < targetState); for (const document of filtered) { - await interruptAndCheck(cancelToken); + await cancelToken.check(); await callback(document); document.state = targetState; await this.notifyDocumentPhase(document, targetState, cancelToken); @@ -472,7 +471,7 @@ export class DefaultDocumentBuilder implements DocumentBuilder { } const listeners = this.buildPhaseListeners.get(state); for (const listener of listeners) { - await interruptAndCheck(cancelToken); + await cancelToken.check(); await listener(documents, cancelToken); } } diff --git a/packages/langium/src/workspace/workspace-lock.ts b/packages/langium/src/workspace/workspace-lock.ts index 846d9722e..0d2bab216 100644 --- a/packages/langium/src/workspace/workspace-lock.ts +++ b/packages/langium/src/workspace/workspace-lock.ts @@ -4,8 +4,8 @@ * terms of the MIT License, which is available in the project root. ******************************************************************************/ -import { CancellationToken, CancellationTokenSource } from '../utils/cancellation.js'; -import { Deferred, isOperationCancelled, type MaybePromise } from '../utils/promise-utils.js'; +import { CancellationToken, CancellationTokenSource, isOperationCancelled } from '../utils/cancellation.js'; +import { Deferred, delayNextTick, type MaybePromise } from '../utils/promise-utils.js'; /** * Utility service to execute mutually exclusive actions. @@ -22,14 +22,14 @@ export interface WorkspaceLock { /** * Performs a single action, like computing completion results or providing workspace symbols. - * Read actions will only be executed after all write actions have finished. They will be executed in parallel if possible. - * - * If a write action is currently running, the read action will be queued up and executed afterwards. - * If a new write action is queued up while a read action is waiting, the write action will receive priority and will be handled before the read action. + * Read actions will be executed in parallel if possible. * * Note that read actions are not allowed to modify anything in the workspace. Please use {@link write} instead. + * + * @param action the action to perform. + * @param priority the priority for this action. See {@link WorkspaceLockPriority} for more info. */ - read(action: () => MaybePromise): Promise; + read(action: () => MaybePromise, priority?: WorkspaceLockPriority): Promise; /** * Cancels the last queued write action. All previous write actions already have been cancelled. @@ -37,6 +37,19 @@ export interface WorkspaceLock { cancelWrite(): void; } +export enum WorkspaceLockPriority { + /** + * The action is put into the queue and executed after the current write action is done. + * If the action in question is a read action, it will be executed in parallel with other read actions. + * The action will block the lock until it is done or has been aborted. + */ + Normal = 0, + /** + * The action should be executed immediately, and afterwards behaves like a normal action. + */ + Immediate = 1 +} + type LockAction = (token: CancellationToken) => MaybePromise; interface LockEntry { @@ -50,7 +63,7 @@ export class DefaultWorkspaceLock implements WorkspaceLock { private previousTokenSource = new CancellationTokenSource(); private writeQueue: LockEntry[] = []; private readQueue: LockEntry[] = []; - private done = true; + private counter = 0; write(action: (token: CancellationToken) => MaybePromise): Promise { this.cancelWrite(); @@ -59,8 +72,26 @@ export class DefaultWorkspaceLock implements WorkspaceLock { return this.enqueue(this.writeQueue, action, tokenSource.token); } - read(action: () => MaybePromise): Promise { - return this.enqueue(this.readQueue, action); + read(action: () => MaybePromise, priority?: WorkspaceLockPriority): Promise { + if (priority === WorkspaceLockPriority.Immediate) { + this.counter++; + const deferred = new Deferred(); + (async () => { + try { + await delayNextTick(); + const result = await action(); + deferred.resolve(result); + } catch (err) { + deferred.reject(err); + } finally { + this.counter--; + this.performNextOperation(); + } + })(); + return deferred.promise; + } else { + return this.enqueue(this.readQueue, action); + } } private enqueue(queue: LockEntry[], action: LockAction, cancellationToken = CancellationToken.None): Promise { @@ -76,7 +107,7 @@ export class DefaultWorkspaceLock implements WorkspaceLock { } private async performNextOperation(): Promise { - if (!this.done) { + if (this.counter > 0) { return; } const entries: LockEntry[] = []; @@ -89,11 +120,12 @@ export class DefaultWorkspaceLock implements WorkspaceLock { } else { return; } - this.done = false; + this.counter += entries.length; await Promise.all(entries.map(async ({ action, deferred, cancellationToken }) => { try { - // Move the execution of the action to the next event loop tick via `Promise.resolve()` - const result = await Promise.resolve().then(() => action(cancellationToken)); + // Move the execution of the action to the next event loop tick + await delayNextTick(); + const result = await action(cancellationToken); deferred.resolve(result); } catch (err) { if (isOperationCancelled(err)) { @@ -104,7 +136,7 @@ export class DefaultWorkspaceLock implements WorkspaceLock { } } })); - this.done = true; + this.counter -= entries.length; this.performNextOperation(); } diff --git a/packages/langium/src/workspace/workspace-manager.ts b/packages/langium/src/workspace/workspace-manager.ts index c9619f61d..367e83233 100644 --- a/packages/langium/src/workspace/workspace-manager.ts +++ b/packages/langium/src/workspace/workspace-manager.ts @@ -9,7 +9,7 @@ import type { WorkspaceFolder } from 'vscode-languageserver-types'; import type { ServiceRegistry } from '../service-registry.js'; import type { LangiumSharedCoreServices } from '../services.js'; import { CancellationToken } from '../utils/cancellation.js'; -import { Deferred, interruptAndCheck } from '../utils/promise-utils.js'; +import { Deferred } from '../utils/promise-utils.js'; import { URI, UriUtils } from '../utils/uri-utils.js'; import type { BuildOptions, DocumentBuilder } from './document-builder.js'; import type { LangiumDocument, LangiumDocuments } from './documents.js'; @@ -99,7 +99,7 @@ export class DefaultWorkspaceManager implements WorkspaceManager { const documents = await this.performStartup(folders); // Only after creating all documents do we check whether we need to cancel the initialization // The document builder will later pick up on all unprocessed documents - await interruptAndCheck(cancelToken); + await cancelToken.check(); await this.documentBuilder.build(documents, this.initialBuildOptions, cancelToken); } diff --git a/packages/langium/test/parser/worker-thread-async-parser.test.ts b/packages/langium/test/parser/worker-thread-async-parser.test.ts index 1dabba00e..acdf3b1b1 100644 --- a/packages/langium/test/parser/worker-thread-async-parser.test.ts +++ b/packages/langium/test/parser/worker-thread-async-parser.test.ts @@ -9,8 +9,7 @@ import { WorkerThreadAsyncParser } from 'langium/node'; import { createLangiumGrammarServices } from 'langium/grammar'; import type { Grammar, LangiumCoreServices, ParseResult } from 'langium'; import type { LangiumServices } from 'langium/lsp'; -import { EmptyFileSystem, GrammarUtils, CstUtils, GrammarAST, isOperationCancelled } from 'langium'; -import { CancellationToken, CancellationTokenSource } from 'vscode-languageserver'; +import { EmptyFileSystem, GrammarUtils, CstUtils, GrammarAST, isOperationCancelled, CancellationToken, CancellationTokenSource } from 'langium'; import { fail } from 'node:assert'; import { fileURLToPath } from 'node:url'; diff --git a/packages/langium/test/workspace/document-builder.test.ts b/packages/langium/test/workspace/document-builder.test.ts index 27d5a6c0e..6e30959a0 100644 --- a/packages/langium/test/workspace/document-builder.test.ts +++ b/packages/langium/test/workspace/document-builder.test.ts @@ -5,11 +5,10 @@ ******************************************************************************/ import type { AstNode, DocumentBuilder, FileSystemProvider, LangiumDocument, LangiumDocumentFactory, LangiumDocuments, Module, Reference, TextDocumentProvider, ValidationChecks } from 'langium'; -import { AstUtils, DocumentState, TextDocument, URI, isOperationCancelled } from 'langium'; +import { AstUtils, DocumentState, TextDocument, URI, isOperationCancelled, CancellationToken, CancellationTokenSource } from 'langium'; import { createServicesForGrammar } from 'langium/grammar'; import { setTextDocument } from 'langium/test'; import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'; -import { CancellationToken, CancellationTokenSource } from 'vscode-languageserver'; import { fail } from 'assert'; import type { LangiumServices, LangiumSharedServices } from '../../lib/lsp/lsp-services.js'; diff --git a/packages/langium/test/workspace/document-factory.test.ts b/packages/langium/test/workspace/document-factory.test.ts index 7e8c5c126..077fea89b 100644 --- a/packages/langium/test/workspace/document-factory.test.ts +++ b/packages/langium/test/workspace/document-factory.test.ts @@ -7,10 +7,9 @@ import type { Grammar } from 'langium'; import type { LangiumServices } from 'langium/lsp'; import { describe, expect, test } from 'vitest'; -import { DocumentState, EmptyFileSystem, TextDocument } from 'langium'; +import { DocumentState, EmptyFileSystem, TextDocument, CancellationToken } from 'langium'; import { createLangiumGrammarServices } from 'langium/grammar'; import { setTextDocument } from 'langium/test'; -import { CancellationToken } from 'vscode-languageserver'; describe('DefaultLangiumDocumentFactory', () => { diff --git a/packages/langium/test/workspace/workspace-lock.test.ts b/packages/langium/test/workspace/workspace-lock.test.ts index 1718d6ee9..5820a95da 100644 --- a/packages/langium/test/workspace/workspace-lock.test.ts +++ b/packages/langium/test/workspace/workspace-lock.test.ts @@ -5,7 +5,7 @@ ******************************************************************************/ import { describe, expect, test } from 'vitest'; -import { Deferred, delayNextTick, DefaultWorkspaceLock } from 'langium'; +import { Deferred, delayNextTick, DefaultWorkspaceLock, WorkspaceLockPriority } from 'langium'; describe('WorkspaceLock', () => { @@ -61,8 +61,8 @@ describe('WorkspaceLock', () => { const mutex = new DefaultWorkspaceLock(); const now = Date.now(); const magicalNumber = await mutex.read(() => new Promise(resolve => setTimeout(() => resolve(42), 10))); - // Confirm that at least 10ms have elapsed - expect(Date.now() - now).toBeGreaterThanOrEqual(10); + // Confirm that at least a few milliseconds have passed + expect(Date.now() - now).toBeGreaterThanOrEqual(5); // Confirm the returned value expect(magicalNumber).toBe(42); }); @@ -112,4 +112,23 @@ describe('WorkspaceLock', () => { // and the second action decreases the value again expect(counter).toBe(0); }); + + test('Read actions can receive priority', async () => { + let counter = 0; + const mutex = new DefaultWorkspaceLock(); + mutex.write(async () => { + await delayNextTick(); + // Set counter to 1 + counter = 1; + }); + await mutex.read(() => { + // Set counter to 5 + counter = 5; + }, WorkspaceLockPriority.Immediate); + // Assert that the read action has received priority + expect(counter).toBe(5); + await delayNextTick(); + // Assert that the write action has been successfully finished afterwards + expect(counter).toBe(1); + }); });