diff options
Diffstat (limited to 'subprojects/frontend/src/xtext/UpdateService.ts')
-rw-r--r-- | subprojects/frontend/src/xtext/UpdateService.ts | 296 |
1 files changed, 223 insertions, 73 deletions
diff --git a/subprojects/frontend/src/xtext/UpdateService.ts b/subprojects/frontend/src/xtext/UpdateService.ts index f8b71160..3b4ae259 100644 --- a/subprojects/frontend/src/xtext/UpdateService.ts +++ b/subprojects/frontend/src/xtext/UpdateService.ts | |||
@@ -5,11 +5,11 @@ import { | |||
5 | StateEffect, | 5 | StateEffect, |
6 | type Transaction, | 6 | type Transaction, |
7 | } from '@codemirror/state'; | 7 | } from '@codemirror/state'; |
8 | import { E_CANCELED, E_TIMEOUT, Mutex, withTimeout } from 'async-mutex'; | ||
9 | import { debounce } from 'lodash-es'; | ||
8 | import { nanoid } from 'nanoid'; | 10 | import { nanoid } from 'nanoid'; |
9 | 11 | ||
10 | import type EditorStore from '../editor/EditorStore'; | 12 | import type EditorStore from '../editor/EditorStore'; |
11 | import ConditionVariable from '../utils/ConditionVariable'; | ||
12 | import Timer from '../utils/Timer'; | ||
13 | import getLogger from '../utils/getLogger'; | 13 | import getLogger from '../utils/getLogger'; |
14 | 14 | ||
15 | import type XtextWebSocketClient from './XtextWebSocketClient'; | 15 | import type XtextWebSocketClient from './XtextWebSocketClient'; |
@@ -19,12 +19,15 @@ import { | |||
19 | DocumentStateResult, | 19 | DocumentStateResult, |
20 | FormattingResult, | 20 | FormattingResult, |
21 | isConflictResult, | 21 | isConflictResult, |
22 | OccurrencesResult, | ||
22 | } from './xtextServiceResults'; | 23 | } from './xtextServiceResults'; |
23 | 24 | ||
24 | const UPDATE_TIMEOUT_MS = 500; | 25 | const UPDATE_TIMEOUT_MS = 500; |
25 | 26 | ||
26 | const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; | 27 | const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; |
27 | 28 | ||
29 | const FORMAT_TEXT_RETRIES = 5; | ||
30 | |||
28 | const log = getLogger('xtext.UpdateService'); | 31 | const log = getLogger('xtext.UpdateService'); |
29 | 32 | ||
30 | /** | 33 | /** |
@@ -38,10 +41,20 @@ const log = getLogger('xtext.UpdateService'); | |||
38 | */ | 41 | */ |
39 | const setDirtyChanges = StateEffect.define<ChangeSet>(); | 42 | const setDirtyChanges = StateEffect.define<ChangeSet>(); |
40 | 43 | ||
41 | export interface IAbortSignal { | 44 | export interface AbortSignal { |
42 | aborted: boolean; | 45 | aborted: boolean; |
43 | } | 46 | } |
44 | 47 | ||
48 | export interface ContentAssistParams { | ||
49 | caretOffset: number; | ||
50 | |||
51 | proposalsLimit: number; | ||
52 | } | ||
53 | |||
54 | export type CancellableResult<T> = | ||
55 | | { cancelled: false; data: T } | ||
56 | | { cancelled: true }; | ||
57 | |||
45 | interface StateUpdateResult<T> { | 58 | interface StateUpdateResult<T> { |
46 | newStateId: string; | 59 | newStateId: string; |
47 | 60 | ||
@@ -57,15 +70,27 @@ interface Delta { | |||
57 | } | 70 | } |
58 | 71 | ||
59 | export default class UpdateService { | 72 | export default class UpdateService { |
60 | resourceName: string; | 73 | readonly resourceName: string; |
61 | 74 | ||
62 | xtextStateId: string | undefined; | 75 | xtextStateId: string | undefined; |
63 | 76 | ||
64 | private readonly store: EditorStore; | 77 | private readonly store: EditorStore; |
65 | 78 | ||
79 | private readonly mutex = withTimeout(new Mutex(), WAIT_FOR_UPDATE_TIMEOUT_MS); | ||
80 | |||
66 | /** | 81 | /** |
67 | * The changes being synchronized to the server if a full or delta text update is running, | 82 | * The changes being synchronized to the server if a full or delta text update is running |
68 | * `undefined` otherwise. | 83 | * withing a `withUpdateExclusive` block, `undefined` otherwise. |
84 | * | ||
85 | * Must be `undefined` before and after entering the critical section of `mutex` | ||
86 | * and may only be changes in the critical section of `mutex`. | ||
87 | * | ||
88 | * Methods named with an `Exclusive` suffix in this class assume that the mutex is held | ||
89 | * and may call `withUpdateExclusive` or `doFallbackUpdateFullTextExclusive` | ||
90 | * to mutate this field. | ||
91 | * | ||
92 | * Methods named with a `do` suffix assume that they are called in a `withUpdateExclusive` | ||
93 | * block and require this field to be non-`undefined`. | ||
69 | */ | 94 | */ |
70 | private pendingUpdate: ChangeSet | undefined; | 95 | private pendingUpdate: ChangeSet | undefined; |
71 | 96 | ||
@@ -76,15 +101,11 @@ export default class UpdateService { | |||
76 | 101 | ||
77 | private readonly webSocketClient: XtextWebSocketClient; | 102 | private readonly webSocketClient: XtextWebSocketClient; |
78 | 103 | ||
79 | private readonly updatedCondition = new ConditionVariable( | 104 | private readonly idleUpdateLater = debounce( |
80 | () => this.pendingUpdate === undefined && this.xtextStateId !== undefined, | 105 | () => this.idleUpdate(), |
81 | WAIT_FOR_UPDATE_TIMEOUT_MS, | 106 | UPDATE_TIMEOUT_MS, |
82 | ); | 107 | ); |
83 | 108 | ||
84 | private readonly idleUpdateTimer = new Timer(() => { | ||
85 | this.handleIdleUpdate(); | ||
86 | }, UPDATE_TIMEOUT_MS); | ||
87 | |||
88 | constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) { | 109 | constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) { |
89 | this.resourceName = `${nanoid(7)}.problem`; | 110 | this.resourceName = `${nanoid(7)}.problem`; |
90 | this.store = store; | 111 | this.store = store; |
@@ -95,6 +116,13 @@ export default class UpdateService { | |||
95 | onReconnect(): void { | 116 | onReconnect(): void { |
96 | this.xtextStateId = undefined; | 117 | this.xtextStateId = undefined; |
97 | this.updateFullText().catch((error) => { | 118 | this.updateFullText().catch((error) => { |
119 | // Let E_TIMEOUT errors propagate, since if the first update times out, | ||
120 | // we can't use the connection. | ||
121 | if (error === E_CANCELED) { | ||
122 | // Content assist will perform a full-text update anyways. | ||
123 | log.debug('Full text update cancelled'); | ||
124 | return; | ||
125 | } | ||
98 | log.error('Unexpected error during initial update', error); | 126 | log.error('Unexpected error during initial update', error); |
99 | }); | 127 | }); |
100 | } | 128 | } |
@@ -106,6 +134,8 @@ export default class UpdateService { | |||
106 | if (setDirtyChangesEffect) { | 134 | if (setDirtyChangesEffect) { |
107 | const { value } = setDirtyChangesEffect; | 135 | const { value } = setDirtyChangesEffect; |
108 | if (this.pendingUpdate !== undefined) { | 136 | if (this.pendingUpdate !== undefined) { |
137 | // Do not clear `pendingUpdate`, because that would indicate an update failure | ||
138 | // to `withUpdateExclusive`. | ||
109 | this.pendingUpdate = ChangeSet.empty(value.length); | 139 | this.pendingUpdate = ChangeSet.empty(value.length); |
110 | } | 140 | } |
111 | this.dirtyChanges = value; | 141 | this.dirtyChanges = value; |
@@ -113,7 +143,7 @@ export default class UpdateService { | |||
113 | } | 143 | } |
114 | if (transaction.docChanged) { | 144 | if (transaction.docChanged) { |
115 | this.dirtyChanges = this.dirtyChanges.compose(transaction.changes); | 145 | this.dirtyChanges = this.dirtyChanges.compose(transaction.changes); |
116 | this.idleUpdateTimer.reschedule(); | 146 | this.idleUpdateLater(); |
117 | } | 147 | } |
118 | } | 148 | } |
119 | 149 | ||
@@ -132,34 +162,42 @@ export default class UpdateService { | |||
132 | ); | 162 | ); |
133 | } | 163 | } |
134 | 164 | ||
135 | private handleIdleUpdate(): void { | 165 | private idleUpdate(): void { |
136 | if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) { | 166 | if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) { |
137 | return; | 167 | return; |
138 | } | 168 | } |
139 | if (this.pendingUpdate === undefined) { | 169 | if (!this.mutex.isLocked()) { |
140 | this.update().catch((error) => { | 170 | this.update().catch((error) => { |
171 | if (error === E_CANCELED || error === E_TIMEOUT) { | ||
172 | log.debug('Idle update cancelled'); | ||
173 | return; | ||
174 | } | ||
141 | log.error('Unexpected error during scheduled update', error); | 175 | log.error('Unexpected error during scheduled update', error); |
142 | }); | 176 | }); |
143 | } | 177 | } |
144 | this.idleUpdateTimer.reschedule(); | 178 | this.idleUpdateLater(); |
145 | } | 179 | } |
146 | 180 | ||
147 | private newEmptyChangeSet(): ChangeSet { | 181 | private newEmptyChangeSet(): ChangeSet { |
148 | return ChangeSet.of([], this.store.state.doc.length); | 182 | return ChangeSet.of([], this.store.state.doc.length); |
149 | } | 183 | } |
150 | 184 | ||
151 | async updateFullText(): Promise<void> { | 185 | private updateFullText(): Promise<void> { |
152 | await this.withUpdate(() => this.doUpdateFullText()); | 186 | return this.runExclusive(() => this.updateFullTextExclusive()); |
153 | } | 187 | } |
154 | 188 | ||
155 | private async doUpdateFullText(): Promise<StateUpdateResult<void>> { | 189 | private async updateFullTextExclusive(): Promise<void> { |
190 | await this.withVoidUpdateExclusive(() => this.doUpdateFullTextExclusive()); | ||
191 | } | ||
192 | |||
193 | private async doUpdateFullTextExclusive(): Promise<string> { | ||
156 | const result = await this.webSocketClient.send({ | 194 | const result = await this.webSocketClient.send({ |
157 | resource: this.resourceName, | 195 | resource: this.resourceName, |
158 | serviceType: 'update', | 196 | serviceType: 'update', |
159 | fullText: this.store.state.doc.sliceString(0), | 197 | fullText: this.store.state.doc.sliceString(0), |
160 | }); | 198 | }); |
161 | const { stateId } = DocumentStateResult.parse(result); | 199 | const { stateId } = DocumentStateResult.parse(result); |
162 | return { newStateId: stateId, data: undefined }; | 200 | return stateId; |
163 | } | 201 | } |
164 | 202 | ||
165 | /** | 203 | /** |
@@ -171,14 +209,26 @@ export default class UpdateService { | |||
171 | * | 209 | * |
172 | * @returns a promise resolving when the update is completed | 210 | * @returns a promise resolving when the update is completed |
173 | */ | 211 | */ |
174 | async update(): Promise<void> { | 212 | private async update(): Promise<void> { |
175 | await this.prepareForDeltaUpdate(); | 213 | // We may check here for the delta to avoid locking, |
214 | // but we'll need to recompute the delta in the critical section, | ||
215 | // because it may have changed by the time we can acquire the lock. | ||
216 | if (this.dirtyChanges.empty) { | ||
217 | return; | ||
218 | } | ||
219 | await this.runExclusive(() => this.updateExclusive()); | ||
220 | } | ||
221 | |||
222 | private async updateExclusive(): Promise<void> { | ||
223 | if (this.xtextStateId === undefined) { | ||
224 | await this.updateFullTextExclusive(); | ||
225 | } | ||
176 | const delta = this.computeDelta(); | 226 | const delta = this.computeDelta(); |
177 | if (delta === undefined) { | 227 | if (delta === undefined) { |
178 | return; | 228 | return; |
179 | } | 229 | } |
180 | log.trace('Editor delta', delta); | 230 | log.trace('Editor delta', delta); |
181 | await this.withUpdate(async () => { | 231 | await this.withVoidUpdateExclusive(async () => { |
182 | const result = await this.webSocketClient.send({ | 232 | const result = await this.webSocketClient.send({ |
183 | resource: this.resourceName, | 233 | resource: this.resourceName, |
184 | serviceType: 'update', | 234 | serviceType: 'update', |
@@ -187,34 +237,98 @@ export default class UpdateService { | |||
187 | }); | 237 | }); |
188 | const parsedDocumentStateResult = DocumentStateResult.safeParse(result); | 238 | const parsedDocumentStateResult = DocumentStateResult.safeParse(result); |
189 | if (parsedDocumentStateResult.success) { | 239 | if (parsedDocumentStateResult.success) { |
190 | return { | 240 | return parsedDocumentStateResult.data.stateId; |
191 | newStateId: parsedDocumentStateResult.data.stateId, | ||
192 | data: undefined, | ||
193 | }; | ||
194 | } | 241 | } |
195 | if (isConflictResult(result, 'invalidStateId')) { | 242 | if (isConflictResult(result, 'invalidStateId')) { |
196 | return this.doFallbackToUpdateFullText(); | 243 | return this.doFallbackUpdateFullTextExclusive(); |
197 | } | 244 | } |
198 | throw parsedDocumentStateResult.error; | 245 | throw parsedDocumentStateResult.error; |
199 | }); | 246 | }); |
200 | } | 247 | } |
201 | 248 | ||
202 | private doFallbackToUpdateFullText(): Promise<StateUpdateResult<void>> { | 249 | async fetchOccurrences( |
203 | if (this.pendingUpdate === undefined) { | 250 | getCaretOffset: () => CancellableResult<number>, |
204 | throw new Error('Only a pending update can be extended'); | 251 | ): Promise<CancellableResult<OccurrencesResult>> { |
252 | try { | ||
253 | await this.update(); | ||
254 | } catch (error) { | ||
255 | if (error === E_CANCELED || error === E_TIMEOUT) { | ||
256 | return { cancelled: true }; | ||
257 | } | ||
258 | throw error; | ||
205 | } | 259 | } |
206 | log.warn('Delta update failed, performing full text update'); | 260 | if (!this.dirtyChanges.empty || this.mutex.isLocked()) { |
207 | this.xtextStateId = undefined; | 261 | // Just give up if another update is in progress. |
208 | this.pendingUpdate = this.pendingUpdate.compose(this.dirtyChanges); | 262 | return { cancelled: true }; |
209 | this.dirtyChanges = this.newEmptyChangeSet(); | 263 | } |
210 | return this.doUpdateFullText(); | 264 | const caretOffsetResult = getCaretOffset(); |
265 | if (caretOffsetResult.cancelled) { | ||
266 | return { cancelled: true }; | ||
267 | } | ||
268 | const expectedStateId = this.xtextStateId; | ||
269 | const data = await this.webSocketClient.send({ | ||
270 | resource: this.resourceName, | ||
271 | serviceType: 'occurrences', | ||
272 | caretOffset: caretOffsetResult.data, | ||
273 | expectedStateId, | ||
274 | }); | ||
275 | if ( | ||
276 | // The query must have reached the server without being conflicted with an update | ||
277 | // or cancelled server-side. | ||
278 | isConflictResult(data) || | ||
279 | // And no state update should have occurred since then. | ||
280 | this.xtextStateId !== expectedStateId || | ||
281 | // And there should be no change to the editor text since then. | ||
282 | !this.dirtyChanges.empty || | ||
283 | // And there should be no state update in progress. | ||
284 | this.mutex.isLocked() | ||
285 | ) { | ||
286 | return { cancelled: true }; | ||
287 | } | ||
288 | const parsedOccurrencesResult = OccurrencesResult.safeParse(data); | ||
289 | if (!parsedOccurrencesResult.success) { | ||
290 | log.error( | ||
291 | 'Unexpected occurences result', | ||
292 | data, | ||
293 | 'not an OccurrencesResult:', | ||
294 | parsedOccurrencesResult.error, | ||
295 | ); | ||
296 | throw parsedOccurrencesResult.error; | ||
297 | } | ||
298 | if (parsedOccurrencesResult.data.stateId !== expectedStateId) { | ||
299 | return { cancelled: true }; | ||
300 | } | ||
301 | return { cancelled: false, data: parsedOccurrencesResult.data }; | ||
211 | } | 302 | } |
212 | 303 | ||
213 | async fetchContentAssist( | 304 | async fetchContentAssist( |
214 | params: Record<string, unknown>, | 305 | params: ContentAssistParams, |
215 | signal: IAbortSignal, | 306 | signal: AbortSignal, |
307 | ): Promise<ContentAssistEntry[]> { | ||
308 | if (!this.mutex.isLocked && this.xtextStateId !== undefined) { | ||
309 | return this.fetchContentAssistFetchOnly(params, this.xtextStateId); | ||
310 | } | ||
311 | // Content assist updates should have priority over other updates. | ||
312 | this.mutex.cancel(); | ||
313 | try { | ||
314 | return await this.runExclusive(() => | ||
315 | this.fetchContentAssistExclusive(params, signal), | ||
316 | ); | ||
317 | } catch (error) { | ||
318 | if ((error === E_CANCELED || error === E_TIMEOUT) && signal.aborted) { | ||
319 | return []; | ||
320 | } | ||
321 | throw error; | ||
322 | } | ||
323 | } | ||
324 | |||
325 | private async fetchContentAssistExclusive( | ||
326 | params: ContentAssistParams, | ||
327 | signal: AbortSignal, | ||
216 | ): Promise<ContentAssistEntry[]> { | 328 | ): Promise<ContentAssistEntry[]> { |
217 | await this.prepareForDeltaUpdate(); | 329 | if (this.xtextStateId === undefined) { |
330 | await this.updateFullTextExclusive(); | ||
331 | } | ||
218 | if (signal.aborted) { | 332 | if (signal.aborted) { |
219 | return []; | 333 | return []; |
220 | } | 334 | } |
@@ -222,8 +336,8 @@ export default class UpdateService { | |||
222 | if (delta !== undefined) { | 336 | if (delta !== undefined) { |
223 | log.trace('Editor delta', delta); | 337 | log.trace('Editor delta', delta); |
224 | // Try to fetch while also performing a delta update. | 338 | // Try to fetch while also performing a delta update. |
225 | const fetchUpdateEntries = await this.withUpdate(() => | 339 | const fetchUpdateEntries = await this.withUpdateExclusive(() => |
226 | this.doFetchContentAssistWithDelta(params, delta), | 340 | this.doFetchContentAssistWithDeltaExclusive(params, delta), |
227 | ); | 341 | ); |
228 | if (fetchUpdateEntries !== undefined) { | 342 | if (fetchUpdateEntries !== undefined) { |
229 | return fetchUpdateEntries; | 343 | return fetchUpdateEntries; |
@@ -235,15 +349,17 @@ export default class UpdateService { | |||
235 | if (this.xtextStateId === undefined) { | 349 | if (this.xtextStateId === undefined) { |
236 | throw new Error('failed to obtain Xtext state id'); | 350 | throw new Error('failed to obtain Xtext state id'); |
237 | } | 351 | } |
238 | return this.doFetchContentAssistFetchOnly(params, this.xtextStateId); | 352 | return this.fetchContentAssistFetchOnly(params, this.xtextStateId); |
239 | } | 353 | } |
240 | 354 | ||
241 | private async doFetchContentAssistWithDelta( | 355 | private async doFetchContentAssistWithDeltaExclusive( |
242 | params: Record<string, unknown>, | 356 | params: ContentAssistParams, |
243 | delta: Delta, | 357 | delta: Delta, |
244 | ): Promise<StateUpdateResult<ContentAssistEntry[] | undefined>> { | 358 | ): Promise<StateUpdateResult<ContentAssistEntry[] | undefined>> { |
245 | const fetchUpdateResult = await this.webSocketClient.send({ | 359 | const fetchUpdateResult = await this.webSocketClient.send({ |
246 | ...params, | 360 | ...params, |
361 | resource: this.resourceName, | ||
362 | serviceType: 'assist', | ||
247 | requiredStateId: this.xtextStateId, | 363 | requiredStateId: this.xtextStateId, |
248 | ...delta, | 364 | ...delta, |
249 | }); | 365 | }); |
@@ -256,7 +372,7 @@ export default class UpdateService { | |||
256 | } | 372 | } |
257 | if (isConflictResult(fetchUpdateResult, 'invalidStateId')) { | 373 | if (isConflictResult(fetchUpdateResult, 'invalidStateId')) { |
258 | log.warn('Server state invalid during content assist'); | 374 | log.warn('Server state invalid during content assist'); |
259 | const { newStateId } = await this.doFallbackToUpdateFullText(); | 375 | const newStateId = await this.doFallbackUpdateFullTextExclusive(); |
260 | // We must finish this state update transaction to prepare for any push events | 376 | // We must finish this state update transaction to prepare for any push events |
261 | // before querying for content assist, so we just return `undefined` and will query | 377 | // before querying for content assist, so we just return `undefined` and will query |
262 | // the content assist service later. | 378 | // the content assist service later. |
@@ -265,14 +381,16 @@ export default class UpdateService { | |||
265 | throw parsedContentAssistResult.error; | 381 | throw parsedContentAssistResult.error; |
266 | } | 382 | } |
267 | 383 | ||
268 | private async doFetchContentAssistFetchOnly( | 384 | private async fetchContentAssistFetchOnly( |
269 | params: Record<string, unknown>, | 385 | params: ContentAssistParams, |
270 | requiredStateId: string, | 386 | requiredStateId: string, |
271 | ): Promise<ContentAssistEntry[]> { | 387 | ): Promise<ContentAssistEntry[]> { |
272 | // Fallback to fetching without a delta update. | 388 | // Fallback to fetching without a delta update. |
273 | const fetchOnlyResult = await this.webSocketClient.send({ | 389 | const fetchOnlyResult = await this.webSocketClient.send({ |
274 | ...params, | 390 | ...params, |
275 | requiredStateId: this.xtextStateId, | 391 | resource: this.resourceName, |
392 | serviceType: 'assist', | ||
393 | requiredStateId, | ||
276 | }); | 394 | }); |
277 | const { stateId, entries: fetchOnlyEntries } = | 395 | const { stateId, entries: fetchOnlyEntries } = |
278 | ContentAssistResult.parse(fetchOnlyResult); | 396 | ContentAssistResult.parse(fetchOnlyResult); |
@@ -285,14 +403,32 @@ export default class UpdateService { | |||
285 | } | 403 | } |
286 | 404 | ||
287 | async formatText(): Promise<void> { | 405 | async formatText(): Promise<void> { |
288 | await this.update(); | 406 | let retries = 0; |
407 | while (retries < FORMAT_TEXT_RETRIES) { | ||
408 | try { | ||
409 | // eslint-disable-next-line no-await-in-loop -- Use a loop for sequential retries. | ||
410 | await this.runExclusive(() => this.formatTextExclusive()); | ||
411 | return; | ||
412 | } catch (error) { | ||
413 | // Let timeout errors propagate to give up formatting on a flaky connection. | ||
414 | if (error === E_CANCELED && retries < FORMAT_TEXT_RETRIES) { | ||
415 | retries += 1; | ||
416 | } else { | ||
417 | throw error; | ||
418 | } | ||
419 | } | ||
420 | } | ||
421 | } | ||
422 | |||
423 | private async formatTextExclusive(): Promise<void> { | ||
424 | await this.updateExclusive(); | ||
289 | let { from, to } = this.store.state.selection.main; | 425 | let { from, to } = this.store.state.selection.main; |
290 | if (to <= from) { | 426 | if (to <= from) { |
291 | from = 0; | 427 | from = 0; |
292 | to = this.store.state.doc.length; | 428 | to = this.store.state.doc.length; |
293 | } | 429 | } |
294 | log.debug('Formatting from', from, 'to', to); | 430 | log.debug('Formatting from', from, 'to', to); |
295 | await this.withUpdate<void>(async () => { | 431 | await this.withVoidUpdateExclusive(async () => { |
296 | const result = await this.webSocketClient.send({ | 432 | const result = await this.webSocketClient.send({ |
297 | resource: this.resourceName, | 433 | resource: this.resourceName, |
298 | serviceType: 'format', | 434 | serviceType: 'format', |
@@ -305,7 +441,7 @@ export default class UpdateService { | |||
305 | to, | 441 | to, |
306 | insert: formattedText, | 442 | insert: formattedText, |
307 | }); | 443 | }); |
308 | return { newStateId: stateId, data: undefined }; | 444 | return stateId; |
309 | }); | 445 | }); |
310 | } | 446 | } |
311 | 447 | ||
@@ -345,6 +481,28 @@ export default class UpdateService { | |||
345 | }); | 481 | }); |
346 | } | 482 | } |
347 | 483 | ||
484 | private runExclusive<T>(callback: () => Promise<T>): Promise<T> { | ||
485 | return this.mutex.runExclusive(async () => { | ||
486 | if (this.pendingUpdate !== undefined) { | ||
487 | throw new Error('Update is pending before entering critical section'); | ||
488 | } | ||
489 | const result = await callback(); | ||
490 | if (this.pendingUpdate !== undefined) { | ||
491 | throw new Error('Update is pending after entering critical section'); | ||
492 | } | ||
493 | return result; | ||
494 | }); | ||
495 | } | ||
496 | |||
497 | private withVoidUpdateExclusive( | ||
498 | callback: () => Promise<string>, | ||
499 | ): Promise<void> { | ||
500 | return this.withUpdateExclusive<void>(async () => { | ||
501 | const newStateId = await callback(); | ||
502 | return { newStateId, data: undefined }; | ||
503 | }); | ||
504 | } | ||
505 | |||
348 | /** | 506 | /** |
349 | * Executes an asynchronous callback that updates the state on the server. | 507 | * Executes an asynchronous callback that updates the state on the server. |
350 | * | 508 | * |
@@ -366,20 +524,18 @@ export default class UpdateService { | |||
366 | * @param callback the asynchronous callback that updates the server state | 524 | * @param callback the asynchronous callback that updates the server state |
367 | * @returns a promise resolving to the second value returned by `callback` | 525 | * @returns a promise resolving to the second value returned by `callback` |
368 | */ | 526 | */ |
369 | private async withUpdate<T>( | 527 | private async withUpdateExclusive<T>( |
370 | callback: () => Promise<StateUpdateResult<T>>, | 528 | callback: () => Promise<StateUpdateResult<T>>, |
371 | ): Promise<T> { | 529 | ): Promise<T> { |
372 | if (this.pendingUpdate !== undefined) { | 530 | if (this.pendingUpdate !== undefined) { |
373 | throw new Error('Another update is pending, will not perform update'); | 531 | throw new Error('Delta updates are not reentrant'); |
374 | } | 532 | } |
375 | this.pendingUpdate = this.dirtyChanges; | 533 | this.pendingUpdate = this.dirtyChanges; |
376 | this.dirtyChanges = this.newEmptyChangeSet(); | 534 | this.dirtyChanges = this.newEmptyChangeSet(); |
535 | let data: T; | ||
377 | try { | 536 | try { |
378 | const { newStateId, data } = await callback(); | 537 | ({ newStateId: this.xtextStateId, data } = await callback()); |
379 | this.xtextStateId = newStateId; | ||
380 | this.pendingUpdate = undefined; | 538 | this.pendingUpdate = undefined; |
381 | this.updatedCondition.notifyAll(); | ||
382 | return data; | ||
383 | } catch (e) { | 539 | } catch (e) { |
384 | log.error('Error while update', e); | 540 | log.error('Error while update', e); |
385 | if (this.pendingUpdate === undefined) { | 541 | if (this.pendingUpdate === undefined) { |
@@ -389,25 +545,19 @@ export default class UpdateService { | |||
389 | } | 545 | } |
390 | this.pendingUpdate = undefined; | 546 | this.pendingUpdate = undefined; |
391 | this.webSocketClient.forceReconnectOnError(); | 547 | this.webSocketClient.forceReconnectOnError(); |
392 | this.updatedCondition.rejectAll(e); | ||
393 | throw e; | 548 | throw e; |
394 | } | 549 | } |
550 | return data; | ||
395 | } | 551 | } |
396 | 552 | ||
397 | /** | 553 | private doFallbackUpdateFullTextExclusive(): Promise<string> { |
398 | * Ensures that there is some state available on the server (`xtextStateId`) | 554 | if (this.pendingUpdate === undefined) { |
399 | * and that there is no pending update. | 555 | throw new Error('Only a pending update can be extended'); |
400 | * | ||
401 | * After this function resolves, a delta text update is possible. | ||
402 | * | ||
403 | * @returns a promise resolving when there is a valid state id but no pending update | ||
404 | */ | ||
405 | private async prepareForDeltaUpdate(): Promise<void> { | ||
406 | // If no update is pending, but the full text hasn't been uploaded to the server yet, | ||
407 | // we must start a full text upload. | ||
408 | if (this.pendingUpdate === undefined && this.xtextStateId === undefined) { | ||
409 | await this.updateFullText(); | ||
410 | } | 556 | } |
411 | await this.updatedCondition.waitFor(); | 557 | log.warn('Delta update failed, performing full text update'); |
558 | this.xtextStateId = undefined; | ||
559 | this.pendingUpdate = this.pendingUpdate.compose(this.dirtyChanges); | ||
560 | this.dirtyChanges = this.newEmptyChangeSet(); | ||
561 | return this.doUpdateFullTextExclusive(); | ||
412 | } | 562 | } |
413 | } | 563 | } |