aboutsummaryrefslogtreecommitdiffstats
path: root/language-web
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2021-10-20 01:49:14 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2021-10-31 19:26:11 +0100
commit7732fedb5933bdc699cd1ef22a766397d5a701d2 (patch)
tree691b0461ee763c0060f901f306a7fd99d756fcce /language-web
parentfeat(web): batched xtext websocket prototype (diff)
downloadrefinery-7732fedb5933bdc699cd1ef22a766397d5a701d2.tar.gz
refinery-7732fedb5933bdc699cd1ef22a766397d5a701d2.tar.zst
refinery-7732fedb5933bdc699cd1ef22a766397d5a701d2.zip
feat(web): push precomputed service results
Diffstat (limited to 'language-web')
-rw-r--r--language-web/build.gradle1
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java16
-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/server/ResponseHandler.java8
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java14
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java26
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java131
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java11
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java79
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java (renamed from language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketOkResponse.java)29
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java81
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java57
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java4
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java15
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java23
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java89
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java68
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java33
-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.java39
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/TransactionExecutor.java137
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java73
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorKind.java14
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorResponse.java95
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketRequest.java97
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketResponse.java11
-rw-r--r--language-web/src/test/java/tools/refinery/language/web/xtext/servlet/AwaitTerminationExecutorServiceProvider.java26
-rw-r--r--language-web/src/test/java/tools/refinery/language/web/xtext/servlet/ProblemWebInjectorProvider.java2
-rw-r--r--language-web/src/test/java/tools/refinery/language/web/xtext/servlet/RestartableCachedThreadPool.java109
-rw-r--r--language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java148
30 files changed, 918 insertions, 542 deletions
diff --git a/language-web/build.gradle b/language-web/build.gradle
index fc2c9bba..970db115 100644
--- a/language-web/build.gradle
+++ b/language-web/build.gradle
@@ -12,6 +12,7 @@ dependencies {
12 implementation "org.eclipse.jetty.websocket:websocket-jetty-server:${jettyVersion}" 12 implementation "org.eclipse.jetty.websocket:websocket-jetty-server:${jettyVersion}"
13 implementation "org.slf4j:slf4j-simple:${slf4JVersion}" 13 implementation "org.slf4j:slf4j-simple:${slf4JVersion}"
14 implementation "org.slf4j:log4j-over-slf4j:${slf4JVersion}" 14 implementation "org.slf4j:log4j-over-slf4j:${slf4JVersion}"
15 testImplementation testFixtures(project(':refinery-language'))
15 testImplementation "org.eclipse.xtext:org.eclipse.xtext.testing:${xtextVersion}" 16 testImplementation "org.eclipse.xtext:org.eclipse.xtext.testing:${xtextVersion}"
16} 17}
17 18
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 04f38414..ac8934ed 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
@@ -4,14 +4,26 @@
4package tools.refinery.language.web; 4package tools.refinery.language.web;
5 5
6import org.eclipse.xtext.web.server.XtextServiceDispatcher; 6import org.eclipse.xtext.web.server.XtextServiceDispatcher;
7import org.eclipse.xtext.web.server.model.IWebDocumentProvider;
8import org.eclipse.xtext.web.server.model.XtextWebDocumentAccess;
7 9
8import tools.refinery.language.web.xtext.server.NoPrecomputedServicesXtextServiceDispatcher; 10import tools.refinery.language.web.xtext.server.push.PushServiceDispatcher;
11import tools.refinery.language.web.xtext.server.push.PushWebDocumentAccess;
12import tools.refinery.language.web.xtext.server.push.PushWebDocumentProvider;
9 13
10/** 14/**
11 * Use this class to register additional components to be used within the web application. 15 * Use this class to register additional components to be used within the web application.
12 */ 16 */
13public class ProblemWebModule extends AbstractProblemWebModule { 17public class ProblemWebModule extends AbstractProblemWebModule {
18 public Class<? extends IWebDocumentProvider> bindIWebDocumentProvider() {
19 return PushWebDocumentProvider.class;
20 }
21
22 public Class<? extends XtextWebDocumentAccess> bindXtextWebDocumentAccess() {
23 return PushWebDocumentAccess.class;
24 }
25
14 public Class<? extends XtextServiceDispatcher> bindXtextServiceDispatcher() { 26 public Class<? extends XtextServiceDispatcher> bindXtextServiceDispatcher() {
15 return NoPrecomputedServicesXtextServiceDispatcher.class; 27 return PushServiceDispatcher.class;
16 } 28 }
17} 29}
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
deleted file mode 100644
index 6660d6ac..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/server/NoPrecomputedServicesXtextServiceDispatcher.java
+++ /dev/null
@@ -1,16 +0,0 @@
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/server/ResponseHandler.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java
new file mode 100644
index 00000000..2a85afe3
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java
@@ -0,0 +1,8 @@
1package tools.refinery.language.web.xtext.server;
2
3import tools.refinery.language.web.xtext.server.message.XtextWebResponse;
4
5@FunctionalInterface
6public interface ResponseHandler {
7 void onResponse(XtextWebResponse response) throws ResponseHandlerException;
8}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java
new file mode 100644
index 00000000..34fcb546
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java
@@ -0,0 +1,14 @@
1package tools.refinery.language.web.xtext.server;
2
3public class ResponseHandlerException extends Exception {
4
5 private static final long serialVersionUID = 3589866922420268164L;
6
7 public ResponseHandlerException(String message, Throwable cause) {
8 super(message, cause);
9 }
10
11 public ResponseHandlerException(String message) {
12 super(message);
13 }
14}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java
new file mode 100644
index 00000000..78e00a9e
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java
@@ -0,0 +1,26 @@
1package tools.refinery.language.web.xtext.server;
2
3import java.util.Set;
4
5import org.eclipse.xtext.web.server.IServiceContext;
6import org.eclipse.xtext.web.server.ISession;
7
8import tools.refinery.language.web.xtext.server.push.PrecomputationListener;
9
10public record SubscribingServiceContext(IServiceContext delegate, PrecomputationListener subscriber)
11 implements IServiceContext {
12 @Override
13 public Set<String> getParameterKeys() {
14 return delegate.getParameterKeys();
15 }
16
17 @Override
18 public String getParameter(String key) {
19 return delegate.getParameter(key);
20 }
21
22 @Override
23 public ISession getSession() {
24 return delegate.getSession();
25 }
26}
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
new file mode 100644
index 00000000..f2f26d98
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java
@@ -0,0 +1,131 @@
1package tools.refinery.language.web.xtext.server;
2
3import java.lang.ref.WeakReference;
4import java.util.HashMap;
5import java.util.Map;
6
7import org.eclipse.emf.common.util.URI;
8import org.eclipse.xtext.resource.IResourceServiceProvider;
9import org.eclipse.xtext.util.IDisposable;
10import org.eclipse.xtext.web.server.IServiceContext;
11import org.eclipse.xtext.web.server.IServiceResult;
12import org.eclipse.xtext.web.server.ISession;
13import org.eclipse.xtext.web.server.InvalidRequestException;
14import org.eclipse.xtext.web.server.InvalidRequestException.UnknownLanguageException;
15import org.eclipse.xtext.web.server.XtextServiceDispatcher;
16
17import com.google.common.base.Strings;
18import com.google.inject.Injector;
19
20import tools.refinery.language.web.xtext.server.message.XtextWebErrorKind;
21import tools.refinery.language.web.xtext.server.message.XtextWebErrorResponse;
22import tools.refinery.language.web.xtext.server.message.XtextWebOkResponse;
23import tools.refinery.language.web.xtext.server.message.XtextWebPushMessage;
24import tools.refinery.language.web.xtext.server.message.XtextWebRequest;
25import tools.refinery.language.web.xtext.server.push.PrecomputationListener;
26import tools.refinery.language.web.xtext.server.push.PushWebDocument;
27import tools.refinery.language.web.xtext.servlet.SimpleServiceContext;
28
29public class TransactionExecutor implements IDisposable, PrecomputationListener {
30 private final ISession session;
31
32 private final IResourceServiceProvider.Registry resourceServiceProviderRegistry;
33
34 private final Map<String, WeakReference<PushWebDocument>> subscriptions = new HashMap<>();
35
36 private ResponseHandler responseHandler;
37
38 public TransactionExecutor(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) {
39 this.session = session;
40 this.resourceServiceProviderRegistry = resourceServiceProviderRegistry;
41 }
42
43 public void setResponseHandler(ResponseHandler responseHandler) {
44 this.responseHandler = responseHandler;
45 }
46
47 public void handleRequest(XtextWebRequest request) throws ResponseHandlerException {
48 var serviceContext = new SimpleServiceContext(session, request.getRequestData());
49 try {
50 var injector = getInjector(serviceContext);
51 var serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class);
52 var service = serviceDispatcher.getService(new SubscribingServiceContext(serviceContext, this));
53 var serviceResult = service.getService().apply();
54 responseHandler.onResponse(new XtextWebOkResponse(request, serviceResult));
55 } catch (InvalidRequestException e) {
56 responseHandler.onResponse(new XtextWebErrorResponse(request, XtextWebErrorKind.REQUEST_ERROR, e));
57 } catch (RuntimeException e) {
58 responseHandler.onResponse(new XtextWebErrorResponse(request, XtextWebErrorKind.SERVER_ERROR, e));
59 }
60 }
61
62 @Override
63 public void onPrecomputedServiceResult(String resourceId, String stateId, String serviceName,
64 IServiceResult serviceResult) throws ResponseHandlerException {
65 responseHandler.onResponse(new XtextWebPushMessage(resourceId, stateId, serviceName, serviceResult));
66 }
67
68 @Override
69 public void onSubscribeToPrecomputationEvents(String resourceId, PushWebDocument document) {
70 PushWebDocument previousDocument = null;
71 var previousSubscription = subscriptions.get(resourceId);
72 if (previousSubscription != null) {
73 previousDocument = previousSubscription.get();
74 }
75 if (previousDocument == document) {
76 return;
77 }
78 if (previousDocument != null) {
79 previousDocument.removePrecomputationListener(this);
80 }
81 subscriptions.put(resourceId, new WeakReference<>(document));
82 }
83
84 /**
85 * Get the injector to satisfy the request in the {@code serviceContext}.
86 *
87 * Based on {@link org.eclipse.xtext.web.servlet.XtextServlet#getInjector}.
88 *
89 * @param serviceContext the Xtext service context of the request
90 * @return the injector for the Xtext language in the request
91 * @throws UnknownLanguageException if the Xtext language cannot be determined
92 */
93 protected Injector getInjector(IServiceContext context) {
94 IResourceServiceProvider resourceServiceProvider = null;
95 var resourceName = context.getParameter("resource");
96 if (resourceName == null) {
97 resourceName = "";
98 }
99 var emfURI = URI.createURI(resourceName);
100 var contentType = context.getParameter("contentType");
101 if (Strings.isNullOrEmpty(contentType)) {
102 resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI);
103 if (resourceServiceProvider == null) {
104 if (emfURI.toString().isEmpty()) {
105 throw new UnknownLanguageException(
106 "Unable to identify the Xtext language: missing parameter 'resource' or 'contentType'.");
107 } else {
108 throw new UnknownLanguageException(
109 "Unable to identify the Xtext language for resource " + emfURI + ".");
110 }
111 }
112 } else {
113 resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI, contentType);
114 if (resourceServiceProvider == null) {
115 throw new UnknownLanguageException(
116 "Unable to identify the Xtext language for contentType " + contentType + ".");
117 }
118 }
119 return resourceServiceProvider.get(Injector.class);
120 }
121
122 @Override
123 public void dispose() {
124 for (var subscription : subscriptions.values()) {
125 var document = subscription.get();
126 if (document != null) {
127 document.removePrecomputationListener(this);
128 }
129 }
130 }
131}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java
new file mode 100644
index 00000000..f74bae74
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java
@@ -0,0 +1,11 @@
1package tools.refinery.language.web.xtext.server.message;
2
3import com.google.gson.annotations.SerializedName;
4
5public enum XtextWebErrorKind {
6 @SerializedName("request")
7 REQUEST_ERROR,
8
9 @SerializedName("server")
10 SERVER_ERROR,
11}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java
new file mode 100644
index 00000000..01d78c31
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java
@@ -0,0 +1,79 @@
1package tools.refinery.language.web.xtext.server.message;
2
3import java.util.Objects;
4
5import com.google.gson.annotations.SerializedName;
6
7public final class XtextWebErrorResponse implements XtextWebResponse {
8 private String id;
9
10 @SerializedName("error")
11 private XtextWebErrorKind errorKind;
12
13 @SerializedName("message")
14 private String errorMessage;
15
16 public XtextWebErrorResponse(String id, XtextWebErrorKind errorKind, String errorMessage) {
17 super();
18 this.id = id;
19 this.errorKind = errorKind;
20 this.errorMessage = errorMessage;
21 }
22
23 public XtextWebErrorResponse(XtextWebRequest request, XtextWebErrorKind errorKind,
24 String errorMessage) {
25 this(request.getId(), errorKind, errorMessage);
26 }
27
28 public XtextWebErrorResponse(XtextWebRequest request, XtextWebErrorKind errorKind, Throwable t) {
29 this(request, errorKind, t.getMessage());
30 }
31
32 public String getId() {
33 return id;
34 }
35
36 public void setId(String id) {
37 this.id = id;
38 }
39
40 public XtextWebErrorKind getErrorKind() {
41 return errorKind;
42 }
43
44 public void setErrorKind(XtextWebErrorKind errorKind) {
45 this.errorKind = errorKind;
46 }
47
48 public String getErrorMessage() {
49 return errorMessage;
50 }
51
52 public void setErrorMessage(String errorMessage) {
53 this.errorMessage = errorMessage;
54 }
55
56 @Override
57 public int hashCode() {
58 return Objects.hash(errorKind, errorMessage, id);
59 }
60
61 @Override
62 public boolean equals(Object obj) {
63 if (this == obj)
64 return true;
65 if (obj == null)
66 return false;
67 if (getClass() != obj.getClass())
68 return false;
69 XtextWebErrorResponse other = (XtextWebErrorResponse) obj;
70 return errorKind == other.errorKind && Objects.equals(errorMessage, other.errorMessage)
71 && Objects.equals(id, other.id);
72 }
73
74 @Override
75 public String toString() {
76 return "XtextWebSocketErrorResponse [id=" + id + ", errorKind=" + errorKind + ", errorMessage=" + errorMessage
77 + "]";
78 }
79}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketOkResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java
index 0a841895..8af27247 100644
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketOkResponse.java
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java
@@ -1,4 +1,4 @@
1package tools.refinery.language.web.xtext.servlet; 1package tools.refinery.language.web.xtext.server.message;
2 2
3import java.util.Objects; 3import java.util.Objects;
4 4
@@ -7,23 +7,20 @@ import org.eclipse.xtext.web.server.IUnwrappableServiceResult;
7 7
8import com.google.gson.annotations.SerializedName; 8import com.google.gson.annotations.SerializedName;
9 9
10public final class XtextWebSocketOkResponse implements XtextWebSocketResponse { 10public final class XtextWebOkResponse implements XtextWebResponse {
11 private String id; 11 private String id;
12 12
13 private int index;
14
15 @SerializedName("response") 13 @SerializedName("response")
16 private Object responseData; 14 private Object responseData;
17 15
18 public XtextWebSocketOkResponse(String id, int index, Object responseData) { 16 public XtextWebOkResponse(String id, Object responseData) {
19 super(); 17 super();
20 this.id = id; 18 this.id = id;
21 this.index = index;
22 this.responseData = responseData; 19 this.responseData = responseData;
23 } 20 }
24 21
25 public XtextWebSocketOkResponse(XtextWebSocketRequest request, int index, IServiceResult result) { 22 public XtextWebOkResponse(XtextWebRequest request, IServiceResult result) {
26 this(request.getId(), index, maybeUnwrap(result)); 23 this(request.getId(), maybeUnwrap(result));
27 } 24 }
28 25
29 public String getId() { 26 public String getId() {
@@ -34,14 +31,6 @@ public final class XtextWebSocketOkResponse implements XtextWebSocketResponse {
34 this.id = id; 31 this.id = id;
35 } 32 }
36 33
37 public int getIndex() {
38 return index;
39 }
40
41 public void setIndex(int index) {
42 this.index = index;
43 }
44
45 public Object getResponseData() { 34 public Object getResponseData() {
46 return responseData; 35 return responseData;
47 } 36 }
@@ -52,7 +41,7 @@ public final class XtextWebSocketOkResponse implements XtextWebSocketResponse {
52 41
53 @Override 42 @Override
54 public int hashCode() { 43 public int hashCode() {
55 return Objects.hash(id, index, responseData); 44 return Objects.hash(id, responseData);
56 } 45 }
57 46
58 @Override 47 @Override
@@ -63,13 +52,13 @@ public final class XtextWebSocketOkResponse implements XtextWebSocketResponse {
63 return false; 52 return false;
64 if (getClass() != obj.getClass()) 53 if (getClass() != obj.getClass())
65 return false; 54 return false;
66 XtextWebSocketOkResponse other = (XtextWebSocketOkResponse) obj; 55 XtextWebOkResponse other = (XtextWebOkResponse) obj;
67 return Objects.equals(id, other.id) && index == other.index && Objects.equals(responseData, other.responseData); 56 return Objects.equals(id, other.id) && Objects.equals(responseData, other.responseData);
68 } 57 }
69 58
70 @Override 59 @Override
71 public String toString() { 60 public String toString() {
72 return "XtextWebSocketOkResponse [id=" + id + ", index=" + index + ", responseData=" + responseData + "]"; 61 return "XtextWebSocketOkResponse [id=" + id + ", responseData=" + responseData + "]";
73 } 62 }
74 63
75 private static Object maybeUnwrap(IServiceResult result) { 64 private static Object maybeUnwrap(IServiceResult result) {
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java
new file mode 100644
index 00000000..c9432e1c
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java
@@ -0,0 +1,81 @@
1package tools.refinery.language.web.xtext.server.message;
2
3import java.util.Objects;
4
5import com.google.gson.annotations.SerializedName;
6
7public final class XtextWebPushMessage implements XtextWebResponse {
8 @SerializedName("resource")
9 private String resourceId;
10
11 private String stateId;
12
13 private String service;
14
15 @SerializedName("push")
16 private Object pushData;
17
18 public XtextWebPushMessage(String resourceId, String stateId, String service, Object pushData) {
19 super();
20 this.resourceId = resourceId;
21 this.stateId = stateId;
22 this.service = service;
23 this.pushData = pushData;
24 }
25
26 public String getResourceId() {
27 return resourceId;
28 }
29
30 public void setResourceId(String resourceId) {
31 this.resourceId = resourceId;
32 }
33
34 public String getStateId() {
35 return stateId;
36 }
37
38 public void setStateId(String stateId) {
39 this.stateId = stateId;
40 }
41
42 public String getService() {
43 return service;
44 }
45
46 public void setService(String service) {
47 this.service = service;
48 }
49
50 public Object getPushData() {
51 return pushData;
52 }
53
54 public void setPushData(Object pushData) {
55 this.pushData = pushData;
56 }
57
58 @Override
59 public int hashCode() {
60 return Objects.hash(pushData, resourceId, service, stateId);
61 }
62
63 @Override
64 public boolean equals(Object obj) {
65 if (this == obj)
66 return true;
67 if (obj == null)
68 return false;
69 if (getClass() != obj.getClass())
70 return false;
71 XtextWebPushMessage other = (XtextWebPushMessage) obj;
72 return Objects.equals(pushData, other.pushData) && Objects.equals(resourceId, other.resourceId)
73 && Objects.equals(service, other.service) && Objects.equals(stateId, other.stateId);
74 }
75
76 @Override
77 public String toString() {
78 return "XtextWebPushMessage [resourceId=" + resourceId + ", stateId=" + stateId + ", service=" + service
79 + ", pushData=" + pushData + "]";
80 }
81}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java
new file mode 100644
index 00000000..959749f8
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java
@@ -0,0 +1,57 @@
1package tools.refinery.language.web.xtext.server.message;
2
3import java.util.Map;
4import java.util.Objects;
5
6import com.google.gson.annotations.SerializedName;
7
8public class XtextWebRequest {
9 private String id;
10
11 @SerializedName("request")
12 private Map<String, String> requestData;
13
14 public XtextWebRequest(String id, Map<String, String> requestData) {
15 super();
16 this.id = id;
17 this.requestData = requestData;
18 }
19
20 public String getId() {
21 return id;
22 }
23
24 public void setId(String id) {
25 this.id = id;
26 }
27
28 public Map<String, String> getRequestData() {
29 return requestData;
30 }
31
32 public void setRequestData(Map<String, String> requestData) {
33 this.requestData = requestData;
34 }
35
36 @Override
37 public int hashCode() {
38 return Objects.hash(id, requestData);
39 }
40
41 @Override
42 public boolean equals(Object obj) {
43 if (this == obj)
44 return true;
45 if (obj == null)
46 return false;
47 if (getClass() != obj.getClass())
48 return false;
49 XtextWebRequest other = (XtextWebRequest) obj;
50 return Objects.equals(id, other.id) && Objects.equals(requestData, other.requestData);
51 }
52
53 @Override
54 public String toString() {
55 return "XtextWebSocketRequest [id=" + id + ", requestData=" + requestData + "]";
56 }
57}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java
new file mode 100644
index 00000000..3bd13047
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java
@@ -0,0 +1,4 @@
1package tools.refinery.language.web.xtext.server.message;
2
3public sealed interface XtextWebResponse permits XtextWebOkResponse,XtextWebErrorResponse,XtextWebPushMessage {
4}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java
new file mode 100644
index 00000000..79a284db
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java
@@ -0,0 +1,15 @@
1package tools.refinery.language.web.xtext.server.push;
2
3import org.eclipse.xtext.web.server.IServiceResult;
4
5import tools.refinery.language.web.xtext.server.ResponseHandlerException;
6
7@FunctionalInterface
8public interface PrecomputationListener {
9 void onPrecomputedServiceResult(String resourceId, String stateId, String serviceName, IServiceResult serviceResult)
10 throws ResponseHandlerException;
11
12 default void onSubscribeToPrecomputationEvents(String resourceId, PushWebDocument document) {
13 // Nothing to handle by default.
14 }
15}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java
new file mode 100644
index 00000000..c7b8108d
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java
@@ -0,0 +1,23 @@
1package tools.refinery.language.web.xtext.server.push;
2
3import org.eclipse.xtext.web.server.IServiceContext;
4import org.eclipse.xtext.web.server.XtextServiceDispatcher;
5import org.eclipse.xtext.web.server.model.XtextWebDocument;
6
7import com.google.inject.Singleton;
8
9import tools.refinery.language.web.xtext.server.SubscribingServiceContext;
10
11@Singleton
12public class PushServiceDispatcher extends XtextServiceDispatcher {
13
14 @Override
15 protected XtextWebDocument getFullTextDocument(String fullText, String resourceId, IServiceContext context) {
16 var document = super.getFullTextDocument(fullText, resourceId, context);
17 if (document instanceof PushWebDocument pushWebDocument
18 && context instanceof SubscribingServiceContext subscribingContext) {
19 pushWebDocument.addPrecomputationListener(subscribingContext.subscriber());
20 }
21 return document;
22 }
23}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java
new file mode 100644
index 00000000..906b9e30
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java
@@ -0,0 +1,89 @@
1package tools.refinery.language.web.xtext.server.push;
2
3import java.util.ArrayList;
4import java.util.HashMap;
5import java.util.List;
6import java.util.Map;
7
8import org.eclipse.xtext.util.CancelIndicator;
9import org.eclipse.xtext.web.server.IServiceResult;
10import org.eclipse.xtext.web.server.model.AbstractCachedService;
11import org.eclipse.xtext.web.server.model.DocumentSynchronizer;
12import org.eclipse.xtext.web.server.model.XtextWebDocument;
13import org.slf4j.Logger;
14import org.slf4j.LoggerFactory;
15
16import com.google.common.collect.ImmutableList;
17
18import tools.refinery.language.web.xtext.server.ResponseHandlerException;
19
20public class PushWebDocument extends XtextWebDocument {
21 private static final Logger LOG = LoggerFactory.getLogger(PushWebDocument.class);
22
23 private final List<PrecomputationListener> precomputationListeners = new ArrayList<>();
24
25 private final Map<Class<?>, IServiceResult> precomputedServices = new HashMap<>();
26
27 public PushWebDocument(String resourceId, DocumentSynchronizer synchronizer) {
28 super(resourceId, synchronizer);
29 if (resourceId == null) {
30 throw new IllegalArgumentException("resourceId must not be null");
31 }
32 }
33
34 public boolean addPrecomputationListener(PrecomputationListener listener) {
35 synchronized (precomputationListeners) {
36 if (precomputationListeners.contains(listener)) {
37 return false;
38 }
39 precomputationListeners.add(listener);
40 listener.onSubscribeToPrecomputationEvents(getResourceId(), this);
41 return true;
42 }
43 }
44
45 public boolean removePrecomputationListener(PrecomputationListener listener) {
46 synchronized (precomputationListeners) {
47 return precomputationListeners.remove(listener);
48 }
49 }
50
51 public <T extends IServiceResult> void precomputeServiceResult(AbstractCachedService<T> service, String serviceName,
52 CancelIndicator cancelIndicator, boolean logCacheMiss) {
53 var result = getCachedServiceResult(service, cancelIndicator, logCacheMiss);
54 if (result == null) {
55 LOG.error("{} service returned null result", serviceName);
56 return;
57 }
58 var serviceClass = service.getClass();
59 var previousResult = precomputedServices.get(serviceClass);
60 if (previousResult != null && previousResult.equals(result)) {
61 return;
62 }
63 precomputedServices.put(serviceClass, result);
64 notifyPrecomputationListeners(serviceName, result);
65 }
66
67 private <T extends IServiceResult> void notifyPrecomputationListeners(String serviceName, T result) {
68 var resourceId = getResourceId();
69 var stateId = getStateId();
70 List<PrecomputationListener> copyOfListeners;
71 synchronized (precomputationListeners) {
72 copyOfListeners = ImmutableList.copyOf(precomputationListeners);
73 }
74 var toRemove = new ArrayList<PrecomputationListener>();
75 for (var listener : copyOfListeners) {
76 try {
77 listener.onPrecomputedServiceResult(resourceId, stateId, serviceName, result);
78 } catch (ResponseHandlerException e) {
79 LOG.error("Delivering precomputation push message failed", e);
80 toRemove.add(listener);
81 }
82 }
83 if (!toRemove.isEmpty()) {
84 synchronized (precomputationListeners) {
85 precomputationListeners.removeAll(toRemove);
86 }
87 }
88 }
89}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java
new file mode 100644
index 00000000..ff4bb035
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java
@@ -0,0 +1,68 @@
1package tools.refinery.language.web.xtext.server.push;
2
3import org.eclipse.xtext.service.OperationCanceledManager;
4import org.eclipse.xtext.util.CancelIndicator;
5import org.eclipse.xtext.util.concurrent.CancelableUnitOfWork;
6import org.eclipse.xtext.web.server.IServiceResult;
7import org.eclipse.xtext.web.server.model.AbstractCachedService;
8import org.eclipse.xtext.web.server.model.IXtextWebDocument;
9import org.eclipse.xtext.web.server.model.PrecomputedServiceRegistry;
10import org.eclipse.xtext.web.server.model.XtextWebDocument;
11import org.eclipse.xtext.web.server.model.XtextWebDocumentAccess;
12import org.eclipse.xtext.web.server.syntaxcoloring.HighlightingService;
13import org.eclipse.xtext.web.server.validation.ValidationService;
14
15import com.google.inject.Inject;
16
17public class PushWebDocumentAccess extends XtextWebDocumentAccess {
18
19 @Inject
20 private PrecomputedServiceRegistry preComputedServiceRegistry;
21
22 @Inject
23 private OperationCanceledManager operationCanceledManager;
24
25 private PushWebDocument pushDocument;
26
27 @Override
28 protected void init(XtextWebDocument document, String requiredStateId, boolean skipAsyncWork) {
29 super.init(document, requiredStateId, skipAsyncWork);
30 if (document instanceof PushWebDocument newPushDocument) {
31 pushDocument = newPushDocument;
32 }
33 }
34
35 @Override
36 protected void performPrecomputation(CancelIndicator cancelIndicator) {
37 if (pushDocument == null) {
38 super.performPrecomputation(cancelIndicator);
39 return;
40 }
41 for (AbstractCachedService<? extends IServiceResult> service : preComputedServiceRegistry
42 .getPrecomputedServices()) {
43 operationCanceledManager.checkCanceled(cancelIndicator);
44 precomputeServiceResult(service, false);
45 }
46 }
47
48 protected <T extends IServiceResult> void precomputeServiceResult(AbstractCachedService<T> service, boolean logCacheMiss) {
49 var serviceName = getPrecomputedServiceName(service);
50 readOnly(new CancelableUnitOfWork<Void, IXtextWebDocument>() {
51 @Override
52 public java.lang.Void exec(IXtextWebDocument d, CancelIndicator cancelIndicator) throws Exception {
53 pushDocument.precomputeServiceResult(service, serviceName, cancelIndicator, logCacheMiss);
54 return null;
55 }
56 });
57 }
58
59 protected String getPrecomputedServiceName(AbstractCachedService<? extends IServiceResult> service) {
60 if (service instanceof ValidationService) {
61 return "validation";
62 }
63 if (service instanceof HighlightingService) {
64 return "highlighting";
65 }
66 throw new IllegalArgumentException("Unknown precomputed service: " + service);
67 }
68}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java
new file mode 100644
index 00000000..fc45f74a
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java
@@ -0,0 +1,33 @@
1package tools.refinery.language.web.xtext.server.push;
2
3import org.eclipse.xtext.web.server.IServiceContext;
4import org.eclipse.xtext.web.server.model.DocumentSynchronizer;
5import org.eclipse.xtext.web.server.model.IWebDocumentProvider;
6import org.eclipse.xtext.web.server.model.XtextWebDocument;
7
8import com.google.inject.Inject;
9import com.google.inject.Provider;
10import com.google.inject.Singleton;
11
12/**
13 * Based on
14 * {@link org.eclipse.xtext.web.server.model.IWebDocumentProvider.DefaultImpl}.
15 *
16 * @author Kristóf Marussy
17 */
18@Singleton
19public class PushWebDocumentProvider implements IWebDocumentProvider {
20 @Inject
21 private Provider<DocumentSynchronizer> synchronizerProvider;
22
23 @Override
24 public XtextWebDocument get(String resourceId, IServiceContext serviceContext) {
25 if (resourceId == null) {
26 return new XtextWebDocument(resourceId, synchronizerProvider.get());
27 } else {
28 // We only need to send push messages if a resourceId is specified.
29 return new PushWebDocument(resourceId,
30 serviceContext.getSession().get(DocumentSynchronizer.class, () -> this.synchronizerProvider.get()));
31 }
32 }
33}
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
deleted file mode 100644
index b1fcbc8b..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/ResponseHandler.java
+++ /dev/null
@@ -1,8 +0,0 @@
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/servlet/SimpleServiceContext.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java
index 945d5db0..43e37160 100644
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java
@@ -5,19 +5,10 @@ import java.util.Set;
5 5
6import org.eclipse.xtext.web.server.IServiceContext; 6import org.eclipse.xtext.web.server.IServiceContext;
7import org.eclipse.xtext.web.server.ISession; 7import org.eclipse.xtext.web.server.ISession;
8import org.eclipse.xtext.web.server.InvalidRequestException;
9 8
10import com.google.common.collect.ImmutableMap;
11import com.google.common.collect.ImmutableSet; 9import com.google.common.collect.ImmutableSet;
12 10
13record SimpleServiceContext(ISession session, Map<String, String> parameters) implements IServiceContext { 11public record SimpleServiceContext(ISession session, Map<String, String> parameters) implements IServiceContext {
14
15 public static final String RESOURCE_NAME_PARAMETER = "resource";
16
17 public static final String CONTENT_TYPE_PARAMETER = "contentType";
18
19 public static final String STATE_ID_PARAMETER = "requiredStateId";
20
21 @Override 12 @Override
22 public Set<String> getParameterKeys() { 13 public Set<String> getParameterKeys() {
23 return ImmutableSet.copyOf(parameters.keySet()); 14 return ImmutableSet.copyOf(parameters.keySet());
@@ -32,32 +23,4 @@ record SimpleServiceContext(ISession session, Map<String, String> parameters) im
32 public ISession getSession() { 23 public ISession getSession() {
33 return session; 24 return session;
34 } 25 }
35
36 public static IServiceContext ofTransaction(ISession session, XtextWebSocketRequest request, String stateId,
37 int index) {
38 var parameters = request.getRequestData().get(index);
39 checkParameters(parameters, RESOURCE_NAME_PARAMETER);
40 checkParameters(parameters, CONTENT_TYPE_PARAMETER);
41 checkParameters(parameters, STATE_ID_PARAMETER);
42 var builder = ImmutableMap.<String, String>builder();
43 builder.putAll(parameters);
44 if (request.getResourceName() != null) {
45 builder.put(RESOURCE_NAME_PARAMETER, request.getResourceName());
46 }
47 if (request.getContentType() != null) {
48 builder.put(CONTENT_TYPE_PARAMETER, request.getContentType());
49 }
50 if (stateId != null) {
51 builder.put(STATE_ID_PARAMETER, stateId);
52 }
53 var allParameters = builder.build();
54 return new SimpleServiceContext(session, allParameters);
55 }
56
57 private static void checkParameters(Map<String, String> parameters, String perTransactionParameter) {
58 if (parameters.containsKey(perTransactionParameter)) {
59 throw new InvalidRequestException.InvalidParametersException(
60 "Parameters map must not contain '" + perTransactionParameter + "' parameter.");
61 }
62 }
63} 26}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/TransactionExecutor.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/TransactionExecutor.java
deleted file mode 100644
index 08687097..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/TransactionExecutor.java
+++ /dev/null
@@ -1,137 +0,0 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import java.io.IOException;
4
5import org.eclipse.emf.common.util.URI;
6import org.eclipse.xtext.resource.IResourceServiceProvider;
7import org.eclipse.xtext.web.server.IServiceResult;
8import org.eclipse.xtext.web.server.ISession;
9import org.eclipse.xtext.web.server.InvalidRequestException;
10import org.eclipse.xtext.web.server.InvalidRequestException.UnknownLanguageException;
11import org.eclipse.xtext.web.server.ServiceConflictResult;
12import org.eclipse.xtext.web.server.XtextServiceDispatcher;
13import org.eclipse.xtext.web.server.contentassist.ContentAssistResult;
14import org.eclipse.xtext.web.server.formatting.FormattingResult;
15import org.eclipse.xtext.web.server.hover.HoverResult;
16import org.eclipse.xtext.web.server.model.DocumentStateResult;
17import org.eclipse.xtext.web.server.occurrences.OccurrencesResult;
18import org.eclipse.xtext.web.server.persistence.ResourceContentResult;
19
20import com.google.common.base.Strings;
21import com.google.inject.Injector;
22
23public class TransactionExecutor {
24 private final ISession session;
25
26 private final IResourceServiceProvider.Registry resourceServiceProviderRegistry;
27
28 public TransactionExecutor(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) {
29 this.session = session;
30 this.resourceServiceProviderRegistry = resourceServiceProviderRegistry;
31 }
32
33 public void handleRequest(XtextWebSocketRequest request, ResponseHandler handler) throws IOException {
34 var requestData = request.getRequestData();
35 if (requestData == null || requestData.isEmpty()) {
36 // Nothing to do.
37 return;
38 }
39 int nCalls = requestData.size();
40 int lastCall = handleTransaction(request, handler);
41 for (int index = lastCall + 1; index < nCalls; index++) {
42 handler.onResponse(
43 new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.TRANSACTION_CANCELLED));
44 }
45 }
46
47 protected int handleTransaction(XtextWebSocketRequest request, ResponseHandler handler) throws IOException {
48 var requestData = request.getRequestData();
49 var stateId = request.getRequiredStateId();
50 int index = 0;
51 try {
52 var injector = getInjector(request);
53 var serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class);
54 int nCalls = requestData.size();
55 for (; index < nCalls; index++) {
56 var serviceContext = SimpleServiceContext.ofTransaction(session, request, stateId, index);
57 var service = serviceDispatcher.getService(serviceContext);
58 var serviceResult = service.getService().apply();
59 handler.onResponse(new XtextWebSocketOkResponse(request, index, serviceResult));
60 if (serviceResult instanceof ServiceConflictResult) {
61 break;
62 }
63 var nextStateId = getNextStateId(serviceResult);
64 if (nextStateId != null) {
65 stateId = nextStateId;
66 }
67 }
68 } catch (InvalidRequestException e) {
69 handler.onResponse(
70 new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.REQUEST_ERROR, e));
71 } catch (RuntimeException e) {
72 handler.onResponse(
73 new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.SERVER_ERROR, e));
74 }
75 return index;
76 }
77
78 /**
79 * Get the injector to satisfy the request in the {@code serviceContext}.
80 *
81 * Based on {@link org.eclipse.xtext.web.servlet.XtextServlet#getInjector}.
82 *
83 * @param serviceContext the Xtext service context of the request
84 * @return the injector for the Xtext language in the request
85 * @throws UnknownLanguageException if the Xtext language cannot be determined
86 */
87 protected Injector getInjector(XtextWebSocketRequest request) {
88 IResourceServiceProvider resourceServiceProvider = null;
89 var resourceName = request.getResourceName();
90 if (resourceName == null) {
91 resourceName = "";
92 }
93 var emfURI = URI.createURI(resourceName);
94 var contentType = request.getContentType();
95 if (Strings.isNullOrEmpty(contentType)) {
96 resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI);
97 if (resourceServiceProvider == null) {
98 if (emfURI.toString().isEmpty()) {
99 throw new UnknownLanguageException(
100 "Unable to identify the Xtext language: missing parameter 'resource' or 'contentType'.");
101 } else {
102 throw new UnknownLanguageException(
103 "Unable to identify the Xtext language for resource " + emfURI + ".");
104 }
105 }
106 } else {
107 resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI, contentType);
108 if (resourceServiceProvider == null) {
109 throw new UnknownLanguageException(
110 "Unable to identify the Xtext language for contentType " + contentType + ".");
111 }
112 }
113 return resourceServiceProvider.get(Injector.class);
114 }
115
116 protected String getNextStateId(IServiceResult serviceResult) {
117 if (serviceResult instanceof ContentAssistResult contentAssistResult) {
118 return contentAssistResult.getStateId();
119 }
120 if (serviceResult instanceof DocumentStateResult documentStateResult) {
121 return documentStateResult.getStateId();
122 }
123 if (serviceResult instanceof FormattingResult formattingResult) {
124 return formattingResult.getStateId();
125 }
126 if (serviceResult instanceof HoverResult hoverResult) {
127 return hoverResult.getStateId();
128 }
129 if (serviceResult instanceof OccurrencesResult occurrencesResult) {
130 return occurrencesResult.getStateId();
131 }
132 if (serviceResult instanceof ResourceContentResult resourceContentResult) {
133 return resourceContentResult.getStateId();
134 }
135 return null;
136 }
137}
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
index 25f67545..a8b4e123 100644
--- 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
@@ -20,16 +20,25 @@ import com.google.gson.Gson;
20import com.google.gson.JsonIOException; 20import com.google.gson.JsonIOException;
21import com.google.gson.JsonParseException; 21import com.google.gson.JsonParseException;
22 22
23import tools.refinery.language.web.xtext.server.ResponseHandler;
24import tools.refinery.language.web.xtext.server.ResponseHandlerException;
25import tools.refinery.language.web.xtext.server.TransactionExecutor;
26import tools.refinery.language.web.xtext.server.message.XtextWebRequest;
27import tools.refinery.language.web.xtext.server.message.XtextWebResponse;
28
23@WebSocket 29@WebSocket
24public class XtextWebSocket { 30public class XtextWebSocket implements WriteCallback, ResponseHandler {
25 private static final Logger LOG = LoggerFactory.getLogger(XtextWebSocket.class); 31 private static final Logger LOG = LoggerFactory.getLogger(XtextWebSocket.class);
26 32
27 private final Gson gson = new Gson(); 33 private final Gson gson = new Gson();
28 34
29 private final TransactionExecutor executor; 35 private final TransactionExecutor executor;
30 36
37 private Session webSocketSession;
38
31 public XtextWebSocket(TransactionExecutor executor) { 39 public XtextWebSocket(TransactionExecutor executor) {
32 this.executor = executor; 40 this.executor = executor;
41 executor.setResponseHandler(this);
33 } 42 }
34 43
35 public XtextWebSocket(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) { 44 public XtextWebSocket(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) {
@@ -38,29 +47,46 @@ public class XtextWebSocket {
38 47
39 @OnWebSocketConnect 48 @OnWebSocketConnect
40 public void onConnect(Session webSocketSession) { 49 public void onConnect(Session webSocketSession) {
50 if (this.webSocketSession != null) {
51 LOG.error("Websocket session onConnect when already connected");
52 return;
53 }
41 LOG.debug("New websocket connection from {}", webSocketSession.getRemoteAddress()); 54 LOG.debug("New websocket connection from {}", webSocketSession.getRemoteAddress());
55 this.webSocketSession = webSocketSession;
42 } 56 }
43 57
44 @OnWebSocketClose 58 @OnWebSocketClose
45 public void onClose(Session webSocketSession, int statusCode, String reason) { 59 public void onClose(int statusCode, String reason) {
60 executor.dispose();
61 if (webSocketSession == null) {
62 return;
63 }
46 if (statusCode == StatusCode.NORMAL) { 64 if (statusCode == StatusCode.NORMAL) {
47 LOG.debug("{} closed connection normally: {}", webSocketSession.getRemoteAddress(), reason); 65 LOG.debug("{} closed connection normally: {}", webSocketSession.getRemoteAddress(), reason);
48 } else { 66 } else {
49 LOG.warn("{} closed connection with status code {}: {}", webSocketSession.getRemoteAddress(), statusCode, 67 LOG.warn("{} closed connection with status code {}: {}", webSocketSession.getRemoteAddress(), statusCode,
50 reason); 68 reason);
51 } 69 }
70 webSocketSession = null;
52 } 71 }
53 72
54 @OnWebSocketError 73 @OnWebSocketError
55 public void onError(Session webSocketSession, Throwable error) { 74 public void onError(Throwable error) {
75 if (webSocketSession == null) {
76 return;
77 }
56 LOG.error("Internal websocket error in connection from" + webSocketSession.getRemoteAddress(), error); 78 LOG.error("Internal websocket error in connection from" + webSocketSession.getRemoteAddress(), error);
57 } 79 }
58 80
59 @OnWebSocketMessage 81 @OnWebSocketMessage
60 public void onMessage(Session webSocketSession, Reader reader) { 82 public void onMessage(Reader reader) {
61 XtextWebSocketRequest request; 83 if (webSocketSession == null) {
84 LOG.error("Trying to receive message when websocket is disconnected");
85 return;
86 }
87 XtextWebRequest request;
62 try { 88 try {
63 request = gson.fromJson(reader, XtextWebSocketRequest.class); 89 request = gson.fromJson(reader, XtextWebRequest.class);
64 } catch (JsonIOException e) { 90 } catch (JsonIOException e) {
65 LOG.error("Cannot read from websocket from" + webSocketSession.getRemoteAddress(), e); 91 LOG.error("Cannot read from websocket from" + webSocketSession.getRemoteAddress(), e);
66 if (webSocketSession.isOpen()) { 92 if (webSocketSession.isOpen()) {
@@ -73,22 +99,35 @@ public class XtextWebSocket {
73 return; 99 return;
74 } 100 }
75 try { 101 try {
76 executor.handleRequest(request, response -> sendResponse(webSocketSession, response)); 102 executor.handleRequest(request);
77 } catch (IOException e) { 103 } catch (ResponseHandlerException e) {
78 LOG.warn("Cannot initiaite async write to websocket " + webSocketSession.getRemoteAddress(), e); 104 LOG.warn("Cannot write websocket response", e);
79 if (webSocketSession.isOpen()) { 105 if (webSocketSession.isOpen()) {
80 webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot write payload"); 106 webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot write response");
81 } 107 }
82 } 108 }
83 } 109 }
84 110
85 protected void sendResponse(Session webSocketSession, XtextWebSocketResponse response) throws IOException { 111 @Override
112 public void onResponse(XtextWebResponse response) throws ResponseHandlerException {
113 if (webSocketSession == null) {
114 throw new ResponseHandlerException("Trying to send message when websocket is disconnected");
115 }
86 var responseString = gson.toJson(response); 116 var responseString = gson.toJson(response);
87 webSocketSession.getRemote().sendPartialString(responseString, true, new WriteCallback() { 117 try {
88 @Override 118 webSocketSession.getRemote().sendPartialString(responseString, true, this);
89 public void writeFailed(Throwable x) { 119 } catch (IOException e) {
90 LOG.warn("Cannot complete async write to websocket " + webSocketSession.getRemoteAddress(), x); 120 throw new ResponseHandlerException(
91 } 121 "Cannot initiaite async write to websocket " + webSocketSession.getRemoteAddress(), e);
92 }); 122 }
123 }
124
125 @Override
126 public void writeFailed(Throwable x) {
127 if (webSocketSession == null) {
128 LOG.error("Cannot complete async write to disconnected websocket", x);
129 return;
130 }
131 LOG.warn("Cannot complete async write to websocket " + webSocketSession.getRemoteAddress(), x);
93 } 132 }
94} 133}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorKind.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorKind.java
deleted file mode 100644
index 66ea227f..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorKind.java
+++ /dev/null
@@ -1,14 +0,0 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import com.google.gson.annotations.SerializedName;
4
5public enum XtextWebSocketErrorKind {
6 @SerializedName("request")
7 REQUEST_ERROR,
8
9 @SerializedName("server")
10 SERVER_ERROR,
11
12 @SerializedName("transaction")
13 TRANSACTION_CANCELLED,
14}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorResponse.java
deleted file mode 100644
index 3be7df39..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorResponse.java
+++ /dev/null
@@ -1,95 +0,0 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import java.util.Objects;
4
5import com.google.gson.annotations.SerializedName;
6
7public final class XtextWebSocketErrorResponse implements XtextWebSocketResponse {
8 private String id;
9
10 private int index;
11
12 @SerializedName("error")
13 private XtextWebSocketErrorKind errorKind;
14
15 @SerializedName("message")
16 private String errorMessage;
17
18 public XtextWebSocketErrorResponse(String id, int index, XtextWebSocketErrorKind errorKind, String errorMessage) {
19 super();
20 this.id = id;
21 this.index = index;
22 this.errorKind = errorKind;
23 this.errorMessage = errorMessage;
24 }
25
26 public XtextWebSocketErrorResponse(XtextWebSocketRequest request, int index, XtextWebSocketErrorKind errorKind,
27 String errorMessage) {
28 this(request.getId(), index, errorKind, errorMessage);
29 }
30
31 public XtextWebSocketErrorResponse(XtextWebSocketRequest request, int index, XtextWebSocketErrorKind errorKind) {
32 this(request, index, errorKind, (String) null);
33 }
34
35 public XtextWebSocketErrorResponse(XtextWebSocketRequest request, int index, XtextWebSocketErrorKind errorKind,
36 Throwable t) {
37 this(request, index, errorKind, t.getMessage());
38 }
39
40 public String getId() {
41 return id;
42 }
43
44 public void setId(String id) {
45 this.id = id;
46 }
47
48 public int getIndex() {
49 return index;
50 }
51
52 public void setIndex(int index) {
53 this.index = index;
54 }
55
56 public XtextWebSocketErrorKind getErrorKind() {
57 return errorKind;
58 }
59
60 public void setErrorKind(XtextWebSocketErrorKind errorKind) {
61 this.errorKind = errorKind;
62 }
63
64 public String getErrorMessage() {
65 return errorMessage;
66 }
67
68 public void setErrorMessage(String errorMessage) {
69 this.errorMessage = errorMessage;
70 }
71
72 @Override
73 public int hashCode() {
74 return Objects.hash(errorKind, errorMessage, id, index);
75 }
76
77 @Override
78 public boolean equals(Object obj) {
79 if (this == obj)
80 return true;
81 if (obj == null)
82 return false;
83 if (getClass() != obj.getClass())
84 return false;
85 XtextWebSocketErrorResponse other = (XtextWebSocketErrorResponse) obj;
86 return errorKind == other.errorKind && Objects.equals(errorMessage, other.errorMessage)
87 && Objects.equals(id, other.id) && index == other.index;
88 }
89
90 @Override
91 public String toString() {
92 return "XtextWebSocketErrorResponse [id=" + id + ", index=" + index + ", errorKind=" + errorKind
93 + ", errorMessage=" + errorMessage + "]";
94 }
95}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketRequest.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketRequest.java
deleted file mode 100644
index 8be67bc1..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketRequest.java
+++ /dev/null
@@ -1,97 +0,0 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import java.util.List;
4import java.util.Map;
5import java.util.Objects;
6
7import com.google.gson.annotations.SerializedName;
8
9public class XtextWebSocketRequest {
10 private String id;
11
12 @SerializedName("resource")
13 private String resourceName;
14
15 private String contentType;
16
17 private String requiredStateId;
18
19 @SerializedName("request")
20 private List<Map<String, String>> requestData;
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
32 public String getId() {
33 return id;
34 }
35
36 public void setId(String id) {
37 this.id = id;
38 }
39
40 public String getResourceName() {
41 return resourceName;
42 }
43
44 public void setResourceName(String resourceName) {
45 this.resourceName = resourceName;
46 }
47
48 public String getContentType() {
49 return contentType;
50 }
51
52 public void setContentType(String contentType) {
53 this.contentType = contentType;
54 }
55
56 public String getRequiredStateId() {
57 return requiredStateId;
58 }
59
60 public void setRequiredStateId(String requiredStateId) {
61 this.requiredStateId = requiredStateId;
62 }
63
64 public List<Map<String, String>> getRequestData() {
65 return requestData;
66 }
67
68 public void setRequestData(List<Map<String, String>> requestData) {
69 this.requestData = requestData;
70 }
71
72 @Override
73 public int hashCode() {
74 return Objects.hash(contentType, id, requestData, requiredStateId, resourceName);
75 }
76
77 @Override
78 public boolean equals(Object obj) {
79 if (this == obj)
80 return true;
81 if (obj == null)
82 return false;
83 if (getClass() != obj.getClass())
84 return false;
85 XtextWebSocketRequest other = (XtextWebSocketRequest) obj;
86 return Objects.equals(contentType, other.contentType) && Objects.equals(id, other.id)
87 && Objects.equals(requestData, other.requestData)
88 && Objects.equals(requiredStateId, other.requiredStateId)
89 && Objects.equals(resourceName, other.resourceName);
90 }
91
92 @Override
93 public String toString() {
94 return "XtextWebSocketRequest [id=" + id + ", resourceName=" + resourceName + ", contentType=" + contentType
95 + ", requiredStateId=" + requiredStateId + ", requestData=" + requestData + "]";
96 }
97}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketResponse.java
deleted file mode 100644
index 2e7cfbbb..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketResponse.java
+++ /dev/null
@@ -1,11 +0,0 @@
1package tools.refinery.language.web.xtext.servlet;
2
3public sealed interface XtextWebSocketResponse permits XtextWebSocketOkResponse,XtextWebSocketErrorResponse {
4 public String getId();
5
6 public void setId(String id);
7
8 public int getIndex();
9
10 public void setIndex(int index);
11}
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
index 08230335..25bcec37 100644
--- 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
@@ -3,7 +3,6 @@ package tools.refinery.language.web.xtext.servlet;
3import java.util.ArrayList; 3import java.util.ArrayList;
4import java.util.List; 4import java.util.List;
5import java.util.concurrent.ExecutorService; 5import java.util.concurrent.ExecutorService;
6import java.util.concurrent.TimeUnit;
7 6
8import org.eclipse.xtext.ide.ExecutorServiceProvider; 7import org.eclipse.xtext.ide.ExecutorServiceProvider;
9 8
@@ -11,24 +10,33 @@ import com.google.inject.Singleton;
11 10
12@Singleton 11@Singleton
13public class AwaitTerminationExecutorServiceProvider extends ExecutorServiceProvider { 12public class AwaitTerminationExecutorServiceProvider extends ExecutorServiceProvider {
14 private List<ExecutorService> servicesToShutDown = new ArrayList<>(); 13 private List<RestartableCachedThreadPool> servicesToShutDown = new ArrayList<>();
15 14
16 @Override 15 @Override
17 protected ExecutorService createInstance(String key) { 16 protected ExecutorService createInstance(String key) {
18 var instance = super.createInstance(key); 17 var instance = new RestartableCachedThreadPool();
19 servicesToShutDown.add(instance); 18 synchronized (servicesToShutDown) {
19 servicesToShutDown.add(instance);
20 }
20 return instance; 21 return instance;
21 } 22 }
22 23
24 public void waitForAllTasksToFinish() {
25 synchronized (servicesToShutDown) {
26 for (var executorService : servicesToShutDown) {
27 executorService.waitForAllTasksToFinish();
28 }
29 }
30 }
31
23 @Override 32 @Override
24 public void dispose() { 33 public void dispose() {
25 super.dispose(); 34 super.dispose();
26 for (var executorService : servicesToShutDown) { 35 synchronized (servicesToShutDown) {
27 try { 36 for (var executorService : servicesToShutDown) {
28 executorService.awaitTermination(1, TimeUnit.SECONDS); 37 executorService.waitForTermination();
29 } catch (InterruptedException e) {
30 // Continue normally.
31 } 38 }
39 servicesToShutDown.clear();
32 } 40 }
33 } 41 }
34} 42}
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
index 3493c9eb..a6d97c8b 100644
--- 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
@@ -26,7 +26,7 @@ public class ProblemWebInjectorProvider extends ProblemInjectorProvider {
26 26
27 protected ProblemWebModule createWebModule() { 27 protected ProblemWebModule createWebModule() {
28 // Await termination of the executor service to avoid race conditions between 28 // Await termination of the executor service to avoid race conditions between
29 // between the tasks in the service and the {@link 29 // the tasks in the service and the {@link
30 // org.eclipse.xtext.testing.extensions.InjectionExtension}. 30 // org.eclipse.xtext.testing.extensions.InjectionExtension}.
31 return new ProblemWebModule() { 31 return new ProblemWebModule() {
32 @SuppressWarnings("unused") 32 @SuppressWarnings("unused")
diff --git a/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/RestartableCachedThreadPool.java b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/RestartableCachedThreadPool.java
new file mode 100644
index 00000000..02ef38e2
--- /dev/null
+++ b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/RestartableCachedThreadPool.java
@@ -0,0 +1,109 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import java.util.Collection;
4import java.util.List;
5import java.util.concurrent.Callable;
6import java.util.concurrent.ExecutionException;
7import java.util.concurrent.ExecutorService;
8import java.util.concurrent.Executors;
9import java.util.concurrent.Future;
10import java.util.concurrent.TimeUnit;
11import java.util.concurrent.TimeoutException;
12
13import org.slf4j.Logger;
14import org.slf4j.LoggerFactory;
15
16public class RestartableCachedThreadPool implements ExecutorService {
17 private static final Logger LOG = LoggerFactory.getLogger(RestartableCachedThreadPool.class);
18
19 private ExecutorService delegate;
20
21 public RestartableCachedThreadPool() {
22 delegate = createExecutorService();
23 }
24
25 public void waitForAllTasksToFinish() {
26 delegate.shutdown();
27 waitForTermination();
28 delegate = createExecutorService();
29 }
30
31 public void waitForTermination() {
32 try {
33 delegate.awaitTermination(1, TimeUnit.SECONDS);
34 } catch (InterruptedException e) {
35 LOG.warn("Interrupted while waiting for delegate executor to stop", e);
36 }
37 }
38
39 protected ExecutorService createExecutorService() {
40 return Executors.newCachedThreadPool();
41 }
42
43 @Override
44 public boolean awaitTermination(long arg0, TimeUnit arg1) throws InterruptedException {
45 return delegate.awaitTermination(arg0, arg1);
46 }
47
48 @Override
49 public void execute(Runnable arg0) {
50 delegate.execute(arg0);
51 }
52
53 @Override
54 public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> arg0, long arg1, TimeUnit arg2)
55 throws InterruptedException {
56 return delegate.invokeAll(arg0, arg1, arg2);
57 }
58
59 @Override
60 public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> arg0) throws InterruptedException {
61 return delegate.invokeAll(arg0);
62 }
63
64 @Override
65 public <T> T invokeAny(Collection<? extends Callable<T>> arg0, long arg1, TimeUnit arg2)
66 throws InterruptedException, ExecutionException, TimeoutException {
67 return delegate.invokeAny(arg0, arg1, arg2);
68 }
69
70 @Override
71 public <T> T invokeAny(Collection<? extends Callable<T>> arg0) throws InterruptedException, ExecutionException {
72 return delegate.invokeAny(arg0);
73 }
74
75 @Override
76 public boolean isShutdown() {
77 return delegate.isShutdown();
78 }
79
80 @Override
81 public boolean isTerminated() {
82 return delegate.isTerminated();
83 }
84
85 @Override
86 public void shutdown() {
87 delegate.shutdown();
88 }
89
90 @Override
91 public List<Runnable> shutdownNow() {
92 return delegate.shutdownNow();
93 }
94
95 @Override
96 public <T> Future<T> submit(Callable<T> arg0) {
97 return delegate.submit(arg0);
98 }
99
100 @Override
101 public <T> Future<T> submit(Runnable arg0, T arg1) {
102 return delegate.submit(arg0, arg1);
103 }
104
105 @Override
106 public Future<?> submit(Runnable arg0) {
107 return delegate.submit(arg0);
108 }
109}
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
index 6ad82d7f..7f7c3e43 100644
--- 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
@@ -4,23 +4,19 @@ import static org.hamcrest.MatcherAssert.assertThat;
4import static org.hamcrest.Matchers.equalTo; 4import static org.hamcrest.Matchers.equalTo;
5import static org.hamcrest.Matchers.hasProperty; 5import static org.hamcrest.Matchers.hasProperty;
6import static org.hamcrest.Matchers.instanceOf; 6import static org.hamcrest.Matchers.instanceOf;
7import static org.junit.jupiter.api.Assertions.fail;
8import static org.mockito.Mockito.mock; 7import static org.mockito.Mockito.mock;
9import static org.mockito.Mockito.times; 8import static org.mockito.Mockito.times;
10import static org.mockito.Mockito.verify; 9import static org.mockito.Mockito.verify;
11 10
12import java.io.IOException;
13import java.util.List; 11import java.util.List;
14import java.util.Map; 12import java.util.Map;
15import java.util.UUID;
16 13
17import org.eclipse.xtext.resource.IResourceServiceProvider; 14import org.eclipse.xtext.resource.IResourceServiceProvider;
18import org.eclipse.xtext.testing.InjectWith; 15import org.eclipse.xtext.testing.InjectWith;
19import org.eclipse.xtext.testing.extensions.InjectionExtension; 16import org.eclipse.xtext.testing.extensions.InjectionExtension;
20import org.eclipse.xtext.web.server.ServiceConflictResult;
21import org.eclipse.xtext.web.server.model.DocumentStateResult; 17import org.eclipse.xtext.web.server.model.DocumentStateResult;
18import org.eclipse.xtext.web.server.syntaxcoloring.HighlightingResult;
22import org.eclipse.xtext.web.server.validation.ValidationResult; 19import org.eclipse.xtext.web.server.validation.ValidationResult;
23import org.hamcrest.Matcher;
24import org.junit.jupiter.api.BeforeEach; 20import org.junit.jupiter.api.BeforeEach;
25import org.junit.jupiter.api.Test; 21import org.junit.jupiter.api.Test;
26import org.junit.jupiter.api.extension.ExtendWith; 22import org.junit.jupiter.api.extension.ExtendWith;
@@ -29,123 +25,121 @@ import org.mockito.junit.jupiter.MockitoExtension;
29 25
30import com.google.inject.Inject; 26import com.google.inject.Inject;
31 27
28import tools.refinery.language.web.xtext.server.ResponseHandler;
29import tools.refinery.language.web.xtext.server.ResponseHandlerException;
30import tools.refinery.language.web.xtext.server.TransactionExecutor;
31import tools.refinery.language.web.xtext.server.message.XtextWebOkResponse;
32import tools.refinery.language.web.xtext.server.message.XtextWebRequest;
33import tools.refinery.language.web.xtext.server.message.XtextWebResponse;
34
32@ExtendWith(MockitoExtension.class) 35@ExtendWith(MockitoExtension.class)
33@ExtendWith(InjectionExtension.class) 36@ExtendWith(InjectionExtension.class)
34@InjectWith(ProblemWebInjectorProvider.class) 37@InjectWith(ProblemWebInjectorProvider.class)
35class TransactionExecutorTest { 38class TransactionExecutorTest {
36 private static final String RESOURCE_NAME = "test.problem"; 39 private static final String RESOURCE_NAME = "test.problem";
37 40
38 private static final String INVALID_STATE_ID = "<invalid_state>";
39
40 private static final String TEST_PROBLEM = """ 41 private static final String TEST_PROBLEM = """
41 class Person { 42 class Person {
42 Person friend[0..*] opposite friend 43 Person[0..*] friend opposite friend
43 } 44 }
44 45
45 friend(a, b). 46 friend(a, b).
46 """; 47 """;
47 48
48 private static final Map<String, String> UPDATE_FULL_TEXT_PARAMS = Map.of("serviceType", "update", "fullText", 49 private static final Map<String, String> UPDATE_FULL_TEXT_PARAMS = Map.of("resource", RESOURCE_NAME, "serviceType",
49 TEST_PROBLEM); 50 "update", "fullText", TEST_PROBLEM);
50
51 private static final Map<String, String> VALIDATE_PARAMS = Map.of("serviceType", "validate");
52 51
53 @Inject 52 @Inject
54 private IResourceServiceProvider.Registry resourceServiceProviderRegistry; 53 private IResourceServiceProvider.Registry resourceServiceProviderRegistry;
55 54
55 @Inject
56 private AwaitTerminationExecutorServiceProvider executorServices;
57
56 private TransactionExecutor transactionExecutor; 58 private TransactionExecutor transactionExecutor;
57 59
58 @BeforeEach 60 @BeforeEach
59 void beforeEach() { 61 void beforeEach() {
60 transactionExecutor = new TransactionExecutor(new SimpleSession(), resourceServiceProviderRegistry); 62 transactionExecutor = new TransactionExecutor(new SimpleSession(), resourceServiceProviderRegistry);
61 } 63 }
62
63 @Test
64 void emptyBatchTest() {
65 performBatchRequest(null);
66 }
67 64
68 @Test 65 @Test
69 void fullTextUpdateTest() { 66 void updateFullTextTest() throws ResponseHandlerException {
70 var response = performSingleRequest(null, UPDATE_FULL_TEXT_PARAMS); 67 var captor = newCaptor();
71 assertThat(response, hasResponseData(instanceOf(DocumentStateResult.class))); 68 var stateId = updateFullText(captor);
69 assertThatPrecomputedMessagesAreReceived(stateId, captor.getAllValues());
72 } 70 }
73 71
74 @Test 72 @Test
75 void validationAfterFullTextUpdateInSameBatchTest() { 73 void updateDeltaTextHighlightAndValidationChange() throws ResponseHandlerException {
76 var response = performBatchRequest(null, UPDATE_FULL_TEXT_PARAMS, VALIDATE_PARAMS).get(1); 74 var stateId = updateFullText();
77 assertThat(response, hasResponseData(instanceOf(ValidationResult.class))); 75 var responseHandler = sendRequestAndWaitForAllResponses(
76 new XtextWebRequest("bar", Map.of("resource", RESOURCE_NAME, "serviceType", "update", "requiredStateId",
77 stateId, "deltaText", "<invalid text>\n", "deltaOffset", "0", "deltaReplaceLength", "0")));
78
79 var captor = newCaptor();
80 verify(responseHandler, times(3)).onResponse(captor.capture());
81 var newStateId = getStateId("bar", captor.getAllValues().get(0));
82 assertThatPrecomputedMessagesAreReceived(newStateId, captor.getAllValues());
78 } 83 }
79 84
80 @Test 85 @Test
81 void validationAfterFullTextUpdateInDifferentBatchTest() { 86 void updateDeltaTextHighlightChangeOnly() throws ResponseHandlerException {
82 var stateId = updateFullText(); 87 var stateId = updateFullText();
83 var validateResponse = performSingleRequest(stateId, VALIDATE_PARAMS); 88 var responseHandler = sendRequestAndWaitForAllResponses(
84 assertThat(validateResponse, hasResponseData(instanceOf(ValidationResult.class))); 89 new XtextWebRequest("bar", Map.of("resource", RESOURCE_NAME, "serviceType", "update", "requiredStateId",
90 stateId, "deltaText", "class Vehicle.\n", "deltaOffset", "0", "deltaReplaceLength", "0")));
91
92 var captor = newCaptor();
93 verify(responseHandler, times(2)).onResponse(captor.capture());
94 var newStateId = getStateId("bar", captor.getAllValues().get(0));
95 assertHighlightingResponse(newStateId, captor.getAllValues().get(1));
85 } 96 }
86 97
87 @Test 98 private ArgumentCaptor<XtextWebResponse> newCaptor() {
88 void conflictTest() { 99 return ArgumentCaptor.forClass(XtextWebResponse.class);
89 updateFullText();
90 var response = performSingleRequest(INVALID_STATE_ID, VALIDATE_PARAMS);
91 assertThat(response, hasResponseData(instanceOf(ServiceConflictResult.class)));
92 } 100 }
93 101
94 @Test 102 private String updateFullText() throws ResponseHandlerException {
95 void transactionCancelledDueToConflictTest() { 103 return updateFullText(newCaptor());
96 updateFullText(); 104 }
97 var response = performBatchRequest(INVALID_STATE_ID, VALIDATE_PARAMS, VALIDATE_PARAMS).get(1); 105
98 assertThat(response, hasErrorKind(equalTo(XtextWebSocketErrorKind.TRANSACTION_CANCELLED))); 106 private String updateFullText(ArgumentCaptor<XtextWebResponse> captor) throws ResponseHandlerException {
107 var responseHandler = sendRequestAndWaitForAllResponses(new XtextWebRequest("foo", UPDATE_FULL_TEXT_PARAMS));
108
109 verify(responseHandler, times(3)).onResponse(captor.capture());
110 return getStateId("foo", captor.getAllValues().get(0));
99 } 111 }
100 112
101 @SafeVarargs 113 private ResponseHandler sendRequestAndWaitForAllResponses(XtextWebRequest request) throws ResponseHandlerException {
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); 114 var responseHandler = mock(ResponseHandler.class);
107 try { 115 transactionExecutor.setResponseHandler(responseHandler);
108 transactionExecutor.handleRequest(request, responseHandler); 116 transactionExecutor.handleRequest(request);
109 } catch (IOException e) { 117 executorServices.waitForAllTasksToFinish();
110 fail("Unexpected IOException", e); 118 return responseHandler;
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 } 119 }
128 120
129 private XtextWebSocketResponse performSingleRequest(String requiredStateId, Map<String, String> param) { 121 private String getStateId(String requestId, XtextWebResponse okResponse) {
130 return performBatchRequest(requiredStateId, param).get(0); 122 assertThat(okResponse, hasProperty("id", equalTo(requestId)));
123 assertThat(okResponse, hasProperty("responseData", instanceOf(DocumentStateResult.class)));
124 return ((DocumentStateResult) ((XtextWebOkResponse) okResponse).getResponseData()).getStateId();
131 } 125 }
132 126
133 private String updateFullText() { 127 private void assertThatPrecomputedMessagesAreReceived(String stateId, List<XtextWebResponse> responses) {
134 var updateResponse = (XtextWebSocketOkResponse) performSingleRequest(null, UPDATE_FULL_TEXT_PARAMS); 128 assertHighlightingResponse(stateId, responses.get(1));
135 var documentStateResult = (DocumentStateResult) updateResponse.getResponseData(); 129 assertValidationResponse(stateId, responses.get(2));
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 } 130 }
142 131
143 private static Matcher<XtextWebSocketResponse> hasResponseData(Matcher<?> responseDataMatcher) { 132 private void assertHighlightingResponse(String stateId, XtextWebResponse highlightingResponse) {
144 return hasProperty("responseData", responseDataMatcher); 133 assertThat(highlightingResponse, hasProperty("resourceId", equalTo(RESOURCE_NAME)));
134 assertThat(highlightingResponse, hasProperty("stateId", equalTo(stateId)));
135 assertThat(highlightingResponse, hasProperty("service", equalTo("highlighting")));
136 assertThat(highlightingResponse, hasProperty("pushData", instanceOf(HighlightingResult.class)));
145 } 137 }
146 138
147 private static Matcher<XtextWebSocketResponse> hasErrorKind( 139 private void assertValidationResponse(String stateId, XtextWebResponse validationResponse) {
148 Matcher<? extends XtextWebSocketErrorKind> errorKindMatcher) { 140 assertThat(validationResponse, hasProperty("resourceId", equalTo(RESOURCE_NAME)));
149 return hasProperty("errorKind", errorKindMatcher); 141 assertThat(validationResponse, hasProperty("stateId", equalTo(stateId)));
142 assertThat(validationResponse, hasProperty("service", equalTo("validation")));
143 assertThat(validationResponse, hasProperty("pushData", instanceOf(ValidationResult.class)));
150 } 144 }
151} 145}