From 80b99f1f2ae86f0ce9652266daa22040b26a5894 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Sat, 30 Oct 2021 17:08:11 +0200 Subject: chore(web): refactor UpdateService extract utils/ConditionVariable from xtext/UpdateService as a generally useful utility class for waiting for conditions --- .../src/main/js/utils/ConditionVariable.ts | 64 ++++++++++++++++++++++ .../src/main/js/xtext/ContentAssistService.ts | 10 ++-- language-web/src/main/js/xtext/UpdateService.ts | 52 +++++------------- 3 files changed, 82 insertions(+), 44 deletions(-) create mode 100644 language-web/src/main/js/utils/ConditionVariable.ts (limited to 'language-web/src/main/js') diff --git a/language-web/src/main/js/utils/ConditionVariable.ts b/language-web/src/main/js/utils/ConditionVariable.ts new file mode 100644 index 00000000..0910dfa6 --- /dev/null +++ b/language-web/src/main/js/utils/ConditionVariable.ts @@ -0,0 +1,64 @@ +import { getLogger } from './logger'; +import { PendingTask } from './PendingTask'; + +const log = getLogger('utils.ConditionVariable'); + +export type Condition = () => boolean; + +export class ConditionVariable { + condition: Condition; + + defaultTimeout: number; + + listeners: PendingTask[] = []; + + constructor(condition: Condition, defaultTimeout = 0) { + this.condition = condition; + this.defaultTimeout = defaultTimeout; + } + + async waitFor(timeoutMs: number | null = null): Promise { + if (this.condition()) { + return; + } + const timeoutOrDefault = timeoutMs || this.defaultTimeout; + let nowMs = Date.now(); + const endMs = nowMs + timeoutOrDefault; + while (!this.condition() && nowMs < endMs) { + const remainingMs = endMs - nowMs; + const promise = new Promise((resolve, reject) => { + if (this.condition()) { + resolve(); + return; + } + const task = new PendingTask(resolve, reject, remainingMs); + this.listeners.push(task); + }); + // We must keep waiting until the update has completed, + // so the tasks can't be started in parallel. + // eslint-disable-next-line no-await-in-loop + await promise; + nowMs = Date.now(); + } + if (!this.condition()) { + log.error('Condition still does not hold after', timeoutOrDefault, 'ms'); + throw new Error('Failed to wait for condition'); + } + } + + notifyAll(): void { + this.clearListenersWith((listener) => listener.resolve()); + } + + rejectAll(error: unknown): void { + this.clearListenersWith((listener) => listener.reject(error)); + } + + private clearListenersWith(callback: (listener: PendingTask) => void) { + // Copy `listeners` so that we don't get into a race condition + // if one of the listeners adds another listener. + const { listeners } = this; + this.listeners = []; + listeners.forEach(callback); + } +} diff --git a/language-web/src/main/js/xtext/ContentAssistService.ts b/language-web/src/main/js/xtext/ContentAssistService.ts index 9cbb385f..ec6b80d2 100644 --- a/language-web/src/main/js/xtext/ContentAssistService.ts +++ b/language-web/src/main/js/xtext/ContentAssistService.ts @@ -3,7 +3,7 @@ import type { CompletionContext, CompletionResult, } from '@codemirror/autocomplete'; -import type { ChangeSet, Transaction } from '@codemirror/state'; +import type { Transaction } from '@codemirror/state'; import escapeStringRegexp from 'escape-string-regexp'; import type { UpdateService } from './UpdateService'; @@ -62,7 +62,7 @@ export class ContentAssistService { } onTransaction(transaction: Transaction): void { - if (this.shouldInvalidateCachedCompletion(transaction.changes)) { + if (this.shouldInvalidateCachedCompletion(transaction)) { this.lastCompletion = null; } } @@ -142,8 +142,8 @@ export class ContentAssistService { return from >= transformedFrom && to <= transformedTo && span && span.exec(text); } - private shouldInvalidateCachedCompletion(changes: ChangeSet) { - if (changes.empty || this.lastCompletion === null) { + private shouldInvalidateCachedCompletion(transaction: Transaction) { + if (!transaction.docChanged || this.lastCompletion === null) { return false; } const { from: lastFrom, to: lastTo } = this.lastCompletion; @@ -152,7 +152,7 @@ export class ContentAssistService { } const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); let invalidate = false; - changes.iterChangedRanges((fromA, toA) => { + transaction.changes.iterChangedRanges((fromA, toA) => { if (fromA < transformedFrom || toA > transformedTo) { invalidate = true; } diff --git a/language-web/src/main/js/xtext/UpdateService.ts b/language-web/src/main/js/xtext/UpdateService.ts index 3ab1daf9..838f9d5b 100644 --- a/language-web/src/main/js/xtext/UpdateService.ts +++ b/language-web/src/main/js/xtext/UpdateService.ts @@ -7,8 +7,8 @@ import { nanoid } from 'nanoid'; import type { EditorStore } from '../editor/EditorStore'; import type { XtextWebSocketClient } from './XtextWebSocketClient'; +import { ConditionVariable } from '../utils/ConditionVariable'; import { getLogger } from '../utils/logger'; -import { PendingTask } from '../utils/PendingTask'; import { Timer } from '../utils/Timer'; import { IContentAssistEntry, @@ -40,7 +40,10 @@ export class UpdateService { private webSocketClient: XtextWebSocketClient; - private updateListeners: PendingTask[] = []; + private updatedCondition = new ConditionVariable( + () => this.pendingUpdate === null && this.xtextStateId !== null, + WAIT_FOR_UPDATE_TIMEOUT_MS, + ); private idleUpdateTimer = new Timer(() => { this.handleIdleUpdate(); @@ -59,9 +62,8 @@ export class UpdateService { } onTransaction(transaction: Transaction): void { - const { changes } = transaction; - if (!changes.empty) { - this.dirtyChanges = this.dirtyChanges.composeDesc(changes.desc); + if (transaction.docChanged) { + this.dirtyChanges = this.dirtyChanges.composeDesc(transaction.changes.desc); this.idleUpdateTimer.reschedule(); } } @@ -221,13 +223,7 @@ export class UpdateService { [newStateId, result] = await callback(); this.xtextStateId = newStateId; this.pendingUpdate = null; - // Copy `updateListeners` so that we don't get into a race condition - // if one of the listeners adds another listener. - const listeners = this.updateListeners; - this.updateListeners = []; - listeners.forEach((listener) => { - listener.resolve(); - }); + this.updatedCondition.notifyAll(); return result; } catch (e) { log.error('Error while update', e); @@ -238,39 +234,17 @@ export class UpdateService { } this.pendingUpdate = null; this.webSocketClient.forceReconnectOnError(); - const listeners = this.updateListeners; - this.updateListeners = []; - listeners.forEach((listener) => { - listener.reject(e); - }); + this.updatedCondition.rejectAll(e); throw e; } } private async prepareForDeltaUpdate() { - if (this.pendingUpdate === null) { - if (this.xtextStateId === null) { - return; - } + // If no update is pending, but the full text hasn't been uploaded to the server yet, + // we must start a full text upload. + if (this.pendingUpdate === null && this.xtextStateId === null) { await this.updateFullText(); } - let nowMs = Date.now(); - const endMs = nowMs + WAIT_FOR_UPDATE_TIMEOUT_MS; - while (this.pendingUpdate !== null && nowMs < endMs) { - const timeoutMs = endMs - nowMs; - const promise = new Promise((resolve, reject) => { - const task = new PendingTask(resolve, reject, timeoutMs); - this.updateListeners.push(task); - }); - // We must keep waiting uptil the update has completed, - // so the tasks can't be started in parallel. - // eslint-disable-next-line no-await-in-loop - await promise; - nowMs = Date.now(); - } - if (this.pendingUpdate !== null || this.xtextStateId === null) { - log.error('No successful update in', WAIT_FOR_UPDATE_TIMEOUT_MS, 'ms'); - throw new Error('Failed to wait for successful update'); - } + await this.updatedCondition.waitFor(); } } -- cgit v1.2.3-54-g00ecf