From 8f97866dfb5303eca7e7344db8e377a60a481d1f Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Mon, 25 Oct 2021 11:37:04 +0200 Subject: feat(web): application-level pings --- .../language/web/xtext/server/PongResult.java | 44 ++++++++++ .../web/xtext/server/TransactionExecutor.java | 5 ++ .../web/xtext/servlet/XtextWebSocketServlet.java | 2 +- language-web/src/main/js/editor/XtextClient.ts | 2 +- .../src/main/js/editor/XtextWebSocketClient.ts | 95 ++++++++++++++++++++-- .../src/main/js/editor/xtextServiceResults.ts | 9 ++ 6 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java 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 @@ +package tools.refinery.language.web.xtext.server; + +import java.util.Objects; + +import org.eclipse.xtext.web.server.IServiceResult; + +public class PongResult implements IServiceResult { + private String pong; + + public PongResult(String pong) { + super(); + this.pong = pong; + } + + public String getPong() { + return pong; + } + + public void setPong(String pong) { + this.pong = pong; + } + + @Override + public int hashCode() { + return Objects.hash(pong); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + PongResult other = (PongResult) obj; + return Objects.equals(pong, other.pong); + } + + @Override + public String toString() { + return "PongResult [pong=" + pong + "]"; + } +} 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 public void handleRequest(XtextWebRequest request) throws ResponseHandlerException { var serviceContext = new SimpleServiceContext(session, request.getRequestData()); + var ping = serviceContext.getParameter("ping"); + if (ping != null) { + responseHandler.onResponse(new XtextWebOkResponse(request, new PongResult(ping))); + return; + } try { var injector = getInjector(serviceContext); 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 */ private static final long MAX_FRAME_SIZE = 4L * 1024L * 1024L; - private static final Duration IDLE_TIMEOUT = Duration.ofMinutes(10); + private static final Duration IDLE_TIMEOUT = Duration.ofSeconds(30); private transient Logger log = LoggerFactory.getLogger(getClass()); 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'; import { ChangeDesc, ChangeSet, - EditorState, Transaction, } from '@codemirror/state'; import { nanoid } from 'nanoid'; @@ -63,6 +62,7 @@ export class XtextClient { onTransaction(transaction: Transaction): void { const { changes } = transaction; if (!changes.empty) { + this.webSocketClient.ensureOpen(); this.dirtyChanges = this.dirtyChanges.composeDesc(changes.desc); this.scheduleUpdate(); } 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 @@ +import { nanoid } from 'nanoid'; + import { getLogger } from '../logging'; import { PendingRequest } from './PendingRequest'; import { @@ -6,6 +8,7 @@ import { isPushMessage, IXtextWebRequest, } from './xtextMessages'; +import { isPongResult } from './xtextServiceResults'; const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; @@ -13,6 +16,10 @@ const WEBSOCKET_CLOSE_OK = 1000; const RECONNECT_DELAY_MS = 1000; +const IDLE_TIMEOUT_MS = 10 * 60 * 1000; + +const PING_TIMEOUT_MS = 10 * 1000; + const log = getLogger('XtextWebSocketClient'); type ReconnectHandler = () => void; @@ -34,6 +41,10 @@ export class XtextWebSocketClient { reconnectTimeout: NodeJS.Timeout | null = null; + idleTimeout: NodeJS.Timeout | null = null; + + pingTimeout: NodeJS.Timeout | null = null; + constructor(onReconnect: ReconnectHandler, onPush: PushHandler) { this.onReconnect = onReconnect; this.onPush = onPush; @@ -44,6 +55,18 @@ export class XtextWebSocketClient { return this.connection.readyState === WebSocket.OPEN; } + get isClosed(): boolean { + return this.connection.readyState === WebSocket.CLOSING + || this.connection.readyState === WebSocket.CLOSED; + } + + ensureOpen(): void { + if (this.isClosed) { + this.closing = false; + this.reconnect(); + } + } + private reconnect() { this.reconnectTimeout = null; const webSocketServer = window.origin.replace(/^http/, 'ws'); @@ -71,18 +94,75 @@ export class XtextWebSocketClient { } this.cleanupAndMaybeReconnect(); }); + this.scheduleIdleTimeout(); + this.schedulePingTimeout(); + } + + private scheduleIdleTimeout() { + if (this.idleTimeout !== null) { + clearTimeout(this.idleTimeout); + } + this.idleTimeout = setTimeout(() => { + log.info('Closing websocket connection due to inactivity'); + this.close(); + }, IDLE_TIMEOUT_MS); + } + + private schedulePingTimeout() { + if (this.pingTimeout !== null) { + return; + } + this.pingTimeout = setTimeout(() => { + if (this.isClosed) { + return; + } + if (this.isOpen) { + const ping = nanoid(); + log.trace('ping:', ping); + this.pingTimeout = null; + this.internalSend({ + ping, + }).catch((error) => { + log.error('ping error', error); + this.forceReconnectDueToError(); + }).then((result) => { + if (!isPongResult(result) || result.pong !== ping) { + log.error('invalid pong'); + this.forceReconnectDueToError(); + } + log.trace('pong:', ping); + }); + } + this.schedulePingTimeout(); + }, PING_TIMEOUT_MS); } private cleanupAndMaybeReconnect() { + this.cleanup(); + if (!this.closing) { + this.delayedReconnect(); + } + } + + private cleanup() { this.pendingRequests.forEach((pendingRequest) => { pendingRequest.reject(new Error('Websocket closed')); }); this.pendingRequests.clear(); - if (this.closing) { - return; + if (this.idleTimeout !== null) { + clearTimeout(this.idleTimeout); + this.idleTimeout = null; } + if (this.pingTimeout !== null) { + clearTimeout(this.pingTimeout); + this.pingTimeout = null; + } + } + + private delayedReconnect() { if (this.reconnectTimeout !== null) { clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; } this.reconnectTimeout = setTimeout(() => { log.info('Attempting to reconnect websocket'); @@ -99,6 +179,11 @@ export class XtextWebSocketClient { if (!this.isOpen) { throw new Error('Connection is not open'); } + this.scheduleIdleTimeout(); + return this.internalSend(request); + } + + private internalSend(request: unknown): Promise { const messageId = this.nextMessageId.toString(16); if (messageId in this.pendingRequests) { log.error('Message id wraparound still pending', messageId); @@ -171,15 +256,15 @@ export class XtextWebSocketClient { } private closeConnection() { - if (this.connection && this.connection.readyState !== WebSocket.CLOSING - && this.connection.readyState !== WebSocket.CLOSED) { + if (!this.isClosed) { log.info('Closing websocket connection'); - this.connection.close(); + this.connection.close(1000, 'end session'); } } close(): void { this.closing = true; this.closeConnection(); + this.cleanup(); } } 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 @@ +export interface IPongResult { + pong: string; +} + +export function isPongResult(result: unknown): result is IPongResult { + const pongResult = result as IPongResult; + return typeof pongResult.pong === 'string'; +} + export interface IDocumentStateResult { stateId: string; } -- cgit v1.2.3-54-g00ecf