aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2021-10-26 21:40:36 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2021-10-31 19:26:12 +0100
commit232fbcafa863a3c28ab907b112c5257f0b6dc8f1 (patch)
tree20b0266e7d325e3c4ad6676616b35a7eb5f91fd8
parentfeat(web): show lint status on lint button (diff)
downloadrefinery-232fbcafa863a3c28ab907b112c5257f0b6dc8f1.tar.gz
refinery-232fbcafa863a3c28ab907b112c5257f0b6dc8f1.tar.zst
refinery-232fbcafa863a3c28ab907b112c5257f0b6dc8f1.zip
chore(web): refactor websocket state machine
-rw-r--r--language-web/src/main/js/editor/PendingRequest.ts10
-rw-r--r--language-web/src/main/js/editor/XtextClient.ts48
-rw-r--r--language-web/src/main/js/editor/XtextWebSocketClient.ts334
-rw-r--r--language-web/src/main/js/utils/Timer.ts33
4 files changed, 271 insertions, 154 deletions
diff --git a/language-web/src/main/js/editor/PendingRequest.ts b/language-web/src/main/js/editor/PendingRequest.ts
index 784f06ec..49d4c36c 100644
--- a/language-web/src/main/js/editor/PendingRequest.ts
+++ b/language-web/src/main/js/editor/PendingRequest.ts
@@ -9,16 +9,24 @@ export class PendingRequest {
9 9
10 private readonly rejectCallback: (reason?: unknown) => void; 10 private readonly rejectCallback: (reason?: unknown) => void;
11 11
12 private readonly timeoutCallback: () => void;
13
12 private resolved = false; 14 private resolved = false;
13 15
14 private timeoutId: NodeJS.Timeout; 16 private timeoutId: NodeJS.Timeout;
15 17
16 constructor(resolve: (value: unknown) => void, reject: (reason?: unknown) => void) { 18 constructor(
19 resolve: (value: unknown) => void,
20 reject: (reason?: unknown) => void,
21 timeout: () => void,
22 ) {
17 this.resolveCallback = resolve; 23 this.resolveCallback = resolve;
18 this.rejectCallback = reject; 24 this.rejectCallback = reject;
25 this.timeoutCallback = timeout;
19 this.timeoutId = setTimeout(() => { 26 this.timeoutId = setTimeout(() => {
20 if (!this.resolved) { 27 if (!this.resolved) {
21 this.reject(new Error('Request timed out')); 28 this.reject(new Error('Request timed out'));
29 this.timeoutCallback();
22 } 30 }
23 }, REQUEST_TIMEOUT_MS); 31 }, REQUEST_TIMEOUT_MS);
24 } 32 }
diff --git a/language-web/src/main/js/editor/XtextClient.ts b/language-web/src/main/js/editor/XtextClient.ts
index 1c6c0ae6..5216154e 100644
--- a/language-web/src/main/js/editor/XtextClient.ts
+++ b/language-web/src/main/js/editor/XtextClient.ts
@@ -8,6 +8,7 @@ import { nanoid } from 'nanoid';
8 8
9import type { EditorStore } from './EditorStore'; 9import type { EditorStore } from './EditorStore';
10import { getLogger } from '../logging'; 10import { getLogger } from '../logging';
11import { Timer } from '../utils/Timer';
11import { 12import {
12 isDocumentStateResult, 13 isDocumentStateResult,
13 isServiceConflictResult, 14 isServiceConflictResult,
@@ -36,7 +37,9 @@ export class XtextClient {
36 37
37 dirtyChanges: ChangeDesc; 38 dirtyChanges: ChangeDesc;
38 39
39 updateTimeout: NodeJS.Timeout | null = null; 40 updateTimer = new Timer(() => {
41 this.handleUpdate();
42 }, UPDATE_TIMEOUT_MS);
40 43
41 store: EditorStore; 44 store: EditorStore;
42 45
@@ -46,15 +49,11 @@ export class XtextClient {
46 this.store = store; 49 this.store = store;
47 this.dirtyChanges = this.newEmptyChangeDesc(); 50 this.dirtyChanges = this.newEmptyChangeDesc();
48 this.webSocketClient = new XtextWebSocketClient( 51 this.webSocketClient = new XtextWebSocketClient(
49 () => { 52 async () => {
50 this.updateFullText().catch((error) => { 53 await this.updateFullText();
51 log.error('Unexpected error during initial update', error);
52 });
53 }, 54 },
54 (resource, stateId, service, push) => { 55 async (resource, stateId, service, push) => {
55 this.onPush(resource, stateId, service, push).catch((error) => { 56 await this.onPush(resource, stateId, service, push);
56 log.error('Unexected error during push message handling', error);
57 });
58 }, 57 },
59 ); 58 );
60 } 59 }
@@ -62,9 +61,8 @@ export class XtextClient {
62 onTransaction(transaction: Transaction): void { 61 onTransaction(transaction: Transaction): void {
63 const { changes } = transaction; 62 const { changes } = transaction;
64 if (!changes.empty) { 63 if (!changes.empty) {
65 this.webSocketClient.ensureOpen();
66 this.dirtyChanges = this.dirtyChanges.composeDesc(changes.desc); 64 this.dirtyChanges = this.dirtyChanges.composeDesc(changes.desc);
67 this.scheduleUpdate(); 65 this.updateTimer.reschedule();
68 } 66 }
69 } 67 }
70 68
@@ -118,22 +116,16 @@ export class XtextClient {
118 return this.pendingUpdate.composeDesc(this.dirtyChanges); 116 return this.pendingUpdate.composeDesc(this.dirtyChanges);
119 } 117 }
120 118
121 private scheduleUpdate() { 119 private handleUpdate() {
122 if (this.updateTimeout !== null) { 120 if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) {
123 clearTimeout(this.updateTimeout); 121 return;
124 } 122 }
125 this.updateTimeout = setTimeout(() => { 123 if (!this.pendingUpdate) {
126 this.updateTimeout = null; 124 this.updateDeltaText().catch((error) => {
127 if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) { 125 log.error('Unexpected error during scheduled update', error);
128 return; 126 });
129 } 127 }
130 if (!this.pendingUpdate) { 128 this.updateTimer.reschedule();
131 this.updateDeltaText().catch((error) => {
132 log.error('Unexpected error during scheduled update', error);
133 });
134 }
135 this.scheduleUpdate();
136 }, UPDATE_TIMEOUT_MS);
137 } 129 }
138 130
139 private newEmptyChangeDesc() { 131 private newEmptyChangeDesc() {
@@ -169,7 +161,7 @@ export class XtextClient {
169 return; 161 return;
170 } 162 }
171 const delta = this.computeDelta(); 163 const delta = this.computeDelta();
172 log.debug('Editor delta', delta); 164 log.trace('Editor delta', delta);
173 await this.withUpdate(async () => { 165 await this.withUpdate(async () => {
174 const result = await this.webSocketClient.send({ 166 const result = await this.webSocketClient.send({
175 resource: this.resourceName, 167 resource: this.resourceName,
@@ -231,7 +223,7 @@ export class XtextClient {
231 this.pendingUpdate = null; 223 this.pendingUpdate = null;
232 switch (newStateId) { 224 switch (newStateId) {
233 case UpdateAction.ForceReconnect: 225 case UpdateAction.ForceReconnect:
234 this.webSocketClient.forceReconnectDueToError(); 226 this.webSocketClient.handleApplicationError();
235 break; 227 break;
236 case UpdateAction.FullTextUpdate: 228 case UpdateAction.FullTextUpdate:
237 await this.updateFullText(); 229 await this.updateFullText();
diff --git a/language-web/src/main/js/editor/XtextWebSocketClient.ts b/language-web/src/main/js/editor/XtextWebSocketClient.ts
index c034f8c8..6766029b 100644
--- a/language-web/src/main/js/editor/XtextWebSocketClient.ts
+++ b/language-web/src/main/js/editor/XtextWebSocketClient.ts
@@ -2,6 +2,7 @@ import { nanoid } from 'nanoid';
2 2
3import { getLogger } from '../logging'; 3import { getLogger } from '../logging';
4import { PendingRequest } from './PendingRequest'; 4import { PendingRequest } from './PendingRequest';
5import { Timer } from '../utils/Timer';
5import { 6import {
6 isErrorResponse, 7 isErrorResponse,
7 isOkResponse, 8 isOkResponse,
@@ -14,7 +15,11 @@ const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1';
14 15
15const WEBSOCKET_CLOSE_OK = 1000; 16const WEBSOCKET_CLOSE_OK = 1000;
16 17
17const RECONNECT_DELAY_MS = 1000; 18const RECONNECT_DELAY_MS = [1000, 5000, 30_000];
19
20const MAX_RECONNECT_DELAY_MS = RECONNECT_DELAY_MS[RECONNECT_DELAY_MS.length - 1];
21
22const MAX_APP_ERROR_COUNT = 1;
18 23
19const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000; 24const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
20 25
@@ -22,15 +27,28 @@ const PING_TIMEOUT_MS = 10 * 1000;
22 27
23const log = getLogger('XtextWebSocketClient'); 28const log = getLogger('XtextWebSocketClient');
24 29
25type ReconnectHandler = () => void; 30type ReconnectHandler = () => Promise<void>;
26 31
27type PushHandler = (resourceId: string, stateId: string, service: string, data: unknown) => void; 32type PushHandler = (
33 resourceId: string,
34 stateId: string,
35 service: string,
36 data: unknown
37) => Promise<void>;
38
39enum State {
40 Initial,
41 Opening,
42 TabVisible,
43 TabHiddenIdle,
44 TabHiddenWaiting,
45 Error,
46 TimedOut,
47}
28 48
29export class XtextWebSocketClient { 49export class XtextWebSocketClient {
30 nextMessageId = 0; 50 nextMessageId = 0;
31 51
32 closing = false;
33
34 connection!: WebSocket; 52 connection!: WebSocket;
35 53
36 pendingRequests = new Map<string, PendingRequest>(); 54 pendingRequests = new Map<string, PendingRequest>();
@@ -39,161 +57,162 @@ export class XtextWebSocketClient {
39 57
40 onPush: PushHandler; 58 onPush: PushHandler;
41 59
42 reconnectTimeout: NodeJS.Timeout | null = null; 60 state = State.Initial;
43 61
44 idleTimeout: NodeJS.Timeout | null = null; 62 appErrorCount = 0;
45 63
46 pingTimeout: NodeJS.Timeout | null = null; 64 reconnectTryCount = 0;
65
66 idleTimer = new Timer(() => {
67 this.handleIdleTimeout();
68 }, BACKGROUND_IDLE_TIMEOUT_MS);
69
70 pingTimer = new Timer(() => {
71 this.sendPing();
72 }, PING_TIMEOUT_MS);
73
74 reconnectTimer = new Timer(() => {
75 this.handleReconnect();
76 });
47 77
48 constructor(onReconnect: ReconnectHandler, onPush: PushHandler) { 78 constructor(onReconnect: ReconnectHandler, onPush: PushHandler) {
49 this.onReconnect = onReconnect; 79 this.onReconnect = onReconnect;
50 this.onPush = onPush; 80 this.onPush = onPush;
51 document.addEventListener('visibilitychange', () => { 81 document.addEventListener('visibilitychange', () => {
52 this.scheduleIdleTimeout(); 82 this.handleVisibilityChange();
53 }); 83 });
54 this.reconnect(); 84 this.reconnect();
55 } 85 }
56 86
57 get isOpen(): boolean { 87 private get isLogicallyClosed(): boolean {
58 return this.connection.readyState === WebSocket.OPEN; 88 return this.state === State.Error || this.state === State.TimedOut;
59 } 89 }
60 90
61 get isClosed(): boolean { 91 get isOpen(): boolean {
62 return this.connection.readyState === WebSocket.CLOSING 92 return this.state === State.TabVisible
63 || this.connection.readyState === WebSocket.CLOSED; 93 || this.state === State.TabHiddenIdle
64 } 94 || this.state === State.TabHiddenWaiting;
65
66 ensureOpen(): void {
67 if (this.isClosed) {
68 this.closing = false;
69 this.reconnect();
70 }
71 } 95 }
72 96
73 private reconnect() { 97 private reconnect() {
74 this.reconnectTimeout = null; 98 if (this.isOpen || this.state === State.Opening) {
99 log.error('Trying to reconnect from', this.state);
100 return;
101 }
102 this.state = State.Opening;
75 const webSocketServer = window.origin.replace(/^http/, 'ws'); 103 const webSocketServer = window.origin.replace(/^http/, 'ws');
76 const webSocketUrl = `${webSocketServer}/xtext-service`; 104 const webSocketUrl = `${webSocketServer}/xtext-service`;
77 this.connection = new WebSocket(webSocketUrl, XTEXT_SUBPROTOCOL_V1); 105 this.connection = new WebSocket(webSocketUrl, XTEXT_SUBPROTOCOL_V1);
78 this.connection.addEventListener('open', () => { 106 this.connection.addEventListener('open', () => {
79 if (this.connection.protocol !== XTEXT_SUBPROTOCOL_V1) { 107 if (this.connection.protocol !== XTEXT_SUBPROTOCOL_V1) {
80 log.error('Unknown subprotocol', this.connection.protocol, 'selected by server'); 108 log.error('Unknown subprotocol', this.connection.protocol, 'selected by server');
81 this.forceReconnectDueToError(); 109 this.handleProtocolError();
82 return;
83 } 110 }
84 log.info('Connected to xtext web services'); 111 if (document.visibilityState === 'hidden') {
85 this.scheduleIdleTimeout(); 112 this.handleTabHidden();
86 this.schedulePingTimeout(); 113 } else {
87 this.onReconnect(); 114 this.handleTabVisibleConnected();
115 }
116 log.info('Connected to websocket');
117 this.nextMessageId = 0;
118 this.appErrorCount = 0;
119 this.reconnectTryCount = 0;
120 this.pingTimer.schedule();
121 this.onReconnect().catch((error) => {
122 log.error('Unexpected error in onReconnect handler', error);
123 });
88 }); 124 });
89 this.connection.addEventListener('error', (event) => { 125 this.connection.addEventListener('error', (event) => {
90 log.error('Unexpected websocket error', event); 126 log.error('Unexpected websocket error', event);
91 this.forceReconnectDueToError(); 127 this.handleProtocolError();
92 }); 128 });
93 this.connection.addEventListener('message', (event) => { 129 this.connection.addEventListener('message', (event) => {
94 this.handleMessage(event.data); 130 this.handleMessage(event.data);
95 }); 131 });
96 this.connection.addEventListener('close', (event) => { 132 this.connection.addEventListener('close', (event) => {
97 if (!this.closing || event.code !== WEBSOCKET_CLOSE_OK) { 133 if (this.isLogicallyClosed && event.code === WEBSOCKET_CLOSE_OK
98 log.error('Websocket closed undexpectedly', event.code, event.reason); 134 && this.pendingRequests.size === 0) {
135 log.info('Websocket closed');
136 return;
99 } 137 }
100 this.cleanupAndMaybeReconnect(); 138 log.error('Websocket closed unexpectedly', event.code, event.reason);
139 this.handleProtocolError();
101 }); 140 });
102 } 141 }
103 142
104 private scheduleIdleTimeout() { 143 private handleVisibilityChange() {
105 if (document.visibilityState === 'hidden') { 144 if (document.visibilityState === 'hidden') {
106 if (this.idleTimeout !== null) { 145 if (this.state === State.TabVisible) {
107 return; 146 this.handleTabHidden();
108 } 147 }
109 log.info('Lost visibility, will disconnect in', BACKGROUND_IDLE_TIMEOUT_MS, 'ms'); 148 return;
110 this.idleTimeout = setTimeout(() => {
111 this.idleTimeout = null;
112 if (!this.isClosed && document.visibilityState === 'hidden') {
113 log.info('Closing websocket connection due to inactivity');
114 this.close();
115 }
116 }, BACKGROUND_IDLE_TIMEOUT_MS);
117 } else {
118 log.info('Gained visibility, connection will be kept alive');
119 if (this.idleTimeout !== null) {
120 clearTimeout(this.idleTimeout);
121 this.idleTimeout = null;
122 }
123 this.ensureOpen();
124 } 149 }
125 } 150 this.idleTimer.cancel();
126 151 if (this.state === State.TabHiddenIdle || this.state === State.TabHiddenWaiting) {
127 private schedulePingTimeout() { 152 this.handleTabVisibleConnected();
128 if (this.pingTimeout !== null) {
129 return; 153 return;
130 } 154 }
131 this.pingTimeout = setTimeout(() => { 155 if (this.state === State.TimedOut) {
132 if (this.isClosed) { 156 this.reconnect();
133 return; 157 }
134 }
135 if (this.isOpen) {
136 const ping = nanoid();
137 log.trace('ping:', ping);
138 this.pingTimeout = null;
139 this.send({
140 ping,
141 }).then((result) => {
142 if (!isPongResult(result) || result.pong !== ping) {
143 log.error('invalid pong');
144 this.forceReconnectDueToError();
145 }
146 log.trace('pong:', ping);
147 }).catch((error) => {
148 log.error('ping error', error);
149 this.forceReconnectDueToError();
150 });
151 }
152 this.schedulePingTimeout();
153 }, PING_TIMEOUT_MS);
154 } 158 }
155 159
156 private cleanupAndMaybeReconnect() { 160 private handleTabHidden() {
157 this.cleanup(); 161 log.trace('Tab became hidden while websocket is connected');
158 if (!this.closing) { 162 this.state = State.TabHiddenIdle;
159 this.delayedReconnect(); 163 this.idleTimer.schedule();
160 }
161 } 164 }
162 165
163 private cleanup() { 166 private handleTabVisibleConnected() {
164 this.pendingRequests.forEach((pendingRequest) => { 167 log.trace('Tab became visible while websocket is connected');
165 pendingRequest.reject(new Error('Websocket closed')); 168 this.state = State.TabVisible;
166 }); 169 }
167 this.pendingRequests.clear(); 170
168 if (this.idleTimeout !== null) { 171 private handleIdleTimeout() {
169 clearTimeout(this.idleTimeout); 172 log.trace('Waiting for pending tasks before disconnect');
170 this.idleTimeout = null; 173 if (this.state === State.TabHiddenIdle) {
171 } 174 this.state = State.TabHiddenWaiting;
172 if (this.pingTimeout !== null) { 175 this.handleWaitingForDisconnect();
173 clearTimeout(this.pingTimeout);
174 this.pingTimeout = null;
175 } 176 }
176 } 177 }
177 178
178 private delayedReconnect() { 179 private handleWaitingForDisconnect() {
179 if (this.reconnectTimeout !== null) { 180 if (this.state !== State.TabHiddenWaiting) {
180 clearTimeout(this.reconnectTimeout); 181 return;
181 this.reconnectTimeout = null;
182 } 182 }
183 this.reconnectTimeout = setTimeout(() => { 183 const pending = this.pendingRequests.size;
184 log.info('Attempting to reconnect websocket'); 184 if (pending === 0) {
185 this.reconnect(); 185 log.info('Closing idle websocket');
186 }, RECONNECT_DELAY_MS); 186 this.state = State.TimedOut;
187 this.closeConnection(1000, 'idle timeout');
188 return;
189 }
190 log.info('Waiting for', pending, 'pending requests before closing websocket');
187 } 191 }
188 192
189 public forceReconnectDueToError(): void { 193 private sendPing() {
190 this.closeConnection(); 194 if (!this.isOpen) {
191 this.cleanupAndMaybeReconnect(); 195 return;
196 }
197 const ping = nanoid();
198 log.trace('Ping', ping);
199 this.send({ ping }).then((result) => {
200 if (isPongResult(result) && result.pong === ping) {
201 log.trace('Pong', ping);
202 this.pingTimer.schedule();
203 } else {
204 log.error('Invalid pong');
205 this.handleProtocolError();
206 }
207 }).catch((error) => {
208 log.error('Error while waiting for ping', error);
209 this.handleProtocolError();
210 });
192 } 211 }
193 212
194 send(request: unknown): Promise<unknown> { 213 send(request: unknown): Promise<unknown> {
195 if (!this.isOpen) { 214 if (!this.isOpen) {
196 throw new Error('Connection is not open'); 215 throw new Error('Not open');
197 } 216 }
198 const messageId = this.nextMessageId.toString(16); 217 const messageId = this.nextMessageId.toString(16);
199 if (messageId in this.pendingRequests) { 218 if (messageId in this.pendingRequests) {
@@ -209,16 +228,20 @@ export class XtextWebSocketClient {
209 id: messageId, 228 id: messageId,
210 request, 229 request,
211 } as IXtextWebRequest); 230 } as IXtextWebRequest);
212 return new Promise((resolve, reject) => { 231 const promise = new Promise((resolve, reject) => {
213 this.connection.send(message); 232 this.pendingRequests.set(messageId, new PendingRequest(resolve, reject, () => {
214 this.pendingRequests.set(messageId, new PendingRequest(resolve, reject)); 233 this.removePendingRequest(messageId);
234 }));
215 }); 235 });
236 log.trace('Sending message', message);
237 this.connection.send(message);
238 return promise;
216 } 239 }
217 240
218 private handleMessage(messageStr: unknown) { 241 private handleMessage(messageStr: unknown) {
219 if (typeof messageStr !== 'string') { 242 if (typeof messageStr !== 'string') {
220 log.error('Unexpected binary message', messageStr); 243 log.error('Unexpected binary message', messageStr);
221 this.forceReconnectDueToError(); 244 this.handleProtocolError();
222 return; 245 return;
223 } 246 }
224 log.trace('Incoming websocket message', messageStr); 247 log.trace('Incoming websocket message', messageStr);
@@ -227,7 +250,7 @@ export class XtextWebSocketClient {
227 message = JSON.parse(messageStr); 250 message = JSON.parse(messageStr);
228 } catch (error) { 251 } catch (error) {
229 log.error('Json parse error', error); 252 log.error('Json parse error', error);
230 this.forceReconnectDueToError(); 253 this.handleProtocolError();
231 return; 254 return;
232 } 255 }
233 if (isOkResponse(message)) { 256 if (isOkResponse(message)) {
@@ -236,21 +259,28 @@ export class XtextWebSocketClient {
236 this.rejectRequest(message.id, new Error(`${message.error} error: ${message.message}`)); 259 this.rejectRequest(message.id, new Error(`${message.error} error: ${message.message}`));
237 if (message.error === 'server') { 260 if (message.error === 'server') {
238 log.error('Reconnecting due to server error: ', message.message); 261 log.error('Reconnecting due to server error: ', message.message);
239 this.forceReconnectDueToError(); 262 this.handleApplicationError();
240 } 263 }
241 } else if (isPushMessage(message)) { 264 } else if (isPushMessage(message)) {
242 this.onPush(message.resource, message.stateId, message.service, message.push); 265 this.onPush(
266 message.resource,
267 message.stateId,
268 message.service,
269 message.push,
270 ).catch((error) => {
271 log.error('Unexpected error in onPush handler', error);
272 });
243 } else { 273 } else {
244 log.error('Unexpected websocket message', message); 274 log.error('Unexpected websocket message', message);
245 this.forceReconnectDueToError(); 275 this.handleProtocolError();
246 } 276 }
247 } 277 }
248 278
249 private resolveRequest(messageId: string, value: unknown) { 279 private resolveRequest(messageId: string, value: unknown) {
250 const pendingRequest = this.pendingRequests.get(messageId); 280 const pendingRequest = this.pendingRequests.get(messageId);
251 this.pendingRequests.delete(messageId);
252 if (pendingRequest) { 281 if (pendingRequest) {
253 pendingRequest.resolve(value); 282 pendingRequest.resolve(value);
283 this.removePendingRequest(messageId);
254 return; 284 return;
255 } 285 }
256 log.error('Trying to resolve unknown request', messageId, 'with', value); 286 log.error('Trying to resolve unknown request', messageId, 'with', value);
@@ -258,24 +288,78 @@ export class XtextWebSocketClient {
258 288
259 private rejectRequest(messageId: string, reason?: unknown) { 289 private rejectRequest(messageId: string, reason?: unknown) {
260 const pendingRequest = this.pendingRequests.get(messageId); 290 const pendingRequest = this.pendingRequests.get(messageId);
261 this.pendingRequests.delete(messageId);
262 if (pendingRequest) { 291 if (pendingRequest) {
263 pendingRequest.reject(reason); 292 pendingRequest.reject(reason);
293 this.removePendingRequest(messageId);
264 return; 294 return;
265 } 295 }
266 log.error('Trying to reject unknown request', messageId, 'with', reason); 296 log.error('Trying to reject unknown request', messageId, 'with', reason);
267 } 297 }
268 298
269 private closeConnection() { 299 private removePendingRequest(messageId: string) {
270 if (!this.isClosed) { 300 this.pendingRequests.delete(messageId);
271 log.info('Closing websocket connection'); 301 this.handleWaitingForDisconnect();
272 this.connection.close(1000, 'end session'); 302 }
303
304 private handleProtocolError() {
305 if (this.isLogicallyClosed) {
306 return;
273 } 307 }
308 this.abortPendingRequests();
309 this.closeConnection(1000, 'reconnecting due to protocol error');
310 log.error('Reconnecting after delay due to protocol error');
311 this.handleErrorState();
274 } 312 }
275 313
276 close(): void { 314 handleApplicationError(): void {
277 this.closing = true; 315 if (this.isLogicallyClosed) {
278 this.closeConnection(); 316 return;
279 this.cleanup(); 317 }
318 this.abortPendingRequests();
319 this.closeConnection(1000, 'reconnecting due to application error');
320 this.appErrorCount += 1;
321 if (this.appErrorCount <= MAX_APP_ERROR_COUNT) {
322 log.error('Immediately reconnecting due to application error');
323 this.state = State.Initial;
324 this.reconnect();
325 } else {
326 log.error('Reconnecting after delay due to application error');
327 this.handleErrorState();
328 }
329 }
330
331 private abortPendingRequests() {
332 this.pendingRequests.forEach((request) => {
333 request.reject(new Error('Websocket disconnect'));
334 });
335 this.pendingRequests.clear();
336 }
337
338 private closeConnection(code: number, reason: string) {
339 this.pingTimer.cancel();
340 const { readyState } = this.connection;
341 if (readyState !== WebSocket.CLOSING && readyState !== WebSocket.CLOSED) {
342 this.connection.close(code, reason);
343 }
344 }
345
346 private handleErrorState() {
347 this.state = State.Error;
348 this.reconnectTryCount += 1;
349 const delay = RECONNECT_DELAY_MS[this.reconnectTryCount - 1] || MAX_RECONNECT_DELAY_MS;
350 log.info('Reconnecting in', delay, 'ms');
351 this.reconnectTimer.schedule(delay);
352 }
353
354 private handleReconnect() {
355 if (this.state !== State.Error) {
356 log.error('Unexpected reconnect in', this.state);
357 return;
358 }
359 if (document.visibilityState === 'hidden') {
360 this.state = State.TimedOut;
361 } else {
362 this.reconnect();
363 }
280 } 364 }
281} 365}
diff --git a/language-web/src/main/js/utils/Timer.ts b/language-web/src/main/js/utils/Timer.ts
new file mode 100644
index 00000000..efde6633
--- /dev/null
+++ b/language-web/src/main/js/utils/Timer.ts
@@ -0,0 +1,33 @@
1export class Timer {
2 readonly callback: () => void;
3
4 readonly defaultTimeout: number;
5
6 timeout: NodeJS.Timeout | null = null;
7
8 constructor(callback: () => void, defaultTimeout = 0) {
9 this.callback = () => {
10 this.timeout = null;
11 callback();
12 };
13 this.defaultTimeout = defaultTimeout;
14 }
15
16 schedule(timeout: number | null = null): void {
17 if (this.timeout === null) {
18 this.timeout = setTimeout(this.callback, timeout || this.defaultTimeout);
19 }
20 }
21
22 reschedule(timeout: number | null = null): void {
23 this.cancel();
24 this.schedule(timeout);
25 }
26
27 cancel(): void {
28 if (this.timeout !== null) {
29 clearTimeout(this.timeout);
30 this.timeout = null;
31 }
32 }
33}