diff options
author | Kristóf Marussy <kristof@marussy.com> | 2021-10-26 21:40:36 +0200 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2021-10-31 19:26:12 +0100 |
commit | 232fbcafa863a3c28ab907b112c5257f0b6dc8f1 (patch) | |
tree | 20b0266e7d325e3c4ad6676616b35a7eb5f91fd8 /language-web/src/main | |
parent | feat(web): show lint status on lint button (diff) | |
download | refinery-232fbcafa863a3c28ab907b112c5257f0b6dc8f1.tar.gz refinery-232fbcafa863a3c28ab907b112c5257f0b6dc8f1.tar.zst refinery-232fbcafa863a3c28ab907b112c5257f0b6dc8f1.zip |
chore(web): refactor websocket state machine
Diffstat (limited to 'language-web/src/main')
-rw-r--r-- | language-web/src/main/js/editor/PendingRequest.ts | 10 | ||||
-rw-r--r-- | language-web/src/main/js/editor/XtextClient.ts | 48 | ||||
-rw-r--r-- | language-web/src/main/js/editor/XtextWebSocketClient.ts | 334 | ||||
-rw-r--r-- | language-web/src/main/js/utils/Timer.ts | 33 |
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 | ||
9 | import type { EditorStore } from './EditorStore'; | 9 | import type { EditorStore } from './EditorStore'; |
10 | import { getLogger } from '../logging'; | 10 | import { getLogger } from '../logging'; |
11 | import { Timer } from '../utils/Timer'; | ||
11 | import { | 12 | import { |
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 | ||
3 | import { getLogger } from '../logging'; | 3 | import { getLogger } from '../logging'; |
4 | import { PendingRequest } from './PendingRequest'; | 4 | import { PendingRequest } from './PendingRequest'; |
5 | import { Timer } from '../utils/Timer'; | ||
5 | import { | 6 | import { |
6 | isErrorResponse, | 7 | isErrorResponse, |
7 | isOkResponse, | 8 | isOkResponse, |
@@ -14,7 +15,11 @@ const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; | |||
14 | 15 | ||
15 | const WEBSOCKET_CLOSE_OK = 1000; | 16 | const WEBSOCKET_CLOSE_OK = 1000; |
16 | 17 | ||
17 | const RECONNECT_DELAY_MS = 1000; | 18 | const RECONNECT_DELAY_MS = [1000, 5000, 30_000]; |
19 | |||
20 | const MAX_RECONNECT_DELAY_MS = RECONNECT_DELAY_MS[RECONNECT_DELAY_MS.length - 1]; | ||
21 | |||
22 | const MAX_APP_ERROR_COUNT = 1; | ||
18 | 23 | ||
19 | const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000; | 24 | const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000; |
20 | 25 | ||
@@ -22,15 +27,28 @@ const PING_TIMEOUT_MS = 10 * 1000; | |||
22 | 27 | ||
23 | const log = getLogger('XtextWebSocketClient'); | 28 | const log = getLogger('XtextWebSocketClient'); |
24 | 29 | ||
25 | type ReconnectHandler = () => void; | 30 | type ReconnectHandler = () => Promise<void>; |
26 | 31 | ||
27 | type PushHandler = (resourceId: string, stateId: string, service: string, data: unknown) => void; | 32 | type PushHandler = ( |
33 | resourceId: string, | ||
34 | stateId: string, | ||
35 | service: string, | ||
36 | data: unknown | ||
37 | ) => Promise<void>; | ||
38 | |||
39 | enum State { | ||
40 | Initial, | ||
41 | Opening, | ||
42 | TabVisible, | ||
43 | TabHiddenIdle, | ||
44 | TabHiddenWaiting, | ||
45 | Error, | ||
46 | TimedOut, | ||
47 | } | ||
28 | 48 | ||
29 | export class XtextWebSocketClient { | 49 | export 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 @@ | |||
1 | export 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 | } | ||