aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/xtext
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-08-25 01:25:40 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-08-25 16:44:01 +0200
commitf6de69943df6c669c81b10279bbfaf3158f1fd61 (patch)
treee19465fd9ae4dc6905ad1aa1f8dbbc92ae43ea62 /subprojects/frontend/src/xtext
parentrefactor(frontend): xtext update improvements (diff)
downloadrefinery-f6de69943df6c669c81b10279bbfaf3158f1fd61.tar.gz
refinery-f6de69943df6c669c81b10279bbfaf3158f1fd61.tar.zst
refinery-f6de69943df6c669c81b10279bbfaf3158f1fd61.zip
fix(frontend): UpdateService synchronization
Also bumps frontend dependencies
Diffstat (limited to 'subprojects/frontend/src/xtext')
-rw-r--r--subprojects/frontend/src/xtext/ContentAssistService.ts8
-rw-r--r--subprojects/frontend/src/xtext/HighlightingService.ts12
-rw-r--r--subprojects/frontend/src/xtext/OccurrencesService.ts119
-rw-r--r--subprojects/frontend/src/xtext/UpdateService.ts296
-rw-r--r--subprojects/frontend/src/xtext/ValidationService.ts12
-rw-r--r--subprojects/frontend/src/xtext/XtextClient.ts6
-rw-r--r--subprojects/frontend/src/xtext/xtextServiceResults.ts5
7 files changed, 282 insertions, 176 deletions
diff --git a/subprojects/frontend/src/xtext/ContentAssistService.ts b/subprojects/frontend/src/xtext/ContentAssistService.ts
index 39042812..9e41f57b 100644
--- a/subprojects/frontend/src/xtext/ContentAssistService.ts
+++ b/subprojects/frontend/src/xtext/ContentAssistService.ts
@@ -104,13 +104,9 @@ function createCompletion(entry: ContentAssistEntry): Completion {
104} 104}
105 105
106export default class ContentAssistService { 106export default class ContentAssistService {
107 private readonly updateService: UpdateService;
108
109 private lastCompletion: CompletionResult | undefined; 107 private lastCompletion: CompletionResult | undefined;
110 108
111 constructor(updateService: UpdateService) { 109 constructor(private readonly updateService: UpdateService) {}
112 this.updateService = updateService;
113 }
114 110
115 onTransaction(transaction: Transaction): void { 111 onTransaction(transaction: Transaction): void {
116 if (this.shouldInvalidateCachedCompletion(transaction)) { 112 if (this.shouldInvalidateCachedCompletion(transaction)) {
@@ -159,8 +155,6 @@ export default class ContentAssistService {
159 this.lastCompletion = undefined; 155 this.lastCompletion = undefined;
160 const entries = await this.updateService.fetchContentAssist( 156 const entries = await this.updateService.fetchContentAssist(
161 { 157 {
162 resource: this.updateService.resourceName,
163 serviceType: 'assist',
164 caretOffset: context.pos, 158 caretOffset: context.pos,
165 proposalsLimit: PROPOSALS_LIMIT, 159 proposalsLimit: PROPOSALS_LIMIT,
166 }, 160 },
diff --git a/subprojects/frontend/src/xtext/HighlightingService.ts b/subprojects/frontend/src/xtext/HighlightingService.ts
index cf618b96..f9ab7b7e 100644
--- a/subprojects/frontend/src/xtext/HighlightingService.ts
+++ b/subprojects/frontend/src/xtext/HighlightingService.ts
@@ -5,14 +5,10 @@ import type UpdateService from './UpdateService';
5import { highlightingResult } from './xtextServiceResults'; 5import { highlightingResult } from './xtextServiceResults';
6 6
7export default class HighlightingService { 7export default class HighlightingService {
8 private readonly store: EditorStore; 8 constructor(
9 9 private readonly store: EditorStore,
10 private readonly updateService: UpdateService; 10 private readonly updateService: UpdateService,
11 11 ) {}
12 constructor(store: EditorStore, updateService: UpdateService) {
13 this.store = store;
14 this.updateService = updateService;
15 }
16 12
17 onPush(push: unknown): void { 13 onPush(push: unknown): void {
18 const { regions } = highlightingResult.parse(push); 14 const { regions } = highlightingResult.parse(push);
diff --git a/subprojects/frontend/src/xtext/OccurrencesService.ts b/subprojects/frontend/src/xtext/OccurrencesService.ts
index 35913f43..c8d6fd7b 100644
--- a/subprojects/frontend/src/xtext/OccurrencesService.ts
+++ b/subprojects/frontend/src/xtext/OccurrencesService.ts
@@ -1,20 +1,15 @@
1import { Transaction } from '@codemirror/state'; 1import { Transaction } from '@codemirror/state';
2import { debounce } from 'lodash-es';
2 3
3import type EditorStore from '../editor/EditorStore'; 4import type EditorStore from '../editor/EditorStore';
4import { 5import {
5 type IOccurrence, 6 type IOccurrence,
6 isCursorWithinOccurence, 7 isCursorWithinOccurence,
7} from '../editor/findOccurrences'; 8} from '../editor/findOccurrences';
8import Timer from '../utils/Timer';
9import getLogger from '../utils/getLogger'; 9import getLogger from '../utils/getLogger';
10 10
11import type UpdateService from './UpdateService'; 11import type UpdateService from './UpdateService';
12import type XtextWebSocketClient from './XtextWebSocketClient'; 12import type { TextRegion } from './xtextServiceResults';
13import {
14 isConflictResult,
15 OccurrencesResult,
16 type TextRegion,
17} from './xtextServiceResults';
18 13
19const FIND_OCCURRENCES_TIMEOUT_MS = 1000; 14const FIND_OCCURRENCES_TIMEOUT_MS = 1000;
20 15
@@ -34,38 +29,23 @@ function transformOccurrences(regions: TextRegion[]): IOccurrence[] {
34} 29}
35 30
36export default class OccurrencesService { 31export default class OccurrencesService {
37 private readonly store: EditorStore;
38
39 private readonly webSocketClient: XtextWebSocketClient;
40
41 private readonly updateService: UpdateService;
42
43 private hasOccurrences = false; 32 private hasOccurrences = false;
44 33
45 private readonly findOccurrencesTimer = new Timer(() => { 34 private readonly findOccurrencesLater = debounce(
46 this.handleFindOccurrences(); 35 () => this.findOccurrences(),
47 }, FIND_OCCURRENCES_TIMEOUT_MS); 36 FIND_OCCURRENCES_TIMEOUT_MS,
48 37 );
49 private readonly clearOccurrencesTimer = new Timer(() => {
50 this.clearOccurrences();
51 });
52 38
53 constructor( 39 constructor(
54 store: EditorStore, 40 private readonly store: EditorStore,
55 webSocketClient: XtextWebSocketClient, 41 private readonly updateService: UpdateService,
56 updateService: UpdateService, 42 ) {}
57 ) {
58 this.store = store;
59 this.webSocketClient = webSocketClient;
60 this.updateService = updateService;
61 }
62 43
63 onTransaction(transaction: Transaction): void { 44 onTransaction(transaction: Transaction): void {
64 if (transaction.docChanged) { 45 if (transaction.docChanged) {
65 // Must clear occurrences asynchronously from `onTransaction`, 46 // Must clear occurrences asynchronously from `onTransaction`,
66 // because we must not emit a conflicting transaction when handling the pending transaction. 47 // because we must not emit a conflicting transaction when handling the pending transaction.
67 this.clearOccurrencesTimer.schedule(); 48 this.clearAndFindOccurrencesLater();
68 this.findOccurrencesTimer.reschedule();
69 return; 49 return;
70 } 50 }
71 if (!transaction.isUserEvent('select')) { 51 if (!transaction.isUserEvent('select')) {
@@ -73,11 +53,10 @@ export default class OccurrencesService {
73 } 53 }
74 if (this.needsOccurrences) { 54 if (this.needsOccurrences) {
75 if (!isCursorWithinOccurence(this.store.state)) { 55 if (!isCursorWithinOccurence(this.store.state)) {
76 this.clearOccurrencesTimer.schedule(); 56 this.clearAndFindOccurrencesLater();
77 this.findOccurrencesTimer.reschedule();
78 } 57 }
79 } else { 58 } else {
80 this.clearOccurrencesTimer.schedule(); 59 this.clearOccurrencesLater();
81 } 60 }
82 } 61 }
83 62
@@ -85,8 +64,26 @@ export default class OccurrencesService {
85 return this.store.state.selection.main.empty; 64 return this.store.state.selection.main.empty;
86 } 65 }
87 66
88 private handleFindOccurrences() { 67 private clearAndFindOccurrencesLater(): void {
89 this.clearOccurrencesTimer.cancel(); 68 this.clearOccurrencesLater();
69 this.findOccurrencesLater();
70 }
71
72 /**
73 * Clears the occurences from a new immediate task to let the current editor transaction finish.
74 */
75 private clearOccurrencesLater() {
76 setTimeout(() => this.clearOccurrences(), 0);
77 }
78
79 private clearOccurrences() {
80 if (this.hasOccurrences) {
81 this.store.updateOccurrences([], []);
82 this.hasOccurrences = false;
83 }
84 }
85
86 private findOccurrences() {
90 this.updateOccurrences().catch((error) => { 87 this.updateOccurrences().catch((error) => {
91 log.error('Unexpected error while updating occurrences', error); 88 log.error('Unexpected error while updating occurrences', error);
92 this.clearOccurrences(); 89 this.clearOccurrences();
@@ -98,43 +95,26 @@ export default class OccurrencesService {
98 this.clearOccurrences(); 95 this.clearOccurrences();
99 return; 96 return;
100 } 97 }
101 await this.updateService.update(); 98 const fetchResult = await this.updateService.fetchOccurrences(() => {
102 const result = await this.webSocketClient.send({ 99 return this.needsOccurrences
103 resource: this.updateService.resourceName, 100 ? {
104 serviceType: 'occurrences', 101 cancelled: false,
105 expectedStateId: this.updateService.xtextStateId, 102 data: this.store.state.selection.main.head,
106 caretOffset: this.store.state.selection.main.head, 103 }
104 : { cancelled: true };
107 }); 105 });
108 const allChanges = this.updateService.computeChangesSinceLastUpdate(); 106 if (fetchResult.cancelled) {
109 if (!allChanges.empty || isConflictResult(result, 'canceled')) {
110 // Stale occurrences result, the user already made some changes. 107 // Stale occurrences result, the user already made some changes.
111 // We can safely ignore the occurrences and schedule a new find occurrences call. 108 // We can safely ignore the occurrences and schedule a new find occurrences call.
112 this.clearOccurrences(); 109 this.clearOccurrences();
113 this.findOccurrencesTimer.schedule(); 110 if (this.needsOccurrences) {
114 return; 111 this.findOccurrencesLater();
115 } 112 }
116 const parsedOccurrencesResult = OccurrencesResult.safeParse(result);
117 if (!parsedOccurrencesResult.success) {
118 log.error(
119 'Unexpected occurences result',
120 result,
121 'not an OccurrencesResult: ',
122 parsedOccurrencesResult.error,
123 );
124 this.clearOccurrences();
125 return;
126 }
127 const { stateId, writeRegions, readRegions } = parsedOccurrencesResult.data;
128 if (stateId !== this.updateService.xtextStateId) {
129 log.error(
130 'Unexpected state id, expected:',
131 this.updateService.xtextStateId,
132 'got:',
133 stateId,
134 );
135 this.clearOccurrences();
136 return; 113 return;
137 } 114 }
115 const {
116 data: { writeRegions, readRegions },
117 } = fetchResult;
138 const write = transformOccurrences(writeRegions); 118 const write = transformOccurrences(writeRegions);
139 const read = transformOccurrences(readRegions); 119 const read = transformOccurrences(readRegions);
140 this.hasOccurrences = write.length > 0 || read.length > 0; 120 this.hasOccurrences = write.length > 0 || read.length > 0;
@@ -147,11 +127,4 @@ export default class OccurrencesService {
147 ); 127 );
148 this.store.updateOccurrences(write, read); 128 this.store.updateOccurrences(write, read);
149 } 129 }
150
151 private clearOccurrences() {
152 if (this.hasOccurrences) {
153 this.store.updateOccurrences([], []);
154 this.hasOccurrences = false;
155 }
156 }
157} 130}
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';
8import { E_CANCELED, E_TIMEOUT, Mutex, withTimeout } from 'async-mutex';
9import { debounce } from 'lodash-es';
8import { nanoid } from 'nanoid'; 10import { nanoid } from 'nanoid';
9 11
10import type EditorStore from '../editor/EditorStore'; 12import type EditorStore from '../editor/EditorStore';
11import ConditionVariable from '../utils/ConditionVariable';
12import Timer from '../utils/Timer';
13import getLogger from '../utils/getLogger'; 13import getLogger from '../utils/getLogger';
14 14
15import type XtextWebSocketClient from './XtextWebSocketClient'; 15import 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
24const UPDATE_TIMEOUT_MS = 500; 25const UPDATE_TIMEOUT_MS = 500;
25 26
26const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; 27const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000;
27 28
29const FORMAT_TEXT_RETRIES = 5;
30
28const log = getLogger('xtext.UpdateService'); 31const log = getLogger('xtext.UpdateService');
29 32
30/** 33/**
@@ -38,10 +41,20 @@ const log = getLogger('xtext.UpdateService');
38 */ 41 */
39const setDirtyChanges = StateEffect.define<ChangeSet>(); 42const setDirtyChanges = StateEffect.define<ChangeSet>();
40 43
41export interface IAbortSignal { 44export interface AbortSignal {
42 aborted: boolean; 45 aborted: boolean;
43} 46}
44 47
48export interface ContentAssistParams {
49 caretOffset: number;
50
51 proposalsLimit: number;
52}
53
54export type CancellableResult<T> =
55 | { cancelled: false; data: T }
56 | { cancelled: true };
57
45interface StateUpdateResult<T> { 58interface StateUpdateResult<T> {
46 newStateId: string; 59 newStateId: string;
47 60
@@ -57,15 +70,27 @@ interface Delta {
57} 70}
58 71
59export default class UpdateService { 72export 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}
diff --git a/subprojects/frontend/src/xtext/ValidationService.ts b/subprojects/frontend/src/xtext/ValidationService.ts
index a0b27251..e78318f7 100644
--- a/subprojects/frontend/src/xtext/ValidationService.ts
+++ b/subprojects/frontend/src/xtext/ValidationService.ts
@@ -6,14 +6,10 @@ import type UpdateService from './UpdateService';
6import { ValidationResult } from './xtextServiceResults'; 6import { ValidationResult } from './xtextServiceResults';
7 7
8export default class ValidationService { 8export default class ValidationService {
9 private readonly store: EditorStore; 9 constructor(
10 10 private readonly store: EditorStore,
11 private readonly updateService: UpdateService; 11 private readonly updateService: UpdateService,
12 12 ) {}
13 constructor(store: EditorStore, updateService: UpdateService) {
14 this.store = store;
15 this.updateService = updateService;
16 }
17 13
18 onPush(push: unknown): void { 14 onPush(push: unknown): void {
19 const { issues } = ValidationResult.parse(push); 15 const { issues } = ValidationResult.parse(push);
diff --git a/subprojects/frontend/src/xtext/XtextClient.ts b/subprojects/frontend/src/xtext/XtextClient.ts
index 7297c674..6351c9fd 100644
--- a/subprojects/frontend/src/xtext/XtextClient.ts
+++ b/subprojects/frontend/src/xtext/XtextClient.ts
@@ -43,11 +43,7 @@ export default class XtextClient {
43 this.updateService, 43 this.updateService,
44 ); 44 );
45 this.validationService = new ValidationService(store, this.updateService); 45 this.validationService = new ValidationService(store, this.updateService);
46 this.occurrencesService = new OccurrencesService( 46 this.occurrencesService = new OccurrencesService(store, this.updateService);
47 store,
48 this.webSocketClient,
49 this.updateService,
50 );
51 } 47 }
52 48
53 onTransaction(transaction: Transaction): void { 49 onTransaction(transaction: Transaction): void {
diff --git a/subprojects/frontend/src/xtext/xtextServiceResults.ts b/subprojects/frontend/src/xtext/xtextServiceResults.ts
index 4cfb9c33..e93c6714 100644
--- a/subprojects/frontend/src/xtext/xtextServiceResults.ts
+++ b/subprojects/frontend/src/xtext/xtextServiceResults.ts
@@ -26,12 +26,13 @@ export type ServiceConflictResult = z.infer<typeof ServiceConflictResult>;
26 26
27export function isConflictResult( 27export function isConflictResult(
28 result: unknown, 28 result: unknown,
29 conflictType: Conflict, 29 conflictType?: Conflict | undefined,
30): boolean { 30): boolean {
31 const parsedConflictResult = ServiceConflictResult.safeParse(result); 31 const parsedConflictResult = ServiceConflictResult.safeParse(result);
32 return ( 32 return (
33 parsedConflictResult.success && 33 parsedConflictResult.success &&
34 parsedConflictResult.data.conflict === conflictType 34 (conflictType === undefined ||
35 parsedConflictResult.data.conflict === conflictType)
35 ); 36 );
36} 37}
37 38