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