aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/xtext/UpdateStateTracker.ts
diff options
context:
space:
mode:
Diffstat (limited to 'subprojects/frontend/src/xtext/UpdateStateTracker.ts')
-rw-r--r--subprojects/frontend/src/xtext/UpdateStateTracker.ts300
1 files changed, 94 insertions, 206 deletions
diff --git a/subprojects/frontend/src/xtext/UpdateStateTracker.ts b/subprojects/frontend/src/xtext/UpdateStateTracker.ts
index 04359060..a529f9a0 100644
--- a/subprojects/frontend/src/xtext/UpdateStateTracker.ts
+++ b/subprojects/frontend/src/xtext/UpdateStateTracker.ts
@@ -1,18 +1,3 @@
1/**
2 * @file State tracker for pushing updates to the Xtext server.
3 *
4 * This file implements complex logic to avoid missing or overwriting state updates
5 * and to avoid sending conflicting updates to the Xtext server.
6 *
7 * The `LockedState` and `PendingUpdate` objects are used as capabilities to
8 * signify whether the socket to the Xtext server is locked for updates and
9 * whether an update is in progress, respectively.
10 * Always use these objects only received as an argument of a lambda expression
11 * or method and never leak them into class field or global variables.
12 * The presence of such an objects in the scope should always imply that
13 * the corresponding condition holds.
14 */
15
16import { 1import {
17 type ChangeDesc, 2 type ChangeDesc,
18 ChangeSet, 3 ChangeSet,
@@ -23,12 +8,9 @@ import {
23import { E_CANCELED, Mutex, withTimeout } from 'async-mutex'; 8import { E_CANCELED, Mutex, withTimeout } from 'async-mutex';
24 9
25import type EditorStore from '../editor/EditorStore'; 10import type EditorStore from '../editor/EditorStore';
26import getLogger from '../utils/getLogger';
27 11
28const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; 12const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000;
29 13
30const log = getLogger('xtext.UpdateStateTracker');
31
32/** 14/**
33 * State effect used to override the dirty changes after a transaction. 15 * State effect used to override the dirty changes after a transaction.
34 * 16 *
@@ -40,57 +22,6 @@ const log = getLogger('xtext.UpdateStateTracker');
40 */ 22 */
41const setDirtyChanges = StateEffect.define<ChangeSet>(); 23const setDirtyChanges = StateEffect.define<ChangeSet>();
42 24
43export interface StateUpdateResult<T> {
44 /** The new state ID on the server or `undefined` if no update was performed. */
45 newStateId: string | undefined;
46
47 /** Optional data payload received during the update. */
48 data: T;
49}
50
51/**
52 * Signifies a capability that the Xtext server state is locked for update.
53 */
54export interface LockedState {
55 /**
56 *
57 * @param callback the asynchronous callback that updates the server state
58 * @returns a promise resolving after the update
59 */
60 updateExclusive(
61 callback: (pendingUpdate: PendingUpdate) => Promise<string | undefined>,
62 ): Promise<void>;
63
64 /**
65 * Executes an asynchronous callback that updates the state on the server.
66 *
67 * If the callback returns `undefined` as the `newStateId`,
68 * the update is assumed to be aborted and any pending changes will be marked as dirt again.
69 * Any exceptions thrown in `callback` will also cause the update to be aborted.
70 *
71 * Ensures that updates happen sequentially and manages `pendingUpdate`
72 * and `dirtyChanges` to reflect changes being synchronized to the server
73 * and not yet synchronized to the server, respectively.
74 *
75 * Optionally, `callback` may return a second value that is retured by this function.
76 *
77 * Once the remote procedure call to update the server state finishes
78 * and returns the new `stateId`, `callback` must return _immediately_
79 * to ensure that the local `stateId` is updated likewise to be able to handle
80 * push messages referring to the new `stateId` from the server.
81 * If additional asynchronous work is needed to compute the second value in some cases,
82 * use `T | undefined` instead of `T` as a return type and signal the need for additional
83 * computations by returning `undefined`. Thus additional computations can be performed
84 * outside of the critical section.
85 *
86 * @param callback the asynchronous callback that updates the server state
87 * @returns a promise resolving to the second value returned by `callback`
88 */
89 withUpdateExclusive<T>(
90 callback: (pendingUpdate: PendingUpdate) => Promise<StateUpdateResult<T>>,
91 ): Promise<T>;
92}
93
94export interface Delta { 25export interface Delta {
95 deltaOffset: number; 26 deltaOffset: number;
96 27
@@ -99,32 +30,19 @@ export interface Delta {
99 deltaText: string; 30 deltaText: string;
100} 31}
101 32
102/**
103 * Signifies a capability that dirty changes are being marked for uploading.
104 */
105export interface PendingUpdate {
106 prepareDeltaUpdateExclusive(): Delta | undefined;
107
108 extendPendingUpdateExclusive(): void;
109
110 applyBeforeDirtyChangesExclusive(changeSpec: ChangeSpec): void;
111}
112
113export default class UpdateStateTracker { 33export default class UpdateStateTracker {
114 xtextStateId: string | undefined; 34 xtextStateId: string | undefined;
115 35
116 /** 36 /**
117 * The changes being synchronized to the server if a full or delta text update is running 37 * The changes marked for synchronization to the server if a full or delta text update
118 * withing a `withUpdateExclusive` block, `undefined` otherwise. 38 * is running, `undefined` otherwise.
119 * 39 *
120 * Must be `undefined` before and after entering the critical section of `mutex` 40 * Must be `undefined` upon entering the critical section of `mutex`,
121 * and may only be changes in the critical section of `mutex`. 41 * may only be changed in the critical section of `mutex`,
42 * and will be set to `undefined` (marking any changes as dirty again) when leaving it.
122 * 43 *
123 * Methods named with an `Exclusive` suffix in this class assume that the mutex is held 44 * Methods named with an `Exclusive` suffix in this class assume that the mutex is held
124 * and may call `updateExclusive` or `withUpdateExclusive` to mutate this field. 45 * and may mutate this field.
125 *
126 * Methods named with a `do` suffix assume that they are called in a `withUpdateExclusive`
127 * block and require this field to be non-`undefined`.
128 */ 46 */
129 private pendingChanges: ChangeSet | undefined; 47 private pendingChanges: ChangeSet | undefined;
130 48
@@ -142,24 +60,24 @@ export default class UpdateStateTracker {
142 this.dirtyChanges = this.newEmptyChangeSet(); 60 this.dirtyChanges = this.newEmptyChangeSet();
143 } 61 }
144 62
145 get locekdForUpdate(): boolean { 63 private get hasDirtyChanges(): boolean {
146 return this.mutex.isLocked(); 64 return !this.dirtyChanges.empty;
147 } 65 }
148 66
149 get hasDirtyChanges(): boolean { 67 get needsUpdate(): boolean {
150 return !this.dirtyChanges.empty; 68 return this.hasDirtyChanges || this.xtextStateId === undefined;
69 }
70
71 get lockedForUpdate(): boolean {
72 return this.mutex.isLocked();
151 } 73 }
152 74
153 get upToDate(): boolean { 75 get hasPendingChanges(): boolean {
154 return !this.locekdForUpdate && !this.hasDirtyChanges; 76 return this.lockedForUpdate || this.needsUpdate;
155 } 77 }
156 78
157 hasChangesSince(xtextStateId: string): boolean { 79 hasChangesSince(xtextStateId: string): boolean {
158 return ( 80 return this.xtextStateId !== xtextStateId || this.hasPendingChanges;
159 this.xtextStateId !== xtextStateId ||
160 this.locekdForUpdate ||
161 this.hasDirtyChanges
162 );
163 } 81 }
164 82
165 /** 83 /**
@@ -211,132 +129,102 @@ export default class UpdateStateTracker {
211 ); 129 );
212 } 130 }
213 131
214 private newEmptyChangeSet(): ChangeSet { 132 prepareDeltaUpdateExclusive(): Delta | undefined {
215 return ChangeSet.of([], this.store.state.doc.length); 133 this.ensureLocked();
134 this.markDirtyChangesAsPendingExclusive();
135 if (this.pendingChanges === undefined || this.pendingChanges.empty) {
136 return undefined;
137 }
138 let minFromA = Number.MAX_SAFE_INTEGER;
139 let maxToA = 0;
140 let minFromB = Number.MAX_SAFE_INTEGER;
141 let maxToB = 0;
142 this.pendingChanges.iterChangedRanges((fromA, toA, fromB, toB) => {
143 minFromA = Math.min(minFromA, fromA);
144 maxToA = Math.max(maxToA, toA);
145 minFromB = Math.min(minFromB, fromB);
146 maxToB = Math.max(maxToB, toB);
147 });
148 return {
149 deltaOffset: minFromA,
150 deltaReplaceLength: maxToA - minFromA,
151 deltaText: this.store.state.doc.sliceString(minFromB, maxToB),
152 };
216 } 153 }
217 154
218 private readonly pendingUpdate: PendingUpdate = { 155 prepareFullTextUpdateExclusive(): void {
219 prepareDeltaUpdateExclusive: (): Delta | undefined => { 156 this.ensureLocked();
220 this.pendingUpdate.extendPendingUpdateExclusive(); 157 this.markDirtyChangesAsPendingExclusive();
221 if (this.pendingChanges === undefined || this.pendingChanges.empty) { 158 }
222 return undefined; 159
223 } 160 private markDirtyChangesAsPendingExclusive(): void {
224 let minFromA = Number.MAX_SAFE_INTEGER; 161 if (!this.lockedForUpdate) {
225 let maxToA = 0; 162 throw new Error('Cannot update state without locking the mutex');
226 let minFromB = Number.MAX_SAFE_INTEGER; 163 }
227 let maxToB = 0; 164 if (this.hasDirtyChanges) {
228 this.pendingChanges.iterChangedRanges((fromA, toA, fromB, toB) => { 165 this.pendingChanges =
229 minFromA = Math.min(minFromA, fromA);
230 maxToA = Math.max(maxToA, toA);
231 minFromB = Math.min(minFromB, fromB);
232 maxToB = Math.max(maxToB, toB);
233 });
234 return {
235 deltaOffset: minFromA,
236 deltaReplaceLength: maxToA - minFromA,
237 deltaText: this.store.state.doc.sliceString(minFromB, maxToB),
238 };
239 },
240 extendPendingUpdateExclusive: (): void => {
241 if (!this.locekdForUpdate) {
242 throw new Error('Cannot update state without locking the mutex');
243 }
244 if (this.hasDirtyChanges) {
245 this.pendingChanges =
246 this.pendingChanges?.compose(this.dirtyChanges) ?? this.dirtyChanges;
247 this.dirtyChanges = this.newEmptyChangeSet();
248 }
249 },
250 applyBeforeDirtyChangesExclusive: (changeSpec: ChangeSpec): void => {
251 if (!this.locekdForUpdate) {
252 throw new Error('Cannot update state without locking the mutex');
253 }
254 const pendingChanges =
255 this.pendingChanges?.compose(this.dirtyChanges) ?? this.dirtyChanges; 166 this.pendingChanges?.compose(this.dirtyChanges) ?? this.dirtyChanges;
256 const revertChanges = pendingChanges.invert(this.store.state.doc); 167 this.dirtyChanges = this.newEmptyChangeSet();
257 const applyBefore = ChangeSet.of(changeSpec, revertChanges.newLength); 168 }
258 const redoChanges = pendingChanges.map(applyBefore.desc); 169 }
259 const changeSet = revertChanges.compose(applyBefore).compose(redoChanges);
260 this.store.dispatch({
261 changes: changeSet,
262 // Keep the current set of dirty changes (but update them according the re-formatting)
263 // and to not add the formatting the dirty changes.
264 effects: [setDirtyChanges.of(redoChanges)],
265 });
266 },
267 };
268 170
269 private readonly lockedState: LockedState = { 171 private newEmptyChangeSet(): ChangeSet {
270 updateExclusive: ( 172 return ChangeSet.of([], this.store.state.doc.length);
271 callback: (pendingUpdate: PendingUpdate) => Promise<string | undefined>, 173 }
272 ): Promise<void> => {
273 return this.lockedState.withUpdateExclusive<void>(
274 async (pendingUpdate) => {
275 const newStateId = await callback(pendingUpdate);
276 return { newStateId, data: undefined };
277 },
278 );
279 },
280 withUpdateExclusive: async <T>(
281 callback: (pendingUpdate: PendingUpdate) => Promise<StateUpdateResult<T>>,
282 ): Promise<T> => {
283 if (!this.locekdForUpdate) {
284 throw new Error('Cannot update state without locking the mutex');
285 }
286 if (this.pendingChanges !== undefined) {
287 throw new Error('Delta updates are not reentrant');
288 }
289 let newStateId: string | undefined;
290 let data: T;
291 try {
292 ({ newStateId, data } = await callback(this.pendingUpdate));
293 } catch (e) {
294 log.error('Error while update', e);
295 this.cancelUpdate();
296 throw e;
297 }
298 if (newStateId === undefined) {
299 this.cancelUpdate();
300 } else {
301 this.xtextStateId = newStateId;
302 this.pendingChanges = undefined;
303 }
304 return data;
305 },
306 };
307 174
308 private cancelUpdate(): void { 175 setStateIdExclusive(
309 if (this.pendingChanges === undefined) { 176 newStateId: string,
310 return; 177 remoteChanges?: ChangeSpec | undefined,
178 ): void {
179 this.ensureLocked();
180 if (remoteChanges !== undefined) {
181 this.applyRemoteChangesExclusive(remoteChanges);
311 } 182 }
312 this.dirtyChanges = this.pendingChanges.compose(this.dirtyChanges); 183 this.xtextStateId = newStateId;
313 this.pendingChanges = undefined; 184 this.pendingChanges = undefined;
314 } 185 }
315 186
316 runExclusive<T>( 187 private applyRemoteChangesExclusive(changeSpec: ChangeSpec): void {
317 callback: (lockedState: LockedState) => Promise<T>, 188 const pendingChanges =
318 ): Promise<T> { 189 this.pendingChanges?.compose(this.dirtyChanges) ?? this.dirtyChanges;
190 const revertChanges = pendingChanges.invert(this.store.state.doc);
191 const applyBefore = ChangeSet.of(changeSpec, revertChanges.newLength);
192 const redoChanges = pendingChanges.map(applyBefore.desc);
193 const changeSet = revertChanges.compose(applyBefore).compose(redoChanges);
194 this.store.dispatch({
195 changes: changeSet,
196 // Keep the current set of dirty changes (but update them according the re-formatting)
197 // and to not add the formatting the dirty changes.
198 effects: [setDirtyChanges.of(redoChanges)],
199 });
200 }
201
202 private ensureLocked(): void {
203 if (!this.lockedForUpdate) {
204 throw new Error('Cannot update state without locking the mutex');
205 }
206 }
207
208 runExclusive<T>(callback: () => Promise<T>): Promise<T> {
319 return this.mutex.runExclusive(async () => { 209 return this.mutex.runExclusive(async () => {
320 if (this.pendingChanges !== undefined) { 210 try {
321 throw new Error('Update is pending before entering critical section'); 211 return await callback();
322 } 212 } finally {
323 const result = await callback(this.lockedState); 213 if (this.pendingChanges !== undefined) {
324 if (this.pendingChanges !== undefined) { 214 this.dirtyChanges = this.pendingChanges.compose(this.dirtyChanges);
325 throw new Error('Update is pending after entering critical section'); 215 this.pendingChanges = undefined;
216 }
326 } 217 }
327 return result;
328 }); 218 });
329 } 219 }
330 220
331 runExclusiveHighPriority<T>( 221 runExclusiveHighPriority<T>(callback: () => Promise<T>): Promise<T> {
332 callback: (lockedState: LockedState) => Promise<T>,
333 ): Promise<T> {
334 this.mutex.cancel(); 222 this.mutex.cancel();
335 return this.runExclusive(callback); 223 return this.runExclusive(callback);
336 } 224 }
337 225
338 async runExclusiveWithRetries<T>( 226 async runExclusiveWithRetries<T>(
339 callback: (lockedState: LockedState) => Promise<T>, 227 callback: () => Promise<T>,
340 maxRetries = 5, 228 maxRetries = 5,
341 ): Promise<T> { 229 ): Promise<T> {
342 let retries = 0; 230 let retries = 0;