diff options
Diffstat (limited to 'subprojects/frontend/src/xtext')
-rw-r--r-- | subprojects/frontend/src/xtext/UpdateService.ts | 198 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/UpdateStateTracker.ts | 300 |
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'; | |||
6 | import type EditorStore from '../editor/EditorStore'; | 6 | import type EditorStore from '../editor/EditorStore'; |
7 | import getLogger from '../utils/getLogger'; | 7 | import getLogger from '../utils/getLogger'; |
8 | 8 | ||
9 | import UpdateStateTracker, { | 9 | import UpdateStateTracker from './UpdateStateTracker'; |
10 | type LockedState, | ||
11 | type PendingUpdate, | ||
12 | } from './UpdateStateTracker'; | ||
13 | import type { StateUpdateResult, Delta } from './UpdateStateTracker'; | ||
14 | import type XtextWebSocketClient from './XtextWebSocketClient'; | 10 | import type XtextWebSocketClient from './XtextWebSocketClient'; |
15 | import { | 11 | import { |
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 | |||
16 | import { | 1 | import { |
17 | type ChangeDesc, | 2 | type ChangeDesc, |
18 | ChangeSet, | 3 | ChangeSet, |
@@ -23,12 +8,9 @@ import { | |||
23 | import { E_CANCELED, Mutex, withTimeout } from 'async-mutex'; | 8 | import { E_CANCELED, Mutex, withTimeout } from 'async-mutex'; |
24 | 9 | ||
25 | import type EditorStore from '../editor/EditorStore'; | 10 | import type EditorStore from '../editor/EditorStore'; |
26 | import getLogger from '../utils/getLogger'; | ||
27 | 11 | ||
28 | const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; | 12 | const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; |
29 | 13 | ||
30 | const 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 | */ |
41 | const setDirtyChanges = StateEffect.define<ChangeSet>(); | 23 | const setDirtyChanges = StateEffect.define<ChangeSet>(); |
42 | 24 | ||
43 | export 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 | */ | ||
54 | export 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 | |||
94 | export interface Delta { | 25 | export 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 | */ | ||
105 | export interface PendingUpdate { | ||
106 | prepareDeltaUpdateExclusive(): Delta | undefined; | ||
107 | |||
108 | extendPendingUpdateExclusive(): void; | ||
109 | |||
110 | applyBeforeDirtyChangesExclusive(changeSpec: ChangeSpec): void; | ||
111 | } | ||
112 | |||
113 | export default class UpdateStateTracker { | 33 | export 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; |