aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/xtext
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-08-26 01:34:40 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-08-26 17:45:20 +0200
commitd5f6643b9e0e675748ca6ec48e49355ca99453ca (patch)
tree288f5538be58ea499a0b1d5ce5314c7698c52060 /subprojects/frontend/src/xtext
parentchore(deps): bump dependencies (diff)
downloadrefinery-d5f6643b9e0e675748ca6ec48e49355ca99453ca.tar.gz
refinery-d5f6643b9e0e675748ca6ec48e49355ca99453ca.tar.zst
refinery-d5f6643b9e0e675748ca6ec48e49355ca99453ca.zip
refactor(frontend): simplify UpdateService further
Diffstat (limited to 'subprojects/frontend/src/xtext')
-rw-r--r--subprojects/frontend/src/xtext/UpdateService.ts198
-rw-r--r--subprojects/frontend/src/xtext/UpdateStateTracker.ts300
2 files changed, 173 insertions, 325 deletions
diff --git a/subprojects/frontend/src/xtext/UpdateService.ts b/subprojects/frontend/src/xtext/UpdateService.ts
index 94e01ca2..d8782d90 100644
--- a/subprojects/frontend/src/xtext/UpdateService.ts
+++ b/subprojects/frontend/src/xtext/UpdateService.ts
@@ -6,11 +6,7 @@ import { nanoid } from 'nanoid';
6import type EditorStore from '../editor/EditorStore'; 6import type EditorStore from '../editor/EditorStore';
7import getLogger from '../utils/getLogger'; 7import getLogger from '../utils/getLogger';
8 8
9import UpdateStateTracker, { 9import UpdateStateTracker from './UpdateStateTracker';
10 type LockedState,
11 type PendingUpdate,
12} from './UpdateStateTracker';
13import type { StateUpdateResult, Delta } from './UpdateStateTracker';
14import type XtextWebSocketClient from './XtextWebSocketClient'; 10import type XtextWebSocketClient from './XtextWebSocketClient';
15import { 11import {
16 type ContentAssistEntry, 12 type ContentAssistEntry,
@@ -86,10 +82,10 @@ export default class UpdateService {
86 } 82 }
87 83
88 private idleUpdate(): void { 84 private idleUpdate(): void {
89 if (!this.webSocketClient.isOpen || !this.tracker.hasDirtyChanges) { 85 if (!this.webSocketClient.isOpen || !this.tracker.needsUpdate) {
90 return; 86 return;
91 } 87 }
92 if (!this.tracker.locekdForUpdate) { 88 if (!this.tracker.lockedForUpdate) {
93 this.updateOrThrow().catch((error) => { 89 this.updateOrThrow().catch((error) => {
94 if (error === E_CANCELED || error === E_TIMEOUT) { 90 if (error === E_CANCELED || error === E_TIMEOUT) {
95 log.debug('Idle update cancelled'); 91 log.debug('Idle update cancelled');
@@ -111,88 +107,64 @@ export default class UpdateService {
111 * @returns a promise resolving when the update is completed 107 * @returns a promise resolving when the update is completed
112 */ 108 */
113 private async updateOrThrow(): Promise<void> { 109 private async updateOrThrow(): Promise<void> {
114 // We may check here for the delta to avoid locking, 110 if (!this.tracker.needsUpdate) {
115 // but we'll need to recompute the delta in the critical section,
116 // because it may have changed by the time we can acquire the lock.
117 if (
118 !this.tracker.hasDirtyChanges &&
119 this.tracker.xtextStateId !== undefined
120 ) {
121 return; 111 return;
122 } 112 }
123 await this.tracker.runExclusive((lockedState) => 113 await this.tracker.runExclusive(() => this.updateExclusive());
124 this.updateExclusive(lockedState),
125 );
126 } 114 }
127 115
128 private async updateExclusive(lockedState: LockedState): Promise<void> { 116 private async updateExclusive(): Promise<void> {
129 if (this.xtextStateId === undefined) { 117 if (this.xtextStateId === undefined) {
130 await this.updateFullTextExclusive(lockedState); 118 await this.updateFullTextExclusive();
131 } 119 }
132 if (!this.tracker.hasDirtyChanges) { 120 const delta = this.tracker.prepareDeltaUpdateExclusive();
121 if (delta === undefined) {
133 return; 122 return;
134 } 123 }
135 await lockedState.updateExclusive(async (pendingUpdate) => { 124 log.trace('Editor delta', delta);
136 const delta = pendingUpdate.prepareDeltaUpdateExclusive(); 125 const result = await this.webSocketClient.send({
137 if (delta === undefined) { 126 resource: this.resourceName,
138 return undefined; 127 serviceType: 'update',
139 } 128 requiredStateId: this.xtextStateId,
140 log.trace('Editor delta', delta); 129 ...delta,
141 const result = await this.webSocketClient.send({
142 resource: this.resourceName,
143 serviceType: 'update',
144 requiredStateId: this.xtextStateId,
145 ...delta,
146 });
147 const parsedDocumentStateResult = DocumentStateResult.safeParse(result);
148 if (parsedDocumentStateResult.success) {
149 return parsedDocumentStateResult.data.stateId;
150 }
151 if (isConflictResult(result, 'invalidStateId')) {
152 return this.doUpdateFullTextExclusive(pendingUpdate);
153 }
154 throw parsedDocumentStateResult.error;
155 }); 130 });
131 const parsedDocumentStateResult = DocumentStateResult.safeParse(result);
132 if (parsedDocumentStateResult.success) {
133 this.tracker.setStateIdExclusive(parsedDocumentStateResult.data.stateId);
134 return;
135 }
136 if (isConflictResult(result, 'invalidStateId')) {
137 await this.updateFullTextExclusive();
138 }
139 throw parsedDocumentStateResult.error;
156 } 140 }
157 141
158 private updateFullTextOrThrow(): Promise<void> { 142 private updateFullTextOrThrow(): Promise<void> {
159 return this.tracker.runExclusive((lockedState) => 143 return this.tracker.runExclusive(() => this.updateFullTextExclusive());
160 this.updateFullTextExclusive(lockedState),
161 );
162 } 144 }
163 145
164 private async updateFullTextExclusive( 146 private async updateFullTextExclusive(): Promise<void> {
165 lockedState: LockedState,
166 ): Promise<void> {
167 await lockedState.updateExclusive((pendingUpdate) =>
168 this.doUpdateFullTextExclusive(pendingUpdate),
169 );
170 }
171
172 private async doUpdateFullTextExclusive(
173 pendingUpdate: PendingUpdate,
174 ): Promise<string> {
175 log.debug('Performing full text update'); 147 log.debug('Performing full text update');
176 pendingUpdate.extendPendingUpdateExclusive(); 148 this.tracker.prepareFullTextUpdateExclusive();
177 const result = await this.webSocketClient.send({ 149 const result = await this.webSocketClient.send({
178 resource: this.resourceName, 150 resource: this.resourceName,
179 serviceType: 'update', 151 serviceType: 'update',
180 fullText: this.store.state.doc.sliceString(0), 152 fullText: this.store.state.doc.sliceString(0),
181 }); 153 });
182 const { stateId } = DocumentStateResult.parse(result); 154 const { stateId } = DocumentStateResult.parse(result);
183 return stateId; 155 this.tracker.setStateIdExclusive(stateId);
184 } 156 }
185 157
186 async fetchContentAssist( 158 async fetchContentAssist(
187 params: ContentAssistParams, 159 params: ContentAssistParams,
188 signal: AbortSignal, 160 signal: AbortSignal,
189 ): Promise<ContentAssistEntry[]> { 161 ): Promise<ContentAssistEntry[]> {
190 if (this.tracker.upToDate && this.xtextStateId !== undefined) { 162 if (!this.tracker.hasPendingChanges && this.xtextStateId !== undefined) {
191 return this.fetchContentAssistFetchOnly(params, this.xtextStateId); 163 return this.fetchContentAssistFetchOnly(params, this.xtextStateId);
192 } 164 }
193 try { 165 try {
194 return await this.tracker.runExclusiveHighPriority((lockedState) => 166 return await this.tracker.runExclusiveHighPriority(() =>
195 this.fetchContentAssistExclusive(params, lockedState, signal), 167 this.fetchContentAssistExclusive(params, signal),
196 ); 168 );
197 } catch (error) { 169 } catch (error) {
198 if ((error === E_CANCELED || error === E_TIMEOUT) && signal.aborted) { 170 if ((error === E_CANCELED || error === E_TIMEOUT) && signal.aborted) {
@@ -204,37 +176,29 @@ export default class UpdateService {
204 176
205 private async fetchContentAssistExclusive( 177 private async fetchContentAssistExclusive(
206 params: ContentAssistParams, 178 params: ContentAssistParams,
207 lockedState: LockedState,
208 signal: AbortSignal, 179 signal: AbortSignal,
209 ): Promise<ContentAssistEntry[]> { 180 ): Promise<ContentAssistEntry[]> {
210 if (this.xtextStateId === undefined) { 181 if (this.xtextStateId === undefined) {
211 await this.updateFullTextExclusive(lockedState); 182 await this.updateFullTextExclusive();
183 if (this.xtextStateId === undefined) {
184 throw new Error('failed to obtain Xtext state id');
185 }
212 } 186 }
213 if (signal.aborted) { 187 if (signal.aborted) {
214 return []; 188 return [];
215 } 189 }
216 if (this.tracker.hasDirtyChanges) { 190 let entries: ContentAssistEntry[] | undefined;
217 // Try to fetch while also performing a delta update. 191 if (this.tracker.needsUpdate) {
218 const fetchUpdateEntries = await lockedState.withUpdateExclusive( 192 entries = await this.fetchContentAssistWithDeltaExclusive(
219 async (pendingUpdate) => { 193 params,
220 const delta = pendingUpdate.prepareDeltaUpdateExclusive(); 194 this.xtextStateId,
221 if (delta === undefined) {
222 return { newStateId: undefined, data: undefined };
223 }
224 log.trace('Editor delta', delta);
225 return this.doFetchContentAssistWithDeltaExclusive(
226 params,
227 pendingUpdate,
228 delta,
229 );
230 },
231 ); 195 );
232 if (fetchUpdateEntries !== undefined) { 196 }
233 return fetchUpdateEntries; 197 if (entries !== undefined) {
234 } 198 return entries;
235 if (signal.aborted) { 199 }
236 return []; 200 if (signal.aborted) {
237 } 201 return [];
238 } 202 }
239 if (this.xtextStateId === undefined) { 203 if (this.xtextStateId === undefined) {
240 throw new Error('failed to obtain Xtext state id'); 204 throw new Error('failed to obtain Xtext state id');
@@ -242,32 +206,35 @@ export default class UpdateService {
242 return this.fetchContentAssistFetchOnly(params, this.xtextStateId); 206 return this.fetchContentAssistFetchOnly(params, this.xtextStateId);
243 } 207 }
244 208
245 private async doFetchContentAssistWithDeltaExclusive( 209 private async fetchContentAssistWithDeltaExclusive(
246 params: ContentAssistParams, 210 params: ContentAssistParams,
247 pendingUpdate: PendingUpdate, 211 requiredStateId: string,
248 delta: Delta, 212 ): Promise<ContentAssistEntry[] | undefined> {
249 ): Promise<StateUpdateResult<ContentAssistEntry[] | undefined>> { 213 const delta = this.tracker.prepareDeltaUpdateExclusive();
214 if (delta === undefined) {
215 return undefined;
216 }
217 log.trace('Editor delta for content assist', delta);
250 const fetchUpdateResult = await this.webSocketClient.send({ 218 const fetchUpdateResult = await this.webSocketClient.send({
251 ...params, 219 ...params,
252 resource: this.resourceName, 220 resource: this.resourceName,
253 serviceType: 'assist', 221 serviceType: 'assist',
254 requiredStateId: this.xtextStateId, 222 requiredStateId,
255 ...delta, 223 ...delta,
256 }); 224 });
257 const parsedContentAssistResult = 225 const parsedContentAssistResult =
258 ContentAssistResult.safeParse(fetchUpdateResult); 226 ContentAssistResult.safeParse(fetchUpdateResult);
259 if (parsedContentAssistResult.success) { 227 if (parsedContentAssistResult.success) {
260 const { stateId, entries: resultEntries } = 228 const {
261 parsedContentAssistResult.data; 229 data: { stateId, entries },
262 return { newStateId: stateId, data: resultEntries }; 230 } = parsedContentAssistResult;
231 this.tracker.setStateIdExclusive(stateId);
232 return entries;
263 } 233 }
264 if (isConflictResult(fetchUpdateResult, 'invalidStateId')) { 234 if (isConflictResult(fetchUpdateResult, 'invalidStateId')) {
265 log.warn('Server state invalid during content assist'); 235 log.warn('Server state invalid during content assist');
266 const newStateId = await this.doUpdateFullTextExclusive(pendingUpdate); 236 await this.updateFullTextExclusive();
267 // We must finish this state update transaction to prepare for any push events 237 return undefined;
268 // before querying for content assist, so we just return `undefined` and will query
269 // the content assist service later.
270 return { newStateId, data: undefined };
271 } 238 }
272 throw parsedContentAssistResult.error; 239 throw parsedContentAssistResult.error;
273 } 240 }
@@ -294,33 +261,30 @@ export default class UpdateService {
294 } 261 }
295 262
296 formatText(): Promise<void> { 263 formatText(): Promise<void> {
297 return this.tracker.runExclusiveWithRetries((lockedState) => 264 return this.tracker.runExclusiveWithRetries(() =>
298 this.formatTextExclusive(lockedState), 265 this.formatTextExclusive(),
299 ); 266 );
300 } 267 }
301 268
302 private async formatTextExclusive(lockedState: LockedState): Promise<void> { 269 private async formatTextExclusive(): Promise<void> {
303 await this.updateExclusive(lockedState); 270 await this.updateExclusive();
304 let { from, to } = this.store.state.selection.main; 271 let { from, to } = this.store.state.selection.main;
305 if (to <= from) { 272 if (to <= from) {
306 from = 0; 273 from = 0;
307 to = this.store.state.doc.length; 274 to = this.store.state.doc.length;
308 } 275 }
309 log.debug('Formatting from', from, 'to', to); 276 log.debug('Formatting from', from, 'to', to);
310 await lockedState.updateExclusive(async (pendingUpdate) => { 277 const result = await this.webSocketClient.send({
311 const result = await this.webSocketClient.send({ 278 resource: this.resourceName,
312 resource: this.resourceName, 279 serviceType: 'format',
313 serviceType: 'format', 280 selectionStart: from,
314 selectionStart: from, 281 selectionEnd: to,
315 selectionEnd: to, 282 });
316 }); 283 const { stateId, formattedText } = FormattingResult.parse(result);
317 const { stateId, formattedText } = FormattingResult.parse(result); 284 this.tracker.setStateIdExclusive(stateId, {
318 pendingUpdate.applyBeforeDirtyChangesExclusive({ 285 from,
319 from, 286 to,
320 to, 287 insert: formattedText,
321 insert: formattedText,
322 });
323 return stateId;
324 }); 288 });
325 } 289 }
326 290
@@ -335,7 +299,8 @@ export default class UpdateService {
335 } 299 }
336 throw error; 300 throw error;
337 } 301 }
338 if (!this.tracker.upToDate) { 302 const expectedStateId = this.xtextStateId;
303 if (expectedStateId === undefined || this.tracker.hasPendingChanges) {
339 // Just give up if another update is in progress. 304 // Just give up if another update is in progress.
340 return { cancelled: true }; 305 return { cancelled: true };
341 } 306 }
@@ -343,11 +308,6 @@ export default class UpdateService {
343 if (caretOffsetResult.cancelled) { 308 if (caretOffsetResult.cancelled) {
344 return { cancelled: true }; 309 return { cancelled: true };
345 } 310 }
346 const expectedStateId = this.xtextStateId;
347 if (expectedStateId === undefined) {
348 // If there is no state on the server, don't bother with finding occurrences.
349 return { cancelled: true };
350 }
351 const data = await this.webSocketClient.send({ 311 const data = await this.webSocketClient.send({
352 resource: this.resourceName, 312 resource: this.resourceName,
353 serviceType: 'occurrences', 313 serviceType: 'occurrences',
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;