aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2021-10-25 11:37:04 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2021-10-31 19:26:11 +0100
commit8f97866dfb5303eca7e7344db8e377a60a481d1f (patch)
tree9e655f090c36cc2dc456bd2c6a0b6f9c44894076
parentfeat(web): add xtext websocket client (diff)
downloadrefinery-8f97866dfb5303eca7e7344db8e377a60a481d1f.tar.gz
refinery-8f97866dfb5303eca7e7344db8e377a60a481d1f.tar.zst
refinery-8f97866dfb5303eca7e7344db8e377a60a481d1f.zip
feat(web): application-level pings
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java44
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java5
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java2
-rw-r--r--language-web/src/main/js/editor/XtextClient.ts2
-rw-r--r--language-web/src/main/js/editor/XtextWebSocketClient.ts95
-rw-r--r--language-web/src/main/js/editor/xtextServiceResults.ts9
6 files changed, 150 insertions, 7 deletions
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java
new file mode 100644
index 00000000..fe510f51
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java
@@ -0,0 +1,44 @@
1package tools.refinery.language.web.xtext.server;
2
3import java.util.Objects;
4
5import org.eclipse.xtext.web.server.IServiceResult;
6
7public class PongResult implements IServiceResult {
8 private String pong;
9
10 public PongResult(String pong) {
11 super();
12 this.pong = pong;
13 }
14
15 public String getPong() {
16 return pong;
17 }
18
19 public void setPong(String pong) {
20 this.pong = pong;
21 }
22
23 @Override
24 public int hashCode() {
25 return Objects.hash(pong);
26 }
27
28 @Override
29 public boolean equals(Object obj) {
30 if (this == obj)
31 return true;
32 if (obj == null)
33 return false;
34 if (getClass() != obj.getClass())
35 return false;
36 PongResult other = (PongResult) obj;
37 return Objects.equals(pong, other.pong);
38 }
39
40 @Override
41 public String toString() {
42 return "PongResult [pong=" + pong + "]";
43 }
44}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java
index f2f26d98..335f0636 100644
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java
@@ -46,6 +46,11 @@ public class TransactionExecutor implements IDisposable, PrecomputationListener
46 46
47 public void handleRequest(XtextWebRequest request) throws ResponseHandlerException { 47 public void handleRequest(XtextWebRequest request) throws ResponseHandlerException {
48 var serviceContext = new SimpleServiceContext(session, request.getRequestData()); 48 var serviceContext = new SimpleServiceContext(session, request.getRequestData());
49 var ping = serviceContext.getParameter("ping");
50 if (ping != null) {
51 responseHandler.onResponse(new XtextWebOkResponse(request, new PongResult(ping)));
52 return;
53 }
49 try { 54 try {
50 var injector = getInjector(serviceContext); 55 var injector = getInjector(serviceContext);
51 var serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class); 56 var serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class);
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java
index 6d4d2cad..942ca380 100644
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java
@@ -31,7 +31,7 @@ public abstract class XtextWebSocketServlet extends JettyWebSocketServlet implem
31 */ 31 */
32 private static final long MAX_FRAME_SIZE = 4L * 1024L * 1024L; 32 private static final long MAX_FRAME_SIZE = 4L * 1024L * 1024L;
33 33
34 private static final Duration IDLE_TIMEOUT = Duration.ofMinutes(10); 34 private static final Duration IDLE_TIMEOUT = Duration.ofSeconds(30);
35 35
36 private transient Logger log = LoggerFactory.getLogger(getClass()); 36 private transient Logger log = LoggerFactory.getLogger(getClass());
37 37
diff --git a/language-web/src/main/js/editor/XtextClient.ts b/language-web/src/main/js/editor/XtextClient.ts
index eeb67d72..27ef4165 100644
--- a/language-web/src/main/js/editor/XtextClient.ts
+++ b/language-web/src/main/js/editor/XtextClient.ts
@@ -2,7 +2,6 @@ import { Diagnostic, setDiagnostics } from '@codemirror/lint';
2import { 2import {
3 ChangeDesc, 3 ChangeDesc,
4 ChangeSet, 4 ChangeSet,
5 EditorState,
6 Transaction, 5 Transaction,
7} from '@codemirror/state'; 6} from '@codemirror/state';
8import { nanoid } from 'nanoid'; 7import { nanoid } from 'nanoid';
@@ -63,6 +62,7 @@ export class XtextClient {
63 onTransaction(transaction: Transaction): void { 62 onTransaction(transaction: Transaction): void {
64 const { changes } = transaction; 63 const { changes } = transaction;
65 if (!changes.empty) { 64 if (!changes.empty) {
65 this.webSocketClient.ensureOpen();
66 this.dirtyChanges = this.dirtyChanges.composeDesc(changes.desc); 66 this.dirtyChanges = this.dirtyChanges.composeDesc(changes.desc);
67 this.scheduleUpdate(); 67 this.scheduleUpdate();
68 } 68 }
diff --git a/language-web/src/main/js/editor/XtextWebSocketClient.ts b/language-web/src/main/js/editor/XtextWebSocketClient.ts
index 131e0067..f930160a 100644
--- a/language-web/src/main/js/editor/XtextWebSocketClient.ts
+++ b/language-web/src/main/js/editor/XtextWebSocketClient.ts
@@ -1,3 +1,5 @@
1import { nanoid } from 'nanoid';
2
1import { getLogger } from '../logging'; 3import { getLogger } from '../logging';
2import { PendingRequest } from './PendingRequest'; 4import { PendingRequest } from './PendingRequest';
3import { 5import {
@@ -6,6 +8,7 @@ import {
6 isPushMessage, 8 isPushMessage,
7 IXtextWebRequest, 9 IXtextWebRequest,
8} from './xtextMessages'; 10} from './xtextMessages';
11import { isPongResult } from './xtextServiceResults';
9 12
10const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; 13const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1';
11 14
@@ -13,6 +16,10 @@ const WEBSOCKET_CLOSE_OK = 1000;
13 16
14const RECONNECT_DELAY_MS = 1000; 17const RECONNECT_DELAY_MS = 1000;
15 18
19const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
20
21const PING_TIMEOUT_MS = 10 * 1000;
22
16const log = getLogger('XtextWebSocketClient'); 23const log = getLogger('XtextWebSocketClient');
17 24
18type ReconnectHandler = () => void; 25type ReconnectHandler = () => void;
@@ -34,6 +41,10 @@ export class XtextWebSocketClient {
34 41
35 reconnectTimeout: NodeJS.Timeout | null = null; 42 reconnectTimeout: NodeJS.Timeout | null = null;
36 43
44 idleTimeout: NodeJS.Timeout | null = null;
45
46 pingTimeout: NodeJS.Timeout | null = null;
47
37 constructor(onReconnect: ReconnectHandler, onPush: PushHandler) { 48 constructor(onReconnect: ReconnectHandler, onPush: PushHandler) {
38 this.onReconnect = onReconnect; 49 this.onReconnect = onReconnect;
39 this.onPush = onPush; 50 this.onPush = onPush;
@@ -44,6 +55,18 @@ export class XtextWebSocketClient {
44 return this.connection.readyState === WebSocket.OPEN; 55 return this.connection.readyState === WebSocket.OPEN;
45 } 56 }
46 57
58 get isClosed(): boolean {
59 return this.connection.readyState === WebSocket.CLOSING
60 || this.connection.readyState === WebSocket.CLOSED;
61 }
62
63 ensureOpen(): void {
64 if (this.isClosed) {
65 this.closing = false;
66 this.reconnect();
67 }
68 }
69
47 private reconnect() { 70 private reconnect() {
48 this.reconnectTimeout = null; 71 this.reconnectTimeout = null;
49 const webSocketServer = window.origin.replace(/^http/, 'ws'); 72 const webSocketServer = window.origin.replace(/^http/, 'ws');
@@ -71,18 +94,75 @@ export class XtextWebSocketClient {
71 } 94 }
72 this.cleanupAndMaybeReconnect(); 95 this.cleanupAndMaybeReconnect();
73 }); 96 });
97 this.scheduleIdleTimeout();
98 this.schedulePingTimeout();
99 }
100
101 private scheduleIdleTimeout() {
102 if (this.idleTimeout !== null) {
103 clearTimeout(this.idleTimeout);
104 }
105 this.idleTimeout = setTimeout(() => {
106 log.info('Closing websocket connection due to inactivity');
107 this.close();
108 }, IDLE_TIMEOUT_MS);
109 }
110
111 private schedulePingTimeout() {
112 if (this.pingTimeout !== null) {
113 return;
114 }
115 this.pingTimeout = setTimeout(() => {
116 if (this.isClosed) {
117 return;
118 }
119 if (this.isOpen) {
120 const ping = nanoid();
121 log.trace('ping:', ping);
122 this.pingTimeout = null;
123 this.internalSend({
124 ping,
125 }).catch((error) => {
126 log.error('ping error', error);
127 this.forceReconnectDueToError();
128 }).then((result) => {
129 if (!isPongResult(result) || result.pong !== ping) {
130 log.error('invalid pong');
131 this.forceReconnectDueToError();
132 }
133 log.trace('pong:', ping);
134 });
135 }
136 this.schedulePingTimeout();
137 }, PING_TIMEOUT_MS);
74 } 138 }
75 139
76 private cleanupAndMaybeReconnect() { 140 private cleanupAndMaybeReconnect() {
141 this.cleanup();
142 if (!this.closing) {
143 this.delayedReconnect();
144 }
145 }
146
147 private cleanup() {
77 this.pendingRequests.forEach((pendingRequest) => { 148 this.pendingRequests.forEach((pendingRequest) => {
78 pendingRequest.reject(new Error('Websocket closed')); 149 pendingRequest.reject(new Error('Websocket closed'));
79 }); 150 });
80 this.pendingRequests.clear(); 151 this.pendingRequests.clear();
81 if (this.closing) { 152 if (this.idleTimeout !== null) {
82 return; 153 clearTimeout(this.idleTimeout);
154 this.idleTimeout = null;
83 } 155 }
156 if (this.pingTimeout !== null) {
157 clearTimeout(this.pingTimeout);
158 this.pingTimeout = null;
159 }
160 }
161
162 private delayedReconnect() {
84 if (this.reconnectTimeout !== null) { 163 if (this.reconnectTimeout !== null) {
85 clearTimeout(this.reconnectTimeout); 164 clearTimeout(this.reconnectTimeout);
165 this.reconnectTimeout = null;
86 } 166 }
87 this.reconnectTimeout = setTimeout(() => { 167 this.reconnectTimeout = setTimeout(() => {
88 log.info('Attempting to reconnect websocket'); 168 log.info('Attempting to reconnect websocket');
@@ -99,6 +179,11 @@ export class XtextWebSocketClient {
99 if (!this.isOpen) { 179 if (!this.isOpen) {
100 throw new Error('Connection is not open'); 180 throw new Error('Connection is not open');
101 } 181 }
182 this.scheduleIdleTimeout();
183 return this.internalSend(request);
184 }
185
186 private internalSend(request: unknown): Promise<unknown> {
102 const messageId = this.nextMessageId.toString(16); 187 const messageId = this.nextMessageId.toString(16);
103 if (messageId in this.pendingRequests) { 188 if (messageId in this.pendingRequests) {
104 log.error('Message id wraparound still pending', messageId); 189 log.error('Message id wraparound still pending', messageId);
@@ -171,15 +256,15 @@ export class XtextWebSocketClient {
171 } 256 }
172 257
173 private closeConnection() { 258 private closeConnection() {
174 if (this.connection && this.connection.readyState !== WebSocket.CLOSING 259 if (!this.isClosed) {
175 && this.connection.readyState !== WebSocket.CLOSED) {
176 log.info('Closing websocket connection'); 260 log.info('Closing websocket connection');
177 this.connection.close(); 261 this.connection.close(1000, 'end session');
178 } 262 }
179 } 263 }
180 264
181 close(): void { 265 close(): void {
182 this.closing = true; 266 this.closing = true;
183 this.closeConnection(); 267 this.closeConnection();
268 this.cleanup();
184 } 269 }
185} 270}
diff --git a/language-web/src/main/js/editor/xtextServiceResults.ts b/language-web/src/main/js/editor/xtextServiceResults.ts
index 2a66566a..8fa7a321 100644
--- a/language-web/src/main/js/editor/xtextServiceResults.ts
+++ b/language-web/src/main/js/editor/xtextServiceResults.ts
@@ -1,3 +1,12 @@
1export interface IPongResult {
2 pong: string;
3}
4
5export function isPongResult(result: unknown): result is IPongResult {
6 const pongResult = result as IPongResult;
7 return typeof pongResult.pong === 'string';
8}
9
1export interface IDocumentStateResult { 10export interface IDocumentStateResult {
2 stateId: string; 11 stateId: string;
3} 12}