aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'subprojects/frontend')
-rw-r--r--subprojects/frontend/.eslintrc.cjs9
-rw-r--r--subprojects/frontend/package.json1
-rw-r--r--subprojects/frontend/src/utils/CancelledError.ts5
-rw-r--r--subprojects/frontend/src/utils/PendingTask.ts9
-rw-r--r--subprojects/frontend/src/utils/PriorityMutex.ts69
-rw-r--r--subprojects/frontend/src/utils/TimeoutError.ts5
-rw-r--r--subprojects/frontend/src/xtext/UpdateService.ts23
-rw-r--r--subprojects/frontend/src/xtext/UpdateStateTracker.ts48
8 files changed, 120 insertions, 49 deletions
diff --git a/subprojects/frontend/.eslintrc.cjs b/subprojects/frontend/.eslintrc.cjs
index 625aab7a..442ed4cd 100644
--- a/subprojects/frontend/.eslintrc.cjs
+++ b/subprojects/frontend/.eslintrc.cjs
@@ -53,6 +53,15 @@ module.exports = {
53 'newlines-between': 'always', 53 'newlines-between': 'always',
54 }, 54 },
55 ], 55 ],
56 // A dangling underscore, while not neccessary for all private fields,
57 // is useful for backing fields of properties that should be read-only from outside the class.
58 'no-underscore-dangle': [
59 'error',
60 {
61 allowAfterThis: true,
62 allowFunctionParams: true,
63 },
64 ],
56 // Use prop spreading to conditionally add props with `exactOptionalPropertyTypes`. 65 // Use prop spreading to conditionally add props with `exactOptionalPropertyTypes`.
57 'react/jsx-props-no-spreading': 'off', 66 'react/jsx-props-no-spreading': 'off',
58 }, 67 },
diff --git a/subprojects/frontend/package.json b/subprojects/frontend/package.json
index cb5dc2a7..f47ced13 100644
--- a/subprojects/frontend/package.json
+++ b/subprojects/frontend/package.json
@@ -40,7 +40,6 @@
40 "@mui/icons-material": "5.10.2", 40 "@mui/icons-material": "5.10.2",
41 "@mui/material": "5.10.2", 41 "@mui/material": "5.10.2",
42 "ansi-styles": "^6.1.0", 42 "ansi-styles": "^6.1.0",
43 "async-mutex": "^0.3.2",
44 "escape-string-regexp": "^5.0.0", 43 "escape-string-regexp": "^5.0.0",
45 "lodash-es": "^4.17.21", 44 "lodash-es": "^4.17.21",
46 "loglevel": "^1.8.0", 45 "loglevel": "^1.8.0",
diff --git a/subprojects/frontend/src/utils/CancelledError.ts b/subprojects/frontend/src/utils/CancelledError.ts
new file mode 100644
index 00000000..8d3e55d8
--- /dev/null
+++ b/subprojects/frontend/src/utils/CancelledError.ts
@@ -0,0 +1,5 @@
1export default class CancelledError extends Error {
2 constructor() {
3 super('Operation cancelled');
4 }
5}
diff --git a/subprojects/frontend/src/utils/PendingTask.ts b/subprojects/frontend/src/utils/PendingTask.ts
index 205c8452..d0b24c1f 100644
--- a/subprojects/frontend/src/utils/PendingTask.ts
+++ b/subprojects/frontend/src/utils/PendingTask.ts
@@ -1,3 +1,4 @@
1import TimeoutError from './TimeoutError';
1import getLogger from './getLogger'; 2import getLogger from './getLogger';
2 3
3const log = getLogger('utils.PendingTask'); 4const log = getLogger('utils.PendingTask');
@@ -15,16 +16,14 @@ export default class PendingTask<T> {
15 resolveCallback: (value: T) => void, 16 resolveCallback: (value: T) => void,
16 rejectCallback: (reason?: unknown) => void, 17 rejectCallback: (reason?: unknown) => void,
17 timeoutMs: number | undefined, 18 timeoutMs: number | undefined,
18 timeoutCallback: () => void | undefined, 19 timeoutCallback?: (() => void) | undefined,
19 ) { 20 ) {
20 this.resolveCallback = resolveCallback; 21 this.resolveCallback = resolveCallback;
21 this.rejectCallback = rejectCallback; 22 this.rejectCallback = rejectCallback;
22 this.timeout = setTimeout(() => { 23 this.timeout = setTimeout(() => {
23 if (!this.resolved) { 24 if (!this.resolved) {
24 this.reject(new Error('Request timed out')); 25 this.reject(new TimeoutError());
25 if (timeoutCallback) { 26 timeoutCallback?.();
26 timeoutCallback();
27 }
28 } 27 }
29 }, timeoutMs); 28 }, timeoutMs);
30 } 29 }
diff --git a/subprojects/frontend/src/utils/PriorityMutex.ts b/subprojects/frontend/src/utils/PriorityMutex.ts
new file mode 100644
index 00000000..78736141
--- /dev/null
+++ b/subprojects/frontend/src/utils/PriorityMutex.ts
@@ -0,0 +1,69 @@
1import CancelledError from './CancelledError';
2import PendingTask from './PendingTask';
3import getLogger from './getLogger';
4
5const log = getLogger('utils.PriorityMutex');
6
7export default class PriorityMutex {
8 private readonly lowPriorityQueue: PendingTask<void>[] = [];
9
10 private readonly highPriorityQueue: PendingTask<void>[] = [];
11
12 private _locked = false;
13
14 constructor(private readonly timeout: number) {}
15
16 get locked(): boolean {
17 return this._locked;
18 }
19
20 async runExclusive<T>(
21 callback: () => Promise<T>,
22 highPriority = false,
23 ): Promise<T> {
24 await this.acquire(highPriority);
25 try {
26 return await callback();
27 } finally {
28 this.release();
29 }
30 }
31
32 cancelAllWaiting(): void {
33 [this.highPriorityQueue, this.lowPriorityQueue].forEach((queue) =>
34 queue.forEach((task) => task.reject(new CancelledError())),
35 );
36 }
37
38 private acquire(highPriority: boolean): Promise<void> {
39 if (!this.locked) {
40 this._locked = true;
41 return Promise.resolve();
42 }
43 const queue = highPriority ? this.highPriorityQueue : this.lowPriorityQueue;
44 return new Promise((resolve, reject) => {
45 const task = new PendingTask(resolve, reject, this.timeout, () => {
46 const index = queue.indexOf(task);
47 if (index < 0) {
48 log.error('Timed out task already removed from queue');
49 return;
50 }
51 queue.splice(index, 1);
52 });
53 queue.push(task);
54 });
55 }
56
57 private release(): void {
58 if (!this.locked) {
59 throw new Error('Trying to release already released mutext');
60 }
61 const task =
62 this.highPriorityQueue.shift() ?? this.lowPriorityQueue.shift();
63 if (task === undefined) {
64 this._locked = false;
65 return;
66 }
67 task.resolve();
68 }
69}
diff --git a/subprojects/frontend/src/utils/TimeoutError.ts b/subprojects/frontend/src/utils/TimeoutError.ts
new file mode 100644
index 00000000..eb800f40
--- /dev/null
+++ b/subprojects/frontend/src/utils/TimeoutError.ts
@@ -0,0 +1,5 @@
1export default class TimeoutError extends Error {
2 constructor() {
3 super('Operation timed out');
4 }
5}
diff --git a/subprojects/frontend/src/xtext/UpdateService.ts b/subprojects/frontend/src/xtext/UpdateService.ts
index d8782d90..f1abce52 100644
--- a/subprojects/frontend/src/xtext/UpdateService.ts
+++ b/subprojects/frontend/src/xtext/UpdateService.ts
@@ -1,9 +1,10 @@
1import type { ChangeDesc, Transaction } from '@codemirror/state'; 1import type { ChangeDesc, Transaction } from '@codemirror/state';
2import { E_CANCELED, E_TIMEOUT } from 'async-mutex';
3import { debounce } from 'lodash-es'; 2import { debounce } from 'lodash-es';
4import { nanoid } from 'nanoid'; 3import { nanoid } from 'nanoid';
5 4
6import type EditorStore from '../editor/EditorStore'; 5import type EditorStore from '../editor/EditorStore';
6import CancelledError from '../utils/CancelledError';
7import TimeoutError from '../utils/TimeoutError';
7import getLogger from '../utils/getLogger'; 8import getLogger from '../utils/getLogger';
8 9
9import UpdateStateTracker from './UpdateStateTracker'; 10import UpdateStateTracker from './UpdateStateTracker';
@@ -66,7 +67,7 @@ export default class UpdateService {
66 this.updateFullTextOrThrow().catch((error) => { 67 this.updateFullTextOrThrow().catch((error) => {
67 // Let E_TIMEOUT errors propagate, since if the first update times out, 68 // Let E_TIMEOUT errors propagate, since if the first update times out,
68 // we can't use the connection. 69 // we can't use the connection.
69 if (error === E_CANCELED) { 70 if (error instanceof CancelledError) {
70 // Content assist will perform a full-text update anyways. 71 // Content assist will perform a full-text update anyways.
71 log.debug('Full text update cancelled'); 72 log.debug('Full text update cancelled');
72 return; 73 return;
@@ -87,7 +88,7 @@ export default class UpdateService {
87 } 88 }
88 if (!this.tracker.lockedForUpdate) { 89 if (!this.tracker.lockedForUpdate) {
89 this.updateOrThrow().catch((error) => { 90 this.updateOrThrow().catch((error) => {
90 if (error === E_CANCELED || error === E_TIMEOUT) { 91 if (error instanceof CancelledError || error instanceof TimeoutError) {
91 log.debug('Idle update cancelled'); 92 log.debug('Idle update cancelled');
92 return; 93 return;
93 } 94 }
@@ -163,11 +164,15 @@ export default class UpdateService {
163 return this.fetchContentAssistFetchOnly(params, this.xtextStateId); 164 return this.fetchContentAssistFetchOnly(params, this.xtextStateId);
164 } 165 }
165 try { 166 try {
166 return await this.tracker.runExclusiveHighPriority(() => 167 return await this.tracker.runExclusive(
167 this.fetchContentAssistExclusive(params, signal), 168 () => this.fetchContentAssistExclusive(params, signal),
169 true,
168 ); 170 );
169 } catch (error) { 171 } catch (error) {
170 if ((error === E_CANCELED || error === E_TIMEOUT) && signal.aborted) { 172 if (
173 (error instanceof CancelledError || error instanceof TimeoutError) &&
174 signal.aborted
175 ) {
171 return []; 176 return [];
172 } 177 }
173 throw error; 178 throw error;
@@ -261,9 +266,7 @@ export default class UpdateService {
261 } 266 }
262 267
263 formatText(): Promise<void> { 268 formatText(): Promise<void> {
264 return this.tracker.runExclusiveWithRetries(() => 269 return this.tracker.runExclusive(() => this.formatTextExclusive());
265 this.formatTextExclusive(),
266 );
267 } 270 }
268 271
269 private async formatTextExclusive(): Promise<void> { 272 private async formatTextExclusive(): Promise<void> {
@@ -294,7 +297,7 @@ export default class UpdateService {
294 try { 297 try {
295 await this.updateOrThrow(); 298 await this.updateOrThrow();
296 } catch (error) { 299 } catch (error) {
297 if (error === E_CANCELED || error === E_TIMEOUT) { 300 if (error instanceof CancelledError || error instanceof TimeoutError) {
298 return { cancelled: true }; 301 return { cancelled: true };
299 } 302 }
300 throw error; 303 throw error;
diff --git a/subprojects/frontend/src/xtext/UpdateStateTracker.ts b/subprojects/frontend/src/xtext/UpdateStateTracker.ts
index a529f9a0..5d4ce49e 100644
--- a/subprojects/frontend/src/xtext/UpdateStateTracker.ts
+++ b/subprojects/frontend/src/xtext/UpdateStateTracker.ts
@@ -5,9 +5,9 @@ import {
5 StateEffect, 5 StateEffect,
6 type Transaction, 6 type Transaction,
7} from '@codemirror/state'; 7} from '@codemirror/state';
8import { E_CANCELED, Mutex, withTimeout } from 'async-mutex';
9 8
10import type EditorStore from '../editor/EditorStore'; 9import type EditorStore from '../editor/EditorStore';
10import PriorityMutex from '../utils/PriorityMutex';
11 11
12const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; 12const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000;
13 13
@@ -31,7 +31,7 @@ export interface Delta {
31} 31}
32 32
33export default class UpdateStateTracker { 33export default class UpdateStateTracker {
34 xtextStateId: string | undefined; 34 private _xtextStateId: string | undefined;
35 35
36 /** 36 /**
37 * The changes marked for synchronization to the server if a full or delta text update 37 * The changes marked for synchronization to the server if a full or delta text update
@@ -54,12 +54,16 @@ export default class UpdateStateTracker {
54 /** 54 /**
55 * Locked when we try to modify the state on the server. 55 * Locked when we try to modify the state on the server.
56 */ 56 */
57 private readonly mutex = withTimeout(new Mutex(), WAIT_FOR_UPDATE_TIMEOUT_MS); 57 private readonly mutex = new PriorityMutex(WAIT_FOR_UPDATE_TIMEOUT_MS);
58 58
59 constructor(private readonly store: EditorStore) { 59 constructor(private readonly store: EditorStore) {
60 this.dirtyChanges = this.newEmptyChangeSet(); 60 this.dirtyChanges = this.newEmptyChangeSet();
61 } 61 }
62 62
63 get xtextStateId(): string | undefined {
64 return this._xtextStateId;
65 }
66
63 private get hasDirtyChanges(): boolean { 67 private get hasDirtyChanges(): boolean {
64 return !this.dirtyChanges.empty; 68 return !this.dirtyChanges.empty;
65 } 69 }
@@ -69,7 +73,7 @@ export default class UpdateStateTracker {
69 } 73 }
70 74
71 get lockedForUpdate(): boolean { 75 get lockedForUpdate(): boolean {
72 return this.mutex.isLocked(); 76 return this.mutex.locked;
73 } 77 }
74 78
75 get hasPendingChanges(): boolean { 79 get hasPendingChanges(): boolean {
@@ -111,7 +115,7 @@ export default class UpdateStateTracker {
111 } 115 }
112 116
113 invalidateStateId(): void { 117 invalidateStateId(): void {
114 this.xtextStateId = undefined; 118 this._xtextStateId = undefined;
115 } 119 }
116 120
117 /** 121 /**
@@ -180,7 +184,7 @@ export default class UpdateStateTracker {
180 if (remoteChanges !== undefined) { 184 if (remoteChanges !== undefined) {
181 this.applyRemoteChangesExclusive(remoteChanges); 185 this.applyRemoteChangesExclusive(remoteChanges);
182 } 186 }
183 this.xtextStateId = newStateId; 187 this._xtextStateId = newStateId;
184 this.pendingChanges = undefined; 188 this.pendingChanges = undefined;
185 } 189 }
186 190
@@ -205,7 +209,10 @@ export default class UpdateStateTracker {
205 } 209 }
206 } 210 }
207 211
208 runExclusive<T>(callback: () => Promise<T>): Promise<T> { 212 runExclusive<T>(
213 callback: () => Promise<T>,
214 highPriority = false,
215 ): Promise<T> {
209 return this.mutex.runExclusive(async () => { 216 return this.mutex.runExclusive(async () => {
210 try { 217 try {
211 return await callback(); 218 return await callback();
@@ -215,31 +222,6 @@ export default class UpdateStateTracker {
215 this.pendingChanges = undefined; 222 this.pendingChanges = undefined;
216 } 223 }
217 } 224 }
218 }); 225 }, highPriority);
219 }
220
221 runExclusiveHighPriority<T>(callback: () => Promise<T>): Promise<T> {
222 this.mutex.cancel();
223 return this.runExclusive(callback);
224 }
225
226 async runExclusiveWithRetries<T>(
227 callback: () => Promise<T>,
228 maxRetries = 5,
229 ): Promise<T> {
230 let retries = 0;
231 while (retries < maxRetries) {
232 try {
233 // eslint-disable-next-line no-await-in-loop -- Use a loop for sequential retries.
234 return await this.runExclusive(callback);
235 } catch (error) {
236 // Let timeout errors propagate to give up retrying on a flaky connection.
237 if (error !== E_CANCELED) {
238 throw error;
239 }
240 retries += 1;
241 }
242 }
243 throw E_CANCELED;
244 } 226 }
245} 227}