diff options
Diffstat (limited to 'subprojects')
-rw-r--r-- | subprojects/frontend/.eslintrc.cjs | 9 | ||||
-rw-r--r-- | subprojects/frontend/package.json | 1 | ||||
-rw-r--r-- | subprojects/frontend/src/utils/CancelledError.ts | 5 | ||||
-rw-r--r-- | subprojects/frontend/src/utils/PendingTask.ts | 9 | ||||
-rw-r--r-- | subprojects/frontend/src/utils/PriorityMutex.ts | 69 | ||||
-rw-r--r-- | subprojects/frontend/src/utils/TimeoutError.ts | 5 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/UpdateService.ts | 23 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/UpdateStateTracker.ts | 48 |
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 @@ | |||
1 | export 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 @@ | |||
1 | import TimeoutError from './TimeoutError'; | ||
1 | import getLogger from './getLogger'; | 2 | import getLogger from './getLogger'; |
2 | 3 | ||
3 | const log = getLogger('utils.PendingTask'); | 4 | const 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 @@ | |||
1 | import CancelledError from './CancelledError'; | ||
2 | import PendingTask from './PendingTask'; | ||
3 | import getLogger from './getLogger'; | ||
4 | |||
5 | const log = getLogger('utils.PriorityMutex'); | ||
6 | |||
7 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | import type { ChangeDesc, Transaction } from '@codemirror/state'; | 1 | import type { ChangeDesc, Transaction } from '@codemirror/state'; |
2 | import { E_CANCELED, E_TIMEOUT } from 'async-mutex'; | ||
3 | import { debounce } from 'lodash-es'; | 2 | import { debounce } from 'lodash-es'; |
4 | import { nanoid } from 'nanoid'; | 3 | import { nanoid } from 'nanoid'; |
5 | 4 | ||
6 | import type EditorStore from '../editor/EditorStore'; | 5 | import type EditorStore from '../editor/EditorStore'; |
6 | import CancelledError from '../utils/CancelledError'; | ||
7 | import TimeoutError from '../utils/TimeoutError'; | ||
7 | import getLogger from '../utils/getLogger'; | 8 | import getLogger from '../utils/getLogger'; |
8 | 9 | ||
9 | import UpdateStateTracker from './UpdateStateTracker'; | 10 | import 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'; |
8 | import { E_CANCELED, Mutex, withTimeout } from 'async-mutex'; | ||
9 | 8 | ||
10 | import type EditorStore from '../editor/EditorStore'; | 9 | import type EditorStore from '../editor/EditorStore'; |
10 | import PriorityMutex from '../utils/PriorityMutex'; | ||
11 | 11 | ||
12 | const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; | 12 | const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; |
13 | 13 | ||
@@ -31,7 +31,7 @@ export interface Delta { | |||
31 | } | 31 | } |
32 | 32 | ||
33 | export default class UpdateStateTracker { | 33 | export 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 | } |