aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2021-10-19 00:49:31 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2021-10-31 19:26:11 +0100
commitac09055140a5c30e73e9ada16986ef60c38c2138 (patch)
tree251e7554359fd3437e0d2aeb3b91545c940a2f10
parentfeat(web): batch operations for websocket protocol (diff)
downloadrefinery-ac09055140a5c30e73e9ada16986ef60c38c2138.tar.gz
refinery-ac09055140a5c30e73e9ada16986ef60c38c2138.tar.zst
refinery-ac09055140a5c30e73e9ada16986ef60c38c2138.zip
feat(web): batched xtext websocket prototype
-rw-r--r--gradle.properties1
-rw-r--r--gradle/junit.gradle2
-rw-r--r--language-web/build.gradle2
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java6
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java2
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java2
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/NoPrecomputedServicesXtextServiceDispatcher.java16
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/ResponseHandler.java8
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java (renamed from language-web/src/main/java/tools/refinery/language/web/xtext/SimpleServiceContext.java)17
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java (renamed from language-web/src/main/java/tools/refinery/language/web/xtext/SimpleSession.java)2
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/TransactionExecutor.java (renamed from language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocket.java)97
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java (renamed from language-web/src/main/java/tools/refinery/language/web/xtext/XtextStatusCode.java)2
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java94
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorKind.java (renamed from language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketErrorKind.java)2
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorResponse.java (renamed from language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketErrorResponse.java)2
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketOkResponse.java (renamed from language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketOkResponse.java)2
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketRequest.java (renamed from language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketRequest.java)12
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketResponse.java (renamed from language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketResponse.java)2
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java (renamed from language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketServlet.java)2
-rw-r--r--language-web/src/test/java/tools/refinery/language/web/xtext/servlet/AwaitTerminationExecutorServiceProvider.java34
-rw-r--r--language-web/src/test/java/tools/refinery/language/web/xtext/servlet/ProblemWebInjectorProvider.java47
-rw-r--r--language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java151
22 files changed, 402 insertions, 103 deletions
diff --git a/gradle.properties b/gradle.properties
index 5c0df1d3..1020bfc9 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -5,6 +5,7 @@ hamcrestVersion=2.2
5jettyVersion=11.0.6 5jettyVersion=11.0.6
6jmhVersion=1.32 6jmhVersion=1.32
7junitVersion=5.8.1 7junitVersion=5.8.1
8mockitoVersion=4.0.0
8mweVersion=1.6.2.M1 9mweVersion=1.6.2.M1
9mwe2Version=2.12.2.M1 10mwe2Version=2.12.2.M1
10nodeVersion=14.18.0 11nodeVersion=14.18.0
diff --git a/gradle/junit.gradle b/gradle/junit.gradle
index da0cea8c..44a463c7 100644
--- a/gradle/junit.gradle
+++ b/gradle/junit.gradle
@@ -5,6 +5,8 @@ dependencies {
5 testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${junitVersion}" 5 testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${junitVersion}"
6 testImplementation "org.junit.jupiter:junit-jupiter-params:${junitVersion}" 6 testImplementation "org.junit.jupiter:junit-jupiter-params:${junitVersion}"
7 testImplementation "org.hamcrest:hamcrest:${hamcrestVersion}" 7 testImplementation "org.hamcrest:hamcrest:${hamcrestVersion}"
8 testImplementation "org.mockito:mockito-core:${mockitoVersion}"
9 testImplementation "org.mockito:mockito-junit-jupiter:${mockitoVersion}"
8} 10}
9 11
10def jacocoTestReport = tasks.named('jacocoTestReport') 12def jacocoTestReport = tasks.named('jacocoTestReport')
diff --git a/language-web/build.gradle b/language-web/build.gradle
index df23bc91..fc2c9bba 100644
--- a/language-web/build.gradle
+++ b/language-web/build.gradle
@@ -1,5 +1,6 @@
1apply plugin: 'java' 1apply plugin: 'java'
2apply from: "${rootDir}/gradle/xtext-common.gradle" 2apply from: "${rootDir}/gradle/xtext-common.gradle"
3apply from: "${rootDir}/gradle/junit.gradle"
3 4
4dependencies { 5dependencies {
5 implementation project(':refinery-language') 6 implementation project(':refinery-language')
@@ -11,6 +12,7 @@ dependencies {
11 implementation "org.eclipse.jetty.websocket:websocket-jetty-server:${jettyVersion}" 12 implementation "org.eclipse.jetty.websocket:websocket-jetty-server:${jettyVersion}"
12 implementation "org.slf4j:slf4j-simple:${slf4JVersion}" 13 implementation "org.slf4j:slf4j-simple:${slf4JVersion}"
13 implementation "org.slf4j:log4j-over-slf4j:${slf4JVersion}" 14 implementation "org.slf4j:log4j-over-slf4j:${slf4JVersion}"
15 testImplementation "org.eclipse.xtext:org.eclipse.xtext.testing:${xtextVersion}"
14} 16}
15 17
16def generateXtextLanguage = project(':refinery-language').tasks.named('generateXtextLanguage') 18def generateXtextLanguage = project(':refinery-language').tasks.named('generateXtextLanguage')
diff --git a/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java b/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java
index 799a9c64..04f38414 100644
--- a/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java
+++ b/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java
@@ -3,9 +3,15 @@
3 */ 3 */
4package tools.refinery.language.web; 4package tools.refinery.language.web;
5 5
6import org.eclipse.xtext.web.server.XtextServiceDispatcher;
7
8import tools.refinery.language.web.xtext.server.NoPrecomputedServicesXtextServiceDispatcher;
6 9
7/** 10/**
8 * Use this class to register additional components to be used within the web application. 11 * Use this class to register additional components to be used within the web application.
9 */ 12 */
10public class ProblemWebModule extends AbstractProblemWebModule { 13public class ProblemWebModule extends AbstractProblemWebModule {
14 public Class<? extends XtextServiceDispatcher> bindXtextServiceDispatcher() {
15 return NoPrecomputedServicesXtextServiceDispatcher.class;
16 }
11} 17}
diff --git a/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java b/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java
index 9ffd6557..df67b521 100644
--- a/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java
+++ b/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java
@@ -3,7 +3,7 @@ package tools.refinery.language.web;
3import org.eclipse.xtext.util.DisposableRegistry; 3import org.eclipse.xtext.util.DisposableRegistry;
4 4
5import jakarta.servlet.ServletException; 5import jakarta.servlet.ServletException;
6import tools.refinery.language.web.xtext.XtextWebSocketServlet; 6import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet;
7 7
8public class ProblemWebSocketServlet extends XtextWebSocketServlet { 8public class ProblemWebSocketServlet extends XtextWebSocketServlet {
9 9
diff --git a/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java b/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java
index f6311070..a71d8e93 100644
--- a/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java
+++ b/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java
@@ -23,7 +23,7 @@ import org.slf4j.LoggerFactory;
23 23
24import jakarta.servlet.DispatcherType; 24import jakarta.servlet.DispatcherType;
25import jakarta.servlet.SessionTrackingMode; 25import jakarta.servlet.SessionTrackingMode;
26import tools.refinery.language.web.xtext.XtextWebSocketServlet; 26import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet;
27 27
28public class ServerLauncher { 28public class ServerLauncher {
29 public static final String DEFAULT_LISTEN_ADDRESS = "localhost"; 29 public static final String DEFAULT_LISTEN_ADDRESS = "localhost";
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/NoPrecomputedServicesXtextServiceDispatcher.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/NoPrecomputedServicesXtextServiceDispatcher.java
new file mode 100644
index 00000000..6660d6ac
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/NoPrecomputedServicesXtextServiceDispatcher.java
@@ -0,0 +1,16 @@
1package tools.refinery.language.web.xtext.server;
2
3import org.eclipse.xtext.web.server.XtextServiceDispatcher;
4import org.eclipse.xtext.web.server.model.PrecomputedServiceRegistry;
5
6import com.google.inject.Singleton;
7
8@Singleton
9public class NoPrecomputedServicesXtextServiceDispatcher extends XtextServiceDispatcher {
10 @Override
11 protected void registerPreComputedServices(PrecomputedServiceRegistry registry) {
12 // Do not register any precomputed services, because we will always send
13 // requests for any pre-computation in the same websocket message as the
14 // document update request.
15 }
16}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/ResponseHandler.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/ResponseHandler.java
new file mode 100644
index 00000000..b1fcbc8b
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/ResponseHandler.java
@@ -0,0 +1,8 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import java.io.IOException;
4
5@FunctionalInterface
6public interface ResponseHandler {
7 void onResponse(XtextWebSocketResponse response) throws IOException;
8}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/SimpleServiceContext.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java
index 8036b749..945d5db0 100644
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/SimpleServiceContext.java
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java
@@ -1,4 +1,4 @@
1package tools.refinery.language.web.xtext; 1package tools.refinery.language.web.xtext.servlet;
2 2
3import java.util.Map; 3import java.util.Map;
4import java.util.Set; 4import java.util.Set;
@@ -8,6 +8,7 @@ import org.eclipse.xtext.web.server.ISession;
8import org.eclipse.xtext.web.server.InvalidRequestException; 8import org.eclipse.xtext.web.server.InvalidRequestException;
9 9
10import com.google.common.collect.ImmutableMap; 10import com.google.common.collect.ImmutableMap;
11import com.google.common.collect.ImmutableSet;
11 12
12record SimpleServiceContext(ISession session, Map<String, String> parameters) implements IServiceContext { 13record SimpleServiceContext(ISession session, Map<String, String> parameters) implements IServiceContext {
13 14
@@ -17,13 +18,9 @@ record SimpleServiceContext(ISession session, Map<String, String> parameters) im
17 18
18 public static final String STATE_ID_PARAMETER = "requiredStateId"; 19 public static final String STATE_ID_PARAMETER = "requiredStateId";
19 20
20 public SimpleServiceContext(ISession session, XtextWebSocketRequest request, String stateId, int index) {
21 this(session, addPerTransactionData(request, stateId, request.getRequestData().get(index)));
22 }
23
24 @Override 21 @Override
25 public Set<String> getParameterKeys() { 22 public Set<String> getParameterKeys() {
26 return parameters.keySet(); 23 return ImmutableSet.copyOf(parameters.keySet());
27 } 24 }
28 25
29 @Override 26 @Override
@@ -36,8 +33,9 @@ record SimpleServiceContext(ISession session, Map<String, String> parameters) im
36 return session; 33 return session;
37 } 34 }
38 35
39 private static Map<String, String> addPerTransactionData(XtextWebSocketRequest request, String stateId, 36 public static IServiceContext ofTransaction(ISession session, XtextWebSocketRequest request, String stateId,
40 Map<String, String> parameters) { 37 int index) {
38 var parameters = request.getRequestData().get(index);
41 checkParameters(parameters, RESOURCE_NAME_PARAMETER); 39 checkParameters(parameters, RESOURCE_NAME_PARAMETER);
42 checkParameters(parameters, CONTENT_TYPE_PARAMETER); 40 checkParameters(parameters, CONTENT_TYPE_PARAMETER);
43 checkParameters(parameters, STATE_ID_PARAMETER); 41 checkParameters(parameters, STATE_ID_PARAMETER);
@@ -52,7 +50,8 @@ record SimpleServiceContext(ISession session, Map<String, String> parameters) im
52 if (stateId != null) { 50 if (stateId != null) {
53 builder.put(STATE_ID_PARAMETER, stateId); 51 builder.put(STATE_ID_PARAMETER, stateId);
54 } 52 }
55 return builder.build(); 53 var allParameters = builder.build();
54 return new SimpleServiceContext(session, allParameters);
56 } 55 }
57 56
58 private static void checkParameters(Map<String, String> parameters, String perTransactionParameter) { 57 private static void checkParameters(Map<String, String> parameters, String perTransactionParameter) {
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/SimpleSession.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java
index 691077d0..09c055a2 100644
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/SimpleSession.java
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java
@@ -1,4 +1,4 @@
1package tools.refinery.language.web.xtext; 1package tools.refinery.language.web.xtext.servlet;
2 2
3import java.util.HashMap; 3import java.util.HashMap;
4import java.util.Map; 4import java.util.Map;
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocket.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/TransactionExecutor.java
index 0849ccb7..08687097 100644
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocket.java
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/TransactionExecutor.java
@@ -1,17 +1,8 @@
1package tools.refinery.language.web.xtext; 1package tools.refinery.language.web.xtext.servlet;
2 2
3import java.io.IOException; 3import java.io.IOException;
4import java.io.Reader;
5 4
6import org.eclipse.emf.common.util.URI; 5import org.eclipse.emf.common.util.URI;
7import org.eclipse.jetty.websocket.api.Session;
8import org.eclipse.jetty.websocket.api.StatusCode;
9import org.eclipse.jetty.websocket.api.WriteCallback;
10import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
11import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
12import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
13import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
14import org.eclipse.jetty.websocket.api.annotations.WebSocket;
15import org.eclipse.xtext.resource.IResourceServiceProvider; 6import org.eclipse.xtext.resource.IResourceServiceProvider;
16import org.eclipse.xtext.web.server.IServiceResult; 7import org.eclipse.xtext.web.server.IServiceResult;
17import org.eclipse.xtext.web.server.ISession; 8import org.eclipse.xtext.web.server.ISession;
@@ -25,87 +16,35 @@ import org.eclipse.xtext.web.server.hover.HoverResult;
25import org.eclipse.xtext.web.server.model.DocumentStateResult; 16import org.eclipse.xtext.web.server.model.DocumentStateResult;
26import org.eclipse.xtext.web.server.occurrences.OccurrencesResult; 17import org.eclipse.xtext.web.server.occurrences.OccurrencesResult;
27import org.eclipse.xtext.web.server.persistence.ResourceContentResult; 18import org.eclipse.xtext.web.server.persistence.ResourceContentResult;
28import org.slf4j.Logger;
29import org.slf4j.LoggerFactory;
30 19
31import com.google.common.base.Strings; 20import com.google.common.base.Strings;
32import com.google.gson.Gson;
33import com.google.gson.JsonIOException;
34import com.google.gson.JsonParseException;
35import com.google.inject.Injector; 21import com.google.inject.Injector;
36 22
37@WebSocket 23public class TransactionExecutor {
38public class XtextWebSocket {
39 private static final Logger LOG = LoggerFactory.getLogger(XtextWebSocket.class);
40
41 private final Gson gson = new Gson();
42
43 private final ISession session; 24 private final ISession session;
44 25
45 private final IResourceServiceProvider.Registry resourceServiceProviderRegistry; 26 private final IResourceServiceProvider.Registry resourceServiceProviderRegistry;
46 27
47 public XtextWebSocket(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) { 28 public TransactionExecutor(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) {
48 this.session = session; 29 this.session = session;
49 this.resourceServiceProviderRegistry = resourceServiceProviderRegistry; 30 this.resourceServiceProviderRegistry = resourceServiceProviderRegistry;
50 } 31 }
51 32
52 @OnWebSocketConnect 33 public void handleRequest(XtextWebSocketRequest request, ResponseHandler handler) throws IOException {
53 public void onConnect(Session webSocketSession) {
54 LOG.debug("New websocket connection from {}", webSocketSession.getRemoteAddress());
55 }
56
57 @OnWebSocketClose
58 public void onClose(Session webSocketSession, int statusCode, String reason) {
59 if (statusCode == StatusCode.NORMAL) {
60 LOG.debug("{} closed connection normally: {}", webSocketSession.getRemoteAddress(), reason);
61 } else {
62 LOG.warn("{} closed connection with status code {}: {}", webSocketSession.getRemoteAddress(), statusCode,
63 reason);
64 }
65 }
66
67 @OnWebSocketError
68 public void onError(Session webSocketSession, Throwable error) {
69 LOG.error("Internal websocket error in connection from" + webSocketSession.getRemoteAddress(), error);
70 }
71
72 @OnWebSocketMessage
73 public void onMessage(Session webSocketSession, Reader reader) {
74 XtextWebSocketRequest request;
75 try {
76 request = gson.fromJson(reader, XtextWebSocketRequest.class);
77 } catch (JsonIOException e) {
78 LOG.error("Cannot read from websocket from" + webSocketSession.getRemoteAddress(), e);
79 if (webSocketSession.isOpen()) {
80 webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot read payload");
81 }
82 return;
83 } catch (JsonParseException e) {
84 LOG.warn("Malformed websocket request from" + webSocketSession.getRemoteAddress(), e);
85 webSocketSession.close(XtextStatusCode.INVALID_JSON, "Invalid JSON payload");
86 return;
87 }
88 var requestData = request.getRequestData(); 34 var requestData = request.getRequestData();
89 if (requestData == null || requestData.isEmpty()) { 35 if (requestData == null || requestData.isEmpty()) {
90 // Nothing to do. 36 // Nothing to do.
91 return; 37 return;
92 } 38 }
93 int nCalls = requestData.size(); 39 int nCalls = requestData.size();
94 try { 40 int lastCall = handleTransaction(request, handler);
95 int lastCall = handleTransaction(webSocketSession, request); 41 for (int index = lastCall + 1; index < nCalls; index++) {
96 for (int index = lastCall + 1; index < nCalls; index++) { 42 handler.onResponse(
97 sendReply(webSocketSession, 43 new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.TRANSACTION_CANCELLED));
98 new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.TRANSACTION_CANCELLED));
99 }
100 } catch (IOException e) {
101 LOG.warn("Cannot initiaite async write to websocket " + webSocketSession.getRemoteAddress(), e);
102 if (webSocketSession.isOpen()) {
103 webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot write payload");
104 }
105 } 44 }
106 } 45 }
107 46
108 protected int handleTransaction(Session webSocketSession, XtextWebSocketRequest request) throws IOException { 47 protected int handleTransaction(XtextWebSocketRequest request, ResponseHandler handler) throws IOException {
109 var requestData = request.getRequestData(); 48 var requestData = request.getRequestData();
110 var stateId = request.getRequiredStateId(); 49 var stateId = request.getRequiredStateId();
111 int index = 0; 50 int index = 0;
@@ -114,10 +53,10 @@ public class XtextWebSocket {
114 var serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class); 53 var serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class);
115 int nCalls = requestData.size(); 54 int nCalls = requestData.size();
116 for (; index < nCalls; index++) { 55 for (; index < nCalls; index++) {
117 var serviceContext = new SimpleServiceContext(session, request, stateId, index); 56 var serviceContext = SimpleServiceContext.ofTransaction(session, request, stateId, index);
118 var service = serviceDispatcher.getService(serviceContext); 57 var service = serviceDispatcher.getService(serviceContext);
119 var serviceResult = service.getService().apply(); 58 var serviceResult = service.getService().apply();
120 sendReply(webSocketSession, new XtextWebSocketOkResponse(request, index, serviceResult)); 59 handler.onResponse(new XtextWebSocketOkResponse(request, index, serviceResult));
121 if (serviceResult instanceof ServiceConflictResult) { 60 if (serviceResult instanceof ServiceConflictResult) {
122 break; 61 break;
123 } 62 }
@@ -127,25 +66,15 @@ public class XtextWebSocket {
127 } 66 }
128 } 67 }
129 } catch (InvalidRequestException e) { 68 } catch (InvalidRequestException e) {
130 sendReply(webSocketSession, 69 handler.onResponse(
131 new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.REQUEST_ERROR, e)); 70 new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.REQUEST_ERROR, e));
132 } catch (RuntimeException e) { 71 } catch (RuntimeException e) {
133 sendReply(webSocketSession, 72 handler.onResponse(
134 new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.SERVER_ERROR, e)); 73 new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.SERVER_ERROR, e));
135 } 74 }
136 return index; 75 return index;
137 } 76 }
138 77
139 protected void sendReply(Session webSocketSession, XtextWebSocketResponse response) throws IOException {
140 var responseString = gson.toJson(response);
141 webSocketSession.getRemote().sendPartialString(responseString, true, new WriteCallback() {
142 @Override
143 public void writeFailed(Throwable x) {
144 LOG.warn("Cannot complete async write to websocket " + webSocketSession.getRemoteAddress(), x);
145 }
146 });
147 }
148
149 /** 78 /**
150 * Get the injector to satisfy the request in the {@code serviceContext}. 79 * Get the injector to satisfy the request in the {@code serviceContext}.
151 * 80 *
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextStatusCode.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java
index 8ef60108..0cd229e8 100644
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextStatusCode.java
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java
@@ -1,4 +1,4 @@
1package tools.refinery.language.web.xtext; 1package tools.refinery.language.web.xtext.servlet;
2 2
3public final class XtextStatusCode { 3public final class XtextStatusCode {
4 public static final int INVALID_JSON = 4007; 4 public static final int INVALID_JSON = 4007;
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java
new file mode 100644
index 00000000..25f67545
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java
@@ -0,0 +1,94 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import java.io.IOException;
4import java.io.Reader;
5
6import org.eclipse.jetty.websocket.api.Session;
7import org.eclipse.jetty.websocket.api.StatusCode;
8import org.eclipse.jetty.websocket.api.WriteCallback;
9import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
10import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
11import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
12import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
13import org.eclipse.jetty.websocket.api.annotations.WebSocket;
14import org.eclipse.xtext.resource.IResourceServiceProvider;
15import org.eclipse.xtext.web.server.ISession;
16import org.slf4j.Logger;
17import org.slf4j.LoggerFactory;
18
19import com.google.gson.Gson;
20import com.google.gson.JsonIOException;
21import com.google.gson.JsonParseException;
22
23@WebSocket
24public class XtextWebSocket {
25 private static final Logger LOG = LoggerFactory.getLogger(XtextWebSocket.class);
26
27 private final Gson gson = new Gson();
28
29 private final TransactionExecutor executor;
30
31 public XtextWebSocket(TransactionExecutor executor) {
32 this.executor = executor;
33 }
34
35 public XtextWebSocket(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) {
36 this(new TransactionExecutor(session, resourceServiceProviderRegistry));
37 }
38
39 @OnWebSocketConnect
40 public void onConnect(Session webSocketSession) {
41 LOG.debug("New websocket connection from {}", webSocketSession.getRemoteAddress());
42 }
43
44 @OnWebSocketClose
45 public void onClose(Session webSocketSession, int statusCode, String reason) {
46 if (statusCode == StatusCode.NORMAL) {
47 LOG.debug("{} closed connection normally: {}", webSocketSession.getRemoteAddress(), reason);
48 } else {
49 LOG.warn("{} closed connection with status code {}: {}", webSocketSession.getRemoteAddress(), statusCode,
50 reason);
51 }
52 }
53
54 @OnWebSocketError
55 public void onError(Session webSocketSession, Throwable error) {
56 LOG.error("Internal websocket error in connection from" + webSocketSession.getRemoteAddress(), error);
57 }
58
59 @OnWebSocketMessage
60 public void onMessage(Session webSocketSession, Reader reader) {
61 XtextWebSocketRequest request;
62 try {
63 request = gson.fromJson(reader, XtextWebSocketRequest.class);
64 } catch (JsonIOException e) {
65 LOG.error("Cannot read from websocket from" + webSocketSession.getRemoteAddress(), e);
66 if (webSocketSession.isOpen()) {
67 webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot read payload");
68 }
69 return;
70 } catch (JsonParseException e) {
71 LOG.warn("Malformed websocket request from" + webSocketSession.getRemoteAddress(), e);
72 webSocketSession.close(XtextStatusCode.INVALID_JSON, "Invalid JSON payload");
73 return;
74 }
75 try {
76 executor.handleRequest(request, response -> sendResponse(webSocketSession, response));
77 } catch (IOException e) {
78 LOG.warn("Cannot initiaite async write to websocket " + webSocketSession.getRemoteAddress(), e);
79 if (webSocketSession.isOpen()) {
80 webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot write payload");
81 }
82 }
83 }
84
85 protected void sendResponse(Session webSocketSession, XtextWebSocketResponse response) throws IOException {
86 var responseString = gson.toJson(response);
87 webSocketSession.getRemote().sendPartialString(responseString, true, new WriteCallback() {
88 @Override
89 public void writeFailed(Throwable x) {
90 LOG.warn("Cannot complete async write to websocket " + webSocketSession.getRemoteAddress(), x);
91 }
92 });
93 }
94}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketErrorKind.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorKind.java
index 5759f39e..66ea227f 100644
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketErrorKind.java
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorKind.java
@@ -1,4 +1,4 @@
1package tools.refinery.language.web.xtext; 1package tools.refinery.language.web.xtext.servlet;
2 2
3import com.google.gson.annotations.SerializedName; 3import com.google.gson.annotations.SerializedName;
4 4
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketErrorResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorResponse.java
index 1d2cf08a..3be7df39 100644
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketErrorResponse.java
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorResponse.java
@@ -1,4 +1,4 @@
1package tools.refinery.language.web.xtext; 1package tools.refinery.language.web.xtext.servlet;
2 2
3import java.util.Objects; 3import java.util.Objects;
4 4
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketOkResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketOkResponse.java
index aa453544..0a841895 100644
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketOkResponse.java
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketOkResponse.java
@@ -1,4 +1,4 @@
1package tools.refinery.language.web.xtext; 1package tools.refinery.language.web.xtext.servlet;
2 2
3import java.util.Objects; 3import java.util.Objects;
4 4
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketRequest.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketRequest.java
index 8aee70a1..8be67bc1 100644
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketRequest.java
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketRequest.java
@@ -1,4 +1,4 @@
1package tools.refinery.language.web.xtext; 1package tools.refinery.language.web.xtext.servlet;
2 2
3import java.util.List; 3import java.util.List;
4import java.util.Map; 4import java.util.Map;
@@ -19,6 +19,16 @@ public class XtextWebSocketRequest {
19 @SerializedName("request") 19 @SerializedName("request")
20 private List<Map<String, String>> requestData; 20 private List<Map<String, String>> requestData;
21 21
22 public XtextWebSocketRequest(String id, String resourceName, String contentType, String requiredStateId,
23 List<Map<String, String>> requestData) {
24 super();
25 this.id = id;
26 this.resourceName = resourceName;
27 this.contentType = contentType;
28 this.requiredStateId = requiredStateId;
29 this.requestData = requestData;
30 }
31
22 public String getId() { 32 public String getId() {
23 return id; 33 return id;
24 } 34 }
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketResponse.java
index 9e15aa69..2e7cfbbb 100644
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketResponse.java
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketResponse.java
@@ -1,4 +1,4 @@
1package tools.refinery.language.web.xtext; 1package tools.refinery.language.web.xtext.servlet;
2 2
3public sealed interface XtextWebSocketResponse permits XtextWebSocketOkResponse,XtextWebSocketErrorResponse { 3public sealed interface XtextWebSocketResponse permits XtextWebSocketOkResponse,XtextWebSocketErrorResponse {
4 public String getId(); 4 public String getId();
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketServlet.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java
index 5769e9e7..6d4d2cad 100644
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketServlet.java
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java
@@ -1,4 +1,4 @@
1package tools.refinery.language.web.xtext; 1package tools.refinery.language.web.xtext.servlet;
2 2
3import java.io.IOException; 3import java.io.IOException;
4import java.time.Duration; 4import java.time.Duration;
diff --git a/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/AwaitTerminationExecutorServiceProvider.java b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/AwaitTerminationExecutorServiceProvider.java
new file mode 100644
index 00000000..08230335
--- /dev/null
+++ b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/AwaitTerminationExecutorServiceProvider.java
@@ -0,0 +1,34 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import java.util.ArrayList;
4import java.util.List;
5import java.util.concurrent.ExecutorService;
6import java.util.concurrent.TimeUnit;
7
8import org.eclipse.xtext.ide.ExecutorServiceProvider;
9
10import com.google.inject.Singleton;
11
12@Singleton
13public class AwaitTerminationExecutorServiceProvider extends ExecutorServiceProvider {
14 private List<ExecutorService> servicesToShutDown = new ArrayList<>();
15
16 @Override
17 protected ExecutorService createInstance(String key) {
18 var instance = super.createInstance(key);
19 servicesToShutDown.add(instance);
20 return instance;
21 }
22
23 @Override
24 public void dispose() {
25 super.dispose();
26 for (var executorService : servicesToShutDown) {
27 try {
28 executorService.awaitTermination(1, TimeUnit.SECONDS);
29 } catch (InterruptedException e) {
30 // Continue normally.
31 }
32 }
33 }
34}
diff --git a/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/ProblemWebInjectorProvider.java b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/ProblemWebInjectorProvider.java
new file mode 100644
index 00000000..3493c9eb
--- /dev/null
+++ b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/ProblemWebInjectorProvider.java
@@ -0,0 +1,47 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import org.eclipse.xtext.ide.ExecutorServiceProvider;
4import org.eclipse.xtext.util.DisposableRegistry;
5import org.eclipse.xtext.util.Modules2;
6
7import com.google.inject.Guice;
8import com.google.inject.Injector;
9
10import tools.refinery.language.ide.ProblemIdeModule;
11import tools.refinery.language.tests.ProblemInjectorProvider;
12import tools.refinery.language.web.ProblemWebModule;
13import tools.refinery.language.web.ProblemWebSetup;
14
15public class ProblemWebInjectorProvider extends ProblemInjectorProvider {
16
17 protected Injector internalCreateInjector() {
18 return new ProblemWebSetup() {
19 @Override
20 public Injector createInjector() {
21 return Guice.createInjector(
22 Modules2.mixin(createRuntimeModule(), new ProblemIdeModule(), createWebModule()));
23 }
24 }.createInjectorAndDoEMFRegistration();
25 }
26
27 protected ProblemWebModule createWebModule() {
28 // Await termination of the executor service to avoid race conditions between
29 // between the tasks in the service and the {@link
30 // org.eclipse.xtext.testing.extensions.InjectionExtension}.
31 return new ProblemWebModule() {
32 @SuppressWarnings("unused")
33 public Class<? extends ExecutorServiceProvider> bindExecutorServiceProvider() {
34 return AwaitTerminationExecutorServiceProvider.class;
35 }
36 };
37 }
38
39 @Override
40 public void restoreRegistry() {
41 // Also make sure to dispose any IDisposable instances (that may depend on the
42 // global state) created by Xtext before restoring the global state.
43 var disposableRegistry = getInjector().getInstance(DisposableRegistry.class);
44 disposableRegistry.dispose();
45 super.restoreRegistry();
46 }
47}
diff --git a/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java
new file mode 100644
index 00000000..6ad82d7f
--- /dev/null
+++ b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java
@@ -0,0 +1,151 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import static org.hamcrest.MatcherAssert.assertThat;
4import static org.hamcrest.Matchers.equalTo;
5import static org.hamcrest.Matchers.hasProperty;
6import static org.hamcrest.Matchers.instanceOf;
7import static org.junit.jupiter.api.Assertions.fail;
8import static org.mockito.Mockito.mock;
9import static org.mockito.Mockito.times;
10import static org.mockito.Mockito.verify;
11
12import java.io.IOException;
13import java.util.List;
14import java.util.Map;
15import java.util.UUID;
16
17import org.eclipse.xtext.resource.IResourceServiceProvider;
18import org.eclipse.xtext.testing.InjectWith;
19import org.eclipse.xtext.testing.extensions.InjectionExtension;
20import org.eclipse.xtext.web.server.ServiceConflictResult;
21import org.eclipse.xtext.web.server.model.DocumentStateResult;
22import org.eclipse.xtext.web.server.validation.ValidationResult;
23import org.hamcrest.Matcher;
24import org.junit.jupiter.api.BeforeEach;
25import org.junit.jupiter.api.Test;
26import org.junit.jupiter.api.extension.ExtendWith;
27import org.mockito.ArgumentCaptor;
28import org.mockito.junit.jupiter.MockitoExtension;
29
30import com.google.inject.Inject;
31
32@ExtendWith(MockitoExtension.class)
33@ExtendWith(InjectionExtension.class)
34@InjectWith(ProblemWebInjectorProvider.class)
35class TransactionExecutorTest {
36 private static final String RESOURCE_NAME = "test.problem";
37
38 private static final String INVALID_STATE_ID = "<invalid_state>";
39
40 private static final String TEST_PROBLEM = """
41 class Person {
42 Person friend[0..*] opposite friend
43 }
44
45 friend(a, b).
46 """;
47
48 private static final Map<String, String> UPDATE_FULL_TEXT_PARAMS = Map.of("serviceType", "update", "fullText",
49 TEST_PROBLEM);
50
51 private static final Map<String, String> VALIDATE_PARAMS = Map.of("serviceType", "validate");
52
53 @Inject
54 private IResourceServiceProvider.Registry resourceServiceProviderRegistry;
55
56 private TransactionExecutor transactionExecutor;
57
58 @BeforeEach
59 void beforeEach() {
60 transactionExecutor = new TransactionExecutor(new SimpleSession(), resourceServiceProviderRegistry);
61 }
62
63 @Test
64 void emptyBatchTest() {
65 performBatchRequest(null);
66 }
67
68 @Test
69 void fullTextUpdateTest() {
70 var response = performSingleRequest(null, UPDATE_FULL_TEXT_PARAMS);
71 assertThat(response, hasResponseData(instanceOf(DocumentStateResult.class)));
72 }
73
74 @Test
75 void validationAfterFullTextUpdateInSameBatchTest() {
76 var response = performBatchRequest(null, UPDATE_FULL_TEXT_PARAMS, VALIDATE_PARAMS).get(1);
77 assertThat(response, hasResponseData(instanceOf(ValidationResult.class)));
78 }
79
80 @Test
81 void validationAfterFullTextUpdateInDifferentBatchTest() {
82 var stateId = updateFullText();
83 var validateResponse = performSingleRequest(stateId, VALIDATE_PARAMS);
84 assertThat(validateResponse, hasResponseData(instanceOf(ValidationResult.class)));
85 }
86
87 @Test
88 void conflictTest() {
89 updateFullText();
90 var response = performSingleRequest(INVALID_STATE_ID, VALIDATE_PARAMS);
91 assertThat(response, hasResponseData(instanceOf(ServiceConflictResult.class)));
92 }
93
94 @Test
95 void transactionCancelledDueToConflictTest() {
96 updateFullText();
97 var response = performBatchRequest(INVALID_STATE_ID, VALIDATE_PARAMS, VALIDATE_PARAMS).get(1);
98 assertThat(response, hasErrorKind(equalTo(XtextWebSocketErrorKind.TRANSACTION_CANCELLED)));
99 }
100
101 @SafeVarargs
102 private List<XtextWebSocketResponse> performBatchRequest(String requiredStateId, Map<String, String>... params) {
103 var id = UUID.randomUUID().toString();
104 var request = new XtextWebSocketRequest(id, RESOURCE_NAME, null, requiredStateId, List.of(params));
105
106 var responseHandler = mock(ResponseHandler.class);
107 try {
108 transactionExecutor.handleRequest(request, responseHandler);
109 } catch (IOException e) {
110 fail("Unexpected IOException", e);
111 }
112
113 var captor = ArgumentCaptor.forClass(XtextWebSocketResponse.class);
114 int nParams = params.length;
115 try {
116 verify(responseHandler, times(nParams)).onResponse(captor.capture());
117 } catch (IOException e) {
118 throw new RuntimeException("Mockito threw unexcepted exception", e);
119 }
120 var allResponses = captor.getAllValues();
121 for (int i = 0; i < nParams; i++) {
122 var response = allResponses.get(i);
123 assertThat(response, hasProperty("id", equalTo(id)));
124 assertThat(response, hasProperty("index", equalTo(i)));
125 }
126 return allResponses;
127 }
128
129 private XtextWebSocketResponse performSingleRequest(String requiredStateId, Map<String, String> param) {
130 return performBatchRequest(requiredStateId, param).get(0);
131 }
132
133 private String updateFullText() {
134 var updateResponse = (XtextWebSocketOkResponse) performSingleRequest(null, UPDATE_FULL_TEXT_PARAMS);
135 var documentStateResult = (DocumentStateResult) updateResponse.getResponseData();
136 var stateId = documentStateResult.getStateId();
137 if (INVALID_STATE_ID.equals(stateId)) {
138 throw new RuntimeException("Service returned unexpected stateId: " + stateId);
139 }
140 return stateId;
141 }
142
143 private static Matcher<XtextWebSocketResponse> hasResponseData(Matcher<?> responseDataMatcher) {
144 return hasProperty("responseData", responseDataMatcher);
145 }
146
147 private static Matcher<XtextWebSocketResponse> hasErrorKind(
148 Matcher<? extends XtextWebSocketErrorKind> errorKindMatcher) {
149 return hasProperty("errorKind", errorKindMatcher);
150 }
151}