From 7732fedb5933bdc699cd1ef22a766397d5a701d2 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Wed, 20 Oct 2021 01:49:14 +0200 Subject: feat(web): push precomputed service results --- language-web/build.gradle | 1 + .../refinery/language/web/ProblemWebModule.java | 16 ++- ...oPrecomputedServicesXtextServiceDispatcher.java | 16 --- .../language/web/xtext/server/ResponseHandler.java | 8 ++ .../web/xtext/server/ResponseHandlerException.java | 14 ++ .../xtext/server/SubscribingServiceContext.java | 26 ++++ .../web/xtext/server/TransactionExecutor.java | 131 ++++++++++++++++++ .../xtext/server/message/XtextWebErrorKind.java | 11 ++ .../server/message/XtextWebErrorResponse.java | 79 +++++++++++ .../xtext/server/message/XtextWebOkResponse.java | 72 ++++++++++ .../xtext/server/message/XtextWebPushMessage.java | 81 +++++++++++ .../web/xtext/server/message/XtextWebRequest.java | 57 ++++++++ .../web/xtext/server/message/XtextWebResponse.java | 4 + .../xtext/server/push/PrecomputationListener.java | 15 +++ .../xtext/server/push/PushServiceDispatcher.java | 23 ++++ .../web/xtext/server/push/PushWebDocument.java | 89 +++++++++++++ .../xtext/server/push/PushWebDocumentAccess.java | 68 ++++++++++ .../xtext/server/push/PushWebDocumentProvider.java | 33 +++++ .../web/xtext/servlet/ResponseHandler.java | 8 -- .../web/xtext/servlet/SimpleServiceContext.java | 39 +----- .../web/xtext/servlet/TransactionExecutor.java | 137 ------------------- .../language/web/xtext/servlet/XtextWebSocket.java | 73 +++++++--- .../web/xtext/servlet/XtextWebSocketErrorKind.java | 14 -- .../xtext/servlet/XtextWebSocketErrorResponse.java | 95 ------------- .../xtext/servlet/XtextWebSocketOkResponse.java | 83 ------------ .../web/xtext/servlet/XtextWebSocketRequest.java | 97 -------------- .../web/xtext/servlet/XtextWebSocketResponse.java | 11 -- .../AwaitTerminationExecutorServiceProvider.java | 26 ++-- .../xtext/servlet/ProblemWebInjectorProvider.java | 2 +- .../xtext/servlet/RestartableCachedThreadPool.java | 109 +++++++++++++++ .../web/xtext/servlet/TransactionExecutorTest.java | 148 ++++++++++----------- 31 files changed, 981 insertions(+), 605 deletions(-) delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/server/NoPrecomputedServicesXtextServiceDispatcher.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/servlet/ResponseHandler.java delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/servlet/TransactionExecutor.java delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorKind.java delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorResponse.java delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketOkResponse.java delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketRequest.java delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketResponse.java create mode 100644 language-web/src/test/java/tools/refinery/language/web/xtext/servlet/RestartableCachedThreadPool.java (limited to 'language-web') 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 { implementation "org.eclipse.jetty.websocket:websocket-jetty-server:${jettyVersion}" implementation "org.slf4j:slf4j-simple:${slf4JVersion}" implementation "org.slf4j:log4j-over-slf4j:${slf4JVersion}" + testImplementation testFixtures(project(':refinery-language')) testImplementation "org.eclipse.xtext:org.eclipse.xtext.testing:${xtextVersion}" } 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 @@ package tools.refinery.language.web; import org.eclipse.xtext.web.server.XtextServiceDispatcher; +import org.eclipse.xtext.web.server.model.IWebDocumentProvider; +import org.eclipse.xtext.web.server.model.XtextWebDocumentAccess; -import tools.refinery.language.web.xtext.server.NoPrecomputedServicesXtextServiceDispatcher; +import tools.refinery.language.web.xtext.server.push.PushServiceDispatcher; +import tools.refinery.language.web.xtext.server.push.PushWebDocumentAccess; +import tools.refinery.language.web.xtext.server.push.PushWebDocumentProvider; /** * Use this class to register additional components to be used within the web application. */ public class ProblemWebModule extends AbstractProblemWebModule { + public Class bindIWebDocumentProvider() { + return PushWebDocumentProvider.class; + } + + public Class bindXtextWebDocumentAccess() { + return PushWebDocumentAccess.class; + } + public Class bindXtextServiceDispatcher() { - return NoPrecomputedServicesXtextServiceDispatcher.class; + return PushServiceDispatcher.class; } } 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 @@ -package tools.refinery.language.web.xtext.server; - -import org.eclipse.xtext.web.server.XtextServiceDispatcher; -import org.eclipse.xtext.web.server.model.PrecomputedServiceRegistry; - -import com.google.inject.Singleton; - -@Singleton -public class NoPrecomputedServicesXtextServiceDispatcher extends XtextServiceDispatcher { - @Override - protected void registerPreComputedServices(PrecomputedServiceRegistry registry) { - // Do not register any precomputed services, because we will always send - // requests for any pre-computation in the same websocket message as the - // document update request. - } -} 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 @@ +package tools.refinery.language.web.xtext.server; + +import tools.refinery.language.web.xtext.server.message.XtextWebResponse; + +@FunctionalInterface +public interface ResponseHandler { + void onResponse(XtextWebResponse response) throws ResponseHandlerException; +} 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 @@ +package tools.refinery.language.web.xtext.server; + +public class ResponseHandlerException extends Exception { + + private static final long serialVersionUID = 3589866922420268164L; + + public ResponseHandlerException(String message, Throwable cause) { + super(message, cause); + } + + public ResponseHandlerException(String message) { + super(message); + } +} 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 @@ +package tools.refinery.language.web.xtext.server; + +import java.util.Set; + +import org.eclipse.xtext.web.server.IServiceContext; +import org.eclipse.xtext.web.server.ISession; + +import tools.refinery.language.web.xtext.server.push.PrecomputationListener; + +public record SubscribingServiceContext(IServiceContext delegate, PrecomputationListener subscriber) + implements IServiceContext { + @Override + public Set getParameterKeys() { + return delegate.getParameterKeys(); + } + + @Override + public String getParameter(String key) { + return delegate.getParameter(key); + } + + @Override + public ISession getSession() { + return delegate.getSession(); + } +} 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 @@ +package tools.refinery.language.web.xtext.server; + +import java.lang.ref.WeakReference; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.emf.common.util.URI; +import org.eclipse.xtext.resource.IResourceServiceProvider; +import org.eclipse.xtext.util.IDisposable; +import org.eclipse.xtext.web.server.IServiceContext; +import org.eclipse.xtext.web.server.IServiceResult; +import org.eclipse.xtext.web.server.ISession; +import org.eclipse.xtext.web.server.InvalidRequestException; +import org.eclipse.xtext.web.server.InvalidRequestException.UnknownLanguageException; +import org.eclipse.xtext.web.server.XtextServiceDispatcher; + +import com.google.common.base.Strings; +import com.google.inject.Injector; + +import tools.refinery.language.web.xtext.server.message.XtextWebErrorKind; +import tools.refinery.language.web.xtext.server.message.XtextWebErrorResponse; +import tools.refinery.language.web.xtext.server.message.XtextWebOkResponse; +import tools.refinery.language.web.xtext.server.message.XtextWebPushMessage; +import tools.refinery.language.web.xtext.server.message.XtextWebRequest; +import tools.refinery.language.web.xtext.server.push.PrecomputationListener; +import tools.refinery.language.web.xtext.server.push.PushWebDocument; +import tools.refinery.language.web.xtext.servlet.SimpleServiceContext; + +public class TransactionExecutor implements IDisposable, PrecomputationListener { + private final ISession session; + + private final IResourceServiceProvider.Registry resourceServiceProviderRegistry; + + private final Map> subscriptions = new HashMap<>(); + + private ResponseHandler responseHandler; + + public TransactionExecutor(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) { + this.session = session; + this.resourceServiceProviderRegistry = resourceServiceProviderRegistry; + } + + public void setResponseHandler(ResponseHandler responseHandler) { + this.responseHandler = responseHandler; + } + + public void handleRequest(XtextWebRequest request) throws ResponseHandlerException { + var serviceContext = new SimpleServiceContext(session, request.getRequestData()); + try { + var injector = getInjector(serviceContext); + var serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class); + var service = serviceDispatcher.getService(new SubscribingServiceContext(serviceContext, this)); + var serviceResult = service.getService().apply(); + responseHandler.onResponse(new XtextWebOkResponse(request, serviceResult)); + } catch (InvalidRequestException e) { + responseHandler.onResponse(new XtextWebErrorResponse(request, XtextWebErrorKind.REQUEST_ERROR, e)); + } catch (RuntimeException e) { + responseHandler.onResponse(new XtextWebErrorResponse(request, XtextWebErrorKind.SERVER_ERROR, e)); + } + } + + @Override + public void onPrecomputedServiceResult(String resourceId, String stateId, String serviceName, + IServiceResult serviceResult) throws ResponseHandlerException { + responseHandler.onResponse(new XtextWebPushMessage(resourceId, stateId, serviceName, serviceResult)); + } + + @Override + public void onSubscribeToPrecomputationEvents(String resourceId, PushWebDocument document) { + PushWebDocument previousDocument = null; + var previousSubscription = subscriptions.get(resourceId); + if (previousSubscription != null) { + previousDocument = previousSubscription.get(); + } + if (previousDocument == document) { + return; + } + if (previousDocument != null) { + previousDocument.removePrecomputationListener(this); + } + subscriptions.put(resourceId, new WeakReference<>(document)); + } + + /** + * Get the injector to satisfy the request in the {@code serviceContext}. + * + * Based on {@link org.eclipse.xtext.web.servlet.XtextServlet#getInjector}. + * + * @param serviceContext the Xtext service context of the request + * @return the injector for the Xtext language in the request + * @throws UnknownLanguageException if the Xtext language cannot be determined + */ + protected Injector getInjector(IServiceContext context) { + IResourceServiceProvider resourceServiceProvider = null; + var resourceName = context.getParameter("resource"); + if (resourceName == null) { + resourceName = ""; + } + var emfURI = URI.createURI(resourceName); + var contentType = context.getParameter("contentType"); + if (Strings.isNullOrEmpty(contentType)) { + resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI); + if (resourceServiceProvider == null) { + if (emfURI.toString().isEmpty()) { + throw new UnknownLanguageException( + "Unable to identify the Xtext language: missing parameter 'resource' or 'contentType'."); + } else { + throw new UnknownLanguageException( + "Unable to identify the Xtext language for resource " + emfURI + "."); + } + } + } else { + resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI, contentType); + if (resourceServiceProvider == null) { + throw new UnknownLanguageException( + "Unable to identify the Xtext language for contentType " + contentType + "."); + } + } + return resourceServiceProvider.get(Injector.class); + } + + @Override + public void dispose() { + for (var subscription : subscriptions.values()) { + var document = subscription.get(); + if (document != null) { + document.removePrecomputationListener(this); + } + } + } +} 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 @@ +package tools.refinery.language.web.xtext.server.message; + +import com.google.gson.annotations.SerializedName; + +public enum XtextWebErrorKind { + @SerializedName("request") + REQUEST_ERROR, + + @SerializedName("server") + SERVER_ERROR, +} 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 @@ +package tools.refinery.language.web.xtext.server.message; + +import java.util.Objects; + +import com.google.gson.annotations.SerializedName; + +public final class XtextWebErrorResponse implements XtextWebResponse { + private String id; + + @SerializedName("error") + private XtextWebErrorKind errorKind; + + @SerializedName("message") + private String errorMessage; + + public XtextWebErrorResponse(String id, XtextWebErrorKind errorKind, String errorMessage) { + super(); + this.id = id; + this.errorKind = errorKind; + this.errorMessage = errorMessage; + } + + public XtextWebErrorResponse(XtextWebRequest request, XtextWebErrorKind errorKind, + String errorMessage) { + this(request.getId(), errorKind, errorMessage); + } + + public XtextWebErrorResponse(XtextWebRequest request, XtextWebErrorKind errorKind, Throwable t) { + this(request, errorKind, t.getMessage()); + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public XtextWebErrorKind getErrorKind() { + return errorKind; + } + + public void setErrorKind(XtextWebErrorKind errorKind) { + this.errorKind = errorKind; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + @Override + public int hashCode() { + return Objects.hash(errorKind, errorMessage, id); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + XtextWebErrorResponse other = (XtextWebErrorResponse) obj; + return errorKind == other.errorKind && Objects.equals(errorMessage, other.errorMessage) + && Objects.equals(id, other.id); + } + + @Override + public String toString() { + return "XtextWebSocketErrorResponse [id=" + id + ", errorKind=" + errorKind + ", errorMessage=" + errorMessage + + "]"; + } +} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java new file mode 100644 index 00000000..8af27247 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java @@ -0,0 +1,72 @@ +package tools.refinery.language.web.xtext.server.message; + +import java.util.Objects; + +import org.eclipse.xtext.web.server.IServiceResult; +import org.eclipse.xtext.web.server.IUnwrappableServiceResult; + +import com.google.gson.annotations.SerializedName; + +public final class XtextWebOkResponse implements XtextWebResponse { + private String id; + + @SerializedName("response") + private Object responseData; + + public XtextWebOkResponse(String id, Object responseData) { + super(); + this.id = id; + this.responseData = responseData; + } + + public XtextWebOkResponse(XtextWebRequest request, IServiceResult result) { + this(request.getId(), maybeUnwrap(result)); + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Object getResponseData() { + return responseData; + } + + public void setResponseData(Object responseData) { + this.responseData = responseData; + } + + @Override + public int hashCode() { + return Objects.hash(id, responseData); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + XtextWebOkResponse other = (XtextWebOkResponse) obj; + return Objects.equals(id, other.id) && Objects.equals(responseData, other.responseData); + } + + @Override + public String toString() { + return "XtextWebSocketOkResponse [id=" + id + ", responseData=" + responseData + "]"; + } + + private static Object maybeUnwrap(IServiceResult result) { + if (result instanceof IUnwrappableServiceResult unwrappableServiceResult + && unwrappableServiceResult.getContent() != null) { + return unwrappableServiceResult.getContent(); + } else { + return 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 @@ +package tools.refinery.language.web.xtext.server.message; + +import java.util.Objects; + +import com.google.gson.annotations.SerializedName; + +public final class XtextWebPushMessage implements XtextWebResponse { + @SerializedName("resource") + private String resourceId; + + private String stateId; + + private String service; + + @SerializedName("push") + private Object pushData; + + public XtextWebPushMessage(String resourceId, String stateId, String service, Object pushData) { + super(); + this.resourceId = resourceId; + this.stateId = stateId; + this.service = service; + this.pushData = pushData; + } + + public String getResourceId() { + return resourceId; + } + + public void setResourceId(String resourceId) { + this.resourceId = resourceId; + } + + public String getStateId() { + return stateId; + } + + public void setStateId(String stateId) { + this.stateId = stateId; + } + + public String getService() { + return service; + } + + public void setService(String service) { + this.service = service; + } + + public Object getPushData() { + return pushData; + } + + public void setPushData(Object pushData) { + this.pushData = pushData; + } + + @Override + public int hashCode() { + return Objects.hash(pushData, resourceId, service, stateId); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + XtextWebPushMessage other = (XtextWebPushMessage) obj; + return Objects.equals(pushData, other.pushData) && Objects.equals(resourceId, other.resourceId) + && Objects.equals(service, other.service) && Objects.equals(stateId, other.stateId); + } + + @Override + public String toString() { + return "XtextWebPushMessage [resourceId=" + resourceId + ", stateId=" + stateId + ", service=" + service + + ", pushData=" + pushData + "]"; + } +} 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 @@ +package tools.refinery.language.web.xtext.server.message; + +import java.util.Map; +import java.util.Objects; + +import com.google.gson.annotations.SerializedName; + +public class XtextWebRequest { + private String id; + + @SerializedName("request") + private Map requestData; + + public XtextWebRequest(String id, Map requestData) { + super(); + this.id = id; + this.requestData = requestData; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Map getRequestData() { + return requestData; + } + + public void setRequestData(Map requestData) { + this.requestData = requestData; + } + + @Override + public int hashCode() { + return Objects.hash(id, requestData); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + XtextWebRequest other = (XtextWebRequest) obj; + return Objects.equals(id, other.id) && Objects.equals(requestData, other.requestData); + } + + @Override + public String toString() { + return "XtextWebSocketRequest [id=" + id + ", requestData=" + requestData + "]"; + } +} 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 @@ +package tools.refinery.language.web.xtext.server.message; + +public sealed interface XtextWebResponse permits XtextWebOkResponse,XtextWebErrorResponse,XtextWebPushMessage { +} 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 @@ +package tools.refinery.language.web.xtext.server.push; + +import org.eclipse.xtext.web.server.IServiceResult; + +import tools.refinery.language.web.xtext.server.ResponseHandlerException; + +@FunctionalInterface +public interface PrecomputationListener { + void onPrecomputedServiceResult(String resourceId, String stateId, String serviceName, IServiceResult serviceResult) + throws ResponseHandlerException; + + default void onSubscribeToPrecomputationEvents(String resourceId, PushWebDocument document) { + // Nothing to handle by default. + } +} 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 @@ +package tools.refinery.language.web.xtext.server.push; + +import org.eclipse.xtext.web.server.IServiceContext; +import org.eclipse.xtext.web.server.XtextServiceDispatcher; +import org.eclipse.xtext.web.server.model.XtextWebDocument; + +import com.google.inject.Singleton; + +import tools.refinery.language.web.xtext.server.SubscribingServiceContext; + +@Singleton +public class PushServiceDispatcher extends XtextServiceDispatcher { + + @Override + protected XtextWebDocument getFullTextDocument(String fullText, String resourceId, IServiceContext context) { + var document = super.getFullTextDocument(fullText, resourceId, context); + if (document instanceof PushWebDocument pushWebDocument + && context instanceof SubscribingServiceContext subscribingContext) { + pushWebDocument.addPrecomputationListener(subscribingContext.subscriber()); + } + return document; + } +} 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 @@ +package tools.refinery.language.web.xtext.server.push; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.xtext.util.CancelIndicator; +import org.eclipse.xtext.web.server.IServiceResult; +import org.eclipse.xtext.web.server.model.AbstractCachedService; +import org.eclipse.xtext.web.server.model.DocumentSynchronizer; +import org.eclipse.xtext.web.server.model.XtextWebDocument; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.collect.ImmutableList; + +import tools.refinery.language.web.xtext.server.ResponseHandlerException; + +public class PushWebDocument extends XtextWebDocument { + private static final Logger LOG = LoggerFactory.getLogger(PushWebDocument.class); + + private final List precomputationListeners = new ArrayList<>(); + + private final Map, IServiceResult> precomputedServices = new HashMap<>(); + + public PushWebDocument(String resourceId, DocumentSynchronizer synchronizer) { + super(resourceId, synchronizer); + if (resourceId == null) { + throw new IllegalArgumentException("resourceId must not be null"); + } + } + + public boolean addPrecomputationListener(PrecomputationListener listener) { + synchronized (precomputationListeners) { + if (precomputationListeners.contains(listener)) { + return false; + } + precomputationListeners.add(listener); + listener.onSubscribeToPrecomputationEvents(getResourceId(), this); + return true; + } + } + + public boolean removePrecomputationListener(PrecomputationListener listener) { + synchronized (precomputationListeners) { + return precomputationListeners.remove(listener); + } + } + + public void precomputeServiceResult(AbstractCachedService service, String serviceName, + CancelIndicator cancelIndicator, boolean logCacheMiss) { + var result = getCachedServiceResult(service, cancelIndicator, logCacheMiss); + if (result == null) { + LOG.error("{} service returned null result", serviceName); + return; + } + var serviceClass = service.getClass(); + var previousResult = precomputedServices.get(serviceClass); + if (previousResult != null && previousResult.equals(result)) { + return; + } + precomputedServices.put(serviceClass, result); + notifyPrecomputationListeners(serviceName, result); + } + + private void notifyPrecomputationListeners(String serviceName, T result) { + var resourceId = getResourceId(); + var stateId = getStateId(); + List copyOfListeners; + synchronized (precomputationListeners) { + copyOfListeners = ImmutableList.copyOf(precomputationListeners); + } + var toRemove = new ArrayList(); + for (var listener : copyOfListeners) { + try { + listener.onPrecomputedServiceResult(resourceId, stateId, serviceName, result); + } catch (ResponseHandlerException e) { + LOG.error("Delivering precomputation push message failed", e); + toRemove.add(listener); + } + } + if (!toRemove.isEmpty()) { + synchronized (precomputationListeners) { + precomputationListeners.removeAll(toRemove); + } + } + } +} 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 @@ +package tools.refinery.language.web.xtext.server.push; + +import org.eclipse.xtext.service.OperationCanceledManager; +import org.eclipse.xtext.util.CancelIndicator; +import org.eclipse.xtext.util.concurrent.CancelableUnitOfWork; +import org.eclipse.xtext.web.server.IServiceResult; +import org.eclipse.xtext.web.server.model.AbstractCachedService; +import org.eclipse.xtext.web.server.model.IXtextWebDocument; +import org.eclipse.xtext.web.server.model.PrecomputedServiceRegistry; +import org.eclipse.xtext.web.server.model.XtextWebDocument; +import org.eclipse.xtext.web.server.model.XtextWebDocumentAccess; +import org.eclipse.xtext.web.server.syntaxcoloring.HighlightingService; +import org.eclipse.xtext.web.server.validation.ValidationService; + +import com.google.inject.Inject; + +public class PushWebDocumentAccess extends XtextWebDocumentAccess { + + @Inject + private PrecomputedServiceRegistry preComputedServiceRegistry; + + @Inject + private OperationCanceledManager operationCanceledManager; + + private PushWebDocument pushDocument; + + @Override + protected void init(XtextWebDocument document, String requiredStateId, boolean skipAsyncWork) { + super.init(document, requiredStateId, skipAsyncWork); + if (document instanceof PushWebDocument newPushDocument) { + pushDocument = newPushDocument; + } + } + + @Override + protected void performPrecomputation(CancelIndicator cancelIndicator) { + if (pushDocument == null) { + super.performPrecomputation(cancelIndicator); + return; + } + for (AbstractCachedService service : preComputedServiceRegistry + .getPrecomputedServices()) { + operationCanceledManager.checkCanceled(cancelIndicator); + precomputeServiceResult(service, false); + } + } + + protected void precomputeServiceResult(AbstractCachedService service, boolean logCacheMiss) { + var serviceName = getPrecomputedServiceName(service); + readOnly(new CancelableUnitOfWork() { + @Override + public java.lang.Void exec(IXtextWebDocument d, CancelIndicator cancelIndicator) throws Exception { + pushDocument.precomputeServiceResult(service, serviceName, cancelIndicator, logCacheMiss); + return null; + } + }); + } + + protected String getPrecomputedServiceName(AbstractCachedService service) { + if (service instanceof ValidationService) { + return "validation"; + } + if (service instanceof HighlightingService) { + return "highlighting"; + } + throw new IllegalArgumentException("Unknown precomputed service: " + service); + } +} 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 @@ +package tools.refinery.language.web.xtext.server.push; + +import org.eclipse.xtext.web.server.IServiceContext; +import org.eclipse.xtext.web.server.model.DocumentSynchronizer; +import org.eclipse.xtext.web.server.model.IWebDocumentProvider; +import org.eclipse.xtext.web.server.model.XtextWebDocument; + +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.Singleton; + +/** + * Based on + * {@link org.eclipse.xtext.web.server.model.IWebDocumentProvider.DefaultImpl}. + * + * @author Kristóf Marussy + */ +@Singleton +public class PushWebDocumentProvider implements IWebDocumentProvider { + @Inject + private Provider synchronizerProvider; + + @Override + public XtextWebDocument get(String resourceId, IServiceContext serviceContext) { + if (resourceId == null) { + return new XtextWebDocument(resourceId, synchronizerProvider.get()); + } else { + // We only need to send push messages if a resourceId is specified. + return new PushWebDocument(resourceId, + serviceContext.getSession().get(DocumentSynchronizer.class, () -> this.synchronizerProvider.get())); + } + } +} 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 @@ -package tools.refinery.language.web.xtext.servlet; - -import java.io.IOException; - -@FunctionalInterface -public interface ResponseHandler { - void onResponse(XtextWebSocketResponse response) throws IOException; -} 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; import org.eclipse.xtext.web.server.IServiceContext; import org.eclipse.xtext.web.server.ISession; -import org.eclipse.xtext.web.server.InvalidRequestException; -import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; -record SimpleServiceContext(ISession session, Map parameters) implements IServiceContext { - - public static final String RESOURCE_NAME_PARAMETER = "resource"; - - public static final String CONTENT_TYPE_PARAMETER = "contentType"; - - public static final String STATE_ID_PARAMETER = "requiredStateId"; - +public record SimpleServiceContext(ISession session, Map parameters) implements IServiceContext { @Override public Set getParameterKeys() { return ImmutableSet.copyOf(parameters.keySet()); @@ -32,32 +23,4 @@ record SimpleServiceContext(ISession session, Map parameters) im public ISession getSession() { return session; } - - public static IServiceContext ofTransaction(ISession session, XtextWebSocketRequest request, String stateId, - int index) { - var parameters = request.getRequestData().get(index); - checkParameters(parameters, RESOURCE_NAME_PARAMETER); - checkParameters(parameters, CONTENT_TYPE_PARAMETER); - checkParameters(parameters, STATE_ID_PARAMETER); - var builder = ImmutableMap.builder(); - builder.putAll(parameters); - if (request.getResourceName() != null) { - builder.put(RESOURCE_NAME_PARAMETER, request.getResourceName()); - } - if (request.getContentType() != null) { - builder.put(CONTENT_TYPE_PARAMETER, request.getContentType()); - } - if (stateId != null) { - builder.put(STATE_ID_PARAMETER, stateId); - } - var allParameters = builder.build(); - return new SimpleServiceContext(session, allParameters); - } - - private static void checkParameters(Map parameters, String perTransactionParameter) { - if (parameters.containsKey(perTransactionParameter)) { - throw new InvalidRequestException.InvalidParametersException( - "Parameters map must not contain '" + perTransactionParameter + "' parameter."); - } - } } 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 @@ -package tools.refinery.language.web.xtext.servlet; - -import java.io.IOException; - -import org.eclipse.emf.common.util.URI; -import org.eclipse.xtext.resource.IResourceServiceProvider; -import org.eclipse.xtext.web.server.IServiceResult; -import org.eclipse.xtext.web.server.ISession; -import org.eclipse.xtext.web.server.InvalidRequestException; -import org.eclipse.xtext.web.server.InvalidRequestException.UnknownLanguageException; -import org.eclipse.xtext.web.server.ServiceConflictResult; -import org.eclipse.xtext.web.server.XtextServiceDispatcher; -import org.eclipse.xtext.web.server.contentassist.ContentAssistResult; -import org.eclipse.xtext.web.server.formatting.FormattingResult; -import org.eclipse.xtext.web.server.hover.HoverResult; -import org.eclipse.xtext.web.server.model.DocumentStateResult; -import org.eclipse.xtext.web.server.occurrences.OccurrencesResult; -import org.eclipse.xtext.web.server.persistence.ResourceContentResult; - -import com.google.common.base.Strings; -import com.google.inject.Injector; - -public class TransactionExecutor { - private final ISession session; - - private final IResourceServiceProvider.Registry resourceServiceProviderRegistry; - - public TransactionExecutor(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) { - this.session = session; - this.resourceServiceProviderRegistry = resourceServiceProviderRegistry; - } - - public void handleRequest(XtextWebSocketRequest request, ResponseHandler handler) throws IOException { - var requestData = request.getRequestData(); - if (requestData == null || requestData.isEmpty()) { - // Nothing to do. - return; - } - int nCalls = requestData.size(); - int lastCall = handleTransaction(request, handler); - for (int index = lastCall + 1; index < nCalls; index++) { - handler.onResponse( - new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.TRANSACTION_CANCELLED)); - } - } - - protected int handleTransaction(XtextWebSocketRequest request, ResponseHandler handler) throws IOException { - var requestData = request.getRequestData(); - var stateId = request.getRequiredStateId(); - int index = 0; - try { - var injector = getInjector(request); - var serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class); - int nCalls = requestData.size(); - for (; index < nCalls; index++) { - var serviceContext = SimpleServiceContext.ofTransaction(session, request, stateId, index); - var service = serviceDispatcher.getService(serviceContext); - var serviceResult = service.getService().apply(); - handler.onResponse(new XtextWebSocketOkResponse(request, index, serviceResult)); - if (serviceResult instanceof ServiceConflictResult) { - break; - } - var nextStateId = getNextStateId(serviceResult); - if (nextStateId != null) { - stateId = nextStateId; - } - } - } catch (InvalidRequestException e) { - handler.onResponse( - new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.REQUEST_ERROR, e)); - } catch (RuntimeException e) { - handler.onResponse( - new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.SERVER_ERROR, e)); - } - return index; - } - - /** - * Get the injector to satisfy the request in the {@code serviceContext}. - * - * Based on {@link org.eclipse.xtext.web.servlet.XtextServlet#getInjector}. - * - * @param serviceContext the Xtext service context of the request - * @return the injector for the Xtext language in the request - * @throws UnknownLanguageException if the Xtext language cannot be determined - */ - protected Injector getInjector(XtextWebSocketRequest request) { - IResourceServiceProvider resourceServiceProvider = null; - var resourceName = request.getResourceName(); - if (resourceName == null) { - resourceName = ""; - } - var emfURI = URI.createURI(resourceName); - var contentType = request.getContentType(); - if (Strings.isNullOrEmpty(contentType)) { - resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI); - if (resourceServiceProvider == null) { - if (emfURI.toString().isEmpty()) { - throw new UnknownLanguageException( - "Unable to identify the Xtext language: missing parameter 'resource' or 'contentType'."); - } else { - throw new UnknownLanguageException( - "Unable to identify the Xtext language for resource " + emfURI + "."); - } - } - } else { - resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI, contentType); - if (resourceServiceProvider == null) { - throw new UnknownLanguageException( - "Unable to identify the Xtext language for contentType " + contentType + "."); - } - } - return resourceServiceProvider.get(Injector.class); - } - - protected String getNextStateId(IServiceResult serviceResult) { - if (serviceResult instanceof ContentAssistResult contentAssistResult) { - return contentAssistResult.getStateId(); - } - if (serviceResult instanceof DocumentStateResult documentStateResult) { - return documentStateResult.getStateId(); - } - if (serviceResult instanceof FormattingResult formattingResult) { - return formattingResult.getStateId(); - } - if (serviceResult instanceof HoverResult hoverResult) { - return hoverResult.getStateId(); - } - if (serviceResult instanceof OccurrencesResult occurrencesResult) { - return occurrencesResult.getStateId(); - } - if (serviceResult instanceof ResourceContentResult resourceContentResult) { - return resourceContentResult.getStateId(); - } - return null; - } -} 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; import com.google.gson.JsonIOException; import com.google.gson.JsonParseException; +import tools.refinery.language.web.xtext.server.ResponseHandler; +import tools.refinery.language.web.xtext.server.ResponseHandlerException; +import tools.refinery.language.web.xtext.server.TransactionExecutor; +import tools.refinery.language.web.xtext.server.message.XtextWebRequest; +import tools.refinery.language.web.xtext.server.message.XtextWebResponse; + @WebSocket -public class XtextWebSocket { +public class XtextWebSocket implements WriteCallback, ResponseHandler { private static final Logger LOG = LoggerFactory.getLogger(XtextWebSocket.class); private final Gson gson = new Gson(); private final TransactionExecutor executor; + private Session webSocketSession; + public XtextWebSocket(TransactionExecutor executor) { this.executor = executor; + executor.setResponseHandler(this); } public XtextWebSocket(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) { @@ -38,29 +47,46 @@ public class XtextWebSocket { @OnWebSocketConnect public void onConnect(Session webSocketSession) { + if (this.webSocketSession != null) { + LOG.error("Websocket session onConnect when already connected"); + return; + } LOG.debug("New websocket connection from {}", webSocketSession.getRemoteAddress()); + this.webSocketSession = webSocketSession; } @OnWebSocketClose - public void onClose(Session webSocketSession, int statusCode, String reason) { + public void onClose(int statusCode, String reason) { + executor.dispose(); + if (webSocketSession == null) { + return; + } if (statusCode == StatusCode.NORMAL) { LOG.debug("{} closed connection normally: {}", webSocketSession.getRemoteAddress(), reason); } else { LOG.warn("{} closed connection with status code {}: {}", webSocketSession.getRemoteAddress(), statusCode, reason); } + webSocketSession = null; } @OnWebSocketError - public void onError(Session webSocketSession, Throwable error) { + public void onError(Throwable error) { + if (webSocketSession == null) { + return; + } LOG.error("Internal websocket error in connection from" + webSocketSession.getRemoteAddress(), error); } @OnWebSocketMessage - public void onMessage(Session webSocketSession, Reader reader) { - XtextWebSocketRequest request; + public void onMessage(Reader reader) { + if (webSocketSession == null) { + LOG.error("Trying to receive message when websocket is disconnected"); + return; + } + XtextWebRequest request; try { - request = gson.fromJson(reader, XtextWebSocketRequest.class); + request = gson.fromJson(reader, XtextWebRequest.class); } catch (JsonIOException e) { LOG.error("Cannot read from websocket from" + webSocketSession.getRemoteAddress(), e); if (webSocketSession.isOpen()) { @@ -73,22 +99,35 @@ public class XtextWebSocket { return; } try { - executor.handleRequest(request, response -> sendResponse(webSocketSession, response)); - } catch (IOException e) { - LOG.warn("Cannot initiaite async write to websocket " + webSocketSession.getRemoteAddress(), e); + executor.handleRequest(request); + } catch (ResponseHandlerException e) { + LOG.warn("Cannot write websocket response", e); if (webSocketSession.isOpen()) { - webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot write payload"); + webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot write response"); } } } - protected void sendResponse(Session webSocketSession, XtextWebSocketResponse response) throws IOException { + @Override + public void onResponse(XtextWebResponse response) throws ResponseHandlerException { + if (webSocketSession == null) { + throw new ResponseHandlerException("Trying to send message when websocket is disconnected"); + } var responseString = gson.toJson(response); - webSocketSession.getRemote().sendPartialString(responseString, true, new WriteCallback() { - @Override - public void writeFailed(Throwable x) { - LOG.warn("Cannot complete async write to websocket " + webSocketSession.getRemoteAddress(), x); - } - }); + try { + webSocketSession.getRemote().sendPartialString(responseString, true, this); + } catch (IOException e) { + throw new ResponseHandlerException( + "Cannot initiaite async write to websocket " + webSocketSession.getRemoteAddress(), e); + } + } + + @Override + public void writeFailed(Throwable x) { + if (webSocketSession == null) { + LOG.error("Cannot complete async write to disconnected websocket", x); + return; + } + LOG.warn("Cannot complete async write to websocket " + webSocketSession.getRemoteAddress(), x); } } 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 @@ -package tools.refinery.language.web.xtext.servlet; - -import com.google.gson.annotations.SerializedName; - -public enum XtextWebSocketErrorKind { - @SerializedName("request") - REQUEST_ERROR, - - @SerializedName("server") - SERVER_ERROR, - - @SerializedName("transaction") - TRANSACTION_CANCELLED, -} 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 @@ -package tools.refinery.language.web.xtext.servlet; - -import java.util.Objects; - -import com.google.gson.annotations.SerializedName; - -public final class XtextWebSocketErrorResponse implements XtextWebSocketResponse { - private String id; - - private int index; - - @SerializedName("error") - private XtextWebSocketErrorKind errorKind; - - @SerializedName("message") - private String errorMessage; - - public XtextWebSocketErrorResponse(String id, int index, XtextWebSocketErrorKind errorKind, String errorMessage) { - super(); - this.id = id; - this.index = index; - this.errorKind = errorKind; - this.errorMessage = errorMessage; - } - - public XtextWebSocketErrorResponse(XtextWebSocketRequest request, int index, XtextWebSocketErrorKind errorKind, - String errorMessage) { - this(request.getId(), index, errorKind, errorMessage); - } - - public XtextWebSocketErrorResponse(XtextWebSocketRequest request, int index, XtextWebSocketErrorKind errorKind) { - this(request, index, errorKind, (String) null); - } - - public XtextWebSocketErrorResponse(XtextWebSocketRequest request, int index, XtextWebSocketErrorKind errorKind, - Throwable t) { - this(request, index, errorKind, t.getMessage()); - } - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public int getIndex() { - return index; - } - - public void setIndex(int index) { - this.index = index; - } - - public XtextWebSocketErrorKind getErrorKind() { - return errorKind; - } - - public void setErrorKind(XtextWebSocketErrorKind errorKind) { - this.errorKind = errorKind; - } - - public String getErrorMessage() { - return errorMessage; - } - - public void setErrorMessage(String errorMessage) { - this.errorMessage = errorMessage; - } - - @Override - public int hashCode() { - return Objects.hash(errorKind, errorMessage, id, index); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) - return true; - if (obj == null) - return false; - if (getClass() != obj.getClass()) - return false; - XtextWebSocketErrorResponse other = (XtextWebSocketErrorResponse) obj; - return errorKind == other.errorKind && Objects.equals(errorMessage, other.errorMessage) - && Objects.equals(id, other.id) && index == other.index; - } - - @Override - public String toString() { - return "XtextWebSocketErrorResponse [id=" + id + ", index=" + index + ", errorKind=" + errorKind - + ", errorMessage=" + errorMessage + "]"; - } -} 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/servlet/XtextWebSocketOkResponse.java deleted file mode 100644 index 0a841895..00000000 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketOkResponse.java +++ /dev/null @@ -1,83 +0,0 @@ -package tools.refinery.language.web.xtext.servlet; - -import java.util.Objects; - -import org.eclipse.xtext.web.server.IServiceResult; -import org.eclipse.xtext.web.server.IUnwrappableServiceResult; - -import com.google.gson.annotations.SerializedName; - -public final class XtextWebSocketOkResponse implements XtextWebSocketResponse { - private String id; - - private int index; - - @SerializedName("response") - private Object responseData; - - public XtextWebSocketOkResponse(String id, int index, Object responseData) { - super(); - this.id = id; - this.index = index; - this.responseData = responseData; - } - - public XtextWebSocketOkResponse(XtextWebSocketRequest request, int index, IServiceResult result) { - this(request.getId(), index, maybeUnwrap(result)); - } - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public int getIndex() { - return index; - } - - public void setIndex(int index) { - this.index = index; - } - - public Object getResponseData() { - return responseData; - } - - public void setResponseData(Object responseData) { - this.responseData = responseData; - } - - @Override - public int hashCode() { - return Objects.hash(id, index, responseData); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) - return true; - if (obj == null) - return false; - if (getClass() != obj.getClass()) - return false; - XtextWebSocketOkResponse other = (XtextWebSocketOkResponse) obj; - return Objects.equals(id, other.id) && index == other.index && Objects.equals(responseData, other.responseData); - } - - @Override - public String toString() { - return "XtextWebSocketOkResponse [id=" + id + ", index=" + index + ", responseData=" + responseData + "]"; - } - - private static Object maybeUnwrap(IServiceResult result) { - if (result instanceof IUnwrappableServiceResult unwrappableServiceResult - && unwrappableServiceResult.getContent() != null) { - return unwrappableServiceResult.getContent(); - } else { - return result; - } - } -} 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 @@ -package tools.refinery.language.web.xtext.servlet; - -import java.util.List; -import java.util.Map; -import java.util.Objects; - -import com.google.gson.annotations.SerializedName; - -public class XtextWebSocketRequest { - private String id; - - @SerializedName("resource") - private String resourceName; - - private String contentType; - - private String requiredStateId; - - @SerializedName("request") - private List> requestData; - - public XtextWebSocketRequest(String id, String resourceName, String contentType, String requiredStateId, - List> requestData) { - super(); - this.id = id; - this.resourceName = resourceName; - this.contentType = contentType; - this.requiredStateId = requiredStateId; - this.requestData = requestData; - } - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getResourceName() { - return resourceName; - } - - public void setResourceName(String resourceName) { - this.resourceName = resourceName; - } - - public String getContentType() { - return contentType; - } - - public void setContentType(String contentType) { - this.contentType = contentType; - } - - public String getRequiredStateId() { - return requiredStateId; - } - - public void setRequiredStateId(String requiredStateId) { - this.requiredStateId = requiredStateId; - } - - public List> getRequestData() { - return requestData; - } - - public void setRequestData(List> requestData) { - this.requestData = requestData; - } - - @Override - public int hashCode() { - return Objects.hash(contentType, id, requestData, requiredStateId, resourceName); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) - return true; - if (obj == null) - return false; - if (getClass() != obj.getClass()) - return false; - XtextWebSocketRequest other = (XtextWebSocketRequest) obj; - return Objects.equals(contentType, other.contentType) && Objects.equals(id, other.id) - && Objects.equals(requestData, other.requestData) - && Objects.equals(requiredStateId, other.requiredStateId) - && Objects.equals(resourceName, other.resourceName); - } - - @Override - public String toString() { - return "XtextWebSocketRequest [id=" + id + ", resourceName=" + resourceName + ", contentType=" + contentType - + ", requiredStateId=" + requiredStateId + ", requestData=" + requestData + "]"; - } -} 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 @@ -package tools.refinery.language.web.xtext.servlet; - -public sealed interface XtextWebSocketResponse permits XtextWebSocketOkResponse,XtextWebSocketErrorResponse { - public String getId(); - - public void setId(String id); - - public int getIndex(); - - public void setIndex(int index); -} 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; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutorService; -import java.util.concurrent.TimeUnit; import org.eclipse.xtext.ide.ExecutorServiceProvider; @@ -11,24 +10,33 @@ import com.google.inject.Singleton; @Singleton public class AwaitTerminationExecutorServiceProvider extends ExecutorServiceProvider { - private List servicesToShutDown = new ArrayList<>(); + private List servicesToShutDown = new ArrayList<>(); @Override protected ExecutorService createInstance(String key) { - var instance = super.createInstance(key); - servicesToShutDown.add(instance); + var instance = new RestartableCachedThreadPool(); + synchronized (servicesToShutDown) { + servicesToShutDown.add(instance); + } return instance; } + public void waitForAllTasksToFinish() { + synchronized (servicesToShutDown) { + for (var executorService : servicesToShutDown) { + executorService.waitForAllTasksToFinish(); + } + } + } + @Override public void dispose() { super.dispose(); - for (var executorService : servicesToShutDown) { - try { - executorService.awaitTermination(1, TimeUnit.SECONDS); - } catch (InterruptedException e) { - // Continue normally. + synchronized (servicesToShutDown) { + for (var executorService : servicesToShutDown) { + executorService.waitForTermination(); } + servicesToShutDown.clear(); } } } 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 { protected ProblemWebModule createWebModule() { // Await termination of the executor service to avoid race conditions between - // between the tasks in the service and the {@link + // the tasks in the service and the {@link // org.eclipse.xtext.testing.extensions.InjectionExtension}. return new ProblemWebModule() { @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 @@ +package tools.refinery.language.web.xtext.servlet; + +import java.util.Collection; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class RestartableCachedThreadPool implements ExecutorService { + private static final Logger LOG = LoggerFactory.getLogger(RestartableCachedThreadPool.class); + + private ExecutorService delegate; + + public RestartableCachedThreadPool() { + delegate = createExecutorService(); + } + + public void waitForAllTasksToFinish() { + delegate.shutdown(); + waitForTermination(); + delegate = createExecutorService(); + } + + public void waitForTermination() { + try { + delegate.awaitTermination(1, TimeUnit.SECONDS); + } catch (InterruptedException e) { + LOG.warn("Interrupted while waiting for delegate executor to stop", e); + } + } + + protected ExecutorService createExecutorService() { + return Executors.newCachedThreadPool(); + } + + @Override + public boolean awaitTermination(long arg0, TimeUnit arg1) throws InterruptedException { + return delegate.awaitTermination(arg0, arg1); + } + + @Override + public void execute(Runnable arg0) { + delegate.execute(arg0); + } + + @Override + public List> invokeAll(Collection> arg0, long arg1, TimeUnit arg2) + throws InterruptedException { + return delegate.invokeAll(arg0, arg1, arg2); + } + + @Override + public List> invokeAll(Collection> arg0) throws InterruptedException { + return delegate.invokeAll(arg0); + } + + @Override + public T invokeAny(Collection> arg0, long arg1, TimeUnit arg2) + throws InterruptedException, ExecutionException, TimeoutException { + return delegate.invokeAny(arg0, arg1, arg2); + } + + @Override + public T invokeAny(Collection> arg0) throws InterruptedException, ExecutionException { + return delegate.invokeAny(arg0); + } + + @Override + public boolean isShutdown() { + return delegate.isShutdown(); + } + + @Override + public boolean isTerminated() { + return delegate.isTerminated(); + } + + @Override + public void shutdown() { + delegate.shutdown(); + } + + @Override + public List shutdownNow() { + return delegate.shutdownNow(); + } + + @Override + public Future submit(Callable arg0) { + return delegate.submit(arg0); + } + + @Override + public Future submit(Runnable arg0, T arg1) { + return delegate.submit(arg0, arg1); + } + + @Override + public Future submit(Runnable arg0) { + return delegate.submit(arg0); + } +} 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; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasProperty; import static org.hamcrest.Matchers.instanceOf; -import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import java.io.IOException; import java.util.List; import java.util.Map; -import java.util.UUID; import org.eclipse.xtext.resource.IResourceServiceProvider; import org.eclipse.xtext.testing.InjectWith; import org.eclipse.xtext.testing.extensions.InjectionExtension; -import org.eclipse.xtext.web.server.ServiceConflictResult; import org.eclipse.xtext.web.server.model.DocumentStateResult; +import org.eclipse.xtext.web.server.syntaxcoloring.HighlightingResult; import org.eclipse.xtext.web.server.validation.ValidationResult; -import org.hamcrest.Matcher; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -29,123 +25,121 @@ import org.mockito.junit.jupiter.MockitoExtension; import com.google.inject.Inject; +import tools.refinery.language.web.xtext.server.ResponseHandler; +import tools.refinery.language.web.xtext.server.ResponseHandlerException; +import tools.refinery.language.web.xtext.server.TransactionExecutor; +import tools.refinery.language.web.xtext.server.message.XtextWebOkResponse; +import tools.refinery.language.web.xtext.server.message.XtextWebRequest; +import tools.refinery.language.web.xtext.server.message.XtextWebResponse; + @ExtendWith(MockitoExtension.class) @ExtendWith(InjectionExtension.class) @InjectWith(ProblemWebInjectorProvider.class) class TransactionExecutorTest { private static final String RESOURCE_NAME = "test.problem"; - private static final String INVALID_STATE_ID = ""; - private static final String TEST_PROBLEM = """ - class Person { - Person friend[0..*] opposite friend + class Person { + Person[0..*] friend opposite friend } friend(a, b). """; - private static final Map UPDATE_FULL_TEXT_PARAMS = Map.of("serviceType", "update", "fullText", - TEST_PROBLEM); - - private static final Map VALIDATE_PARAMS = Map.of("serviceType", "validate"); + private static final Map UPDATE_FULL_TEXT_PARAMS = Map.of("resource", RESOURCE_NAME, "serviceType", + "update", "fullText", TEST_PROBLEM); @Inject private IResourceServiceProvider.Registry resourceServiceProviderRegistry; + @Inject + private AwaitTerminationExecutorServiceProvider executorServices; + private TransactionExecutor transactionExecutor; @BeforeEach void beforeEach() { transactionExecutor = new TransactionExecutor(new SimpleSession(), resourceServiceProviderRegistry); } - - @Test - void emptyBatchTest() { - performBatchRequest(null); - } @Test - void fullTextUpdateTest() { - var response = performSingleRequest(null, UPDATE_FULL_TEXT_PARAMS); - assertThat(response, hasResponseData(instanceOf(DocumentStateResult.class))); + void updateFullTextTest() throws ResponseHandlerException { + var captor = newCaptor(); + var stateId = updateFullText(captor); + assertThatPrecomputedMessagesAreReceived(stateId, captor.getAllValues()); } @Test - void validationAfterFullTextUpdateInSameBatchTest() { - var response = performBatchRequest(null, UPDATE_FULL_TEXT_PARAMS, VALIDATE_PARAMS).get(1); - assertThat(response, hasResponseData(instanceOf(ValidationResult.class))); + void updateDeltaTextHighlightAndValidationChange() throws ResponseHandlerException { + var stateId = updateFullText(); + var responseHandler = sendRequestAndWaitForAllResponses( + new XtextWebRequest("bar", Map.of("resource", RESOURCE_NAME, "serviceType", "update", "requiredStateId", + stateId, "deltaText", "\n", "deltaOffset", "0", "deltaReplaceLength", "0"))); + + var captor = newCaptor(); + verify(responseHandler, times(3)).onResponse(captor.capture()); + var newStateId = getStateId("bar", captor.getAllValues().get(0)); + assertThatPrecomputedMessagesAreReceived(newStateId, captor.getAllValues()); } @Test - void validationAfterFullTextUpdateInDifferentBatchTest() { + void updateDeltaTextHighlightChangeOnly() throws ResponseHandlerException { var stateId = updateFullText(); - var validateResponse = performSingleRequest(stateId, VALIDATE_PARAMS); - assertThat(validateResponse, hasResponseData(instanceOf(ValidationResult.class))); + var responseHandler = sendRequestAndWaitForAllResponses( + new XtextWebRequest("bar", Map.of("resource", RESOURCE_NAME, "serviceType", "update", "requiredStateId", + stateId, "deltaText", "class Vehicle.\n", "deltaOffset", "0", "deltaReplaceLength", "0"))); + + var captor = newCaptor(); + verify(responseHandler, times(2)).onResponse(captor.capture()); + var newStateId = getStateId("bar", captor.getAllValues().get(0)); + assertHighlightingResponse(newStateId, captor.getAllValues().get(1)); } - @Test - void conflictTest() { - updateFullText(); - var response = performSingleRequest(INVALID_STATE_ID, VALIDATE_PARAMS); - assertThat(response, hasResponseData(instanceOf(ServiceConflictResult.class))); + private ArgumentCaptor newCaptor() { + return ArgumentCaptor.forClass(XtextWebResponse.class); } - @Test - void transactionCancelledDueToConflictTest() { - updateFullText(); - var response = performBatchRequest(INVALID_STATE_ID, VALIDATE_PARAMS, VALIDATE_PARAMS).get(1); - assertThat(response, hasErrorKind(equalTo(XtextWebSocketErrorKind.TRANSACTION_CANCELLED))); + private String updateFullText() throws ResponseHandlerException { + return updateFullText(newCaptor()); + } + + private String updateFullText(ArgumentCaptor captor) throws ResponseHandlerException { + var responseHandler = sendRequestAndWaitForAllResponses(new XtextWebRequest("foo", UPDATE_FULL_TEXT_PARAMS)); + + verify(responseHandler, times(3)).onResponse(captor.capture()); + return getStateId("foo", captor.getAllValues().get(0)); } - @SafeVarargs - private List performBatchRequest(String requiredStateId, Map... params) { - var id = UUID.randomUUID().toString(); - var request = new XtextWebSocketRequest(id, RESOURCE_NAME, null, requiredStateId, List.of(params)); - + private ResponseHandler sendRequestAndWaitForAllResponses(XtextWebRequest request) throws ResponseHandlerException { var responseHandler = mock(ResponseHandler.class); - try { - transactionExecutor.handleRequest(request, responseHandler); - } catch (IOException e) { - fail("Unexpected IOException", e); - } - - var captor = ArgumentCaptor.forClass(XtextWebSocketResponse.class); - int nParams = params.length; - try { - verify(responseHandler, times(nParams)).onResponse(captor.capture()); - } catch (IOException e) { - throw new RuntimeException("Mockito threw unexcepted exception", e); - } - var allResponses = captor.getAllValues(); - for (int i = 0; i < nParams; i++) { - var response = allResponses.get(i); - assertThat(response, hasProperty("id", equalTo(id))); - assertThat(response, hasProperty("index", equalTo(i))); - } - return allResponses; + transactionExecutor.setResponseHandler(responseHandler); + transactionExecutor.handleRequest(request); + executorServices.waitForAllTasksToFinish(); + return responseHandler; } - private XtextWebSocketResponse performSingleRequest(String requiredStateId, Map param) { - return performBatchRequest(requiredStateId, param).get(0); + private String getStateId(String requestId, XtextWebResponse okResponse) { + assertThat(okResponse, hasProperty("id", equalTo(requestId))); + assertThat(okResponse, hasProperty("responseData", instanceOf(DocumentStateResult.class))); + return ((DocumentStateResult) ((XtextWebOkResponse) okResponse).getResponseData()).getStateId(); } - private String updateFullText() { - var updateResponse = (XtextWebSocketOkResponse) performSingleRequest(null, UPDATE_FULL_TEXT_PARAMS); - var documentStateResult = (DocumentStateResult) updateResponse.getResponseData(); - var stateId = documentStateResult.getStateId(); - if (INVALID_STATE_ID.equals(stateId)) { - throw new RuntimeException("Service returned unexpected stateId: " + stateId); - } - return stateId; + private void assertThatPrecomputedMessagesAreReceived(String stateId, List responses) { + assertHighlightingResponse(stateId, responses.get(1)); + assertValidationResponse(stateId, responses.get(2)); } - private static Matcher hasResponseData(Matcher responseDataMatcher) { - return hasProperty("responseData", responseDataMatcher); + private void assertHighlightingResponse(String stateId, XtextWebResponse highlightingResponse) { + assertThat(highlightingResponse, hasProperty("resourceId", equalTo(RESOURCE_NAME))); + assertThat(highlightingResponse, hasProperty("stateId", equalTo(stateId))); + assertThat(highlightingResponse, hasProperty("service", equalTo("highlighting"))); + assertThat(highlightingResponse, hasProperty("pushData", instanceOf(HighlightingResult.class))); } - private static Matcher hasErrorKind( - Matcher errorKindMatcher) { - return hasProperty("errorKind", errorKindMatcher); + private void assertValidationResponse(String stateId, XtextWebResponse validationResponse) { + assertThat(validationResponse, hasProperty("resourceId", equalTo(RESOURCE_NAME))); + assertThat(validationResponse, hasProperty("stateId", equalTo(stateId))); + assertThat(validationResponse, hasProperty("service", equalTo("validation"))); + assertThat(validationResponse, hasProperty("pushData", instanceOf(ValidationResult.class))); } } -- cgit v1.2.3-54-g00ecf