diff options
author | 2022-08-25 01:25:40 +0200 | |
---|---|---|
committer | 2022-08-25 16:44:01 +0200 | |
commit | f6de69943df6c669c81b10279bbfaf3158f1fd61 (patch) | |
tree | e19465fd9ae4dc6905ad1aa1f8dbbc92ae43ea62 /subprojects/frontend/src/xtext | |
parent | refactor(frontend): xtext update improvements (diff) | |
download | refinery-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.ts | 8 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/HighlightingService.ts | 12 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/OccurrencesService.ts | 119 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/UpdateService.ts | 296 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/ValidationService.ts | 12 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/XtextClient.ts | 6 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/xtextServiceResults.ts | 5 |
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 | ||
106 | export default class ContentAssistService { | 106 | export 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'; | |||
5 | import { highlightingResult } from './xtextServiceResults'; | 5 | import { highlightingResult } from './xtextServiceResults'; |
6 | 6 | ||
7 | export default class HighlightingService { | 7 | export 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 @@ | |||
1 | import { Transaction } from '@codemirror/state'; | 1 | import { Transaction } from '@codemirror/state'; |
2 | import { debounce } from 'lodash-es'; | ||
2 | 3 | ||
3 | import type EditorStore from '../editor/EditorStore'; | 4 | import type EditorStore from '../editor/EditorStore'; |
4 | import { | 5 | import { |
5 | type IOccurrence, | 6 | type IOccurrence, |
6 | isCursorWithinOccurence, | 7 | isCursorWithinOccurence, |
7 | } from '../editor/findOccurrences'; | 8 | } from '../editor/findOccurrences'; |
8 | import Timer from '../utils/Timer'; | ||
9 | import getLogger from '../utils/getLogger'; | 9 | import getLogger from '../utils/getLogger'; |
10 | 10 | ||
11 | import type UpdateService from './UpdateService'; | 11 | import type UpdateService from './UpdateService'; |
12 | import type XtextWebSocketClient from './XtextWebSocketClient'; | 12 | import type { TextRegion } from './xtextServiceResults'; |
13 | import { | ||
14 | isConflictResult, | ||
15 | OccurrencesResult, | ||
16 | type TextRegion, | ||
17 | } from './xtextServiceResults'; | ||
18 | 13 | ||
19 | const FIND_OCCURRENCES_TIMEOUT_MS = 1000; | 14 | const FIND_OCCURRENCES_TIMEOUT_MS = 1000; |
20 | 15 | ||
@@ -34,38 +29,23 @@ function transformOccurrences(regions: TextRegion[]): IOccurrence[] { | |||
34 | } | 29 | } |
35 | 30 | ||
36 | export default class OccurrencesService { | 31 | export 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'; |
8 | import { E_CANCELED, E_TIMEOUT, Mutex, withTimeout } from 'async-mutex'; | ||
9 | import { debounce } from 'lodash-es'; | ||
8 | import { nanoid } from 'nanoid'; | 10 | import { nanoid } from 'nanoid'; |
9 | 11 | ||
10 | import type EditorStore from '../editor/EditorStore'; | 12 | import type EditorStore from '../editor/EditorStore'; |
11 | import ConditionVariable from '../utils/ConditionVariable'; | ||
12 | import Timer from '../utils/Timer'; | ||
13 | import getLogger from '../utils/getLogger'; | 13 | import getLogger from '../utils/getLogger'; |
14 | 14 | ||
15 | import type XtextWebSocketClient from './XtextWebSocketClient'; | 15 | import 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 | ||
24 | const UPDATE_TIMEOUT_MS = 500; | 25 | const UPDATE_TIMEOUT_MS = 500; |
25 | 26 | ||
26 | const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; | 27 | const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; |
27 | 28 | ||
29 | const FORMAT_TEXT_RETRIES = 5; | ||
30 | |||
28 | const log = getLogger('xtext.UpdateService'); | 31 | const log = getLogger('xtext.UpdateService'); |
29 | 32 | ||
30 | /** | 33 | /** |
@@ -38,10 +41,20 @@ const log = getLogger('xtext.UpdateService'); | |||
38 | */ | 41 | */ |
39 | const setDirtyChanges = StateEffect.define<ChangeSet>(); | 42 | const setDirtyChanges = StateEffect.define<ChangeSet>(); |
40 | 43 | ||
41 | export interface IAbortSignal { | 44 | export interface AbortSignal { |
42 | aborted: boolean; | 45 | aborted: boolean; |
43 | } | 46 | } |
44 | 47 | ||
48 | export interface ContentAssistParams { | ||
49 | caretOffset: number; | ||
50 | |||
51 | proposalsLimit: number; | ||
52 | } | ||
53 | |||
54 | export type CancellableResult<T> = | ||
55 | | { cancelled: false; data: T } | ||
56 | | { cancelled: true }; | ||
57 | |||
45 | interface StateUpdateResult<T> { | 58 | interface StateUpdateResult<T> { |
46 | newStateId: string; | 59 | newStateId: string; |
47 | 60 | ||
@@ -57,15 +70,27 @@ interface Delta { | |||
57 | } | 70 | } |
58 | 71 | ||
59 | export default class UpdateService { | 72 | export 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'; | |||
6 | import { ValidationResult } from './xtextServiceResults'; | 6 | import { ValidationResult } from './xtextServiceResults'; |
7 | 7 | ||
8 | export default class ValidationService { | 8 | export 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 | ||
27 | export function isConflictResult( | 27 | export 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 | ||