aboutsummaryrefslogtreecommitdiffstats
path: root/language-web/src/main/js/xtext/UpdateService.ts
diff options
context:
space:
mode:
Diffstat (limited to 'language-web/src/main/js/xtext/UpdateService.ts')
-rw-r--r--language-web/src/main/js/xtext/UpdateService.ts310
1 files changed, 310 insertions, 0 deletions
diff --git a/language-web/src/main/js/xtext/UpdateService.ts b/language-web/src/main/js/xtext/UpdateService.ts
new file mode 100644
index 00000000..9b672e79
--- /dev/null
+++ b/language-web/src/main/js/xtext/UpdateService.ts
@@ -0,0 +1,310 @@
1import {
2 ChangeDesc,
3 ChangeSet,
4 Transaction,
5} from '@codemirror/state';
6import { nanoid } from 'nanoid';
7
8import type { EditorStore } from '../editor/EditorStore';
9import type { XtextWebSocketClient } from './XtextWebSocketClient';
10import { ConditionVariable } from '../utils/ConditionVariable';
11import { getLogger } from '../utils/logger';
12import { Timer } from '../utils/Timer';
13import {
14 IContentAssistEntry,
15 isContentAssistResult,
16 isDocumentStateResult,
17 isInvalidStateIdConflictResult,
18} from './xtextServiceResults';
19
20const UPDATE_TIMEOUT_MS = 500;
21
22const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000;
23
24const log = getLogger('xtext.UpdateService');
25
26export interface IAbortSignal {
27 aborted: boolean;
28}
29
30export class UpdateService {
31 resourceName: string;
32
33 xtextStateId: string | null = null;
34
35 private readonly store: EditorStore;
36
37 /**
38 * The changes being synchronized to the server if a full or delta text update is running,
39 * `null` otherwise.
40 */
41 private pendingUpdate: ChangeDesc | null = null;
42
43 /**
44 * Local changes not yet sychronized to the server and not part of the running update, if any.
45 */
46 private dirtyChanges: ChangeDesc;
47
48 private readonly webSocketClient: XtextWebSocketClient;
49
50 private readonly updatedCondition = new ConditionVariable(
51 () => this.pendingUpdate === null && this.xtextStateId !== null,
52 WAIT_FOR_UPDATE_TIMEOUT_MS,
53 );
54
55 private readonly idleUpdateTimer = new Timer(() => {
56 this.handleIdleUpdate();
57 }, UPDATE_TIMEOUT_MS);
58
59 constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) {
60 this.resourceName = `${nanoid(7)}.problem`;
61 this.store = store;
62 this.dirtyChanges = this.newEmptyChangeDesc();
63 this.webSocketClient = webSocketClient;
64 }
65
66 onReconnect(): void {
67 this.xtextStateId = null;
68 this.updateFullText().catch((error) => {
69 log.error('Unexpected error during initial update', error);
70 });
71 }
72
73 onTransaction(transaction: Transaction): void {
74 if (transaction.docChanged) {
75 this.dirtyChanges = this.dirtyChanges.composeDesc(transaction.changes.desc);
76 this.idleUpdateTimer.reschedule();
77 }
78 }
79
80 /**
81 * Computes the summary of any changes happened since the last complete update.
82 *
83 * The result reflects any changes that happened since the `xtextStateId`
84 * version was uploaded to the server.
85 *
86 * @return the summary of changes since the last update
87 */
88 computeChangesSinceLastUpdate(): ChangeDesc {
89 return this.pendingUpdate?.composeDesc(this.dirtyChanges) || this.dirtyChanges;
90 }
91
92 private handleIdleUpdate() {
93 if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) {
94 return;
95 }
96 if (this.pendingUpdate === null) {
97 this.update().catch((error) => {
98 log.error('Unexpected error during scheduled update', error);
99 });
100 }
101 this.idleUpdateTimer.reschedule();
102 }
103
104 private newEmptyChangeDesc() {
105 const changeSet = ChangeSet.of([], this.store.state.doc.length);
106 return changeSet.desc;
107 }
108
109 async updateFullText(): Promise<void> {
110 await this.withUpdate(() => this.doUpdateFullText());
111 }
112
113 private async doUpdateFullText(): Promise<[string, void]> {
114 const result = await this.webSocketClient.send({
115 resource: this.resourceName,
116 serviceType: 'update',
117 fullText: this.store.state.doc.sliceString(0),
118 });
119 if (isDocumentStateResult(result)) {
120 return [result.stateId, undefined];
121 }
122 log.error('Unexpected full text update result:', result);
123 throw new Error('Full text update failed');
124 }
125
126 /**
127 * Makes sure that the document state on the server reflects recent
128 * local changes.
129 *
130 * Performs either an update with delta text or a full text update if needed.
131 * If there are not local dirty changes, the promise resolves immediately.
132 *
133 * @return a promise resolving when the update is completed
134 */
135 async update(): Promise<void> {
136 await this.prepareForDeltaUpdate();
137 const delta = this.computeDelta();
138 if (delta === null) {
139 return;
140 }
141 log.trace('Editor delta', delta);
142 await this.withUpdate(async () => {
143 const result = await this.webSocketClient.send({
144 resource: this.resourceName,
145 serviceType: 'update',
146 requiredStateId: this.xtextStateId,
147 ...delta,
148 });
149 if (isDocumentStateResult(result)) {
150 return [result.stateId, undefined];
151 }
152 if (isInvalidStateIdConflictResult(result)) {
153 return this.doFallbackToUpdateFullText();
154 }
155 log.error('Unexpected delta text update result:', result);
156 throw new Error('Delta text update failed');
157 });
158 }
159
160 private doFallbackToUpdateFullText() {
161 if (this.pendingUpdate === null) {
162 throw new Error('Only a pending update can be extended');
163 }
164 log.warn('Delta update failed, performing full text update');
165 this.xtextStateId = null;
166 this.pendingUpdate = this.pendingUpdate.composeDesc(this.dirtyChanges);
167 this.dirtyChanges = this.newEmptyChangeDesc();
168 return this.doUpdateFullText();
169 }
170
171 async fetchContentAssist(
172 params: Record<string, unknown>,
173 signal: IAbortSignal,
174 ): Promise<IContentAssistEntry[]> {
175 await this.prepareForDeltaUpdate();
176 if (signal.aborted) {
177 return [];
178 }
179 const delta = this.computeDelta();
180 if (delta !== null) {
181 log.trace('Editor delta', delta);
182 const entries = await this.withUpdate(async () => {
183 const result = await this.webSocketClient.send({
184 ...params,
185 requiredStateId: this.xtextStateId,
186 ...delta,
187 });
188 if (isContentAssistResult(result)) {
189 return [result.stateId, result.entries];
190 }
191 if (isInvalidStateIdConflictResult(result)) {
192 const [newStateId] = await this.doFallbackToUpdateFullText();
193 // We must finish this state update transaction to prepare for any push events
194 // before querying for content assist, so we just return `null` and will query
195 // the content assist service later.
196 return [newStateId, null];
197 }
198 log.error('Unextpected content assist result with delta update', result);
199 throw new Error('Unexpexted content assist result with delta update');
200 });
201 if (entries !== null) {
202 return entries;
203 }
204 if (signal.aborted) {
205 return [];
206 }
207 }
208 // Poscondition of `prepareForDeltaUpdate`: `xtextStateId !== null`
209 return this.doFetchContentAssist(params, this.xtextStateId as string);
210 }
211
212 private async doFetchContentAssist(params: Record<string, unknown>, expectedStateId: string) {
213 const result = await this.webSocketClient.send({
214 ...params,
215 requiredStateId: expectedStateId,
216 });
217 if (isContentAssistResult(result) && result.stateId === expectedStateId) {
218 return result.entries;
219 }
220 log.error('Unexpected content assist result', result);
221 throw new Error('Unexpected content assist result');
222 }
223
224 private computeDelta() {
225 if (this.dirtyChanges.empty) {
226 return null;
227 }
228 let minFromA = Number.MAX_SAFE_INTEGER;
229 let maxToA = 0;
230 let minFromB = Number.MAX_SAFE_INTEGER;
231 let maxToB = 0;
232 this.dirtyChanges.iterChangedRanges((fromA, toA, fromB, toB) => {
233 minFromA = Math.min(minFromA, fromA);
234 maxToA = Math.max(maxToA, toA);
235 minFromB = Math.min(minFromB, fromB);
236 maxToB = Math.max(maxToB, toB);
237 });
238 return {
239 deltaOffset: minFromA,
240 deltaReplaceLength: maxToA - minFromA,
241 deltaText: this.store.state.doc.sliceString(minFromB, maxToB),
242 };
243 }
244
245 /**
246 * Executes an asynchronous callback that updates the state on the server.
247 *
248 * Ensures that updates happen sequentially and manages `pendingUpdate`
249 * and `dirtyChanges` to reflect changes being synchronized to the server
250 * and not yet synchronized to the server, respectively.
251 *
252 * Optionally, `callback` may return a second value that is retured by this function.
253 *
254 * Once the remote procedure call to update the server state finishes
255 * and returns the new `stateId`, `callback` must return _immediately_
256 * to ensure that the local `stateId` is updated likewise to be able to handle
257 * push messages referring to the new `stateId` from the server.
258 * If additional work is needed to compute the second value in some cases,
259 * use `T | null` instead of `T` as a return type and signal the need for additional
260 * computations by returning `null`. Thus additional computations can be performed
261 * outside of the critical section.
262 *
263 * @param callback the asynchronous callback that updates the server state
264 * @return a promise resolving to the second value returned by `callback`
265 */
266 private async withUpdate<T>(callback: () => Promise<[string, T]>): Promise<T> {
267 if (this.pendingUpdate !== null) {
268 throw new Error('Another update is pending, will not perform update');
269 }
270 this.pendingUpdate = this.dirtyChanges;
271 this.dirtyChanges = this.newEmptyChangeDesc();
272 let newStateId: string | null = null;
273 try {
274 let result: T;
275 [newStateId, result] = await callback();
276 this.xtextStateId = newStateId;
277 this.pendingUpdate = null;
278 this.updatedCondition.notifyAll();
279 return result;
280 } catch (e) {
281 log.error('Error while update', e);
282 if (this.pendingUpdate === null) {
283 log.error('pendingUpdate was cleared during update');
284 } else {
285 this.dirtyChanges = this.pendingUpdate.composeDesc(this.dirtyChanges);
286 }
287 this.pendingUpdate = null;
288 this.webSocketClient.forceReconnectOnError();
289 this.updatedCondition.rejectAll(e);
290 throw e;
291 }
292 }
293
294 /**
295 * Ensures that there is some state available on the server (`xtextStateId`)
296 * and that there is not pending update.
297 *
298 * After this function resolves, a delta text update is possible.
299 *
300 * @return a promise resolving when there is a valid state id but no pending update
301 */
302 private async prepareForDeltaUpdate() {
303 // If no update is pending, but the full text hasn't been uploaded to the server yet,
304 // we must start a full text upload.
305 if (this.pendingUpdate === null && this.xtextStateId === null) {
306 await this.updateFullText();
307 }
308 await this.updatedCondition.waitFor();
309 }
310}