diff options
Diffstat (limited to 'language-web/src')
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 @@ | |||
1 | package tools.refinery.language.web.xtext.server; | ||
2 | |||
3 | import java.util.Objects; | ||
4 | |||
5 | import org.eclipse.xtext.web.server.IServiceResult; | ||
6 | |||
7 | public 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'; | |||
2 | import { | 2 | import { |
3 | ChangeDesc, | 3 | ChangeDesc, |
4 | ChangeSet, | 4 | ChangeSet, |
5 | EditorState, | ||
6 | Transaction, | 5 | Transaction, |
7 | } from '@codemirror/state'; | 6 | } from '@codemirror/state'; |
8 | import { nanoid } from 'nanoid'; | 7 | import { 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 @@ | |||
1 | import { nanoid } from 'nanoid'; | ||
2 | |||
1 | import { getLogger } from '../logging'; | 3 | import { getLogger } from '../logging'; |
2 | import { PendingRequest } from './PendingRequest'; | 4 | import { PendingRequest } from './PendingRequest'; |
3 | import { | 5 | import { |
@@ -6,6 +8,7 @@ import { | |||
6 | isPushMessage, | 8 | isPushMessage, |
7 | IXtextWebRequest, | 9 | IXtextWebRequest, |
8 | } from './xtextMessages'; | 10 | } from './xtextMessages'; |
11 | import { isPongResult } from './xtextServiceResults'; | ||
9 | 12 | ||
10 | const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; | 13 | const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; |
11 | 14 | ||
@@ -13,6 +16,10 @@ const WEBSOCKET_CLOSE_OK = 1000; | |||
13 | 16 | ||
14 | const RECONNECT_DELAY_MS = 1000; | 17 | const RECONNECT_DELAY_MS = 1000; |
15 | 18 | ||
19 | const IDLE_TIMEOUT_MS = 10 * 60 * 1000; | ||
20 | |||
21 | const PING_TIMEOUT_MS = 10 * 1000; | ||
22 | |||
16 | const log = getLogger('XtextWebSocketClient'); | 23 | const log = getLogger('XtextWebSocketClient'); |
17 | 24 | ||
18 | type ReconnectHandler = () => void; | 25 | type 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 @@ | |||
1 | export interface IPongResult { | ||
2 | pong: string; | ||
3 | } | ||
4 | |||
5 | export function isPongResult(result: unknown): result is IPongResult { | ||
6 | const pongResult = result as IPongResult; | ||
7 | return typeof pongResult.pong === 'string'; | ||
8 | } | ||
9 | |||
1 | export interface IDocumentStateResult { | 10 | export interface IDocumentStateResult { |
2 | stateId: string; | 11 | stateId: string; |
3 | } | 12 | } |